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 8 of 16.
Breaks: the conversation visibly closes, then reappears in the sidebar on the next refresh — or after a restart. The per-message delete 240 lines above already carries the fix and the comment explaining it (ui/index.html:1259-1262: "Removing it from the screen anyway is how a message looked deleted and came back on reopen").
Who / likelihood: read-only or permission-changed data dir, a synced folder mid-conflict, and every malformed id (a ValueError from _chat_path is swallowed too). Reproduced with r3.py: chmod 500 chats/ → DELETE /chats/good1 → 200 {"ok": true}, file still on disk, still listed; DELETE /chats/../.. → 200 {"ok": true}.
Where: engine/server.py:1567-1575.
--- a/engine/server.py
+++ b/engine/server.py
@@ -1567,10 +1567,15 @@ class Handler(BaseHTTPRequestHandler):
def do_DELETE(self):
if not self.path.startswith("/chats/"):
self.send_error(404)
return
- try:
- _chat_path(self.path.rsplit("/", 1)[-1]).unlink(missing_ok=True)
- except (OSError, ValueError):
- pass
- self._json({"ok": True})
+ try:
+ _chat_path(self.path.rsplit("/", 1)[-1]).unlink(missing_ok=True)
+ except ValueError as exc:
+ self._json({"ok": False, "error": str(exc)}, 400)
+ return
+ except OSError as exc:
+ # Reporting a delete that did not happen is how a conversation
+ # closed on screen and was back in the sidebar on reopen.
+ self._json({"ok": False, "error": str(exc)}, 500)
+ return
+ self._json({"ok": True})
plus the caller, which currently discards the response (ui/index.html:1018-1020):
- await fetch(`http://127.0.0.1:${PORT}/chats/${c.id}`,{method:'DELETE'});
- if(c.id===chatId){ chatId=rid(); turns.innerHTML=''; syncEmpty(); }
+ const r=await fetch(`http://127.0.0.1:${PORT}/chats/${c.id}`,{method:'DELETE'})
+ .catch(()=>null);
+ if(!r||!r.ok){ toast('Could not delete that conversation.'); return; }
+ if(c.id===chatId){ chatId=rid(); turns.innerHTML=''; syncEmpty(); }
runAction('clear') (ui/index.html:1883) has the same hole across the whole loop and is fixed by the same response check.
Test — engine/test_train_routes.py for the route (it already drives a real engine over HTTP): chmod 0o500 the chats dir, DELETE, assert the status is not 200 and GET /chats still lists the chat (i.e. the response matches reality either way). UI half — frontend/tests/buttons.test.mjs: stub the DELETE as 500 and assert #turns still has its children afterwards.
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: the conversation visibly closes, then reappears in the sidebar on the next refresh — or after a restart. The per-message delete 240 lines above already carries the fix and the comment explaining it (
ui/index.html:1259-1262: "Removing it from the screen anyway is how a message looked deleted and came back on reopen").Who / likelihood: read-only or permission-changed data dir, a synced folder mid-conflict, and every malformed id (a
ValueErrorfrom_chat_pathis swallowed too). Reproduced withr3.py:chmod 500 chats/→DELETE /chats/good1 → 200 {"ok": true}, file still on disk, still listed;DELETE /chats/../..→200 {"ok": true}.Where:
engine/server.py:1567-1575.plus the caller, which currently discards the response (
ui/index.html:1018-1020):runAction('clear')(ui/index.html:1883) has the same hole across the whole loop and is fixed by the same response check.Test —
engine/test_train_routes.pyfor the route (it already drives a real engine over HTTP):chmod 0o500the chats dir,DELETE, assert the status is not 200 andGET /chatsstill lists the chat (i.e. the response matches reality either way). UI half —frontend/tests/buttons.test.mjs: stub the DELETE as 500 and assert#turnsstill has its children afterwards.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.