Skip to content

desktop: DELETE /chats/<id> answers {"ok": true} whether or not anything was deleted, and the UI blanks the transcript regardless #4495

Description

@MervinPraison

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingclaudeAuto-trigger Claude analysis

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions