From 09ccffd32e13971114920dcdef1dca75419432e9 Mon Sep 17 00:00:00 2001 From: d fei Date: Wed, 30 Sep 2026 23:28:08 -0700 Subject: [PATCH] fix(adopt): never drive, write to, or answer for a session we did not start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review items 3, 4 and the smaller ones on #510. An adopted session wraps a process someone else launched, and its mode is whatever the probe saw — very often `claude`. Every guard below is therefore its own `isAdopted` check rather than a side effect of the external-CLI rule: the two ask different questions, and merging them means the day the first loosens, Codeman starts acting on a live conversation that is not ours. - The boot workspace-hook sweep skipped only non-claude and `session.remote`. An adopted session keeps its connection on `adopt.remote` / `adopt.docker`, so a tab wrapping someone's in-container or over-ssh claude looked local and Codeman wrote `.claude/settings.local.json` into their repo on every restart. - The workspace-trust auto-accept read the pane and pressed keys for 90s after `startInteractive()`. In an adopted pane that is someone else's dialog, and boot recovery re-opens the window on every restart. - Auto-clear, auto-compact, auto-resume and the Ralph auto-enable in `POST /interactive` all drove the pane. Each now refuses with its own gate. - The wrapper's `workingDir` was the foreign pane's cwd. That cwd can carry characters outside SAFE_PATH_PATTERN (`~/c++`, `Program Files (x86)`), which makes `createSession` throw so the tab never starts, and for a container or ssh candidate it is not a path on this host at all. It is now a neutral local `/tmp`, the same cwd `resolveMuxAttachCwd` already gives the pane; the foreign cwd stays on `adopt.paneCurrentPath` for display. - The CLI signature table hardcoded ids, which CLAUDE.md forbids outside `stock.ts`, and had already gone stale: `omp` was missing, so a hand-started omp pane read as `shell`. It is now derived from each entry's `discovery.binaries` at call time, regex-escaped, longest name first. Stock entries only, since `SessionMode` is the closed union of stock ids. - The descendant walk now passes `onTruncated`. A capped walk returns "nothing matched", which is byte-identical to the answer for a real shell. - The home screen's wide scan is one-shot. Riding `docker exec` and a full ssh handshake per target, each with a ~12s timeout, on the 8s poll stacked connections on a slow host. Wide rows are kept apart and merged, because a local-only payload cannot contain them. - Adoption failures report the server's own message. `_apiJson` folds a non-2xx to null, which collapsed "could not reach it just now" into "that session is gone" and reported a live remote session as deleted on every link flicker. Tests: route tests for the refusals, the admin gate, the 404 re-resolve, the one-wrapper-per-target reuse and the adopt round-trip; unit tests for the two non-HTTP guards, the registry-derived signatures, truncation reporting and the frontend's scan and error paths. Each guard was removed in turn to confirm its own tests go red. --- src/foreign-tmux.ts | 86 ++++-- src/session.ts | 8 + src/web/public/foreign-sessions.js | 146 +++++++++-- src/web/routes/session-routes.ts | 56 +++- src/web/server.ts | 7 + test/foreign-adoption-guards.test.ts | 124 +++++++++ test/foreign-sessions-ui.test.ts | 184 +++++++++++++ test/foreign-tmux.test.ts | 93 ++++++- test/mocks/mock-route-context.ts | 4 + test/mocks/mock-session.ts | 19 ++ test/routes/foreign-adoption-routes.test.ts | 273 ++++++++++++++++++++ 11 files changed, 954 insertions(+), 46 deletions(-) create mode 100644 test/foreign-adoption-guards.test.ts create mode 100644 test/foreign-sessions-ui.test.ts create mode 100644 test/routes/foreign-adoption-routes.test.ts diff --git a/src/foreign-tmux.ts b/src/foreign-tmux.ts index 013c140c..b275fb5f 100644 --- a/src/foreign-tmux.ts +++ b/src/foreign-tmux.ts @@ -69,6 +69,7 @@ */ import { collectDescendants } from './proc-tree.js'; +import { listClis } from './config/cli-registry/registry.js'; import { FOREIGN_MAX_PANES, FOREIGN_MAX_PROCS, FOREIGN_MAX_SOCKETS } from './config/foreign-tmux.js'; import type { ForeignPaneProbe, ForeignTmuxLocationKind } from './types/foreign-tmux.js'; import type { SessionMode } from './types/session.js'; @@ -239,23 +240,69 @@ export function parseForeignProbeOutput(stdout: string): ForeignProbeOutput { // =========================================================================== /** - * argv signatures for the CLIs Codeman knows, most specific first. + * argv signatures, derived from the registry rather than written here. * * Matched against the WHOLE argv of a pane descendant, so `node .../bin/claude` - * hits the claude rule via the path. `dsh` needs a tighter rule than the rest: - * Debian ships an unrelated `dsh` (distributed shell), which is exactly the trap - * `deepseek-cli-resolver.ts` guards against with an identity probe. + * hits the claude rule via the path. The binary names come from each entry's + * `discovery.binaries` — the one place that already knows a mode name is NOT + * always a binary name (`antigravity` runs `agy`). The hand-written table this + * replaces keyed on CLI ids, which CLAUDE.md forbids outside `stock.ts`, and it + * had already fallen behind: `omp` was missing, so a hand-started omp pane read + * as a plain shell. + * + * Resolved at CALL time, never into a module-level const: a CLI enabled (or a + * custom entry added) at runtime must be recognised without a restart. + * + * ⚠️ Longest name first. `pi` is two characters and its "command itself" form + * would otherwise be tried before `opencode`'s; length order is what keeps a + * short generic name from claiming a pane that belongs to a longer one. + * + * ⚠️ Names are regex-ESCAPED. They reach here from `~/.codeman/clis.json`, and + * while the schema's SHELL_TOKEN already refuses the dangerous shapes, building + * a pattern out of an unescaped foreign string is how a config value turns into + * a regex. Escaping means the worst a bad name can do is match nothing. */ -export const FOREIGN_CLI_SIGNATURES: ReadonlyArray<{ mode: SessionMode; test: RegExp }> = [ - { mode: 'claude', test: /(^|\/)claude(\s|$)|[/\\]\.?claude[/\\][^\s]*cli\.js|[/\\]bin[/\\]claude\b/ }, - { mode: 'codex', test: /(^|\/)codex(\s|$)|[/\\]bin[/\\]codex\b|[/\\]@openai[/\\]codex/ }, - { mode: 'opencode', test: /(^|\/)opencode(\s|$)|[/\\]bin[/\\]opencode\b/ }, - { mode: 'antigravity', test: /(^|\/)agy(\s|$)|[/\\]bin[/\\]agy\b|antigravity/ }, - { mode: 'gemini', test: /(^|\/)gemini(\s|$)|[/\\]bin[/\\]gemini\b/ }, - { mode: 'grok', test: /(^|\/)grok(\s|$)|[/\\]bin[/\\]grok\b/ }, - { mode: 'deepseek', test: /(^|\/)dsh(\s|$)|[/\\]bin[/\\]dsh\b|deepseek[-_]?harness/i }, - { mode: 'pi', test: /(^|\/)pi(\s|$)|[/\\]bin[/\\]pi\b/ }, -]; +export function foreignCliSignatures(): ReadonlyArray<{ mode: SessionMode; test: RegExp }> { + const seen = new Set(); + const out: { mode: SessionMode; test: RegExp }[] = []; + // `listClis()`, not `enabledClis()`: classification is a description of what is + // ALREADY running in someone else's pane, not a decision to launch anything. + // A disabled CLI still deserves its real name on the row. + const named: { mode: SessionMode; name: string }[] = []; + for (const entry of listClis()) { + // ⚠️ Stock entries only, and this is a `stock` check rather than an id check + // on purpose: `SessionMode` is the CLOSED union of the stock ids, so a custom + // entry has no SessionMode to be. Letting one through would put a value the + // union does not contain on `SessionState.mode`. A custom CLI's pane falls to + // the `shell` answer below, which is the honest one — Codeman has no name for + // it, and an adopted wrapper runs `attach` either way. + if (!entry.stock) continue; + for (const binary of entry.discovery.binaries) { + // `shell` declares none, which is right: it is the FALLBACK answer, never a + // signature match. + if (!binary || seen.has(binary)) continue; + seen.add(binary); + named.push({ mode: entry.id as SessionMode, name: binary }); + } + } + named.sort((a, b) => b.name.length - a.name.length); + for (const { mode, name } of named) { + const n = escapeRegExp(name); + out.push({ + mode, + test: new RegExp( + // the command itself · a `bin/` path · a node launcher under the CLI's + // own dot-directory (`~/.claude/local/.../cli.js`) + `(^|[/\\\\])${n}(\\s|$)|[/\\\\]bin[/\\\\]${n}\\b|[/\\\\]\\.?${n}[/\\\\][^\\s]*cli\\.js` + ), + }); + } + return out; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} /** Commands that mean "this is a plain shell", not an unidentified agent. */ const SHELL_COMMANDS = new Set([ @@ -300,7 +347,14 @@ export function classifyForeignPaneMode( const direct = matchSignature(own); if (direct) return { mode: direct, command: truncate(own) }; - const descendants = collectDescendants(pane.panePid, probe.byParent); + // ⚠️ Report truncation. A walk that stopped at a cap returns "nothing matched", + // which is byte-identical to the answer for a real shell — without this line a + // deep or wide tree silently downgrades an agent pane to `shell` and there is + // nothing anywhere to tell the two apart. + const descendants = collectDescendants(pane.panePid, probe.byParent, { + onTruncated: (root, cap, reason) => + console.warn(`[foreign-tmux] descendant walk for ${root} hit the ${cap}-${reason} cap; truncating`), + }); for (const pid of descendants) { const argv = probe.argvByPid.get(pid); if (!argv) continue; @@ -325,7 +379,7 @@ function matchSignature(argv: string): SessionMode | null { if (!argv) return null; const base = basenameOf(argv); if (SHELL_COMMANDS.has(base)) return null; - for (const sig of FOREIGN_CLI_SIGNATURES) { + for (const sig of foreignCliSignatures()) { if (sig.test.test(argv)) return sig.mode; } return null; diff --git a/src/session.ts b/src/session.ts index 8b7d2f83..91dcd8a7 100644 --- a/src/session.ts +++ b/src/session.ts @@ -3028,6 +3028,14 @@ export class Session extends EventEmitter { */ private _maybeAcceptTrustDialog(): void { if (this._trustDialogAccepted) return; + // ⚠️ Never answer a dialog in someone else's pane. An adopted session wraps a + // claude the human started, and this scan reads the pane and SENDS KEYS for 90 + // seconds after `startInteractive()` — which boot recovery re-runs for every + // wrapper, so it is not a one-time window at adoption. + if (this._adopt) { + this._trustDialogAccepted = true; + return; + } const now = Date.now(); if (now - this._interactiveStartedAt > TRUST_DIALOG_WINDOW_MS) { this._trustDialogAccepted = true; // window closed; anything matching now is not the dialog diff --git a/src/web/public/foreign-sessions.js b/src/web/public/foreign-sessions.js index 1b23a4fe..246baa35 100644 --- a/src/web/public/foreign-sessions.js +++ b/src/web/public/foreign-sessions.js @@ -21,11 +21,17 @@ * tab keeps sweeping tmux sockets forever. `startForeignPolling` / * `stopForeignPolling` are called from `showWelcome`/`hideWelcome`. * - * ⚠️ Docker and remote locations are NOT polled. Each costs a `docker exec` or a - * full ssh handshake per target, and doing that on every home-screen load is the - * one cost the server-side design explicitly refuses. They are fetched only when - * the user asks, via the "scan containers & hosts" toggle, which then rides along - * with the same poll. + * ⚠️ Docker and remote locations are NOT in that poll. Each costs a `docker exec` + * or a full ssh handshake per target, each with a ~12s timeout, so riding them + * along on an 8s tick stacks connections on a slow host — the one cost the + * server-side design explicitly refuses. The wide scan is therefore ONE-SHOT: + * turning the toggle on fetches once, and after that it is the user's Rescan + * button. The periodic poll stays local-only forever. + * + * The wide rows are kept separately (`_foreignWideSessions`) and merged into + * every local render, because a local-only poll's payload does not contain them + * and would otherwise wipe the container/remote rows off the list one tick after + * they appeared. Turning the toggle off discards them. * * ⚠️ Adoption creates a REAL session, so the button locks while in flight and the * result goes through the app's normal idempotent create path @@ -74,6 +80,7 @@ Object.assign(CodemanApp.prototype, { if (this._foreignPollTimer) return; void this.loadForeignSessions(); this._foreignPollTimer = setInterval(() => { + // Local only — see @fileoverview. The wide scan never rides the timer. void this.loadForeignSessions(); }, this._foreignPollMs || FOREIGN_POLL_FALLBACK_MS); }, @@ -90,19 +97,46 @@ Object.assign(CodemanApp.prototype, { return this._foreignScanRemote === true; }, + /** + * Flip the wide scan. ON fetches containers and hosts ONCE; OFF discards what + * that fetch found, because those rows are not re-confirmed by any later poll + * and leaving them up would show a container session that may be long gone. + */ toggleForeignScanRemote() { this._foreignScanRemote = !this._foreignScanRemote; - void this.loadForeignSessions(); + if (!this._foreignScanRemote) { + this._foreignWideSessions = []; + this._foreignWideNotes = []; + this.renderAllForeignSessions(); + return; + } + void this.loadForeignSessions({ wide: true }); + }, + + /** Re-run the expensive scan on demand. The only way wide rows ever refresh. */ + rescanForeignWide() { + if (!this.foreignScanRemote()) return; + void this.loadForeignSessions({ wide: true }); }, // ═══════════════════════════════════════════════════════════════ // Data // ═══════════════════════════════════════════════════════════════ - async loadForeignSessions() { - const wide = this.foreignScanRemote(); + /** + * Fetch the list. `wide` asks the server for containers and ssh hosts too, and + * ONLY an explicit user action ever passes it — never the interval. + */ + async loadForeignSessions({ wide = false } = {}) { const qs = wide ? '?docker=1&remote=1' : ''; - const data = await this._apiJson(`/api/mux/foreign${qs}`); + if (wide) this._foreignWideLoading = true; + this.renderAllForeignSessions(); + let data; + try { + data = await this._apiJson(`/api/mux/foreign${qs}`); + } finally { + if (wide) this._foreignWideLoading = false; + } if (!data) { // A failed poll must not blank a list the user is looking at: keep the // last good result and let the next tick recover. @@ -111,13 +145,34 @@ Object.assign(CodemanApp.prototype, { return; } this._foreignError = false; - this._foreignSessions = Array.isArray(data.sessions) ? data.sessions : []; - this._foreignNotes = Array.isArray(data.notes) ? data.notes : []; + const rows = Array.isArray(data.sessions) ? data.sessions : []; + const notes = Array.isArray(data.notes) ? data.notes : []; + if (wide) { + // Split by location: the local rows refresh on every tick, the rest only + // here, so they have to be stored apart or the next local-only payload + // (which cannot contain them) would read as "those sessions are gone". + this._foreignSessions = rows.filter((r) => (r.location || 'local') === 'local'); + this._foreignWideSessions = rows.filter((r) => (r.location || 'local') !== 'local'); + this._foreignNotes = notes; + this._foreignWideNotes = []; + this._foreignWideAt = Date.now(); + } else { + this._foreignSessions = rows; + this._foreignNotes = notes; + } this._foreignCanScanWide = data.canScanWide === true; if (Number.isFinite(data.pollIntervalMs)) this._foreignPollMs = data.pollIntervalMs; this.renderAllForeignSessions(); }, + /** Local rows plus whatever the last wide scan found. The render's one input. */ + _allForeignRows() { + const local = Array.isArray(this._foreignSessions) ? this._foreignSessions : []; + if (!this.foreignScanRemote()) return local; + const wide = Array.isArray(this._foreignWideSessions) ? this._foreignWideSessions : []; + return wide.length ? local.concat(wide) : local; + }, + /** * Adopt one candidate. * @@ -138,22 +193,30 @@ Object.assign(CodemanApp.prototype, { body.docker = true; body.remote = true; } - const data = await this._apiJson('/api/sessions/adopt', { method: 'POST', body }); - if (!data || !data.session) { - this.showToast?.( - codemanT - ? codemanT('That session is gone. Refreshing the list.') - : 'That session is gone. Refreshing the list.', - 'error' - ); + // ⚠️ `_api`, not `_apiJson`: the latter folds a network failure, a non-2xx + // and `success:false` all into null, and the server deliberately tells + // two different stories here — "that tmux session is no longer there" vs + // "could not reach it just now (…) — try again". Collapsing them showed a + // live remote session as deleted every time the link flickered. + const res = await this._api('/api/sessions/adopt', { method: 'POST', body }); + const payload = await this._readJsonBody(res); + const session = payload && payload.success !== false ? payload.data?.session : null; + if (!session) { + const fallback = codemanT + ? codemanT('Could not open that session. Refreshing the list.') + : 'Could not open that session. Refreshing the list.'; + this.showToast?.(payload?.error || fallback, 'error'); await this.loadForeignSessions(); return; } + const data = { session }; // Go through the app's normal create path so tab order, lineage lines and // SSE-vs-POST ordering behave exactly as they do for a Run. this._onSessionCreated?.(data.session); await this.selectSession(data.session.id); - await this.loadForeignSessions(); + // Still one explicit user action, so a wide row's "Open" refreshes the + // wide list too — otherwise the row just adopted keeps reading as free. + await this.loadForeignSessions({ wide: this.foreignScanRemote() }); } finally { this._foreignAdoptInFlight = false; if (buttonEl) { @@ -163,6 +226,16 @@ Object.assign(CodemanApp.prototype, { } }, + /** The error envelope, or null when there was no readable body at all. */ + async _readJsonBody(res) { + if (!res) return null; + try { + return await res.json(); + } catch { + return null; + } + }, + // ═══════════════════════════════════════════════════════════════ // Render // ═══════════════════════════════════════════════════════════════ @@ -183,7 +256,7 @@ Object.assign(CodemanApp.prototype, { * near `innerHTML`. */ renderForeignSessions(container) { - const rows = Array.isArray(this._foreignSessions) ? this._foreignSessions : []; + const rows = this._allForeignRows(); container.innerHTML = ''; if (!rows.length) { @@ -192,7 +265,9 @@ Object.assign(CodemanApp.prototype, { // A note is a REASON the list is empty, so a block carrying one must stay // visible — hiding it is exactly how "where did my session go" becomes // unanswerable. - const hasNotes = Array.isArray(this._foreignNotes) && this._foreignNotes.length > 0; + const hasNotes = + (Array.isArray(this._foreignNotes) && this._foreignNotes.length > 0) || + (Array.isArray(this._foreignWideNotes) && this._foreignWideNotes.length > 0); // ⚠️ `canScanWide` must keep the block visible even with nothing to show. // The scan toggle lives in the header, so hiding an empty block also hides // the only control that could fill it — on a host with containers but no @@ -242,7 +317,9 @@ Object.assign(CodemanApp.prototype, { * as text. */ _appendForeignNotes(container) { - const notes = Array.isArray(this._foreignNotes) ? this._foreignNotes : []; + const local = Array.isArray(this._foreignNotes) ? this._foreignNotes : []; + const wide = this.foreignScanRemote() && Array.isArray(this._foreignWideNotes) ? this._foreignWideNotes : []; + const notes = wide.length ? local.concat(wide) : local; if (!notes.length) return; const box = document.createElement('div'); box.className = 'foreign-notes'; @@ -284,6 +361,27 @@ Object.assign(CodemanApp.prototype, { scan.textContent = this.foreignScanRemote() ? '⟳ all' : '⟳ local'; header.appendChild(scan); + // The wide scan is one-shot, so without this button its rows could only + // ever be refreshed by toggling off and on again. + if (this.foreignScanRemote()) { + const rescan = document.createElement('button'); + rescan.type = 'button'; + rescan.className = 'foreign-scan-toggle'; + rescan.dataset.foreignAction = 'rescan-wide'; + rescan.disabled = this._foreignWideLoading === true; + rescan.title = codemanT + ? codemanT('Scan containers and remote hosts again') + : 'Scan containers and remote hosts again'; + rescan.textContent = this._foreignWideLoading + ? codemanT + ? codemanT('Scanning…') + : 'Scanning…' + : codemanT + ? codemanT('Rescan') + : 'Rescan'; + header.appendChild(rescan); + } + return header; }, @@ -353,6 +451,8 @@ Object.assign(CodemanApp.prototype, { void this.selectSession(el.dataset.foreignSession); } else if (action === 'toggle-scan') { this.toggleForeignScanRemote(); + } else if (action === 'rescan-wide') { + this.rescanForeignWide(); } }); }, diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 0d72c87d..2fa3a3c8 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -1191,6 +1191,13 @@ export function registerSessionRoutes( * grant gates elsewhere. The admin gate subsumes it, so there is deliberately * no second grant check here. */ + /** + * The wrapper pane for an adopted session runs `tmux attach`, never the agent, + * so it needs no workspace — and must not borrow the foreign one. Same value + * `resolveMuxAttachCwd()` gives remote and docker panes. + */ + const ADOPTED_WRAPPER_WORKING_DIR = '/tmp'; + app.post('/api/sessions/adopt', async (req, reply) => { if (isMultiUserMode() && !requireAdmin(req, reply)) return; @@ -1277,13 +1284,16 @@ export function registerSessionRoutes( const adoptHistoryConfig = await ctx.getTerminalHistoryConfig(); - // ⚠️ `workingDir` for an adopted session is the FOREIGN pane's cwd, which may - // not exist on this host (a container path, a remote path). It is recorded as - // an observation for display; the wrapper pane is never `cd`'d into it, and - // the case-space confinement that guards a real workingDir does not apply - // because nothing is created there. + // ⚠️ The wrapper's `workingDir` is a NEUTRAL LOCAL path, never the foreign + // pane's cwd. That cwd is an observation about another machine: it can carry + // characters outside SAFE_PATH_PATTERN (`~/c++`, `Program Files (x86)`), which + // makes `createSession` throw so the tab never starts at all, and when it is a + // container or ssh path every local consumer of `workingDir` — the Files panel, + // the boot hook sweep, the fs watchers — reads it as a path on THIS host. The + // foreign cwd stays on `adopt.paneCurrentPath`, which is for display only. + // `/tmp` is the same neutral cwd `resolveMuxAttachCwd` already gives the pane. const session = new Session({ - workingDir: target.workingDir || process.cwd(), + workingDir: ADOPTED_WRAPPER_WORKING_DIR, mode: target.mode, name: body.name || target.sessionName, mux: ctx.mux, @@ -1712,6 +1722,10 @@ export function registerSessionRoutes( // and belongs in its own PR, not in a refactor that is meant to change nothing. if ( !isExternalCliMode(session.mode) && + // ⚠️ And never for an adopted session, whose mode can be claude while the + // agent in that pane belongs to someone else. Kept as its own clause for + // the same reason the respawn and Ralph routes keep theirs separate. + !session.isAdopted && ctx.store.getConfig().ralphEnabled && !session.ralphTracker.autoEnableDisabled ) { @@ -3303,6 +3317,16 @@ export function registerSessionRoutes( const { id } = req.params as { id: string }; const body = parseBody(AutoClearSchema, req.body, 'Invalid request body'); const session = findSessionOrFail(ctx, id, req); + // ⚠️ Separate from the external-CLI gate on purpose: an adopted session's mode + // can perfectly well BE claude, and this drives the pane of a session someone + // else is using. Merging the two checks means a future change to the + // external-CLI rule silently reopens this one. + if (session.isAdopted) { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + 'Auto-clear is not available for adopted sessions: Codeman did not start this agent and must not drive it' + ); + } session.setAutoClear(body.enabled, body.threshold); persistAndBroadcastSession(ctx, session); @@ -3324,6 +3348,16 @@ export function registerSessionRoutes( const { id } = req.params as { id: string }; const body = parseBody(AutoCompactSchema, req.body, 'Invalid request body'); const session = findSessionOrFail(ctx, id, req); + // ⚠️ Separate from the external-CLI gate on purpose: an adopted session's mode + // can perfectly well BE claude, and this drives the pane of a session someone + // else is using. Merging the two checks means a future change to the + // external-CLI rule silently reopens this one. + if (session.isAdopted) { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + 'Auto-compact is not available for adopted sessions: Codeman did not start this agent and must not drive it' + ); + } session.setAutoCompact(body.enabled, body.threshold, body.prompt); persistAndBroadcastSession(ctx, session); @@ -3346,6 +3380,16 @@ export function registerSessionRoutes( const { id } = req.params as { id: string }; const body = parseBody(AutoResumeSchema, req.body, 'Invalid request body'); const session = findSessionOrFail(ctx, id, req); + // ⚠️ Separate from the external-CLI gate on purpose: an adopted session's mode + // can perfectly well BE claude, and this drives the pane of a session someone + // else is using. Merging the two checks means a future change to the + // external-CLI rule silently reopens this one. + if (session.isAdopted) { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + 'Auto-resume is not available for adopted sessions: Codeman did not start this agent and must not drive it' + ); + } session.setAutoResume(body.enabled); persistAndBroadcastSession(ctx, session); diff --git a/src/web/server.ts b/src/web/server.ts index 1ab399fa..ec31a0ac 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -3796,6 +3796,13 @@ export class WebServer extends EventEmitter { const workspaces = new Set(); for (const session of this.sessions.values()) { if (session.mode !== 'claude' || session.remote) continue; + // ⚠️ An ADOPTED session is not caught by the `remote` check above: its + // connection facts live on `adopt.remote` / `adopt.docker`, so a session + // wrapping someone's in-container or over-ssh claude looks local here. + // Its workspace was never Codeman's to begin with, so installing hooks + // there writes `.claude/settings.local.json` into the human's own repo — + // on EVERY restart, since this sweep runs from boot recovery. + if (session.isAdopted) continue; if (session.docker && !session.docker.hooksEnabled) continue; if (session.workingDir) workspaces.add(session.workingDir); } diff --git a/test/foreign-adoption-guards.test.ts b/test/foreign-adoption-guards.test.ts new file mode 100644 index 00000000..dbf87b85 --- /dev/null +++ b/test/foreign-adoption-guards.test.ts @@ -0,0 +1,124 @@ +/** + * @fileoverview The two adoption guards that are not reachable over HTTP: the + * boot-time workspace-hook sweep, and the workspace-trust auto-accept. + * + * Both act on a session's WORKSPACE or its PANE, both run unattended, and both + * previously keyed off `session.remote` or the mode alone — neither of which + * sees an adopted session, whose connection facts live on `adopt.remote` / + * `adopt.docker`. So a tab wrapping someone's claude read as a plain local + * claude: Codeman wrote `.claude/settings.local.json` into their repo on every + * restart, and answered a trust dialog that was theirs to answer. + * + * Each one is paired with a session Codeman DID start — without that control, + * "nothing was written" is equally explained by a disabled setting or a loop + * that never ran. + * + * Port: none (no server is listened on). + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const applyWorkspaceHooks = vi.fn(async () => {}); +vi.mock('../src/hooks-config.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, applyWorkspaceHooks: (...args: unknown[]) => applyWorkspaceHooks(...(args as [])) }; +}); + +const { WebServer } = await import('../src/web/server.js'); +const { Session } = await import('../src/session.js'); + +type AnySession = Record; + +/** The minimum a session needs to reach the sweep's `workspaces` set. */ +function fakeSession(over: AnySession): AnySession { + return { mode: 'claude', workingDir: '/srv/app', isAdopted: false, ...over }; +} + +describe('the boot workspace-hook sweep', () => { + let server: InstanceType; + + beforeEach(() => { + applyWorkspaceHooks.mockClear(); + server = new WebServer({ port: 3197 }); + (server as unknown as { getWorkspaceHooksEnabled: () => Promise }).getWorkspaceHooksEnabled = async () => + true; + }); + afterEach(async () => { + await server.stop().catch(() => {}); + }); + + const sweep = async (sessions: AnySession[]) => { + const map = new Map(); + sessions.forEach((s, i) => map.set(`s${i}`, s)); + (server as unknown as { sessions: Map }).sessions = map; + await ( + server as unknown as { ensureHooksForRecoveredWorkspaces: () => Promise } + ).ensureHooksForRecoveredWorkspaces(); + return applyWorkspaceHooks.mock.calls.map((c) => c[0]); + }; + + it('installs hooks into a workspace Codeman owns', async () => { + expect(await sweep([fakeSession({ workingDir: '/srv/ours' })])).toContain('/srv/ours'); + }); + + it('skips an adopted session even though its mode is claude and `remote` is unset', async () => { + // This is the exact shape the bug had: the ssh/docker facts hang off + // `adopt`, so the pre-existing `session.remote` check sees nothing. + const adopted = fakeSession({ + workingDir: '/home/them/repo', + isAdopted: true, + adopt: { location: 'remote', remote: { host: 'box' } }, + }); + expect(await sweep([adopted])).not.toContain('/home/them/repo'); + }); + + it('still skips a plain remote session — the older rule is untouched', async () => { + expect(await sweep([fakeSession({ workingDir: '/srv/far', remote: { host: 'box' } })])).not.toContain('/srv/far'); + }); +}); + +describe('the workspace-trust auto-accept', () => { + const makeSession = (adopt?: Record) => + new Session({ + id: adopt ? 'adopt-trust' : 'own-trust', + name: 't', + workingDir: '/tmp', + mode: 'claude', + ...(adopt ? { adopt } : {}), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + const scan = (s: InstanceType) => { + const priv = s as unknown as { + _interactiveStartedAt: number; + _trustDialogAccepted: boolean; + _maybeAcceptTrustDialog: () => void; + _trustDialogTimer: unknown; + }; + priv._interactiveStartedAt = Date.now(); // inside the 90s startup window + priv._trustDialogAccepted = false; + priv._maybeAcceptTrustDialog(); + return priv; + }; + + it('arms the scan for a session Codeman started', async () => { + const s = makeSession(); + const priv = scan(s); + expect(priv._trustDialogAccepted).toBe(false); + await s.stop(true).catch(() => {}); + }); + + it('never answers a dialog in an adopted pane', async () => { + // That pane holds SOMEONE ELSE'S claude. And boot recovery re-opens the 90s + // window on every restart, so this is not a one-shot at adoption time. + const s = makeSession({ + location: 'local', + socketPath: '/tmp/tmux-0/default', + targetSession: 'work', + viewSession: '', + }); + const priv = scan(s); + expect(priv._trustDialogAccepted).toBe(true); // short-circuited, never scans again + expect(priv._trustDialogTimer).toBeFalsy(); + await s.stop(true).catch(() => {}); + }); +}); diff --git a/test/foreign-sessions-ui.test.ts b/test/foreign-sessions-ui.test.ts new file mode 100644 index 00000000..0cbdeede --- /dev/null +++ b/test/foreign-sessions-ui.test.ts @@ -0,0 +1,184 @@ +/** + * @fileoverview The home screen's foreign-session block: the wide scan is + * one-shot, and an adoption failure says what the server said. + * + * Containers and ssh hosts cost a `docker exec` or a full ssh handshake each, + * with a ~12s timeout. Riding them along on the 8s poll stacked connections on + * a slow host, so the wide scan now runs only when the user asks. That makes a + * second property load-bearing: the periodic poll's payload cannot contain + * those rows, so they are kept apart and merged — assert only "the request was + * right" and the real failure (the next tick wiping the container rows off the + * list) sails through. + * + * Port: none. + */ +import { describe, it, expect, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import vm from 'node:vm'; + +const SRC = readFileSync(fileURLToPath(new URL('../src/web/public/foreign-sessions.js', import.meta.url)), 'utf8'); + +function stubEl(): Record { + const el: Record = { + children: [] as unknown[], + dataset: {}, + disabled: false, + textContent: '', + title: '', + type: '', + className: '', + hidden: false, + classList: { add() {}, remove() {}, contains: () => false }, + appendChild(c: unknown) { + (el.children as unknown[]).push(c); + return c; + }, + setAttribute() {}, + }; + Object.defineProperty(el, 'innerHTML', { get: () => '', set: () => void (el.children = []) }); + return el; +} + +/** The module is a classic script that mixes into CodemanApp.prototype. */ +function loadApp() { + const host = stubEl(); + const ctx: Record = { + console, + Date, + Number, + Array, + Object, + JSON, + Math, + String, + setInterval: () => 1, + clearInterval() {}, + codemanT: null, + document: { + createElement: () => stubEl(), + getElementById: (id: string) => (id === 'foreignSessions' ? host : null), + addEventListener() {}, + }, + CodemanApp: function () {} as unknown, + }; + vm.createContext(ctx); + vm.runInContext(SRC, ctx); + const Ctor = ctx.CodemanApp as { prototype: object }; + return Object.create(Ctor.prototype) as Record & { + toggleForeignScanRemote(): void; + rescanForeignWide(): void; + loadForeignSessions(o?: { wide?: boolean }): Promise; + adoptForeignSession(id: string, el?: unknown): Promise; + _allForeignRows(): Array<{ id: string }>; + renderAllForeignSessions(): void; + }; +} + +const LOCAL = { id: 'L1', location: 'local', sessionName: 'work', mode: 'claude' }; +const DOCKER = { id: 'D1', location: 'docker', sessionName: 'inbox', mode: 'codex' }; + +describe('the wide scan is one-shot', () => { + function wired() { + const app = loadApp(); + const calls: string[] = []; + app._apiJson = vi.fn(async (path: string) => { + calls.push(path); + return path.includes('docker=1') + ? { sessions: [LOCAL, DOCKER], notes: [], canScanWide: true } + : { sessions: [LOCAL], notes: [], canScanWide: true }; + }); + return { app, calls }; + } + + it('fetches containers and hosts once when the toggle goes on', async () => { + const { app, calls } = wired(); + app.toggleForeignScanRemote(); + // Wait on the STATE, not on the call count: the stub pushes its path before + // it resolves, so a call-count wait returns while the rows are still unset. + await vi.waitFor(() => expect(app._allForeignRows()).toHaveLength(2)); + expect(calls[0]).toContain('docker=1'); + expect(calls[0]).toContain('remote=1'); + expect(app._allForeignRows().map((r) => r.id)).toEqual(['L1', 'D1']); + }); + + it('keeps the periodic poll local-only, and the container row survives it', async () => { + // Both halves matter. The first is the cost fix; the second is what makes it + // usable — a local-only payload has no container rows in it. + const { app, calls } = wired(); + app.toggleForeignScanRemote(); + await vi.waitFor(() => expect(app._allForeignRows()).toHaveLength(2)); + await app.loadForeignSessions(); // the interval's call shape + expect(calls[1]).toBe('/api/mux/foreign'); + expect(app._allForeignRows().map((r) => r.id)).toEqual(['L1', 'D1']); + }); + + it('discards the wide rows when the toggle goes off', async () => { + // Nothing re-confirms them, so leaving them up would show a container + // session that may be long gone. + const { app, calls } = wired(); + app.toggleForeignScanRemote(); + await vi.waitFor(() => expect(app._allForeignRows()).toHaveLength(2)); + app.toggleForeignScanRemote(); + expect(app._allForeignRows().map((r) => r.id)).toEqual(['L1']); + }); + + it('Rescan re-runs the wide scan, and does nothing while the toggle is off', async () => { + const { app, calls } = wired(); + app.rescanForeignWide(); + expect(calls).toHaveLength(0); + app.toggleForeignScanRemote(); + await vi.waitFor(() => expect(app._allForeignRows()).toHaveLength(2)); + app.rescanForeignWide(); + await vi.waitFor(() => expect(calls).toHaveLength(2)); + expect(calls[1]).toContain('docker=1'); + }); +}); + +describe('an adoption failure reports what the server said', () => { + function adoptHarness(status: number, body: unknown) { + const app = loadApp(); + const toasts: string[] = []; + app.showToast = (msg: string) => void toasts.push(msg); + app._api = vi.fn(async () => ({ ok: status < 400, status, json: async () => body })); + app._apiJson = vi.fn(async () => ({ sessions: [], notes: [], canScanWide: false })); + app._onSessionCreated = vi.fn(); + app.selectSession = vi.fn(async () => {}); + return { app, toasts }; + } + + it('shows "could not reach it" verbatim instead of "that session is gone"', async () => { + // The server deliberately tells two different stories. `_apiJson` folds a + // non-2xx to null, which collapsed both into one message and reported a + // live remote session as deleted every time the link flickered. + const msg = 'Could not reach it just now (ssh box: timeout). It may still be running — try again.'; + const { app, toasts } = adoptHarness(404, { success: false, error: msg, errorCode: 'NOT_FOUND' }); + await app.adoptForeignSession('cand-1'); + expect(toasts[0]).toBe(msg); + }); + + it('still shows the gone message when that is what the server said', async () => { + const msg = 'That tmux session is no longer there. Refresh the list and try again.'; + const { app, toasts } = adoptHarness(404, { success: false, error: msg, errorCode: 'NOT_FOUND' }); + await app.adoptForeignSession('cand-1'); + expect(toasts[0]).toBe(msg); + }); + + it('falls back to its own wording when the server sent no readable body', async () => { + const app = loadApp(); + const toasts: string[] = []; + app.showToast = (msg: string) => void toasts.push(msg); + app._api = vi.fn(async () => null); // network failure + app._apiJson = vi.fn(async () => ({ sessions: [], notes: [], canScanWide: false })); + await app.adoptForeignSession('cand-1'); + expect(toasts[0]).toMatch(/Could not open that session/); + }); + + it('goes through the app’s normal create path on success', async () => { + const session = { id: 'new-1' }; + const { app } = adoptHarness(200, { success: true, data: { session } }); + await app.adoptForeignSession('cand-1'); + expect(app._onSessionCreated).toHaveBeenCalledWith(session); + expect(app.selectSession).toHaveBeenCalledWith('new-1'); + }); +}); diff --git a/test/foreign-tmux.test.ts b/test/foreign-tmux.test.ts index 377dee7b..78470650 100644 --- a/test/foreign-tmux.test.ts +++ b/test/foreign-tmux.test.ts @@ -6,7 +6,7 @@ * would quietly undo. Each one has a comment naming what actually went wrong. */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { buildForeignProbeScript, parseForeignProbeOutput, @@ -20,7 +20,11 @@ import { buildForeignDockerAttachCommand, buildForeignRemoteAttachCommand, buildForeignTmuxInvocation, + foreignCliSignatures, } from '../src/foreign-tmux.js'; +import { PROC_WALK_MAX_NODES } from '../src/proc-tree.js'; +import { STOCK_CLIS } from '../src/config/cli-registry/stock.js'; +import { hooksAvailableForMode, sessionHookOptions } from '../src/web/session-wait-registry.js'; // A probe transcript in exactly the shape a real run produces. The pane rows use // the LITERAL backslash-t that tmux's `-F` emits (verified on next-3.7 and 3.3a), @@ -385,3 +389,90 @@ describe('attach command builders', () => { }); }); }); + +describe('foreignCliSignatures', () => { + it('covers every stock CLI that declares a binary — including the one the hand-written table missed', () => { + // The table this replaced listed eight CLI ids by hand and had already gone + // stale: `omp` shipped without a signature, so a hand-started omp pane was + // classified `shell`. Deriving from `discovery.binaries` makes that class of + // drift impossible rather than merely fixed once. + const covered = new Set(foreignCliSignatures().map((sig) => sig.mode)); + const expected = STOCK_CLIS.filter((e) => e.discovery.binaries.length > 0).map((e) => e.id as string); + expect(expected).toContain('omp'); + for (const id of expected) expect(covered).toContain(id); + }); + + it('leaves shell out — it declares no binary and is the FALLBACK answer', () => { + expect(foreignCliSignatures().some((sig) => sig.mode === 'shell')).toBe(false); + }); + + it('orders longest binary name first, so a two-letter name cannot win early', () => { + // `pi` matches `(^|/)pi(\s|$)`, which is the loosest rule in the table. If it + // were tried before `opencode`'s, nothing would break on these two names, but + // the ordering is the only thing standing between a future one-letter binary + // and every pane on the machine. + const names = foreignCliSignatures().map((sig) => sig.mode); + expect(names.indexOf('pi')).toBeGreaterThan(names.indexOf('opencode')); + }); + + it('classifies an omp pane as omp rather than shell', () => { + const empty = { byParent: new Map(), argvByPid: new Map() }; + expect(classifyForeignPaneMode({ panePid: 1, paneCurrentCommand: 'omp' }, empty).mode).toBe('omp'); + }); + + it('still finds claude under its own dot-directory node launcher', () => { + // The derived "node launcher" shape has to reproduce what claude's hand-written + // `[/\\]\.?claude[/\\][^\s]*cli\.js` rule covered, or every claude installed + // the normal way (a `node ~/.claude/local/.../cli.js` argv) silently reads as shell. + const argv = 'node /home/u/.claude/local/node_modules/@anthropic-ai/claude-code/cli.js'; + const probe = { byParent: new Map([[7, [8]]]), argvByPid: new Map([[8, argv]]) }; + expect(classifyForeignPaneMode({ panePid: 7, paneCurrentCommand: 'node' }, probe).mode).toBe('claude'); + }); + + it('does not let a binary name match inside a longer command', () => { + const empty = { byParent: new Map(), argvByPid: new Map() }; + expect(classifyForeignPaneMode({ panePid: 1, paneCurrentCommand: '/usr/bin/pip install x' }, empty).mode).toBe( + 'shell' + ); + }); +}); + +describe('classifyForeignPaneMode truncation reporting', () => { + it('reports a truncated descendant walk instead of silently answering shell', () => { + // A capped walk returns "nothing matched", which is byte-identical to the + // answer for a real shell. Without the warning there is nothing anywhere to + // tell a wide process tree apart from a plain bash pane. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const byParent = new Map(); + byParent.set( + 1, + Array.from({ length: PROC_WALK_MAX_NODES + 10 }, (_, i) => i + 100) + ); + const result = classifyForeignPaneMode( + { panePid: 1, paneCurrentCommand: 'bash' }, + { byParent, argvByPid: new Map() } + ); + expect(result.mode).toBe('shell'); + expect(warn).toHaveBeenCalled(); + expect(String(warn.mock.calls[0]?.[0])).toMatch(/nodes cap|-nodes/); + } finally { + warn.mockRestore(); + } + }); +}); + +describe('hooksAvailableForMode for an adopted session', () => { + it('refuses claude when the process was adopted', () => { + // Adoption asks who STARTED the process. Codeman never installed its hooks + // block in that workspace, so answering by mode would dress an infinite wait + // up as a timeout. + expect(hooksAvailableForMode('claude')).toBe(true); + expect(hooksAvailableForMode('claude', { adopted: true })).toBe(false); + }); + + it('derives `adopted` from the session, so every call site inherits it', () => { + expect(sessionHookOptions({ adopt: { targetSession: 'work' } }).adopted).toBe(true); + expect(sessionHookOptions({}).adopted).toBe(false); + }); +}); diff --git a/test/mocks/mock-route-context.ts b/test/mocks/mock-route-context.ts index 9cb26d6a..e79a449e 100644 --- a/test/mocks/mock-route-context.ts +++ b/test/mocks/mock-route-context.ts @@ -147,6 +147,10 @@ export function createMockRouteContext(options?: { createSession: vi.fn(), killSession: vi.fn(), listSessions: vi.fn(() => []), + // A DIFFERENT method from listSessions, and the adoption routes call this + // one. Missing it is not a type error — the handler throws and the route + // answers 500, which reads as a broken endpoint rather than a broken mock. + getSessions: vi.fn(() => []), getStats: vi.fn(() => ({})), updateSessionName: vi.fn(() => true), getSession: vi.fn(() => null), diff --git a/test/mocks/mock-session.ts b/test/mocks/mock-session.ts index 5f04d97e..815d9924 100644 --- a/test/mocks/mock-session.ts +++ b/test/mocks/mock-session.ts @@ -19,6 +19,13 @@ export class MockSession extends EventEmitter { * over ssh instead of with local `fs` (#415). */ remote?: SessionRemote; + /** + * Mirrors `Session.isAdopted` — true when this session wraps a process SOMEONE + * ELSE started (a hand-opened tmux session Codeman attached to). A plain field + * rather than a getter over `adopt`, because the routes only ever read the + * boolean and the tests only ever need to set it. + */ + isAdopted: boolean = false; /** Mirrors Session.attachmentHistory (the attachment panel's source of truth). */ attachmentHistory: SessionAttachmentHistoryItem[] = []; /** Mirrors Session.getAttachmentHistoryForPersist(). */ @@ -323,6 +330,18 @@ export class MockSession extends EventEmitter { } /** Auto-resume on usage limit (token pause control) */ + autoClearEnabled: boolean = false; + autoClearThreshold: number = 0; + setAutoClear = vi.fn((enabled: boolean, threshold?: number) => { + this.autoClearEnabled = enabled; + if (typeof threshold === 'number') this.autoClearThreshold = threshold; + }); + autoCompactEnabled: boolean = false; + autoCompactThreshold: number = 0; + setAutoCompact = vi.fn((enabled: boolean, threshold?: number) => { + this.autoCompactEnabled = enabled; + if (typeof threshold === 'number') this.autoCompactThreshold = threshold; + }); autoResumeEnabled: boolean = false; autoResumeAt: number | null = null; isLimitPaused: boolean = false; diff --git a/test/routes/foreign-adoption-routes.test.ts b/test/routes/foreign-adoption-routes.test.ts new file mode 100644 index 00000000..41ad6780 --- /dev/null +++ b/test/routes/foreign-adoption-routes.test.ts @@ -0,0 +1,273 @@ +/** + * @fileoverview The HTTP surface of foreign-tmux adoption, and the gates that + * keep Codeman from driving a session it did not start. + * + * An adopted session wraps a process SOMEONE ELSE launched. Its `mode` is + * whatever the probe saw — very often `claude` — so every refusal here has to + * be its own `isAdopted` check and not a side effect of the external-CLI rule. + * The two questions are different ("is this CLI one we drive?" vs "did we start + * this process?"), and folding them into one means the day the first loosens, + * Codeman silently starts sending `/clear` into someone's live conversation. + * + * Every refusal is paired with a session Codeman DID start, or a broken + * endpoint would satisfy the refusal assertion just as well. + * + * Port: none (app.inject()). + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +// The adopt route RE-RESOLVES the candidate rather than trusting the body, and +// real discovery is disabled under VITEST, so the success path is unreachable +// without standing in for it. Everything downstream of the lookup is real. +const discoverForeignSessions = vi.fn(); +const invalidateForeignCache = vi.fn(); +vi.mock('../../src/foreign-tmux-discovery.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + discoverForeignSessions: (...a: unknown[]) => discoverForeignSessions(...(a as [])), + invalidateForeignCache: () => invalidateForeignCache(), + readAllDockerCases: async () => [], + readAllRemoteHosts: async () => [], + }; +}); + +import { createRouteTestHarness } from './_route-test-utils.js'; +import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; +import { registerRespawnRoutes } from '../../src/web/routes/respawn-routes.js'; +import { registerRalphRoutes } from '../../src/web/routes/ralph-routes.js'; +import { registerMuxRoutes } from '../../src/web/routes/mux-routes.js'; + +const SID = 'adopted-1'; + +// The stand-in defaults to "found nothing", which is what real discovery gives +// under VITEST — so mocking the module does not change the tests that predate it. +beforeEach(() => { + discoverForeignSessions.mockReset(); + discoverForeignSessions.mockResolvedValue({ sessions: [], notes: [] }); + invalidateForeignCache.mockClear(); +}); + +describe('adopted sessions refuse every auto-op that drives the pane', () => { + for (const route of ['auto-clear', 'auto-compact', 'auto-resume']) { + it(`refuses POST /api/sessions/:id/${route}`, async () => { + const h = await createRouteTestHarness(registerSessionRoutes, { sessionId: SID }); + (h.ctx._session as unknown as { isAdopted: boolean }).isAdopted = true; + const res = await h.app.inject({ + method: 'POST', + url: `/api/sessions/${SID}/${route}`, + payload: { enabled: true }, + }); + const body = res.json(); + expect(body.success).toBe(false); + expect(body.error).toMatch(/adopted/i); + await h.app.close(); + }); + + it(`still allows ${route} on a session Codeman started`, async () => { + const h = await createRouteTestHarness(registerSessionRoutes, { sessionId: SID }); + const res = await h.app.inject({ + method: 'POST', + url: `/api/sessions/${SID}/${route}`, + payload: { enabled: true }, + }); + expect(res.json().success).toBe(true); + await h.app.close(); + }); + } + + it('refuses respawn, for a harder reason: it kills and restarts the pane', async () => { + const h = await createRouteTestHarness(registerRespawnRoutes, { sessionId: SID }); + (h.ctx._session as unknown as { isAdopted: boolean }).isAdopted = true; + const res = await h.app.inject({ method: 'POST', url: `/api/sessions/${SID}/respawn/start`, payload: {} }); + expect(res.json().success).toBe(false); + expect(res.json().error).toMatch(/adopted/i); + await h.app.close(); + }); + + it('refuses Ralph config', async () => { + const h = await createRouteTestHarness(registerRalphRoutes, { sessionId: SID }); + (h.ctx._session as unknown as { isAdopted: boolean }).isAdopted = true; + const res = await h.app.inject({ + method: 'POST', + url: `/api/sessions/${SID}/ralph-config`, + payload: { maxTodos: 10 }, + }); + expect(res.json().success).toBe(false); + expect(res.json().error).toMatch(/adopted/i); + await h.app.close(); + }); +}); + +describe('GET /api/mux/foreign', () => { + let prev: string | undefined; + beforeEach(() => { + prev = process.env.CODEMAN_MULTIUSER; + }); + afterEach(() => { + if (prev === undefined) delete process.env.CODEMAN_MULTIUSER; + else process.env.CODEMAN_MULTIUSER = prev; + }); + + it('is admin-only in multi-user mode — it exposes every user’s processes and cwds', async () => { + process.env.CODEMAN_MULTIUSER = '1'; + const { app } = await createRouteTestHarness(registerMuxRoutes, { + authUser: { username: 'bob', role: 'user' }, + }); + const res = await app.inject({ method: 'GET', url: '/api/mux/foreign' }); + expect(res.statusCode).toBeGreaterThanOrEqual(400); + await app.close(); + }); + + it('answers an admin', async () => { + process.env.CODEMAN_MULTIUSER = '1'; + const { app } = await createRouteTestHarness(registerMuxRoutes, { + authUser: { username: 'root', role: 'admin' }, + }); + expect((await app.inject({ method: 'GET', url: '/api/mux/foreign' })).statusCode).toBe(200); + await app.close(); + }); + + it('answers in single-user mode, where the admin gate is a no-op', async () => { + delete process.env.CODEMAN_MULTIUSER; + const { app } = await createRouteTestHarness(registerMuxRoutes); + const res = await app.inject({ method: 'GET', url: '/api/mux/foreign' }); + expect(res.statusCode).toBe(200); + // Real discovery is disabled under VITEST, so this pins the shape, not the + // content. The body is bare here: the uniform envelope is applied by the + // server's own hook, which a single-route harness does not install. + expect(res.json()).toHaveProperty('sessions'); + expect(res.json()).toHaveProperty('canScanWide'); + await app.close(); + }); +}); + +describe('POST /api/sessions/adopt', () => { + let prev: string | undefined; + beforeEach(() => { + prev = process.env.CODEMAN_MULTIUSER; + }); + afterEach(() => { + if (prev === undefined) delete process.env.CODEMAN_MULTIUSER; + else process.env.CODEMAN_MULTIUSER = prev; + }); + + it('is admin-only in multi-user mode — adopting a shell is arbitrary host execution', async () => { + process.env.CODEMAN_MULTIUSER = '1'; + const { app } = await createRouteTestHarness(registerSessionRoutes, { + authUser: { username: 'bob', role: 'user' }, + }); + const res = await app.inject({ method: 'POST', url: '/api/sessions/adopt', payload: { id: 'x' } }); + expect(res.statusCode).toBeGreaterThanOrEqual(400); + await app.close(); + }); + + it('404s when the re-resolve no longer sees the target', async () => { + // The browser hands back only an opaque id; the socket path and session name + // are re-derived server-side, so a vanished target is simply absent from the + // fresh list. + const { app } = await createRouteTestHarness(registerSessionRoutes); + const res = await app.inject({ method: 'POST', url: '/api/sessions/adopt', payload: { id: 'nope' } }); + expect(res.json().success).toBe(false); + expect(res.json().errorCode).toBe('NOT_FOUND'); + await app.close(); + }); + + it('rejects a body that tries to supply a socket path or session name', async () => { + // The schema takes the opaque id and nothing else. That is why a path can + // never reach the launch chain from the browser. + const { app } = await createRouteTestHarness(registerSessionRoutes); + const res = await app.inject({ + method: 'POST', + url: '/api/sessions/adopt', + payload: { id: 'x', socketPath: '/tmp/tmux-0/default', targetSession: 'work' }, + }); + expect(res.json().success).toBe(false); + expect(res.json().errorCode).toBe('INVALID_INPUT'); + await app.close(); + }); +}); + +describe('a successful adoption', () => { + /** A candidate whose cwd is BOTH unsafe for SAFE_PATH_PATTERN and not on this host. */ + const CANDIDATE = { + id: 'cand-1', + location: 'local' as const, + socketPath: '/tmp/tmux-0/default', + sessionName: 'work', + mode: 'claude' as const, + workingDir: '/home/them/Program Files (x86)/c++', + command: 'claude', + panePid: 4242, + paneCurrentCommand: 'node', + createdAt: 1, + windows: 1, + attached: false, + }; + + beforeEach(() => { + discoverForeignSessions.mockResolvedValue({ sessions: [CANDIDATE], notes: [] }); + }); + + it('gives the wrapper a neutral LOCAL workingDir, never the foreign pane\u2019s cwd', async () => { + // That cwd carries `+`, spaces and parentheses, so using it would make + // createSession throw and the tab would never start; and when the candidate + // is a container or ssh pane it is not a path on this host at all, which is + // what sent the boot hook sweep into someone else's repo. + const { app, ctx } = await createRouteTestHarness(registerSessionRoutes); + const res = await app.inject({ method: 'POST', url: '/api/sessions/adopt', payload: { id: 'cand-1' } }); + expect(res.statusCode).toBe(200); + expect(ctx.addSession).toHaveBeenCalledTimes(1); + const created = ctx.addSession.mock.calls[0][0] as unknown as { + workingDir: string; + adopt?: { paneCurrentPath?: string; targetSession?: string; viewSession?: string }; + }; + expect(created.workingDir).toBe('/tmp'); + expect(created.workingDir).not.toBe(CANDIDATE.workingDir); + // The foreign cwd survives for display only. + expect(created.adopt?.paneCurrentPath).toBe(CANDIDATE.workingDir); + await app.close(); + }); + + it('reuses the existing wrapper rather than making a second one for the same target', async () => { + // Keyed on (socket, session) and not on our opaque candidate id: a wrapper + // restored after a server restart does not carry that id. + const { app, ctx } = await createRouteTestHarness(registerSessionRoutes); + ctx.sessions.set('wrapper-1', ctx._session); + ctx.mux.getSessions = vi.fn(() => [ + { sessionId: 'wrapper-1', adopt: { socketPath: CANDIDATE.socketPath, targetSession: CANDIDATE.sessionName } }, + ]) as unknown as typeof ctx.mux.getSessions; + const res = await app.inject({ method: 'POST', url: '/api/sessions/adopt', payload: { id: 'cand-1' } }); + expect(res.statusCode).toBe(200); + expect(res.json().alreadyAdopted).toBe(true); + expect(ctx.addSession).not.toHaveBeenCalled(); + await app.close(); + }); + + it('says "could not reach it" rather than "gone" when discovery left a note', async () => { + // On a flaky link the session is alive and sitting right there; reporting + // it as deleted is the wrong answer, not just an unhelpful one. + discoverForeignSessions.mockResolvedValue({ sessions: [], notes: ['ssh box: connection timed out'] }); + const { app } = await createRouteTestHarness(registerSessionRoutes); + const res = await app.inject({ method: 'POST', url: '/api/sessions/adopt', payload: { id: 'cand-1' } }); + expect(res.json().success).toBe(false); + expect(res.json().error).toMatch(/Could not reach it/); + expect(res.json().error).toContain('connection timed out'); + await app.close(); + }); + + it('carries the adopt metadata that a restart needs to rebuild the command', async () => { + const { app, ctx } = await createRouteTestHarness(registerSessionRoutes); + await app.inject({ method: 'POST', url: '/api/sessions/adopt', payload: { id: 'cand-1' } }); + const created = ctx.addSession.mock.calls[0][0] as unknown as { + adopt?: { socketPath?: string; targetSession?: string; viewSession?: string }; + isAdopted?: boolean; + }; + expect(created.isAdopted).toBe(true); + expect(created.adopt?.socketPath).toBe(CANDIDATE.socketPath); + expect(created.adopt?.targetSession).toBe(CANDIDATE.sessionName); + // Derived from the Codeman session id, so it is provably ours to kill. + expect(created.adopt?.viewSession).toMatch(/^codeman-view-/); + await app.close(); + }); +});