From 245ee68c7ced6392cd965dd6a266d5dba1763d0a Mon Sep 17 00:00:00 2001 From: d fei Date: Tue, 29 Sep 2026 18:01:47 -0700 Subject: [PATCH 1/3] feat(adopt): adopt tmux sessions a human started, in all three locations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tmux session someone opened by hand (`tmux new -s work`, then `claude`, or just a shell) is invisible to Codeman: it lives on the default socket, not the instance-scoped one Codeman owns. The home screen now lists those, and one click wraps one in a Codeman tab. Local, in-container and over-ssh all work. The shape is a WRAPPER session, not a direct attach: adoption creates an ordinary `codeman-<8hex>` session on our own socket whose pane runs the attach. That makes "detach, never kill the foreign session" structural rather than a matter of discipline — `killSession` can only ever reach our own wrapper, so no shape of caller bug reaches the foreign server. View reclamation differs by location: local relies on client death and ssh on SIGHUP, but a `docker exec` does NOT die with its client, so the in-container view has to be reclaimed explicitly on kill or every adoption leaks one. Measured facts, recorded in the code comments: - A grouped session (`new-session -t`) buys `status off` and an independent current window. It does NOT buy an independent size. A window is one object with one size and a group shares it: on tmux 3.3a a bare attach and a grouped attach both shrink a 200x49 target to 80x23. Only `window-size largest` holds it, and that is a shared window option which survives our detach, so setting it would permanently rewrite the owner's configuration. Left unset. - Session names come from other people, while the local launch chain ends in `bash -c` + `JSON.stringify`, which escapes neither `$` nor backticks — the outer shell performs command substitution before the inner single quotes are considered (verified: both substitutions inside the inner quotes ran). So session names and socket paths go through a character allowlist and are dropped at DISCOVERY, which means a non-conforming candidate never gets an id and the adopt endpoint fails closed when it re-resolves. - `pane_current_command` is `node` for both claude and codex, so the mode can only come from a bounded process-tree walk of `pane_pid`'s argv; anything unrecognized is treated as a shell. An adopted session has to say no to every assumption that Codeman owns the pane: respawn, Ralph, the orchestrator and hook waits all refuse. The hook check runs BEFORE the mode check, because adoption asks who launched the process and no mode can vouch for a workspace Codeman never touched. `paneExit` is forced to unknown, since the pane runs `tmux attach` rather than the agent and its exiting with 0 only means the connection ended. Reboot restore refuses too: all Codeman ever had was an attach command, and replaying it would build a connection to a foreign session a reboot has almost certainly taken with it, while presenting whatever it lands on as a session we restored. Adoption is admin-only under multi-user mode: adopting someone's shell is equivalent to arbitrary host execution. Discovery is read-only, never creates a session, and degrades to an empty list whenever a probe cannot reach its target. --- src/config/foreign-tmux.ts | 38 ++ src/foreign-tmux-discovery.ts | 325 ++++++++++++++ src/foreign-tmux.ts | 597 ++++++++++++++++++++++++++ src/mux-interface.ts | 12 + src/reboot-restore.ts | 10 + src/session.ts | 67 ++- src/tmux-manager.ts | 161 ++++++- src/types/foreign-tmux.ts | 102 +++++ src/types/index.ts | 1 + src/types/session.ts | 80 ++++ src/web/public/app.js | 36 +- src/web/public/foreign-sessions.js | 359 ++++++++++++++++ src/web/public/index.html | 15 +- src/web/public/mobile-overview.js | 10 + src/web/public/mobile.css | 12 + src/web/public/styles.css | 170 ++++++++ src/web/public/terminal-ui.js | 8 + src/web/routes/mux-routes.ts | 63 ++- src/web/routes/ralph-routes.ts | 11 + src/web/routes/respawn-routes.ts | 33 ++ src/web/routes/session-routes.ts | 153 +++++++ src/web/schemas.ts | 21 + src/web/server.ts | 21 +- src/web/session-wait-registry.ts | 17 + test/docker-adopted-container.test.ts | 11 +- test/foreign-tmux.test.ts | 314 ++++++++++++++ test/reboot-restore.test.ts | 10 + 27 files changed, 2622 insertions(+), 35 deletions(-) create mode 100644 src/config/foreign-tmux.ts create mode 100644 src/foreign-tmux-discovery.ts create mode 100644 src/foreign-tmux.ts create mode 100644 src/types/foreign-tmux.ts create mode 100644 src/web/public/foreign-sessions.js create mode 100644 test/foreign-tmux.test.ts diff --git a/src/config/foreign-tmux.ts b/src/config/foreign-tmux.ts new file mode 100644 index 000000000..e4c838c22 --- /dev/null +++ b/src/config/foreign-tmux.ts @@ -0,0 +1,38 @@ +/** + * @fileoverview Bounds for FOREIGN tmux discovery (sessions a human started + * outside Codeman). + * + * Two facts drive every number here. First, the number of tmux sockets and panes + * on a machine is NOT under Codeman's control — a discovery walk with no ceiling + * is an unbounded loop over data someone else produces, so sockets and panes are + * both hard-capped. Second, an ssh handshake is an order of magnitude slower than + * a local `exec`; reusing the shared 5s `EXEC_TIMEOUT_MS` would classify every + * remote host as unreachable, so the probe gets its own timeout. + * + * @module config/foreign-tmux + */ + +/** How often the browser re-polls `/api/mux/foreign` while the home screen is visible. */ +export const FOREIGN_POLL_INTERVAL_MS = 8000; + +/** + * Server-side cache TTL for a LOCAL scan. This, not the poll interval, is what + * bounds the real cost: N open tabs polling at 8s still trigger at most one scan + * per TTL. + */ +export const FOREIGN_CACHE_TTL_MS = 5000; + +/** Timeout for one probe invocation (local exec, `docker exec`, or one ssh). */ +export const FOREIGN_PROBE_TIMEOUT_MS = 12000; + +/** Max tmux sockets inspected per location, oldest-first by directory order. */ +export const FOREIGN_MAX_SOCKETS = 16; + +/** Max pane rows parsed from one probe. Panes past this are dropped, not errors. */ +export const FOREIGN_MAX_PANES = 400; + +/** Max process rows parsed from one probe's `ps` snapshot. */ +export const FOREIGN_MAX_PROCS = 4000; + +/** Max bytes of probe stdout kept. A runaway `ps` must not become a heap problem. */ +export const FOREIGN_PROBE_MAX_BYTES = 2 * 1024 * 1024; diff --git a/src/foreign-tmux-discovery.ts b/src/foreign-tmux-discovery.ts new file mode 100644 index 000000000..bd66b0a7e --- /dev/null +++ b/src/foreign-tmux-discovery.ts @@ -0,0 +1,325 @@ +/** + * @fileoverview Runs the foreign-tmux probe at the three locations Codeman + * already knows how to reach: this host, inside a container, and across ssh. + * + * All the judgement lives in `foreign-tmux.ts` (pure). This module is only + * transport + caching + the promise that it never throws. + * + * ## Cost policy + * + * The home screen shows LOCAL results automatically, so a local scan runs on a + * TTL cache: N browser tabs polling every few seconds still trigger at most one + * scan per `FOREIGN_CACHE_TTL_MS`. Docker and remote are ON DEMAND only — + * opening the home page must never fan out one ssh connection per saved host. + * + * ## ⚠️ The probe must not cross an extra shell + * + * The script contains `$TMUX_TMPDIR` and `$(id -u)`, which the INNER shell has to + * expand. Local and docker therefore spawn with an argv array (`execFile`), where + * no outer shell exists to eat them first. Remote is the exception: the ssh + * command line is a string like every other ssh call in this codebase, so the + * script is wrapped in SINGLE quotes there — which is exactly why + * `buildForeignProbeScript()` may not contain one. + * + * This was measured, not reasoned: running the probe through `execSync` (which + * spawns `sh -c`) expanded `$TMUX_TMPDIR` and `$U` in the OUTER shell, leaving the + * inner loop iterating over the literal `/tmux-/*` and reporting zero sessions on + * a machine that had three. + * + * @module foreign-tmux-discovery + */ + +import { execFile, exec } from 'node:child_process'; +import { promisify } from 'node:util'; +import { + buildForeignProbeScript, + classifyForeignPaneMode, + foreignSessionId, + isAdoptableSessionName, + isAdoptableSocketPath, + isCodemanOwnedPane, + parseForeignProbeOutput, +} from './foreign-tmux.js'; +import { buildDockerBaseArgs, shellescape } from './docker-hosts.js'; +import { buildSshConnectionArgs, remoteSshTarget } from './remote-hosts.js'; +import { readDockerCases, readDockerHosts } from './docker-hosts.js'; +import { readRemoteHosts } from './remote-hosts.js'; +import { resolveTmuxSocketName, getDataDir } from './config/instance.js'; +import { FOREIGN_CACHE_TTL_MS, FOREIGN_PROBE_MAX_BYTES, FOREIGN_PROBE_TIMEOUT_MS } from './config/foreign-tmux.js'; +import type { ForeignDiscoveryResult, ForeignTmuxSession } from './types/foreign-tmux.js'; +import type { DockerCase, DockerHost, RemoteHost, SessionDocker } from './types/session.js'; + +const execFileAsync = promisify(execFile); +const execAsync = promisify(exec); + +const IS_TEST_MODE = process.env.VITEST === 'true' || process.env.NODE_ENV === 'test'; + +const PROBE_OPTS = { + encoding: 'utf-8' as const, + timeout: FOREIGN_PROBE_TIMEOUT_MS, + maxBuffer: FOREIGN_PROBE_MAX_BYTES, +}; + +/** + * Turn one probe's stdout into candidates. Shared by all three transports, so a + * container's sessions and the host's are classified by identical rules. + */ +function candidatesFrom( + stdout: string, + location: ForeignTmuxSession['location'], + hostKey: string, + extra: Pick, + notes?: string[] +): ForeignTmuxSession[] { + const probe = parseForeignProbeOutput(stdout); + const ownSocket = resolveTmuxSocketName(); + const out: ForeignTmuxSession[] = []; + const seen = new Set(); + let unsafe = 0; + + for (const pane of probe.panes) { + if (isCodemanOwnedPane(pane, ownSocket)) continue; + // ⚠️ THE security gate, and it lives here rather than at the adopt endpoint + // on purpose: a name that cannot cross the launch chain safely never gets an + // id, so there is nothing for a caller to send. Adoption re-resolves through + // this same function, which makes the endpoint fail closed for free. + // See `isAdoptableSessionName` for the injection this prevents. + if (!isAdoptableSessionName(pane.sessionName) || !isAdoptableSocketPath(pane.socketPath)) { + unsafe += 1; + continue; + } + // One row per SESSION, not per pane: the first pane is the one we would land + // on, and a multi-pane session is still a single thing to adopt. + // \u0000 as an ESCAPE, never the literal byte: a raw NUL in the source makes git + // treat this whole file as binary (no diff, no review), and the allowlists above + // already guarantee neither half can contain one. + const key = `${pane.socketPath}\u0000${pane.sessionName}`; + if (seen.has(key)) continue; + seen.add(key); + + const classified = classifyForeignPaneMode(pane, probe); + out.push({ + id: foreignSessionId(location, hostKey, pane.socketPath, pane.sessionName), + location, + ...extra, + socketPath: pane.socketPath, + sessionName: pane.sessionName, + windows: pane.windows, + attached: pane.sessionAttached, + createdAt: pane.sessionCreated * 1000, + mode: classified.mode, + command: classified.command, + workingDir: pane.paneCurrentPath, + }); + } + if (unsafe && notes) { + notes.push( + `${unsafe} session(s) skipped: the tmux session name or socket path contains characters Codeman will not pass to a shell` + ); + } + return out; +} + +// =========================================================================== +// Local +// =========================================================================== + +let localCache: { at: number; result: ForeignDiscoveryResult } | null = null; +let localInFlight: Promise | null = null; + +/** + * Scan this host. TTL-cached and single-flight, because this is the one location + * the home screen polls on its own. + */ +export async function discoverLocalForeign(force = false): Promise { + if (IS_TEST_MODE) return { sessions: [], scannedAt: Date.now(), notes: [] }; + + const now = Date.now(); + if (!force && localCache && now - localCache.at < FOREIGN_CACHE_TTL_MS) return localCache.result; + if (localInFlight) return localInFlight; + + localInFlight = (async (): Promise => { + const notes: string[] = []; + let sessions: ForeignTmuxSession[] = []; + try { + // argv array: no outer shell, so the script's own `$TMUX_TMPDIR`/`$(id -u)` + // reach the inner sh intact (see @fileoverview). + const { stdout } = await execFileAsync('sh', ['-c', buildForeignProbeScript()], PROBE_OPTS); + sessions = candidatesFrom(stdout, 'local', 'local', {}, notes); + } catch { + // No tmux, no sockets, or a probe that timed out. "Nothing to adopt here" + // is the only honest answer and it is never an error to the caller. + notes.push('local: tmux probe produced no result'); + } + const result: ForeignDiscoveryResult = { sessions, scannedAt: Date.now(), notes }; + localCache = { at: Date.now(), result }; + return result; + })().finally(() => { + localInFlight = null; + }); + + return localInFlight; +} + +/** Drop the local cache so the next scan is real (used right after an adopt). */ +export function invalidateForeignCache(): void { + localCache = null; +} + +// =========================================================================== +// Docker +// =========================================================================== + +/** Connection facts for one adoptable container, resolved from the registries. */ +interface DockerTargetInfo { + hostId: string; + label: string; + containerName: string; + base: Pick; +} + +function dockerTargets(cases: DockerCase[], hosts: DockerHost[]): DockerTargetInfo[] { + const byId = new Map(hosts.map((h) => [h.id, h])); + const out: DockerTargetInfo[] = []; + const seen = new Set(); + for (const c of cases) { + const container = c.container || `codeman-case-${c.name}`; + if (seen.has(container)) continue; + seen.add(container); + const host = byId.get(c.hostId); + out.push({ + hostId: c.hostId, + label: host?.label || c.name, + containerName: container, + base: { + engine: host?.engine ?? 'docker', + context: host?.context, + daemonHost: host?.daemonHost, + }, + }); + } + return out; +} + +/** + * Scan the containers behind the caller's docker cases. On demand only. + * + * Read-only by construction: one `docker exec` running the probe. Never inspects + * lifecycle, never starts anything — a stopped or missing container simply + * contributes a note, mirroring `probeAdoptableContainer`'s "engine unreachable + * degrades to an empty list" rule. + */ +export async function discoverDockerForeign(cases: DockerCase[]): Promise { + if (IS_TEST_MODE) return { sessions: [], scannedAt: Date.now(), notes: [] }; + + const hosts = await readDockerHosts(getDataDir()).catch(() => [] as DockerHost[]); + const targets = dockerTargets(cases, hosts); + const notes: string[] = []; + const sessions: ForeignTmuxSession[] = []; + + await Promise.all( + targets.map(async (t) => { + const [engine, ...baseFlags] = buildDockerBaseArgs(t.base); + try { + const { stdout } = await execFileAsync( + engine, + [...baseFlags, 'exec', t.containerName, 'sh', '-lc', buildForeignProbeScript()], + PROBE_OPTS + ); + sessions.push( + ...candidatesFrom( + stdout, + 'docker', + t.containerName, + { hostId: t.hostId, hostLabel: t.label, containerName: t.containerName }, + notes + ) + ); + } catch { + notes.push(`docker ${t.containerName}: not running, no tmux, or engine unreachable`); + } + }) + ); + + return { sessions, scannedAt: Date.now(), notes }; +} + +// =========================================================================== +// Remote +// =========================================================================== + +/** + * Scan saved ssh hosts. On demand only — this is the expensive one. + * + * ⚠️ The ssh command is a STRING (as every ssh call in this codebase is), so the + * probe crosses one extra shell and must be single-quoted. `shellescape` does + * that, and `buildForeignProbeScript()` is single-quote-free precisely so the + * escaping stays a wrap rather than a rewrite. + */ +export async function discoverRemoteForeign(hosts: RemoteHost[]): Promise { + if (IS_TEST_MODE) return { sessions: [], scannedAt: Date.now(), notes: [] }; + + const notes: string[] = []; + const sessions: ForeignTmuxSession[] = []; + + await Promise.all( + hosts.map(async (host) => { + const cmd = [...buildSshConnectionArgs(host), remoteSshTarget(host), shellescape(buildForeignProbeScript())].join( + ' ' + ); + try { + const { stdout } = await execAsync(cmd, PROBE_OPTS); + sessions.push(...candidatesFrom(stdout, 'remote', host.id, { hostId: host.id, hostLabel: host.label })); + } catch { + notes.push(`remote ${host.label}: unreachable, or no tmux`); + } + }) + ); + + return { sessions, scannedAt: Date.now(), notes }; +} + +// =========================================================================== +// Facade +// =========================================================================== + +export interface ForeignDiscoveryRequest { + /** Always scanned (cheap, cached). */ + local?: boolean; + /** Docker cases the CALLER may see — ownership filtering happens in the route. */ + dockerCases?: DockerCase[]; + /** Remote hosts the CALLER may see. */ + remoteHosts?: RemoteHost[]; + /** Skip the local TTL cache. */ + force?: boolean; +} + +/** + * One scan across the requested locations. Merges notes, never throws. + * + * The caller supplies the docker cases and remote hosts rather than this module + * reading the registries itself: which of them a user may see is an ownership + * question, and ownership belongs to the route layer. + */ +export async function discoverForeignSessions(req: ForeignDiscoveryRequest): Promise { + const parts: ForeignDiscoveryResult[] = []; + if (req.local !== false) parts.push(await discoverLocalForeign(req.force)); + if (req.dockerCases?.length) parts.push(await discoverDockerForeign(req.dockerCases)); + if (req.remoteHosts?.length) parts.push(await discoverRemoteForeign(req.remoteHosts)); + + return { + sessions: parts.flatMap((p) => p.sessions), + scannedAt: parts.length ? Math.max(...parts.map((p) => p.scannedAt)) : Date.now(), + notes: parts.flatMap((p) => p.notes), + }; +} + +/** Re-read the caller-visible docker cases, for routes that only hold a username. */ +export async function readAllDockerCases(): Promise { + return readDockerCases(getDataDir()).catch(() => [] as DockerCase[]); +} + +/** Re-read the saved remote hosts. */ +export async function readAllRemoteHosts(): Promise { + return readRemoteHosts(getDataDir()).catch(() => [] as RemoteHost[]); +} diff --git a/src/foreign-tmux.ts b/src/foreign-tmux.ts new file mode 100644 index 000000000..cb54bc4cc --- /dev/null +++ b/src/foreign-tmux.ts @@ -0,0 +1,597 @@ +/** + * @fileoverview Pure core for FOREIGN tmux sessions — the ones a human started + * by hand, which Codeman neither created nor owns. + * + * Everything here is a string in / structure out, so all three locations (local, + * inside a container, across ssh) go through ONE probe script, ONE parser and ONE + * classifier. Writing a second copy per location is exactly how the two would + * drift into disagreeing about what a session is. + * + * ## Why the probe script is dumb + * + * It runs two commands and prints them: `tmux list-panes` per socket, and one + * `ps` snapshot. No filtering, no logic. All judgement happens in Node, where it + * is pure and unit-testable, instead of in a shell string that is embedded three + * different ways and can only be debugged against a real host. + * + * ⚠️ The script MUST NOT contain a single quote. It is wrapped in single quotes + * to cross `ssh ' + diff --git a/src/web/public/mobile-overview.js b/src/web/public/mobile-overview.js index 764697a68..24f116ff0 100644 --- a/src/web/public/mobile-overview.js +++ b/src/web/public/mobile-overview.js @@ -507,6 +507,16 @@ Object.assign(CodemanApp.prototype, { ) ); + // Sessions a human opened outside Codeman. Its own container, rebuilt by the + // ONE renderer in foreign-sessions.js — the phone must not grow a second row + // builder that could describe the same session differently from the desktop. + const foreign = document.createElement('div'); + foreign.className = 'foreign-sessions mobile-foreign-sessions'; + foreign.id = 'mobileForeignSessions'; + foreign.hidden = true; + el.appendChild(foreign); + this.renderForeignSessions?.(foreign); + el.appendChild( this._buildMobileOverviewSection( 'Past sessions', diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index 5d47f3170..445db7f2e 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -4027,6 +4027,18 @@ html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-close { } } +/* Foreign sessions block inside the phone overview (foreign-sessions.js). + The desktop block sits inside the welcome column; here it is a full-width + section between CURRENT and PAST, so it only needs the surrounding spacing — + every row style is shared with styles.css on purpose. */ +.mobile-foreign-sessions { + margin: 0.75rem 0.75rem 0; +} + +.mobile-foreign-sessions .foreign-list { + max-height: none; +} + /* Folding devices, tabletop pose: cap the response viewer to the bottom segment. Twin of the rule at the end of styles.css, needed here because the phone block (under 600px) above sets `max-height: 92dvh` on the same selector at the same diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 050671a48..e7c4135ab 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -19088,3 +19088,173 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle font-size: 13px; color: var(--text-muted); } + +/* ═══════════════════════════════════════════════════════════════ + Foreign sessions — tmux sessions a human started outside Codeman + (foreign-sessions.js). Rendered on the welcome screen and, with the + same row builder, inside the phone overview. + + Colour vocabulary is deliberately the session-tab one: a mode dot on + the left, name over a dim meta line, action pinned right. A block that + invented its own language here would read as a different product. + ═══════════════════════════════════════════════════════════════ */ + +.welcome-foreign { + width: 100%; + margin-top: 0.75rem; +} + +.foreign-sessions { + display: flex; + flex-direction: column; + gap: 0.4rem; + text-align: left; +} + +/* `.foreign-sessions` is a flex container, so `[hidden]` needs re-asserting or + the module's only visibility lever does nothing (same trap as .home-sessions). */ +.foreign-sessions[hidden] { + display: none; +} + +.foreign-header { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.foreign-title { + font-size: 0.85rem; + color: var(--text-dim); + font-weight: 500; + text-align: left; +} + +.foreign-count { + font-size: 0.68rem; + color: var(--text-dim); + background: rgba(255, 255, 255, 0.05); + border-radius: 999px; + padding: 0.1rem 0.45rem; + white-space: nowrap; +} + +.foreign-scan-toggle { + margin-left: auto; + font-size: 0.68rem; + color: var(--text-dim); + background: transparent; + border: 1px solid var(--border); + border-radius: 999px; + padding: 0.12rem 0.5rem; + cursor: pointer; +} + +.foreign-scan-toggle[aria-pressed='true'] { + color: var(--session-blue, #4a9eff); + border-color: var(--session-blue, #4a9eff); +} + +.foreign-list { + display: flex; + flex-direction: column; + gap: 0.3rem; + max-height: min(40vh, 320px); + overflow-y: auto; +} + +.foreign-row { + display: flex; + align-items: center; + gap: 0.55rem; + padding: 0.4rem 0.55rem; + border: 1px solid var(--border); + border-radius: 6px; + background: rgba(255, 255, 255, 0.02); + min-width: 0; +} + +.foreign-row--open { + opacity: 0.62; +} + +.foreign-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex: 0 0 auto; + background: var(--text-muted, #888); +} + +.foreign-dot--claude { + background: #d97757; +} +.foreign-dot--codex { + background: #9b8cff; +} +.foreign-dot--shell { + background: #4caf7d; +} + +.foreign-row-body { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1 1 auto; +} + +.foreign-row-name { + font-size: 0.82rem; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.foreign-row-sub { + font-size: 0.68rem; + color: var(--text-dim); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.foreign-open-btn { + flex: 0 0 auto; + font-size: 0.7rem; + padding: 0.22rem 0.6rem; + border-radius: 5px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.04); + color: var(--text); + cursor: pointer; +} + +.foreign-open-btn:hover:not(:disabled) { + border-color: var(--session-blue, #4a9eff); + color: var(--session-blue, #4a9eff); +} + +.foreign-open-btn:disabled { + opacity: 0.55; + cursor: default; +} + +.foreign-empty { + font-size: 0.72rem; + color: var(--text-dim); + padding: 0.3rem 0.1rem; +} + +.foreign-notes { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.foreign-note { + font-size: 0.66rem; + color: var(--text-dim); + opacity: 0.85; + line-height: 1.35; +} diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 657e16246..4bfe4d12c 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -2215,6 +2215,9 @@ Object.assign(CodemanApp.prototype, { if (overlay) overlay.classList.remove('visible'); this.hideHomeSessions?.(); this.showMobileOverview(); + // The phone overview hosts the same list in its own container. + this.wireForeignSessions?.(); + this.startForeignPolling?.(); this._updateCjkInputState?.(); return; } @@ -2229,6 +2232,10 @@ Object.assign(CodemanApp.prototype, { // Open tabs down the left gutter. Self-gating: a window too narrow to hold // the column without overlapping the content leaves it hidden. this.showHomeSessions?.(); + // Sessions a human opened outside Codeman. Polls only while this screen is + // up (stopped in hideWelcome) — see foreign-sessions.js. + this.wireForeignSessions?.(); + this.startForeignPolling?.(); } // Home screen has no input target — hide the CJK textarea (activeSessionId // is null by the time we get here). Guarded: defined on the app object. @@ -2238,6 +2245,7 @@ Object.assign(CodemanApp.prototype, { hideWelcome() { this.hideMobileOverview?.(); this.hideHomeSessions?.(); + this.stopForeignPolling?.(); const overlay = document.getElementById('welcomeOverlay'); if (overlay) { overlay.classList.remove('visible'); diff --git a/src/web/routes/mux-routes.ts b/src/web/routes/mux-routes.ts index 12b22229e..b4e42ce92 100644 --- a/src/web/routes/mux-routes.ts +++ b/src/web/routes/mux-routes.ts @@ -1,6 +1,13 @@ /** * @fileoverview Mux (tmux) session management routes. - * Provides mux session listing, killing, reconciliation, and stats control. + * Provides mux session listing, killing, reconciliation, stats control, and + * discovery of FOREIGN tmux sessions (ones a human started outside Codeman). + * + * Discovery lives here rather than beside the adopt endpoint on purpose: like + * every other route in this file it exposes cross-user process state — other + * people's session names, commands and working directories — so it inherits the + * admin gate this file already applies. Adoption is a session CREATE and stays in + * `session-routes.ts`, where the owner, capacity and case-space gates live. */ import { FastifyInstance } from 'fastify'; @@ -8,6 +15,8 @@ import type { InfraPort } from '../ports/index.js'; import { STATS_COLLECTION_INTERVAL_MS } from '../../config/server-timing.js'; import { requireAdmin } from '../route-helpers.js'; import { isMultiUserMode } from '../../config/multiuser.js'; +import { discoverForeignSessions, readAllDockerCases, readAllRemoteHosts } from '../../foreign-tmux-discovery.js'; +import { FOREIGN_POLL_INTERVAL_MS } from '../../config/foreign-tmux.js'; export function registerMuxRoutes(app: FastifyInstance, ctx: InfraPort): void { app.get('/api/mux-sessions', async (req, reply) => { @@ -36,6 +45,58 @@ export function registerMuxRoutes(app: FastifyInstance, ctx: InfraPort): void { return result; }); + /** + * Foreign tmux sessions available for adoption. + * + * LOCAL results are always included and are TTL-cached, because the home screen + * polls this endpoint while it is open. DOCKER and REMOTE are opt-in per + * request (`?docker=1`, `?remote=1`): each costs one `docker exec` or one ssh + * per target, and having the home page fan those out on every load is the one + * cost this design refuses to pay. + * + * `adoptedBy` is filled from the live mux sessions, so a target Codeman already + * wraps renders as "open" rather than offering a second wrapper. + */ + app.get('/api/mux/foreign', async (req, reply) => { + if (isMultiUserMode() && !requireAdmin(req, reply)) return; + const q = (req.query ?? {}) as Record; + const wantDocker = q.docker === '1' || q.docker === 'true'; + const wantRemote = q.remote === '1' || q.remote === 'true'; + + // Read the registries either way: `canScanWide` tells the browser whether the + // expensive scan has anywhere to go. Without it the UI hides an empty block — + // and with it the toggle that is the ONLY way to populate that block, which on + // a host with containers but no local tmux sessions made the feature invisible. + const dockerCases = await readAllDockerCases(); + const remoteHosts = await readAllRemoteHosts(); + + const result = await discoverForeignSessions({ + local: true, + force: q.force === '1', + dockerCases: wantDocker ? dockerCases : undefined, + remoteHosts: wantRemote ? remoteHosts : undefined, + }); + + // Match on the (socket, session) pair rather than on our opaque candidate id: + // the id encodes a host key that a restored wrapper does not carry, while the + // pair is exactly what the wrapper stores and what it re-attaches to. + const wrapped = new Map(); + for (const m of ctx.mux.getSessions()) { + if (m.adopt) wrapped.set(`${m.adopt.socketPath}\u0000${m.adopt.targetSession}`, m.sessionId); + } + + return { + sessions: result.sessions.map((f) => ({ + ...f, + adoptedBy: wrapped.get(`${f.socketPath}\u0000${f.sessionName}`), + })), + scannedAt: result.scannedAt, + notes: result.notes, + pollIntervalMs: FOREIGN_POLL_INTERVAL_MS, + canScanWide: dockerCases.length > 0 || remoteHosts.length > 0, + }; + }); + app.post('/api/mux-sessions/stats/start', async (req, reply) => { // Multi-user: process-wide stats collection toggle → admin-only. if (isMultiUserMode() && !requireAdmin(req, reply)) return; diff --git a/src/web/routes/ralph-routes.ts b/src/web/routes/ralph-routes.ts index 24b08a485..d7c9e27e1 100644 --- a/src/web/routes/ralph-routes.ts +++ b/src/web/routes/ralph-routes.ts @@ -53,6 +53,17 @@ export function registerRalphRoutes( }; const session = findSessionOrFail(ctx, id, req); + // ⚠️ Adoption gate, kept SEPARATE from the external-CLI gate above: an adopted + // session can be `mode: 'claude'` and still be a process we never launched. + // Everything below drives the pane on the assumption Codeman owns what runs + // in it — sending `/clear`, killing and relaunching the agent — which against + // someone else's live session is destructive, not merely unsupported. + if (session.isAdopted) { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + 'The Ralph tracker is not available for adopted sessions: Codeman did not start this agent and must not drive its lifecycle' + ); + } // Ralph tracker is not supported for external-CLI sessions (opencode/codex) if (isExternalCliMode(session.mode)) { return createErrorResponse( diff --git a/src/web/routes/respawn-routes.ts b/src/web/routes/respawn-routes.ts index 56543997b..049028132 100644 --- a/src/web/routes/respawn-routes.ts +++ b/src/web/routes/respawn-routes.ts @@ -98,6 +98,17 @@ export function registerRespawnRoutes( } const session = findSessionOrFail(ctx, id, req); + // ⚠️ Adoption gate, kept SEPARATE from the external-CLI gate above: an adopted + // session can be `mode: 'claude'` and still be a process we never launched. + // Everything below drives the pane on the assumption Codeman owns what runs + // in it — sending `/clear`, killing and relaunching the agent — which against + // someone else's live session is destructive, not merely unsupported. + if (session.isAdopted) { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + 'Respawn is not available for adopted sessions: Codeman did not start this agent and must not drive its lifecycle' + ); + } // Respawn is not supported for external-CLI sessions (opencode/codex) if (isExternalCliMode(session.mode)) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Respawn is not supported for ${session.mode} sessions`); @@ -241,6 +252,17 @@ export function registerRespawnRoutes( return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy'); } + // ⚠️ Adoption gate, kept SEPARATE from the external-CLI gate above: an adopted + // session can be `mode: 'claude'` and still be a process we never launched. + // Everything below drives the pane on the assumption Codeman owns what runs + // in it — sending `/clear`, killing and relaunching the agent — which against + // someone else's live session is destructive, not merely unsupported. + if (session.isAdopted) { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + 'Respawn is not available for adopted sessions: Codeman did not start this agent and must not drive its lifecycle' + ); + } // Respawn is not supported for external-CLI sessions (opencode/codex) if (isExternalCliMode(session.mode)) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Respawn is not supported for ${session.mode} sessions`); @@ -310,6 +332,17 @@ export function registerRespawnRoutes( const body = reResult.data as { config?: Partial; durationMinutes?: number }; const session = findSessionOrFail(ctx, id, req); + // ⚠️ Adoption gate, kept SEPARATE from the external-CLI gate above: an adopted + // session can be `mode: 'claude'` and still be a process we never launched. + // Everything below drives the pane on the assumption Codeman owns what runs + // in it — sending `/clear`, killing and relaunching the agent — which against + // someone else's live session is destructive, not merely unsupported. + if (session.isAdopted) { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + 'Respawn is not available for adopted sessions: Codeman did not start this agent and must not drive its lifecycle' + ); + } // Respawn is not supported for external-CLI sessions (opencode/codex) if (isExternalCliMode(session.mode)) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Respawn is not supported for ${session.mode} sessions`); diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 6a7b78ad6..0d72c87d8 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -30,6 +30,16 @@ import { type OmpConfig, type RemoteHost, } from '../../types.js'; +import { AdoptForeignSessionSchema } from '../schemas.js'; +import { + discoverForeignSessions, + invalidateForeignCache, + readAllDockerCases, + readAllRemoteHosts, +} from '../../foreign-tmux-discovery.js'; +import { foreignViewSessionName } from '../../foreign-tmux.js'; +import { requireAdmin } from '../route-helpers.js'; +import type { SessionAdopt } from '../../types/session.js'; import { Session, isAltScreenStripMode, isExternalCliMode, isMuxAltScreenOnlyStripMode } from '../../session.js'; import type { PaneCaptureOptions } from '../../mux-interface.js'; import { SseEvent } from '../sse-events.js'; @@ -1156,6 +1166,149 @@ export function registerSessionRoutes( return { session: lightState }; }); + // ========== Adopt a foreign tmux session ========== + + /** + * Wrap a tmux session a HUMAN started (local, in a container, or over ssh) in a + * Codeman session, so it appears as a tab and can be driven from the browser. + * + * Four things make this safe, and each is load-bearing: + * + * 1. **The body carries only an opaque id.** The socket path, session name and + * host are re-resolved by re-running discovery here. A browser therefore + * never supplies a fragment of the command we are about to run, which is the + * same rule that keeps docker-adopt and remote-attach injection-free. + * 2. **The candidate must still exist.** Discovery is re-run rather than cached, + * so a session that died between the listing and the click fails with a 404 + * instead of producing a wrapper attached to nothing. + * 3. **One wrapper per target.** Two wrappers on one foreign session would each + * create their own grouped view and each think they own the tab; the guard + * is here rather than in the button's in-flight lock, which only stops a + * double-click on one device. + * 4. **Admin-only under multi-user.** Discovery already is (it exposes other + * users' processes), and adopting someone's `shell` is arbitrary execution + * as the server account — which is exactly what the `can-bypass-permissions` + * grant gates elsewhere. The admin gate subsumes it, so there is deliberately + * no second grant check here. + */ + app.post('/api/sessions/adopt', async (req, reply) => { + if (isMultiUserMode() && !requireAdmin(req, reply)) return; + + const owner = ownerFor(req); + const capMsg = sessionCapacityMessage(ctx.sessions, owner); + if (capMsg) return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg); + + const body = parseBody(AdoptForeignSessionSchema, req.body, 'Invalid request body'); + + // Re-resolve rather than trust: point 1 and 2 above. + const found = await discoverForeignSessions({ + local: true, + force: true, + dockerCases: body.docker ? await readAllDockerCases() : undefined, + remoteHosts: body.remote ? await readAllRemoteHosts() : undefined, + }); + const target = found.sessions.find((f) => f.id === body.id); + if (!target) { + // ⚠️ "Not in the re-resolve" has two very different causes and they must not + // be reported as one. The session really being gone is the ordinary case; + // the OTHER case is a location we could not reach this time, which on a + // flaky link makes a perfectly live remote session read as deleted. Measured + // against a real VM whose ssh path dropped ~10% of connections: clicking + // Open failed with "no longer there" while the session was sitting right + // there. Discovery already knows which it was — it wrote a note — so say so. + const reach = found.notes.filter((n) => !/skipped/.test(n)); + return createErrorResponse( + ApiErrorCode.NOT_FOUND, + reach.length + ? `Could not reach it just now (${reach.join('; ')}). It may still be running — try again.` + : 'That tmux session is no longer there. Refresh the list and try again.' + ); + } + + // Point 3 — one wrapper per (socket, session). + const existing = ctx.mux + .getSessions() + .find((m) => m.adopt?.socketPath === target.socketPath && m.adopt?.targetSession === target.sessionName); + if (existing) { + const live = ctx.sessions.get(existing.sessionId); + if (live) return { session: ctx.getSessionStateWithRespawn(live), alreadyAdopted: true }; + } + + // Connection facts are copied onto the session rather than referenced by id: + // a wrapper restored after a server restart must be able to rebuild its + // command even if the host registry was edited in the meantime. + const adopt: SessionAdopt = { + location: target.location, + socketPath: target.socketPath, + targetSession: target.sessionName, + viewSession: '', + paneCurrentPath: target.workingDir, + }; + + if (target.location === 'docker') { + const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR); + const host = hosts.find((h) => h.id === target.hostId); + if (!target.containerName) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Container name missing for a docker candidate'); + } + adopt.docker = { + hostId: target.hostId ?? '', + label: target.hostLabel ?? target.containerName, + engine: host?.engine ?? 'docker', + containerName: target.containerName, + daemonHost: host?.daemonHost, + context: host?.context, + }; + } else if (target.location === 'remote') { + const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((h) => h.id === target.hostId); + if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found'); + adopt.remote = { + hostId: host.id, + label: host.label, + host: host.host, + username: host.username, + port: host.port, + identityFile: host.identityFile, + socksProxy: host.socksProxy, + jumpHost: host.jumpHost, + extraSshOptions: host.extraSshOptions, + }; + } + + 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. + const session = new Session({ + workingDir: target.workingDir || process.cwd(), + mode: target.mode, + name: body.name || target.sessionName, + mux: ctx.mux, + useMux: true, + tmuxHistoryLimit: adoptHistoryConfig.tmuxHistoryLimit, + adopt, + owner, + parentSessionId: resolveParentSessionId(ctx, req, body.parentSessionId, owner), + }); + // The view session name is derived from the Codeman session id, so it can only + // be filled once the Session exists. + adopt.viewSession = foreignViewSessionName(session.id); + + await ctx.addSession(session); + ctx.store.incrementSessionsCreated(); + ctx.persistSessionState(session); + await ctx.setupSessionListeners(session); + getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name }); + invalidateForeignCache(); + + const lightState = ctx.getSessionStateWithRespawn(session); + ctx.broadcast(SseEvent.SessionCreated, lightState); + return { session: lightState, adopted: true }; + }); + // ========== Rename Session ========== app.put('/api/sessions/:id/name', async (req) => { diff --git a/src/web/schemas.ts b/src/web/schemas.ts index c12a83a9a..0726bdab3 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -2067,3 +2067,24 @@ export const CustomModelSelectionSchema = z.union([ }), z.object({ clear: z.literal(true) }), ]); +/** + * Adopt a FOREIGN tmux session (one a human started outside Codeman). + * + * ⚠️ The body carries ONLY the opaque candidate id from `GET /api/mux/foreign`. + * The socket path, session name and host are re-resolved server-side by re-running + * discovery, so a browser can never hand the launch chain a path or a session name + * to interpolate. That is the same discipline that keeps the docker-adopt and + * remote-attach paths free of caller-supplied command fragments. + */ +export const AdoptForeignSessionSchema = z + .object({ + id: z.string().min(1).max(64), + /** Optional tab name; defaults to the foreign session's own name. */ + name: z.string().max(128).optional(), + /** Include docker locations in the re-resolve (must match the listing call). */ + docker: z.boolean().optional(), + /** Include remote locations in the re-resolve. */ + remote: z.boolean().optional(), + parentSessionId: z.string().max(64).optional(), + }) + .strict(); diff --git a/src/web/server.ts b/src/web/server.ts index d70577e67..1ab399faf 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1821,13 +1821,20 @@ export class WebServer extends EventEmitter { this.runSummaryTrackers.set(session.id, summaryTracker); summaryTracker.recordSessionStarted(session.mode, session.workingDir); - // Set working directory for Ralph tracker to auto-load @fix_plan.md (not supported for external CLIs) - if (!isExternalCliMode(session.mode)) { + // Set working directory for Ralph tracker to auto-load @fix_plan.md (not supported for external CLIs). + // ⚠️ Also skipped for an ADOPTED session, and for two reasons: Ralph is refused + // for one anyway, and its `workingDir` is the FOREIGN pane's cwd — a path that + // need not exist on this host at all. Watching it logged a caught ENOENT on + // every in-container adoption (`watch '/workspace/pythonserver'`), which is + // noise pointing at a real category error rather than a real failure. + if (!isExternalCliMode(session.mode) && !session.isAdopted) { session.ralphTracker.setWorkingDir(session.workingDir); } // Start watching for new images in this session's working directory (if enabled globally and per-session) - if ((await this.isImageWatcherEnabled()) && session.imageWatcherEnabled) { + if ((await this.isImageWatcherEnabled()) && session.imageWatcherEnabled && !session.isAdopted) { + // Same reason as the Ralph watcher above: an adopted session's workingDir is + // an observation about ANOTHER host's (or container's) filesystem. imageWatcher.watchSession(session.id, session.workingDir); } @@ -3524,6 +3531,14 @@ export class WebServer extends EventEmitter { // MuxSession.docker; state.json carries SessionState.docker), so recovery // rebuilds the `docker exec` launch instead of a broken local command. docker: muxSession.docker ?? savedState?.docker, + // Adoption metadata round-trips for the same reason remote/docker do, + // and one more: it is the ONLY thing that marks this session as + // wrapping a process Codeman never launched. Dropping it on recovery + // silently re-enabled respawn, Ralph and hook-backed waits against + // someone else's live tmux session after every server restart — + // measured, not hypothetical. The mux record is preferred because it + // is what `killSession`'s detach-not-kill guard already reads. + adopt: muxSession.adopt ?? savedState?.adopt, owner: recoveredOwner, // Tab lineage survives a restart. It is only decoration, so a parent // that did NOT come back is harmless: the frontend draws an edge only diff --git a/src/web/session-wait-registry.ts b/src/web/session-wait-registry.ts index e734927d7..2507f2261 100644 --- a/src/web/session-wait-registry.ts +++ b/src/web/session-wait-registry.ts @@ -190,6 +190,18 @@ export interface HookCapabilityOptions { * timeout on every turn. */ deepSeekBridgeUnreachable?: boolean; + /** + * True when the session is a WRAPPER around a tmux session a human started + * outside Codeman. + * + * This one overrides the mode entirely, and it has to: an adopted session can + * be `mode: 'claude'` and still have no hooks, because hooks are installed into + * a WORKSPACE at session-create time (`applyWorkspaceHooks`) and we never + * created this one. Answering from the mode there would promise `stop` and + * `blocked` for a process that can never post either — the exact + * infinite-wait-dressed-as-a-timeout this predicate exists to prevent. + */ + adopted?: boolean; } /** @@ -224,6 +236,9 @@ export interface HookCapabilityOptions { * function only about hook SIGNALS. */ export function hooksAvailableForMode(mode: SessionMode, options: HookCapabilityOptions = {}): boolean { + // Checked BEFORE the mode: adoption is about who launched the process, and no + // mode can vouch for a workspace Codeman never touched. See `adopted` above. + if (options.adopted) return false; // A TRI-state capability, not a boolean, because the three answers are genuinely // different questions — see CliCapabilities.hooks. switch (getCli(mode)?.capabilities.hooks) { @@ -259,10 +274,12 @@ export function sessionHookOptions(session: { deepSeekStatusReporting?: boolean; docker?: unknown; remote?: unknown; + adopt?: unknown; }): HookCapabilityOptions { return { deepSeekStatusReporting: session.deepSeekStatusReporting, deepSeekBridgeUnreachable: Boolean(session.docker || session.remote), + adopted: Boolean(session.adopt), }; } diff --git a/test/docker-adopted-container.test.ts b/test/docker-adopted-container.test.ts index 1f3c4afb1..c85768194 100644 --- a/test/docker-adopted-container.test.ts +++ b/test/docker-adopted-container.test.ts @@ -218,8 +218,15 @@ describe('adopted container: the host is not required to have the CLI', () => { expect(src.match(/if \(mode === '[a-z]+' && !cliDir\)/g)).toBeNull(); }); - it('derives the flag from the docker metadata the session already carries', () => { - expect(src).toContain('const cliRunsInContainer = !!docker;'); + it('derives the flag from the location metadata the session already carries', () => { + // Adoption joined the condition for the same reason docker is in it: an + // adopted session's CLI was started by a human in a process Codeman never + // spawned, so the host binary is irrelevant there too — and demanding it + // would reject adopting a claude that lives in a container, on an ssh host, + // or simply outside the server process's PATH (the systemd/launchd case). + // What the assertion still pins is that the flag comes from the session's + // OWN metadata rather than from anything ambient. + expect(src).toContain('const cliRunsInContainer = !!docker || !!adopt;'); }); }); diff --git a/test/foreign-tmux.test.ts b/test/foreign-tmux.test.ts new file mode 100644 index 000000000..b11216f85 --- /dev/null +++ b/test/foreign-tmux.test.ts @@ -0,0 +1,314 @@ +/** + * Foreign tmux adoption — the pure core. + * + * These pin the properties that were established by MEASUREMENT against a real + * tmux (3.3a) while the feature was built, and that a plausible-looking refactor + * would quietly undo. Each one has a comment naming what actually went wrong. + */ + +import { describe, it, expect } from 'vitest'; +import { + buildForeignProbeScript, + parseForeignProbeOutput, + classifyForeignPaneMode, + isCodemanOwnedPane, + foreignSessionId, + foreignViewSessionName, + isAdoptableSessionName, + isAdoptableSocketPath, + buildForeignAttachCommand, + buildForeignDockerAttachCommand, + buildForeignRemoteAttachCommand, + buildForeignTmuxInvocation, +} from '../src/foreign-tmux.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), +// while the socket line is space-separated because `sh`'s builtin `echo` expands +// a backslash-t to a real TAB — two different meanings for one escape, two lines +// apart, which is why the socket marker carries no separator at all. +const PROBE = [ + 'CMFS /tmp/tmux-0/default', + 'CMFP\\t/tmp/tmux-0/default\\t631\\t0\\t1\\t1788092494\\t1\\t%0\\tclaude\\twork\\t/srv/app', + 'CMFP\\t/tmp/tmux-0/default\\t900\\t0\\t1\\t1788092500\\t0\\t%1\\tbash\\tscratch\\t/home/me', + 'CMFP\\t/tmp/tmux-0/default\\t950\\t0\\t2\\t1788092600\\t0\\t%2\\tnode\\tcodex-work\\t/srv/app', + 'CMFQ', + ' 631 630 -bash', + ' 4056 631 claude --dangerously-skip-permissions', + ' 4104 4056 /usr/local/bin/ortg --repo /ortg mcp', + ' 900 630 -bash', + ' 950 630 node /opt/homebrew/bin/codex', +].join('\n'); + +describe('buildForeignProbeScript', () => { + it('contains no single quote — it is wrapped in one to cross ssh and docker exec', () => { + // The script is embedded as `ssh host '