fix(profiles): guard non-string cached model in dropdown render + purge from cache predicate (Codex gate finding on #5412)

A poisoned cache row {name:'x', model:{}} passed the name-only cache predicate
but threw 'p.model.split is not a function' synchronously on profile-chip click,
bricking profile switching until localStorage was cleared. Guard both render
sites (typeof p.model==='string') and reject unsafe model types in
_profileDropdownDataCacheUsable; regression covers {model:{}} and {model:123}.
This commit is contained in:
nesquena-hermes committed 2026-07-02 18:27:27 +00:00
1 parent cd5cb02635
commit 176720b55a
2 files changed
+25 -4

No files matched your search

+12 -3
View File
@@ -5938,7 +5938,16 @@ function _profileDropdownDataCacheUsable(data){
data &&
Array.isArray(data.profiles) &&
data.profiles.length &&
data.profiles.every(p=>p&&typeof p.name==='string')
data.profiles.every(p=>
p &&
typeof p.name==='string' &&
// Renderer-read fields must be safe types: renderProfileDropdown /
// renderProfilesPanel call p.model.split('/') guarded only by truthiness,
// so a poisoned cached row like {name:"x", model:{}} would pass a
// name-only check yet throw synchronously on dropdown open (bricking
// profile switching). Reject rows whose model is a non-string truthy value.
(p.model==null || typeof p.model==='string')
)
);
}
@@ -6112,7 +6121,7 @@ async function loadProfilesPanel() {
card.className = 'profile-card';
card.dataset.name = p.name;
const meta = [];
if (p.model) meta.push(p.model.split('/').pop());
if (typeof p.model === 'string' && p.model) meta.push(p.model.split('/').pop());
if (p.provider) meta.push(p.provider);
if (p.total_skills && p.total_skills > 0) meta.push(t('profile_skill_count', p.total_skills).replace(String(p.total_skills), `${p.enabled_skills} / ${p.total_skills}`));
const gwDot = p.gateway_running
@@ -6291,7 +6300,7 @@ function renderProfileDropdown(data) {
const opt = document.createElement('div');
opt.className = 'profile-opt' + (p.name === active ? ' active' : '');
const meta = [];
if (p.model) meta.push(p.model.split('/').pop());
if (typeof p.model === 'string' && p.model) meta.push(p.model.split('/').pop());
if (p.total_skills && p.total_skills > 0) meta.push(t('profile_skill_count', p.total_skills).replace(String(p.total_skills), `${p.enabled_skills} / ${p.total_skills}`));
const gwDot = `<span class="profile-opt-badge ${p.gateway_running ? 'running' : 'stopped'}"></span>`;
const checkmark = p.name === active ? ' <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--link)" stroke-width="3" style="vertical-align:-1px"><polyline points="20 6 9 17 4 12"/></svg>' : '';
+13 -1
View File
@@ -45,7 +45,11 @@ def test_profile_dropdown_opens_shell_before_network_fetch():
def test_profile_dropdown_uses_shared_fetch_promise_and_validated_local_storage_cache():
data_cache_body = _function_body(PANELS_JS, "function _profileDropdownDataCacheUsable(data){")
assert "Array.isArray(data.profiles)" in data_cache_body
assert "data.profiles.every(p=>p&&typeof p.name==='string')" in data_cache_body
assert "data.profiles.every(" in data_cache_body
assert "typeof p.name==='string'" in data_cache_body
# #5412 gate: the predicate must also reject rows whose renderer-read `model`
# field is a non-string truthy value (poison {name,model:{}} bricked the dropdown).
assert "typeof p.model==='string'" in data_cache_body
cache_body = _function_body(PANELS_JS, "function _profileDropdownCacheUsable(data){")
assert "_profileDropdownDataCacheUsable(data)" in cache_body
@@ -259,6 +263,14 @@ def test_poisoned_profile_cache_opens_then_switches_after_fresh_refresh():
(async () => {{
await runPoisonedCase([null]);
await runPoisonedCase([{{}}]);
// Regression (#5412 gate): a row that PASSES the name check but has a
// non-string `model` used to slip the cache predicate and throw
// `p.model.split is not a function` synchronously on dropdown open,
// bricking profile switching until localStorage was cleared. The
// predicate must now reject it (usable===false) AND the renderer must
// not throw even if such a row reaches it.
await runPoisonedCase([{{ name: 'default', is_default: true, model: {{}} }}, {{ name: 'other', visible: true }}]);
await runPoisonedCase([{{ name: 'default', is_default: true, model: 123 }}, {{ name: 'other', visible: true }}]);
await runSingleProfilePreservesSharedCache();
await runFreshSingleProfileReplacesStaleMultiCache();
}})().catch((err) => {{ console.error(err && err.stack || err); process.exit(1); }});