From 076f937248b15ea1c8f72835095beaa810765fda Mon Sep 17 00:00:00 2001 From: Lexus2016 Date: Tue, 25 Aug 2026 01:28:07 +0200 Subject: [PATCH 1/2] fix: a remote SSH chat must never become permanently unusable (#67) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported: after a remote run exhausts its auto-continues the chat stops responding, and "Restart Session" does not recover it. Root cause is a missed termination, not the auto-continue budget. `runSshSingle()` awaits a promise that resolves ONLY from `onDone`, and four paths in `ClaudeSSH.send()` ended a run without reaching it: 1. a socket closing with no 'error' and no channel open (sshd's ClientAlive/LoginGraceTime drop, a NAT idle-reap) — there was no `conn.on('close')` handler at all 2. the idle watchdog and the hard cap, which called `conn.end()` and nothing else; that settles a run only if a channel exists to be torn down with it 3. an abort arriving before `conn.exec` — the listener lived INSIDE the exec callback, so Stop during the handshake was silently ignored 4. `conn.on('error')` calling `h.onDone` directly, bypassing the `finished` guard, so the socket close behind it fired onDone twice A pending await keeps the session's `activeTasks` entry alive, and nothing reaps it: the 15s orphan sweeper opens with `if (activeTasks.has(sid)) continue`. The chat then refuses every new message AND `restart_session` answered "Task is still running" — no way out but a server restart. Every ending now goes through `finish()`, and the abort listener is registered at `send()` entry. Restart Session becomes a recovery action instead of a refusal: it aborts the live turn, waits up to 5s for that turn's own `finally` to release the session (which also flushes partial output to SQLite), identity-compares before reaping so a NEW turn's entry is never removed, and drops `activeChatSessions` + `_tabBusy` with it. It is also now reachable from the session bar — the in-message button only renders when the server got a `session_restart_available` frame out, and the run that needs recovering is precisely the one that never sends one. Failure notices name the root cause (`describeStopReason`), so "hit the turn limit three times" is distinguishable from "the link died three times", and the session-reset path no longer ends on a bare `break` with no word that it gave up. Tests: `ssh-termination.test.js` drives `ClaudeSSH.send()` through a fake ssh2 Client and asserts onDone fires exactly once on eight endings (the first scenario hangs on the pre-fix code); `session-restart.test.js` boots a real server against a `claude` that never exits and pins that a restart aborts and releases the session (3 assertions fail pre-fix). Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 47 ++++++- claude-ssh.js | 72 ++++++++--- package.json | 2 +- public/index.html | 43 ++++++- server.js | 74 ++++++++++- test/session-restart.test.js | 191 ++++++++++++++++++++++++++++ test/ssh-termination.test.js | 232 +++++++++++++++++++++++++++++++++++ 7 files changed, 631 insertions(+), 30 deletions(-) create mode 100644 test/session-restart.test.js create mode 100644 test/ssh-termination.test.js diff --git a/CLAUDE.md b/CLAUDE.md index 827ef7fc..a2e410df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ docker compose up -d docker compose logs -f claude-chat ``` -No linting and no build step configured. `npm test` chains 66 test files under `test/`: 18 DOM-less render/UI-logic tests (`test/render/*.test.mjs`, run through `node --test`) plus 48 plain-`node` suites in `test/` covering the overload detector, env load order, multi-agent results, terminals, bots, telegram, updates, kanban scheduling, i18n completeness, the config precedence resolver plus its secret masking, usage-limit detection, the filesystem path guard (including the SVG sandbox header and the symlink rule on the `@`-mention search endpoints) plus the tunnel-blocks-terminal rule, WS session re-subscription, the SSH remote CLI-session import, the live engine pane / interactive-prompt watchdog, the cross-project global workspace aggregation, the rule that an SSH credential never leaves the server process, the Windows command-quoting oracle, the auth token lifecycle, the multi-agent dependency scheduler (waves, plan sanitising, and the rule that a failure warning must survive dep-context truncation), the SSH stream parser's three guards, the remote non-interactive shell environment (`remote-env.test.js`, which runs the generated prelude through real `bash -lc`: it must parse, print nothing on stdout, and never end on a false test — the caller chains `&& claude …` behind it), the remote CLI-list framing parser, the bot inbox's SQL seam (`bot-inbox.test.js` pins that `from_bot AS "from"` keeps the exact key `planInboxDelivery` reads — rename one without the other and every letter is silently retired as malformed), the one-time config/.env migration onto CCS_CONFIG_PATH and the mid-task clarification delivery contract on the subscription engine (`interrupt-delivery.test.js` — pins that the tmux injection block sits BEFORE the poll loop's completion `break`, that draining does not imply delivery, that a failed paste is re-queued and warns non-terminally, and that the task runner passes the same callbacks the chat path does), the CLAUDE.md / AGENTS.md discovery rules (`agents-md.test.js`, which also pins that AGENTS.md reaches the subprocess as `--append-system-prompt` and never as `--system-prompt`), and the remote file browser's three guard layers (`remote-files.test.js` runs the generated POSIX script through a real `/bin/sh` against a temp tree that contains symlinks OUT of the project; `remote-files-api.test.js` boots a server against a fake remote via `CCS_REMOTE_EXEC_HOOK` and drives `/api/files` the way the SPA does), the editor deep links (`editor-links.test.js` pins the two URI shapes literally — the browser link puts `vscode-remote` in the AUTHORITY and the CLI argument puts it in the SCHEME, and collapsing the two silently breaks one path; `editor-open-api.test.js` boots a real server with `PATH` pointed at an EMPTY directory, which both makes the `opened:'client'` fallback deterministic and guarantees the suite never launches an editor window on a developer's desktop), and the new-chat defaults chain (`chat-defaults.test.js` pins the pure resolver — the built-ins are asserted to be exactly what the SPA hardcoded before #58, and the choice lists to be exactly the toolbar's `data-v` sets and `MODEL_MAP`'s aliases; `chat-defaults-api.test.js` boots a real server in a throwaway `APP_DIR` and pins that a project writes back a SPARSE override object — a five-key snapshot passes every other assertion in that file and still breaks the feature). On the render side, `tables.test.mjs` also pins the ReDoS bound in renderMd step 3.4, `xss.test.mjs` runs 24 adversarial payloads end-to-end, and `forged-tokens.test.mjs` covers the case where user text contains the renderer's own placeholder control bytes, and `pane-font.test.mjs` pins the clamp DIRECTION of `_fitEnginePaneFont` (a wide engine pane may only shrink; a narrow split pane must be allowed to grow). `script-scope.test.mjs` pins which `