Found by a six-lens audit of the desktop app on 27 Aug 2026. Every finding was reproduced against the code, not inferred.
Severity: medium · audit rank 10 of 16.
Breaks: saveCfg mutates CFG/cfg, calls applyPrefs, then await fetch(POST /settings) with no r.ok check and no catch. apply (:1952-1958) awaits it and only then runs applyTheme(nv) and renderSettings(), and b.onclick = () => set(o.value) (:1820) discards the promise. When the POST rejects — and the base_url/api_key rows at :716,720 explicitly tell the user the engine will restart — clicking "Light" produces no data-theme, no highlight, no error, no toast. Toggles are worse: cfg is already mutated but the switch never moves, so visible and in-memory state diverge and the next click flips the invisible one back. A 500 is silent in the other direction: the row grows a "reset to default" affordance for a value that was never persisted. setTextSize (:1783) does not even await, and toasts "Text size 16 px" while every write fails.
Who / likelihood: any settings change made across an engine restart — which the UI itself tells users to expect.
Where: ui/index.html:1801-1806.
--- a/ui/index.html
+++ b/ui/index.html
@@ -1801,7 +1801,15 @@
async function saveCfg(patch){
- CFG={...CFG,...patch}; applyPrefs(CFG);
- cfg = {...cfg, ...patch};
- await fetch('http://127.0.0.1:'+PORT+'/settings',{method:'POST',
- headers:{'content-type':'application/json'},body:JSON.stringify(patch)});
+ // Apply only what was stored. Mutating first and never checking is how a
+ // toggle moved in memory, never moved on screen, and un-flipped itself on
+ // the next click.
+ let r=null;
+ try{
+ r=await fetch('http://127.0.0.1:'+PORT+'/settings',{method:'POST',
+ headers:{'content-type':'application/json'},body:JSON.stringify(patch)});
+ }catch(_){}
+ if(!r||!r.ok){ toast('Could not save that setting.'); return false; }
+ CFG={...CFG,...patch}; applyPrefs(CFG);
+ cfg = {...cfg, ...patch};
+ return true;
}
with apply (:1952) gating applyTheme/renderSettings on the returned boolean.
Test — frontend/tests/settings-effects.test.mjs (already boots the real page per-config and asserts observable differences): stub POST /settings as 500, click Theme → Light, and assert both that document.documentElement has no data-theme and that a toast/error is visible; then click a toggle and assert cfgGet(key) is unchanged. Currently the first passes vacuously and the rest fail.
Not yet fixed. Filed so it is not lost with the session that found it. The fix and the test above are proposals from the audit — worth re-checking against current main before implementing, since the file has moved since.
Breaks:
saveCfgmutatesCFG/cfg, callsapplyPrefs, thenawait fetch(POST /settings)with nor.okcheck and nocatch.apply(:1952-1958) awaits it and only then runsapplyTheme(nv)andrenderSettings(), andb.onclick = () => set(o.value)(:1820) discards the promise. When the POST rejects — and thebase_url/api_keyrows at:716,720explicitly tell the user the engine will restart — clicking "Light" produces nodata-theme, no highlight, no error, no toast. Toggles are worse:cfgis already mutated but the switch never moves, so visible and in-memory state diverge and the next click flips the invisible one back. A 500 is silent in the other direction: the row grows a "reset to default" affordance for a value that was never persisted.setTextSize(:1783) does not even await, and toasts "Text size 16 px" while every write fails.Who / likelihood: any settings change made across an engine restart — which the UI itself tells users to expect.
Where:
ui/index.html:1801-1806.with
apply(:1952) gatingapplyTheme/renderSettingson the returned boolean.Test —
frontend/tests/settings-effects.test.mjs(already boots the real page per-config and asserts observable differences): stubPOST /settingsas 500, click Theme → Light, and assert both thatdocument.documentElementhas nodata-themeand that a toast/error is visible; then click a toggle and assertcfgGet(key)is unchanged. Currently the first passes vacuously and the rest fail.Not yet fixed. Filed so it is not lost with the session that found it. The fix and the test above are proposals from the audit — worth re-checking against current
mainbefore implementing, since the file has moved since.