diff --git a/src/config/foreign-tmux.ts b/src/config/foreign-tmux.ts new file mode 100644 index 00000000..e4c838c2 --- /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/docker-hosts.ts b/src/docker-hosts.ts index e2a92486..e2801b55 100644 --- a/src/docker-hosts.ts +++ b/src/docker-hosts.ts @@ -55,6 +55,31 @@ export const DEFAULT_AGENT_IMAGE = 'codeman/agent:base'; /** HOME inside the base image (the `agent` user). Cred mounts + hook-secret land under it. */ export const CONTAINER_HOME = '/home/agent'; +/** + * Modes the adoption preflight probes for inside an existing container. `shell` + * is omitted deliberately: it needs no CLI binary and is always available, so it + * is reported as available without a `command -v` lookup. + */ +export const DOCKER_ADOPT_PROBE_MODES = [ + 'claude', + 'codex', + 'opencode', + 'gemini', + 'antigravity', + 'pi', + 'grok', + 'deepseek', + 'shell', +] as const satisfies readonly SessionMode[]; + +/** + * The BINARY a mode looks for inside a container. Not always the mode name: + * `antigravity` ships as `agy` and `deepseek` as `dsh`, so probing by mode name + * would report those two as missing on a container that has them. Single source + * with `defaultDockerCommandForMode`, which launches the same binaries. + */ +const MODE_BINARIES: Partial> = { antigravity: 'agy', deepseek: 'dsh' }; + /** Per-case container name prefix. The `case` letters deliberately do NOT matter to * tmux; this is a DOCKER name (`^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`), and case names are * already validated `^[a-zA-Z0-9_-]+$`, so `codeman-case-` is always valid. */ @@ -135,11 +160,16 @@ export function dockerContainerName(caseName: string): string { } /** Default pane command per CLI mode (mirror of defaultRemoteCommandForMode). */ -export function defaultDockerCommandForMode(mode: SessionMode): string { +export function defaultDockerCommandForMode(mode: SessionMode, runsAsRoot = false): string { const commands: Record = { shell: 'exec bash -l', - // Mirror the LOCAL claude default so the in-container agent runs non-interactively. - claude: 'exec claude --dangerously-skip-permissions', + // Mirror the LOCAL claude default so the in-container agent runs + // non-interactively — EXCEPT as root, where Claude Code refuses the flag + // outright ("cannot be used with root/sudo privileges"). Our base image runs + // a non-root user so an owned container never hits this; an adopted + // container's user belongs to its owner and is frequently root, and keeping + // the flag there kills the pane with a message only visible inside it. + claude: runsAsRoot ? 'exec claude' : 'exec claude --dangerously-skip-permissions', opencode: 'exec opencode', codex: 'exec codex', gemini: 'exec gemini', @@ -251,7 +281,22 @@ export function toSessionDocker(host: DockerHost, dockerCase: DockerCase): Sessi extraCreateArgs: host.extraCreateArgs, extraExecArgs: host.extraExecArgs, }; - return { ...base, configHash: dockerConfigHash(base) }; + // `owned` is deliberately applied AFTER the hash: dockerConfigHash() picks an + // explicit field list, so ownership can never shift an existing case's hash and + // mass-trip the drift gate. + const session: SessionDocker = { ...base, configHash: dockerConfigHash(base) }; + if (dockerCase.owned === false) session.owned = false; + return session; +} + +/** + * An ADOPTED container is one the user built and runs themselves. Codeman may + * only exec into it; it must never create, start, stop, restart or remove it. + * Every lifecycle branch routes through this one predicate so a new call site + * cannot silently opt out. + */ +export function isAdoptedContainer(docker: Pick): boolean { + return docker.owned === false; } // ========== Shell escaping ========== @@ -713,9 +758,15 @@ export interface DockerDriftStatus { * daemon down) means there is nothing to drift. No-op under VITEST. */ export async function checkDockerConfigDrift( - docker: Pick + docker: Pick ): Promise { if (IS_TEST_MODE) return { exists: false, running: false, drifted: false }; + // An ADOPTED container carries no `codeman.confighash` label — it was never + // created from our config — so every comparison would report drift and the + // launch gate would demand a recreate we are not allowed to perform. Ownership + // of its configuration belongs to the user; report "no drift" and never offer + // to rebuild it. + if (isAdoptedContainer(docker)) return { exists: true, running: false, drifted: false }; const argv = dockerEngineArgv(docker); try { const { stdout } = await execFileAsync( @@ -743,8 +794,15 @@ export async function checkDockerConfigDrift( * case's lastClaudeSessionId. No-op under VITEST. */ export async function removeDockerContainer( - docker: Pick + docker: Pick ): Promise { + // Fail CLOSED at the lowest layer: an adopted container is the user's, and no + // caller — recreate-on-drift, case delete, a future teardown — may remove it. + if (isAdoptedContainer(docker)) { + throw new Error( + `Refusing to remove adopted container "${docker.containerName}": Codeman does not own its lifecycle.` + ); + } if (IS_TEST_MODE) return; const argv = dockerEngineArgv(docker); await execFileAsync(argv[0], [...argv.slice(1), 'rm', '-f', docker.containerName], { timeout: 30_000 }); @@ -990,6 +1048,251 @@ export async function checkDockerTmuxAvailable( } } +/** Preflight facts about an ALREADY-RUNNING container the user wants to adopt. */ +export interface AdoptedContainerProbe { + ok: boolean; + exists: boolean; + running: boolean; + /** The container's own image ref (informational — we never enforce ours on it). */ + image?: string; + /** `command -v tmux` inside the container; required for durable sessions. */ + tmuxPath?: string; + /** Modes whose CLI resolved inside the container (`command -v `). */ + availableModes?: SessionMode[]; + /** Whether the requested working directory exists INSIDE the container. */ + workdirExists?: boolean; + /** Whether the container's exec user is root (uid 0). */ + runsAsRoot?: boolean; + error?: string; +} + +/** One container on the engine, as offered to the adoption picker. */ +export interface DockerContainerInfo { + name: string; + image: string; + running: boolean; + /** Engine's own status string, e.g. "Up 3 hours" / "Exited (0) 2 days ago". */ + status: string; +} + +/** + * List the engine's containers for the adoption picker (mirror of + * `listRemoteCodemanSessions`). Read-only and NEVER throws: an unreachable + * daemon, a missing engine or zero containers all return `[]`, because this + * feeds a convenience picker whose input the user can always type by hand. + * + * Stopped containers ARE included, sorted after running ones and carrying their + * status: adoption requires a running container, but hiding a stopped one turns + * "my container is not in the list" into a dead end with no explanation, while + * showing `my-box (Exited (0) 2 days ago)` says exactly what to fix. + */ +export async function listDockerContainers( + docker: Pick +): Promise { + if (IS_TEST_MODE) return []; + const argv = dockerEngineArgv(docker); + try { + const { stdout } = await execFileAsync( + argv[0], + [...argv.slice(1), 'ps', '-a', '--format', '{{.Names}}\t{{.Image}}\t{{.State}}\t{{.Status}}'], + { timeout: DOCKER_PROBE_TIMEOUT_MS } + ); + const rows = stdout + .split('\n') + .map((line) => line.split('\t')) + .filter((parts) => parts.length >= 4 && parts[0]) + .map(([name, image, state, status]) => ({ + name, + image: image || '', + running: state === 'running', + status: status || '', + })); + // Running first, then by name, so the containers a user can actually adopt + // are the ones at the top of the list. + return rows.sort((a, b) => Number(b.running) - Number(a.running) || a.name.localeCompare(b.name)); + } catch { + return []; + } +} + +/** + * Preflight an EXISTING container for adoption. Read-only by construction: it + * runs `inspect` plus one `exec` of `command -v`, and never creates, starts or + * modifies anything. Refusing here is what keeps the failure at link time — a + * clear message — instead of at session launch, where the only alternatives + * would be a dead pane or starting a container we do not own. + * + * `--pull=never` is irrelevant here: adoption never touches images. The image + * ref is reported only so the UI can show what the user is attaching to. + */ +export async function probeAdoptableContainer( + docker: Pick, + modes: SessionMode[] = [], + containerWorkdir?: string +): Promise { + if (IS_TEST_MODE) { + return { + ok: true, + exists: true, + running: true, + tmuxPath: '/usr/bin/tmux', + availableModes: modes, + workdirExists: true, + }; + } + const argv = dockerEngineArgv(docker); + let running = false; + let image: string | undefined; + try { + const { stdout } = await execFileAsync( + argv[0], + [...argv.slice(1), 'inspect', '-f', '{{.State.Running}}\t{{.Config.Image}}', docker.containerName], + { timeout: DOCKER_PROBE_TIMEOUT_MS } + ); + const [state = '', img = ''] = stdout.trim().split('\t'); + running = state === 'true'; + image = img || undefined; + } catch { + return { + ok: false, + exists: false, + running: false, + error: `container "${docker.containerName}" not found (adoption never creates a container — start it yourself first)`, + }; + } + if (!running) { + return { + ok: false, + exists: true, + running: false, + image, + error: `container "${docker.containerName}" exists but is not running (Codeman never starts a container it does not own — start it yourself, then retry)`, + }; + } + // One exec resolves tmux plus every requested CLI, so adoption costs a single + // round trip. Binaries are fixed mode names, never user input. + const wanted = modes.filter((m) => m !== 'shell'); + const binaryFor = (mode: SessionMode) => MODE_BINARIES[mode] ?? mode; + const probes = ['tmux', ...wanted.map(binaryFor)]; + // `; exit 0` is load-bearing: the script's status is its LAST command's, so a + // missing final CLI made the whole `sh -lc` exit 1 and the probe reported + // "could not exec into the container" for a container that was perfectly fine. + // Absence of a CLI is data here, not failure — only a real exec error is. + const steps = probes.map((bin) => `command -v ${bin} >/dev/null 2>&1 && echo ${bin}`); + // The workdir is checked INSIDE the container, and that is a fact independent + // of hostWorkspacePath: an owned container gets the host dir bind-mounted at the + // same absolute path at create time, but adoption mounts nothing, so the two + // paths only coincide if the user mounted it there themselves. `docker exec + // --workdir ` fails with an OCI chdir error the pane surfaces as a bare + // "execvp failed", so it is resolved here into an actionable message. + if (containerWorkdir) steps.push(`[ -d ${shellescape(containerWorkdir)} ] && echo __workdir__`); + // Claude Code REFUSES --dangerously-skip-permissions as root. Our own base + // image runs a non-root user so an owned container never hits it; an adopted + // container's user belongs to its owner and is frequently root. + steps.push(`[ "$(id -u)" = 0 ] && echo __root__`); + const script = `${steps.join('; ')}; exit 0`; + try { + const { stdout } = await execFileAsync( + argv[0], + [...argv.slice(1), 'exec', docker.containerName, 'sh', '-lc', script], + { timeout: DOCKER_PROBE_TIMEOUT_MS } + ); + const found = new Set( + stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + ); + if (!found.has('tmux')) { + return { + ok: false, + exists: true, + running: true, + image, + error: `container "${docker.containerName}" has no tmux (required for durable sessions; install it inside the container)`, + }; + } + const workdirExists = containerWorkdir ? found.has('__workdir__') : undefined; + if (containerWorkdir && !workdirExists) { + return { + ok: false, + exists: true, + running: true, + image, + workdirExists: false, + error: `"${containerWorkdir}" does not exist inside container "${docker.containerName}". Adoption mounts nothing, so the container workdir must already exist there — set it to a path inside the container (it need not match the host workspace path).`, + }; + } + return { + ok: true, + exists: true, + running: true, + image, + tmuxPath: 'tmux', + availableModes: modes.filter((m) => m === 'shell' || found.has(binaryFor(m))), + workdirExists, + runsAsRoot: found.has('__root__'), + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { ok: false, exists: true, running: true, image, error: `could not exec into the container: ${msg}` }; + } +} + +/** One directory listing from INSIDE a container, shaped like the host picker's. */ +export interface DockerBrowseResult { + path: string; + parent: string | null; + entries: Array<{ name: string; path: string; type: 'directory' | 'file' }>; + error?: string; +} + +/** + * List a directory INSIDE a container, for the adoption form's container-workdir + * picker. The host filesystem picker cannot serve this: the path lives in the + * container, and for an adopted container nothing is mounted at a matching host + * location, so the user would otherwise be typing a path blind. + * + * Read-only: one `ls` through `docker exec`, no writes, no lifecycle. The path + * is shell-escaped like every other value this module interpolates, and output + * is parsed as NUL-free lines with a leading type marker so a filename with + * spaces survives. + */ +export async function browseInContainer( + docker: Pick, + path: string +): Promise { + const target = path && path.startsWith('/') ? path : '/'; + const parent = target === '/' ? null : target.replace(/\/+$/, '').split('/').slice(0, -1).join('/') || '/'; + if (IS_TEST_MODE) return { path: target, parent, entries: [] }; + const argv = dockerEngineArgv(docker); + // `-p` marks directories with a trailing slash; `-A` shows dotfiles but not + // the . and .. entries the picker navigates with its own Up control. + const script = `cd ${shellescape(target)} 2>/dev/null && ls -Ap 2>/dev/null || echo __ERR__`; + try { + const { stdout } = await execFileAsync( + argv[0], + [...argv.slice(1), 'exec', docker.containerName, 'sh', '-lc', script], + { timeout: DOCKER_PROBE_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024 } + ); + if (stdout.includes('__ERR__')) return { path: target, parent, entries: [], error: 'Not a readable directory' }; + const base = target.endsWith('/') ? target : `${target}/`; + const entries = stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((name) => { + const isDir = name.endsWith('/'); + const clean = isDir ? name.slice(0, -1) : name; + return { name: clean, path: `${base}${clean}`, type: (isDir ? 'directory' : 'file') as 'directory' | 'file' }; + }) + .sort((a, b) => Number(b.type === 'directory') - Number(a.type === 'directory') || a.name.localeCompare(b.name)); + return { path: target, parent, entries }; + } catch (err) { + return { path: target, parent, entries: [], error: err instanceof Error ? err.message : String(err) }; + } +} + /** * Resolve the host's IP on the default docker bridge (the address a container * reaches as `host.docker.internal`), so the server can bind a hooks-only listener @@ -1052,9 +1355,18 @@ export async function reapOrphanedDockerContainers( } const cases = await readDockerCases(configDir); const expected = new Set(cases.map((c) => c.container ?? dockerContainerName(c.name))); + // ADOPTED containers are never reapable, and this guard is deliberately + // independent of the two conditions that already cover them (we never applied + // the `codeman.managed=1` label filtered on above, and they are referenced by a + // live case so they are in `expected`). An adopted container is the user's + // property; it must survive even if a future edit narrows either condition. + const adopted = new Set( + cases.filter((item) => item.owned === false).map((item) => item.container ?? dockerContainerName(item.name)) + ); const reaped: string[] = []; for (const { name, inst } of rows) { if (inst !== instance) continue; // only THIS instance's containers + if (adopted.has(name)) continue; // never reap a container we do not own if (expected.has(name)) continue; // still referenced by a live case try { await execFileAsync(bin, ['rm', '-f', name], { timeout: DOCKER_PROBE_TIMEOUT_MS }); diff --git a/src/foreign-tmux-discovery.ts b/src/foreign-tmux-discovery.ts new file mode 100644 index 00000000..ab3c1017 Binary files /dev/null and b/src/foreign-tmux-discovery.ts differ diff --git a/src/foreign-tmux.ts b/src/foreign-tmux.ts new file mode 100644 index 00000000..cb54bc4c --- /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/keyboard-accessory.js b/src/web/public/keyboard-accessory.js index 35b67c3e..7ec9a890 100644 --- a/src/web/public/keyboard-accessory.js +++ b/src/web/public/keyboard-accessory.js @@ -185,9 +185,13 @@ const PathPicker = { if (this._options.sessionId) params.set('sessionId', this._options.sessionId); if (this._showHidden) params.set('showHidden', 'true'); try { - const response = await fetch(`/api/filesystem/browse?${params.toString()}`); - const result = await response.json(); - if (!response.ok || !result.success) throw new Error(result.error || 'Failed to browse this folder'); + // A caller may supply its own source (the container-workdir picker browses + // INSIDE a container, which the host filesystem endpoint cannot answer). + // It returns the same shape, so everything below is unchanged. + const result = this._options.fetchListing + ? await this._options.fetchListing(path) + : await (await fetch(`/api/filesystem/browse?${params.toString()}`)).json(); + if (!result?.success) throw new Error(result?.error || 'Failed to browse this folder'); if (!this.overlay || loadSequence !== this._loadSequence) return; this.render(result.data); } catch (error) { diff --git a/src/web/public/mobile-overview.js b/src/web/public/mobile-overview.js index c9c32bb4..ab798212 100644 --- a/src/web/public/mobile-overview.js +++ b/src/web/public/mobile-overview.js @@ -431,6 +431,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 e0ddb53d..da66a27a 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -3816,3 +3816,15 @@ html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-close { transition: none; } } + +/* 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; +} diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 6de0507c..11fd15a0 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -187,6 +187,13 @@ Object.assign(CodemanApp.prototype, { this.closeCasePicker(); this.updateDirDisplayForCase(select.value); this.updateMobileCaseLabel(select.value); + // Warm the container's CLI list HERE rather than when the run menu opens. + // The probe is a `docker exec` round trip, so gating it on the menu meant the + // menu painted every mode first and only narrowed a moment later — which + // reads as "it shows all of them" and lets a mode be picked that the + // container does not have. + const picked = (this.cases || []).find((c) => c.name === select.value); + if (picked?.location === 'docker') void this._probeDockerCaseModes(picked, null); if (save) { this.saveLastUsedCase(select.value); } @@ -474,10 +481,35 @@ Object.assign(CodemanApp.prototype, { * run modes like the rest, and neither `agy` nor `pi` is likely to be installed. */ _refreshRunModeAvailability(menu) { + // A DOCKER case runs its agents INSIDE the container, so host CLI + // availability answers the wrong question: the host may have no claude at + // all while the container ships one, and gating on the host hides a mode + // that would have worked. Adoption records what the container really has + // (`availableModes`); an owned container runs our base image, which ships + // every CLI, so an absent list means "do not gate" rather than "nothing". + // Same source every run* path reads the selected case from. + const caseName = document.getElementById('quickStartCase')?.value; + const activeCase = caseName ? (this.cases || []).find((c) => c.name === caseName) : null; + const isDocker = activeCase?.location === 'docker'; + // Prefer a LIVE probe over the value stored at attach time: a container's + // CLIs can be installed or removed long after the case was linked, and a + // case linked before that field existed has none at all. + const containerModes = isDocker + ? this._dockerCaseModes?.[caseName] || activeCase.docker?.availableModes || null + : null; + if (isDocker && !this._dockerCaseModes?.[caseName]) void this._probeDockerCaseModes(activeCase, menu); + // An unreachable container hides every agent mode and explains why, instead + // of silently offering modes that cannot start. + const probeError = isDocker ? this._dockerCaseProbeError?.[caseName] : null; for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']) { const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`); - if (btn) btn.style.display = this.isCliAvailable(mode) ? 'flex' : 'none'; + if (!btn) continue; + let available; + if (isDocker) available = probeError ? false : containerModes ? containerModes.includes(mode) : true; + else available = this.isCliAvailable(mode); + btn.style.display = available ? 'flex' : 'none'; } + this._renderRunModeNotice(menu, probeError); // DeepSeek is the one mode whose availability has two halves: `dsh` can be // perfectly installed while no pane-capable profile exists, because DeepSeek // ships no terminal front door. In that state the honest offer is "add one", @@ -619,6 +651,74 @@ Object.assign(CodemanApp.prototype, { } }, + /** + * One-line explanation at the top of the run menu. Only a container that could + * not be read produces one; everything else removes it, so a stale reason can + * never outlive the condition that caused it. + */ + _renderRunModeNotice(menu, message) { + if (!menu) return; + let el = menu.querySelector('.run-mode-notice'); + if (!message) { + el?.remove(); + return; + } + if (!el) { + el = document.createElement('div'); + el.className = 'run-mode-notice'; + menu.prepend(el); + } + // Server-supplied text: set it, never parse it as markup. + el.textContent = message; + }, + + /** + * Ask the container which CLIs it actually has, and re-gate the menu once the + * answer lands. Cached per case for the page's lifetime: the menu re-opens + * often and the probe is a `docker exec` round trip. + * + * Best-effort by design — an unreachable daemon or a stopped container leaves + * the cache empty, which the caller reads as "unknown" and therefore does not + * gate. Hiding every mode because a probe failed would be worse than showing + * one that turns out to be missing, which the launch path already refuses with + * a specific message. + */ + async _probeDockerCaseModes(activeCase, menu) { + const name = activeCase?.name; + const container = activeCase?.docker?.container; + const hostId = activeCase?.docker?.hostId; + if (!name || !container || !hostId) return; + this._dockerCaseModes = this._dockerCaseModes || {}; + if (this._dockerModeProbeInFlight?.[name]) return; + this._dockerModeProbeInFlight = this._dockerModeProbeInFlight || {}; + this._dockerModeProbeInFlight[name] = true; + try { + // ⚠️ _api serializes `body` and sets Content-Type itself. Passing an + // already-stringified body double-encodes it and the server rejects a + // JSON string where it expects an object (400 INVALID_INPUT). + const probe = await this._apiJson('/api/docker-cases/adopt-preflight', { + method: 'POST', + body: { hostId, container }, + }); + if (probe?.ok && Array.isArray(probe.availableModes)) { + this._dockerCaseModes[name] = probe.availableModes; + delete this._dockerCaseProbeError?.[name]; + } else { + // A container that cannot be probed — recreated, stopped, engine down — + // must NOT fall through to "show everything". Offering claude on a + // container that is not running is a click that can only fail, with the + // reason visible nowhere. Record the reason and say it in the menu. + this._dockerCaseProbeError = this._dockerCaseProbeError || {}; + this._dockerCaseProbeError[name] = probe?.error || `Could not read container "${container}".`; + delete this._dockerCaseModes[name]; + } + // Only repaint while the menu the user opened is still on screen. + if (menu?.classList.contains('active')) this._refreshRunModeAvailability(menu); + } finally { + delete this._dockerModeProbeInFlight[name]; + } + }, + async _loadRunModeHistory() { const container = document.getElementById('runModeHistory'); if (!container) return; @@ -2334,6 +2434,28 @@ Object.assign(CodemanApp.prototype, { modal.querySelectorAll('.set-rail-item').forEach(btn => { btn.onclick = () => this.switchCaseModalTab(btn.dataset.tab); }); + // Adopt-an-existing-container toggle + its read-only preflight. Assigned (not + // addEventListener) so reopening the modal cannot stack duplicate handlers, + // matching the rail wiring right above. + const adoptToggle = document.getElementById('dockerAdoptExisting'); + if (adoptToggle) adoptToggle.onchange = () => this._syncDockerAdoptMode(); + const adoptCheck = document.getElementById('dockerAdoptCheckBtn'); + if (adoptCheck) adoptCheck.onclick = () => this._dockerAdoptPreflight(); + const adoptJump = document.getElementById('dockerAdoptJumpBtn'); + if (adoptJump) adoptJump.onclick = () => this.jumpToDockerAdopt(); + // Containers come from the host profile, so switching Host ID invalidates the + // suggestions. Dropping the marker (rather than refetching here) keeps the + // fetch lazy — it happens when adopt mode is actually on. + const hostIdInput = document.getElementById('dockerHostId'); + if (hostIdInput) { + hostIdInput.onchange = () => { + delete document.getElementById('dockerContainerList')?.dataset.loadedFor; + if (document.getElementById('dockerAdoptExisting')?.checked) void this._loadDockerContainerOptions(); + }; + } + // A fresh open re-reads the engine: containers start and stop between visits. + delete document.getElementById('dockerContainerList')?.dataset.loadedFor; + this._syncDockerAdoptMode(); // Scroll-into-view on focus for mobile keyboard visibility modal.querySelectorAll('input[type="text"]').forEach(input => { if (!input._mobileScrollWired) { @@ -2809,6 +2931,61 @@ Object.assign(CodemanApp.prototype, { }); }, + /** HOST workspace directory — the same picker Link Existing uses. */ + openDockerWorkspacePathPicker() { + const pathInput = document.getElementById('dockerWorkspacePath'); + PathPicker.open({ + title: 'Select Host Workspace Folder', + initialPath: pathInput.value.trim(), + directoriesOnly: true, + onSelect: (path) => { + pathInput.value = path; + const nameInput = document.getElementById('dockerCaseName'); + if (nameInput && !nameInput.value.trim()) { + const folder = path.split('/').filter(Boolean).pop() || ''; + if (/^[a-zA-Z0-9_-]+$/.test(folder)) nameInput.value = folder; + } + }, + }); + }, + + /** + * Container workdir. Browses INSIDE the container, because for an adopted + * container nothing is mounted at a matching host path — the host picker would + * be listing a different filesystem, and typing this field blind is exactly + * what makes the launch fail with an OCI chdir error. + */ + openDockerWorkdirPicker() { + const pathInput = document.getElementById('dockerAdoptWorkdir'); + const container = document.getElementById('dockerContainerName')?.value.trim(); + const hostId = document.getElementById('dockerHostId')?.value.trim() || 'local'; + if (!container) { + this.showToast('Enter the container name first', 'error'); + return; + } + PathPicker.open({ + title: `Select Folder Inside ${container}`, + initialPath: pathInput.value.trim() || '/', + directoriesOnly: true, + fetchListing: async (path) => { + const data = await this._apiJson('/api/docker-cases/browse', { + method: 'POST', + body: { hostId, container, path: path || '/' }, + }); + if (!data) return { success: false, error: `Could not read ${container}. Is it running?` }; + if (data.error) return { success: false, error: data.error }; + // Shape it like the host endpoint: one root, so Up/Location behave. + return { + success: true, + data: { ...data, root: '/', roots: [{ label: container, path: '/' }], truncated: false }, + }; + }, + onSelect: (path) => { + pathInput.value = path; + }, + }); + }, + async linkRemoteCase() { const name = document.getElementById('remoteCaseName').value.trim(); const remotePath = document.getElementById('remoteCasePath').value.trim(); @@ -2890,10 +3067,107 @@ Object.assign(CodemanApp.prototype, { } }, + /** + * Reflect the "attach to an existing container" checkbox onto the modal so CSS + * can swap which half of the Docker panel applies. An attribute rather than + * per-row inline styles: the panel is rebuilt by nothing, but the create-time + * rows are a SET (image, network, advanced block) and one attribute keeps them + * in lockstep with the container-name row. + */ + _syncDockerAdoptMode() { + const modal = document.getElementById('createCaseModal'); + if (!modal) return; + const adopting = document.getElementById('dockerAdoptExisting')?.checked; + if (adopting) modal.setAttribute('data-docker-adopt', '1'); + else modal.removeAttribute('data-docker-adopt'); + if (adopting) void this._loadDockerContainerOptions(); + }, + + /** + * Fill the container-name ``. A native datalist is deliberate: the + * field must accept a free-typed name (the engine may be remote, or the + * container may not exist yet when the form is filled), and datalist gives + * type-to-filter over the suggestions without a custom dropdown. + * + * Best-effort by design — the endpoint returns [] for an unreachable daemon, + * and an empty list simply leaves the field as plain text input. + */ + async _loadDockerContainerOptions() { + const list = document.getElementById('dockerContainerList'); + if (!list) return; + const hostId = document.getElementById('dockerHostId')?.value.trim() || 'local'; + if (list.dataset.loadedFor === hostId) return; // one fetch per host per open + const data = await this._apiJson(`/api/docker-hosts/${encodeURIComponent(hostId)}/containers`); + const containers = data?.containers || []; + list.textContent = ''; + for (const c of containers) { + const option = document.createElement('option'); + option.value = c.name; + // Engine-supplied strings: set as text, never as markup. + option.textContent = c.running ? `${c.image} · ${c.status}` : `${c.image} · ${c.status} (not running)`; + list.appendChild(option); + } + list.dataset.loadedFor = hostId; + }, + + /** + * Cross-link from the Create New tab's "Run in an isolated Docker container" + * row. Adoption lives on the Docker tab, but the place users actually look for + * anything container-shaped is that checkbox, so this jumps them there with the + * toggle already on rather than leaving the feature undiscoverable. + */ + jumpToDockerAdopt() { + this.switchCaseModalTab('case-docker'); + const toggle = document.getElementById('dockerAdoptExisting'); + if (toggle) toggle.checked = true; + this._syncDockerAdoptMode(); + document.getElementById('dockerContainerName')?.focus(); + }, + + /** + * Read-only preflight against an existing container. It links nothing, so the + * user can find out "not running" / "no tmux" / "codex present, claude missing" + * before committing to a case name — the same reason the server refuses at link + * time rather than at session launch. + */ + async _dockerAdoptPreflight() { + const statusEl = document.getElementById('dockerLinkStatus'); + const container = document.getElementById('dockerContainerName')?.value.trim(); + const containerWorkdir = document.getElementById('dockerAdoptWorkdir')?.value.trim(); + const hostId = document.getElementById('dockerHostId').value.trim() || 'local'; + if (!container) { + if (statusEl) statusEl.textContent = 'Enter a container name first.'; + return; + } + if (statusEl) statusEl.textContent = 'Inspecting container...'; + // _apiJson folds every failure to null, and a preflight's whole value is the + // reason it failed, so the envelope is unwrapped by hand here. + const probe = await this._apiJson('/api/docker-cases/adopt-preflight', { + method: 'POST', + body: { hostId, container, ...(containerWorkdir ? { containerWorkdir } : {}) }, + }); + if (!statusEl) return; + if (!probe) { + statusEl.textContent = 'Could not reach the docker host profile. Save a Host ID first.'; + return; + } + if (!probe.ok) { + statusEl.textContent = probe.error || 'Container is not adoptable.'; + return; + } + const modes = (probe.availableModes || []).filter((m) => m !== 'shell'); + statusEl.textContent = modes.length + ? `Running (${probe.image || 'unknown image'}). Available: ${modes.join(', ')}.` + : `Running (${probe.image || 'unknown image'}), but no agent CLI found inside — only Shell will work.`; + }, + async linkDockerCase() { const name = document.getElementById('dockerCaseName').value.trim(); const hostWorkspacePath = document.getElementById('dockerWorkspacePath').value.trim(); const hostId = document.getElementById('dockerHostId').value.trim() || 'local'; + const adopting = !!document.getElementById('dockerAdoptExisting')?.checked; + const container = document.getElementById('dockerContainerName')?.value.trim() || ''; + const adoptWorkdir = document.getElementById('dockerAdoptWorkdir')?.value.trim() || ''; const image = document.getElementById('dockerImage').value.trim() || 'codeman/agent:base'; const network = document.getElementById('dockerNetwork').value; const memory = document.getElementById('dockerMemory').value.trim(); @@ -2914,9 +3188,15 @@ Object.assign(CodemanApp.prototype, { this.showToast('Workspace path must be absolute', 'error'); return; } + if (adopting && !container) { + this.showToast('Enter the name of the running container to attach to', 'error'); + return; + } try { - if (statusEl) statusEl.textContent = 'Checking docker daemon + base image...'; + if (statusEl) { + statusEl.textContent = adopting ? 'Inspecting the existing container...' : 'Checking docker daemon + base image...'; + } // omitted optionals sent as UNDEFINED (never null — Zod .optional() rejects null) const resources = {}; if (memory) resources.memory = memory; @@ -2947,16 +3227,27 @@ Object.assign(CodemanApp.prototype, { } if (!hostData.success) throw new Error(hostData.error || 'Failed to save docker host'); - const caseRes = await fetch('/api/cases/docker-link', { + // Adoption reuses this whole flow and differs only in the final call: a + // different endpoint (which never creates a container) plus the container + // name. The host upsert above still applies — it is what resolves the + // engine/context/daemon for the `docker exec`; its create-time fields are + // simply never read for an adopted case. + const caseRes = await fetch(adopting ? '/api/cases/docker-adopt' : '/api/cases/docker-link', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, hostId, hostWorkspacePath }), + body: JSON.stringify( + adopting + ? { name, hostId, hostWorkspacePath, container, ...(adoptWorkdir ? { containerWorkdir: adoptWorkdir } : {}) } + : { name, hostId, hostWorkspacePath } + ), }); const caseData = await caseRes.json(); if (caseData.success) { this.closeCreateCaseModal(); const caps = caseData.data?.capsEnforced === false ? ' (resource caps are advisory on this engine)' : ''; - this.showToast(`Docker case "${name}" linked${caps}`, 'success'); + const modes = (caseData.data?.availableModes || []).filter((m) => m !== 'shell'); + const found = adopting && modes.length ? ` — found ${modes.join(', ')}` : ''; + this.showToast(`Docker case "${name}" ${adopting ? 'attached' : 'linked'}${caps}${found}`, 'success'); await this.loadQuickStartCases(name); await this.saveLastUsedCase(name); } else { diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 8efb776e..dc5a874e 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -16601,6 +16601,44 @@ html[data-tab-orientation='vertical'] .home-sessions { label as a row label, its `.form-hint` as a row description. Scoped to the document, so `.form-row` everywhere else is untouched. ─────────────────────────────────────────────────────────────────────────── */ +/* Adopt-an-existing-container mode swaps which half of the Docker panel applies: + the create-time fields (image, network, resources, credential mounts) describe + a `docker create` that adoption never runs, and the container name is the one + field only adoption needs. `.docker-adopt-only` is hidden by default so the + panel stays exactly as it was until the checkbox is ticked. Rules carry + `!important` because the adapter block above paints `.form-row` as a row card + and `details.advanced-options` has its own display. */ +/* Run-menu notice: why a container case is offering no agent modes. Lives at the + top of the menu so the reason is where the missing entries would have been. */ +.run-mode-notice { + padding: 8px 12px; + margin: 0 0 4px; + font-size: 12px; + line-height: 1.45; + color: var(--text-muted, #9aa0a6); + border-bottom: 1px solid var(--border, #333); + white-space: normal; +} + +#createCaseModal .docker-adopt-only { + display: none !important; +} +#createCaseModal[data-docker-adopt='1'] .docker-adopt-only { + display: block !important; +} +#createCaseModal[data-docker-adopt='1'] .docker-create-only { + display: none !important; +} +#createCaseModal .btn-inline-check { + background: none; + border: none; + padding: 0; + font: inherit; + color: var(--accent, #4a9eff); + cursor: pointer; + text-decoration: underline; +} + #createCaseModal .set-doc .form-row { margin: 0 0 3px; padding: 7px 10px; @@ -17536,3 +17574,173 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle transition: none; } } + +/* ═══════════════════════════════════════════════════════════════ + 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 84451307..6e509288 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -1985,6 +1985,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; } @@ -1999,6 +2002,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. @@ -2008,6 +2015,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/case-routes.ts b/src/web/routes/case-routes.ts index 978ac941..2024d994 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -13,7 +13,7 @@ import fs from 'node:fs/promises'; import { join, resolve, basename } from 'node:path'; import { fileURLToPath } from 'node:url'; import { homedir } from 'node:os'; -import type { ApiResponse, CaseInfo, DockerHost, RemoteSessionInfo, SessionDocker } from '../../types.js'; +import type { ApiResponse, CaseInfo, DockerHost, RemoteSessionInfo, SessionDocker, SessionMode } from '../../types.js'; import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js'; import { CreateCaseSchema, @@ -24,6 +24,9 @@ import { RemoteCaseLinkSchema, RemoteHostSchema, DockerCaseLinkSchema, + DockerCaseAdoptSchema, + DockerAdoptPreflightSchema, + DockerBrowseSchema, DockerHostSchema, DockerExportSchema, DockerImportSchema, @@ -66,6 +69,10 @@ import { DEFAULT_AGENT_IMAGE, dockerContainerName, dockerDisplayPath, + probeAdoptableContainer, + listDockerContainers, + browseInContainer, + DOCKER_ADOPT_PROBE_MODES, readDockerCases, readDockerHosts, removeDockerContainer, @@ -73,6 +80,7 @@ import { writeDockerCases, writeDockerHosts, } from '../../docker-hosts.js'; +import type { AdoptedContainerProbe, DockerBrowseResult, DockerContainerInfo } from '../../docker-hosts.js'; import { buildDockerRemoveCommand } from '../../tmux-manager.js'; import { checkRemoteTmuxAvailable, @@ -291,6 +299,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config image: host.image, path: dockerCase.hostWorkspacePath, network: host.network ?? 'bridge', + ...(dockerCase.availableModes ? { availableModes: dockerCase.availableModes } : {}), }, }; const existingIndex = cases.findIndex((item) => item.name === dockerCase.name); @@ -771,6 +780,162 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config } ); + /** + * ADOPT an already-running container (`owned: false`). The mirror of the + * remote-SSH attach path: Codeman execs into a container the user built and + * runs, and never creates, starts, stops, restarts or removes it. + * + * Everything here is read-only toward the container. The preflight refuses at + * LINK time — missing, stopped, or no tmux inside — because the alternative is + * failing at session launch, where the only ways out would be a dead pane or + * starting a container we do not own. There is no image gate and no + * `ensureCaseImage`: adoption never runs `docker create`, so the container's + * image is the user's business. + */ + app.post( + '/api/cases/docker-adopt', + async (req): Promise> => { + const dockerCase = { + ...parseBody(DockerCaseAdoptSchema, req.body), + type: 'docker' as const, + owner: ownerFor(req), + owned: false as const, + }; + const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId); + if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found'); + + const linkedCases = await readLinkedCases(); + const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR); + if ( + dockerCases.some((item) => item.name === dockerCase.name) || + linkedCases[dockerCase.name] || + existsSync(join(resolveCasesDir(getAuthUser(req)), dockerCase.name)) + ) { + return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists'); + } + // Two cases must never share one adopted container: session close kills the + // in-container tmux by session id, but a shared adoption would let one case's + // teardown and another's launch race over the same tmux server. + const container = dockerCase.container; + if (dockerCases.some((item) => (item.container ?? dockerContainerName(item.name)) === container)) { + return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, `Container "${container}" is already linked to a case`); + } + + if (!isWorkingDirAllowed(getAuthUser(req), dockerCase.hostWorkspacePath)) { + return createErrorResponse(ApiErrorCode.FORBIDDEN, 'hostWorkspacePath is outside your workspace'); + } + // The workspace must ALREADY exist: it mirrors a path inside a container we + // did not create, so silently mkdir-ing it would invent a host directory that + // does not correspond to whatever is actually mounted there. + if (!existsSync(dockerCase.hostWorkspacePath)) { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + 'hostWorkspacePath does not exist. Adoption mirrors an existing container, so point this at the real host directory already mounted into it.' + ); + } + + const availability = await checkDockerAvailable(host.engine); + if (!availability.ok) { + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + availability.error || 'docker daemon is not available' + ); + } + // The container workdir is validated INSIDE the container. It defaults to + // hostWorkspacePath only because that is what an owned container's bind + // mount guarantees; adoption mounts nothing, so the probe has to prove it. + const adoptDocker = toSessionDocker(host, dockerCase); + const probe = await probeAdoptableContainer( + adoptDocker, + [...DOCKER_ADOPT_PROBE_MODES], + adoptDocker.containerWorkdir + ); + if (!probe.ok) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, probe.error || 'container is not adoptable'); + } + + // Persist what the container actually has: the run-mode picker gates on + // HOST CLIs, which is the wrong question for a case whose agents run inside + // a container the host knows nothing about. + const adoptedCase = { ...dockerCase, availableModes: probe.availableModes }; + await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, adoptedCase]); + ctx.broadcast(SseEvent.CaseLinked, { + name: adoptedCase.name, + path: adoptedCase.hostWorkspacePath, + type: 'docker', + }); + return { + success: true, + data: { case: adoptedCase, image: probe.image, availableModes: probe.availableModes }, + }; + } + ); + + /** + * Preflight an existing container WITHOUT linking anything, so the UI can tell + * the user "not running" / "no tmux" / "codex present, claude missing" before + * they commit to a case name. Read-only; never touches container lifecycle. + */ + /** + * Containers on the host's engine, for the adoption picker. Read-only and + * best-effort (mirror of the remote `:hostId/sessions` discovery route): an + * unreachable daemon yields an empty list rather than an error, because the + * container name is a free-text field the user can always type by hand. + */ + app.get( + '/api/docker-hosts/:hostId/containers', + async (req): Promise> => { + const { hostId } = req.params as { hostId: string }; + const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === hostId); + if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found'); + const containers = await listDockerContainers({ + engine: host.engine ?? 'docker', + context: host.context, + daemonHost: host.daemonHost, + }); + return { success: true, data: { containers } }; + } + ); + + /** + * Browse a directory INSIDE a container, for the adoption form's + * container-workdir picker. The host picker cannot answer this: for an adopted + * container nothing is mounted at a matching host path, so the field would + * otherwise be typed blind. Read-only — one `ls` through `docker exec`. + */ + app.post('/api/docker-cases/browse', async (req): Promise> => { + const body = parseBody(DockerBrowseSchema, req.body); + const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === body.hostId); + if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found'); + const result = await browseInContainer( + { + engine: host.engine ?? 'docker', + context: host.context, + daemonHost: host.daemonHost, + containerName: body.container, + }, + body.path || '/' + ); + return { success: true, data: result }; + }); + + app.post('/api/docker-cases/adopt-preflight', async (req): Promise> => { + const body = parseBody(DockerAdoptPreflightSchema, req.body); + const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === body.hostId); + if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found'); + const probe = await probeAdoptableContainer( + { + engine: host.engine ?? 'docker', + context: host.context, + daemonHost: host.daemonHost, + containerName: body.container, + }, + [...DOCKER_ADOPT_PROBE_MODES], + body.containerWorkdir + ); + return { success: true, data: probe }; + }); + // One-click "Run in Docker": create a NORMAL case (folder in CASES_DIR, scaffolded) // AND link it to a hardened container with default settings, auto-provisioning a // shared `default` docker host so the user never touches host/image/network fields. @@ -1042,8 +1207,22 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config '/api/docker-cases/:name/recreate', async (req): Promise> => { const { name } = req.params as { name: string }; - const dockerCase = (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === name); + // Ownership gate: recreate DESTROYS a container, so it must be scoped like + // delete is (`canAccessOwned`). Without it any user could rebuild another + // user's container by name. + const dockerCase = (await readDockerCases(CODEMAN_CONFIG_DIR)).find( + (item) => item.name === name && canAccessOwned(getAuthUser(req), item.owner) + ); if (!dockerCase) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker case not found'); + // An ADOPTED container is the user's own: there is nothing to recreate it + // from (no create-config, no image gate) and destroying it is exactly what + // adoption promises never to do. + if (dockerCase.owned === false) { + return createErrorResponse( + ApiErrorCode.FORBIDDEN, + `Case "${name}" adopted an existing container. Codeman does not own its lifecycle and will not recreate it — rebuild it yourself, or unlink the case.` + ); + } const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId); if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found'); const sessionDocker = toSessionDocker(host, dockerCase); @@ -1154,7 +1333,13 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config ); // Best-effort `docker rm -f` the per-case container (case-delete is the // explicit teardown that removes it; the bind-mounted workspace survives). - const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId); + // An ADOPTED container is skipped entirely: unlinking the case must leave + // the user's own container running and untouched. The seed file is skipped + // with it — adoption never wrote one. + const host = + dockerCase.owned === false + ? undefined + : (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId); if (host) { const sessionDocker = toSessionDocker(host, dockerCase); try { diff --git a/src/web/routes/file-routes.ts b/src/web/routes/file-routes.ts index 20a567f3..adffc9dd 100644 --- a/src/web/routes/file-routes.ts +++ b/src/web/routes/file-routes.ts @@ -38,7 +38,7 @@ import { import { generateFirstPageThumbnail } from '../../document-thumbnailer.js'; import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js'; import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js'; -import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js'; +import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from '../../config/attachment-guard.js'; import { isMultiUserMode, userSpacePath } from '../../config/multiuser.js'; import { CASES_DIR, @@ -425,6 +425,40 @@ function getFilesystemPreviewKind(fileName: string): FilesystemPreviewKind | und return undefined; } +/** + * Blocked trees, minus any tree that would swallow a configured picker root + * whole. + * + * `/root` is a default blocked tree, and Codeman running as root (containers, + * plenty of servers) makes `homedir()` exactly `/root` — so the picker's own + * allowlisted Home root was blocked by the attachment guard, every other + * candidate lives under it or does not exist, and the endpoint answered 403 + * "No filesystem browse roots are available" with no root the user could reach. + * + * Dropping the tree does NOT expose secrets: `isSensitivePath` independently + * matches `.ssh/`, `.env`, `credentials*` and friends at any depth, and it is + * what the directory probe below asks about. Trees with no configured root + * beneath them (`/etc`) are untouched. + */ +function pickerBlockedTrees(blockedTrees: readonly string[], roots: readonly string[]): readonly string[] { + if (roots.length === 0) return blockedTrees; + return blockedTrees.filter((tree) => !roots.some((root) => isUnderTree(root, tree))); +} + +/** Resolve candidate roots to realpaths, dropping the ones that do not exist. */ +function resolveCandidateRootPaths(candidates: ReadonlyArray<{ path: string }>): string[] { + const out: string[] = []; + for (const candidate of candidates) { + if (!isAbsolute(candidate.path)) continue; + try { + out.push(realpathSync(candidate.path)); + } catch { + // Optional roots (for example /mnt/d on non-WSL hosts) are omitted. + } + } + return out; +} + function isBlockedPickerPath(path: string, blockedTrees: readonly string[], directory = false): boolean { if (isBlockedAttachmentPath(path, blockedTrees)) return true; // The shared sensitive-path matcher describes file locations such as @@ -491,13 +525,14 @@ async function resolveFilesystemPickerRoots( } const guard = await loadAttachmentGuardConfig(); + const trees = pickerBlockedTrees(guard.blockedTrees, resolveCandidateRootPaths(candidates)); const roots: FilesystemBrowseRoot[] = []; const seen = new Set(); for (const candidate of candidates) { if (!isAbsolute(candidate.path)) continue; try { const resolved = realpathSync(candidate.path); - if (seen.has(resolved) || isBlockedPickerPath(resolved, guard.blockedTrees, true)) continue; + if (seen.has(resolved) || isBlockedPickerPath(resolved, trees, true)) continue; const stat = await fs.stat(resolved); if (!stat.isDirectory()) continue; seen.add(resolved); @@ -556,7 +591,19 @@ async function resolveFilesystemPickerPath( } const guard = await loadAttachmentGuardConfig(); - return { candidatePath, resolvedPath, roots, matchingRoot, blockedTrees: guard.blockedTrees }; + // Navigation must use the SAME narrowed list the roots were selected with. + // Handing the raw trees down here would admit a root and then refuse every + // path inside it, which reads as a picker that opens and then does nothing. + return { + candidatePath, + resolvedPath, + roots, + matchingRoot, + blockedTrees: pickerBlockedTrees( + guard.blockedTrees, + roots.map((root) => root.path) + ), + }; } function appendDownloadFlag(url: string): string { diff --git a/src/web/routes/mux-routes.ts b/src/web/routes/mux-routes.ts index 12b22229..b4e42ce9 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 24b08a48..d7c9e27e 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 56543997..04902813 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 3e7c258f..5cb4514d 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -27,6 +27,16 @@ import { type GrokConfig, type DeepSeekConfig, } 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, isMuxAltScreenOnlyStripMode } from '../../session.js'; import { SseEvent } from '../sse-events.js'; import { @@ -127,6 +137,7 @@ import { import { checkDockerAvailable, checkDockerConfigDrift, + probeAdoptableContainer, checkDockerTmuxAvailable, ensureAgentBaseImage, DEFAULT_AGENT_IMAGE, @@ -1085,6 +1096,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) => { @@ -2957,25 +3111,46 @@ export function registerSessionRoutes( ); } const sessionDocker = toSessionDocker(host, dockerCase); - // Ensure the base image exists, auto-building the default image on first use so - // it is never a blocker. Dedup'd with any build kicked off at case-create, so - // this awaits the SAME in-flight build rather than starting a second one. - const ensured = await ensureAgentBaseImage(sessionDocker, sessionDocker.image, { - onProgress: (line) => ctx.broadcast(SseEvent.DockerImageBuildProgress, { name: dockerCase.name, line }), - }); - if (!ensured.ok) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, ensured.error || 'base image not available'); - } - if (ensured.built) { - ctx.broadcast(SseEvent.DockerImageBuildComplete, { name: dockerCase.name, image: sessionDocker.image }); - } - // tmux is a hard prerequisite (the in-container tmux makes reconnect durable). - // Skip the extra container-run probe for our OWN default image (the baked - // Dockerfile always contains tmux); still verify a custom image. - if (sessionDocker.image !== DEFAULT_AGENT_IMAGE) { - const tmuxCheck = await checkDockerTmuxAvailable(sessionDocker); - if (!tmuxCheck.ok) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'base image is missing tmux'); + // An ADOPTED container skips every image-side gate: we never run `docker + // create`, so the image is the user's business, and `ensureAgentBaseImage` + // would build/require an image that has nothing to do with their container. + // The prerequisite that DOES still hold is tmux inside it, so probe the live + // container (not the image) and refuse before launch rather than dead-paning. + if (sessionDocker.owned === false) { + const probe = await probeAdoptableContainer(sessionDocker, [mode]); + if (!probe.ok) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, probe.error || 'container is not usable'); + } + // The probe already exec'd into the container; carry its facts onto the + // live session so the launch chain does not have to re-ask. + sessionDocker.runsAsRoot = probe.runsAsRoot; + if (mode !== 'shell' && !probe.availableModes?.includes(mode)) { + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + `"${mode}" is not installed in container "${sessionDocker.containerName}". Adoption never modifies the container — install it inside, or pick another mode.` + ); + } + } else { + // Ensure the base image exists, auto-building the default image on first use so + // it is never a blocker. Dedup'd with any build kicked off at case-create, so + // this awaits the SAME in-flight build rather than starting a second one. + const ensured = await ensureAgentBaseImage(sessionDocker, sessionDocker.image, { + onProgress: (line) => ctx.broadcast(SseEvent.DockerImageBuildProgress, { name: dockerCase.name, line }), + }); + if (!ensured.ok) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, ensured.error || 'base image not available'); + } + if (ensured.built) { + ctx.broadcast(SseEvent.DockerImageBuildComplete, { name: dockerCase.name, image: sessionDocker.image }); + } + // tmux is a hard prerequisite (the in-container tmux makes reconnect durable). + // Skip the extra container-run probe for our OWN default image (the baked + // Dockerfile always contains tmux); still verify a custom image. + if (sessionDocker.image !== DEFAULT_AGENT_IMAGE) { + const tmuxCheck = await checkDockerTmuxAvailable(sessionDocker); + if (!tmuxCheck.ok) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'base image is missing tmux'); + } } } diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 1641e61f..7de99732 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -792,6 +792,74 @@ export const DockerCaseLinkSchema = z.object({ .optional(), }); +/** + * ADOPT an already-running container the user built and runs themselves. The + * container name is REQUIRED (there is nothing to derive it from — we are not + * creating it), and `hostWorkspacePath` still points at real host bytes so the + * file routes, watchers and transcript correlation keep working exactly as they + * do for an owned case. Everything that only makes sense at container-create + * time (image, network, resources, gpus, credential mounts) is deliberately + * absent: adoption never runs `docker create`. + */ +export const DockerCaseAdoptSchema = z.object({ + name: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format'), + hostId: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid docker host id'), + container: z + .string() + .min(2) + .max(128) + .regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/, 'Invalid container name'), + hostWorkspacePath: z + .string() + .min(1) + .max(2000) + .regex(/^\//, 'Workspace path must be absolute') + .regex(/^[^,]*$/, 'Workspace path must not contain commas (docker --mount is comma-delimited)') + .regex(NO_SHELL_META, 'Invalid characters in workspace path'), + containerWorkdir: z + .string() + .min(1) + .max(2000) + .regex(/^\//, 'Container workdir must be absolute') + .regex(/^[^,]*$/, 'Container workdir must not contain commas (docker --mount is comma-delimited)') + .regex(NO_SHELL_META, 'Invalid characters in container workdir') + .optional(), +}); + +/** Read-only adoption preflight: report on an existing container, link nothing. */ +export const DockerAdoptPreflightSchema = z.object({ + hostId: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid docker host id'), + container: z + .string() + .min(2) + .max(128) + .regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/, 'Invalid container name'), + /** Optional: also verify this path exists INSIDE the container. */ + containerWorkdir: z + .string() + .min(1) + .max(2000) + .regex(/^\//, 'Container workdir must be absolute') + .regex(NO_SHELL_META, 'Invalid characters in container workdir') + .optional(), +}); + +/** Read-only directory listing inside a container (adoption workdir picker). */ +export const DockerBrowseSchema = z.object({ + hostId: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid docker host id'), + container: z + .string() + .min(2) + .max(128) + .regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/, 'Invalid container name'), + path: z + .string() + .max(2000) + .regex(/^\//, 'Path must be absolute') + .regex(NO_SHELL_META, 'Invalid characters in path') + .optional(), +}); + export const DockerExportSchema = z.object({ mode: z.enum(['full', 'workspace']).optional(), }); @@ -1716,3 +1784,25 @@ export const WebviewUpdateSchema = WebviewBaseSchema.partial(); /** POST /api/webviews/probe: reachability + framing check for the editor's Test button. */ export const WebviewProbeSchema = z.object({ url: webviewUrlSchema }); + +/** + * 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 8ef0b24a..7055795c 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1555,13 +1555,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); } @@ -2805,6 +2812,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 0592d952..c5f97b5c 100644 --- a/src/web/session-wait-registry.ts +++ b/src/web/session-wait-registry.ts @@ -189,6 +189,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; } /** @@ -223,6 +235,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; if (mode === 'claude') return true; // `deepseek` earns this the same way `claude` does — by emitting DEFINITIVE // signals rather than having them inferred. The DeepSeek Harness terminal @@ -250,10 +265,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 new file mode 100644 index 00000000..a522532a --- /dev/null +++ b/test/docker-adopted-container.test.ts @@ -0,0 +1,373 @@ +/** + * @fileoverview Adopting an ALREADY-RUNNING container (`DockerCase.owned === false`). + * + * The whole point of adoption is a negative guarantee: Codeman execs into a + * container the user built and runs, and never creates, starts, stops, restarts + * or removes it. A negative guarantee cannot be observed by using the feature — + * only by asserting that the mutating verbs are absent — so these tests read the + * generated command strings and assert on what is NOT in them. + * + * Mirror of the `owned:false` remote-SSH contract (COD-105). + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { + defaultDockerCommandForMode, + toSessionDocker, + isAdoptedContainer, + removeDockerContainer, + checkDockerConfigDrift, + dockerConfigHash, +} from '../src/docker-hosts.js'; +import { + buildDockerLaunchCommand, + buildDockerStopCommand, + buildDockerRemoveCommand, + buildDockerKillCommand, +} from '../src/tmux-manager.js'; +import type { DockerCase, DockerHost, SessionDocker } from '../src/types.js'; + +const HOST: DockerHost = { id: 'h1', label: 'local', engine: 'docker', image: 'codeman/agent:base' }; + +function caseFor(owned: boolean | undefined): DockerCase { + return { + name: 'adopted', + type: 'docker', + hostId: 'h1', + hostWorkspacePath: '/srv/work', + container: 'my-own-container', + ...(owned === undefined ? {} : { owned }), + }; +} + +function launchFor(docker: SessionDocker): string { + return buildDockerLaunchCommand({ + mode: 'codex', + docker, + sessionId: '11111111-2222-3333-4444-555555555555', + createContext: { + docker, + sessionId: '11111111-2222-3333-4444-555555555555', + instance: 'default', + userArgs: ['--user', '1000:0'], + credentialMounts: [], + extraMounts: [], + envCreate: { HOME: '/home/agent' }, + addHostGateway: true, + gatewayAlias: 'host.docker.internal', + }, + execEnv: { TERM: 'xterm-256color' }, + execEnvNames: [], + seedCopies: [{ from: '/seed/creds.json', to: '/home/agent/.claude/.credentials.json' }], + }); +} + +describe('adopted container: ownership plumbing', () => { + it('carries owned:false from the case onto the live session metadata', () => { + expect(toSessionDocker(HOST, caseFor(false)).owned).toBe(false); + expect(isAdoptedContainer(toSessionDocker(HOST, caseFor(false)))).toBe(true); + }); + + it('treats an absent flag as owned, so existing cases are unchanged', () => { + const docker = toSessionDocker(HOST, caseFor(undefined)); + expect(docker.owned).toBeUndefined(); + expect(isAdoptedContainer(docker)).toBe(false); + }); + + it('keeps ownership OUT of the config hash so adoption cannot mass-trip drift', () => { + // A drift-hash that moved with `owned` would flag every pre-existing case the + // moment this field shipped, and the remedy the UI offers is "recreate". + const owned = toSessionDocker(HOST, caseFor(undefined)); + const adopted = toSessionDocker(HOST, caseFor(false)); + expect(adopted.configHash).toBe(owned.configHash); + expect(dockerConfigHash({ ...owned, owned: false } as never)).toBe(owned.configHash); + }); +}); + +describe('adopted container: the launch chain never mutates lifecycle', () => { + const adopted = launchFor(toSessionDocker(HOST, caseFor(false))); + const owned = launchFor(toSessionDocker(HOST, caseFor(undefined))); + + it('never creates the container', () => { + expect(owned).toContain('docker create'); + expect(adopted).not.toContain('docker create'); + }); + + it('never starts the container', () => { + expect(owned).toContain('docker start'); + expect(adopted).not.toContain('docker start'); + }); + + it('never stops or removes the container', () => { + for (const verb of ['docker stop', 'docker rm', 'docker restart', 'docker kill']) { + expect(adopted).not.toContain(verb); + } + }); + + it('fails closed when the container is missing instead of creating it', () => { + expect(adopted).toContain('docker inspect'); + expect(adopted).toMatch(/not found.*start it yourself/i); + }); + + it('fails closed when the container is stopped instead of starting it', () => { + expect(adopted).toMatch(/\{\{\.State\.Running\}\}/); + expect(adopted).toMatch(/not running.*never starts a container it does not own/i); + }); + + it('uses no double quote and no command substitution in the launch chain', () => { + // The whole chain is embedded in an outer `bash -c "…"`. An unescaped `"` + // closes that string early, the remainder is re-tokenized, and tmux fails to + // exec with a bare `execvp(3) failed: No such file or directory` — no hint + // that the command was ever malformed. `$(…)` is banned with it because it + // is then evaluated by the wrong shell at the wrong time. + expect(adopted).not.toContain('"'); + expect(adopted).not.toContain('$('); + // Every other line already quotes with the single-quote helper. + expect(adopted).toContain('grep -qx true'); + }); + + it('skips the base-image gate, which describes an image adoption never uses', () => { + expect(owned).toContain('image inspect'); + expect(adopted).not.toContain('image inspect'); + }); + + it('never seeds host credentials into a container it does not own', () => { + expect(owned).toContain('.credentials.json'); + expect(adopted).not.toContain('.credentials.json'); + }); + + it('still execs into the in-container tmux, which is the whole point', () => { + expect(adopted).toContain('docker exec -it'); + expect(adopted).toContain('new-session -A'); + }); +}); + +describe('adopted container: the probe request must reach the server', () => { + const ui = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf8'); + const api = readFileSync(new URL('../src/web/public/api-client.js', import.meta.url), 'utf8'); + + it('never hands _apiJson an already-stringified body', () => { + // _api serializes `body` and sets Content-Type itself. Passing a string + // double-encodes it, the server sees a JSON string where it expects an + // object, and answers 400 INVALID_INPUT — which the caller reads as "the + // container could not be probed", so the menu silently showed every mode. + expect(api).toContain('fetchOpts.body = JSON.stringify(body)'); + const calls = [...ui.matchAll(/_apiJson\([^)]*\{[\s\S]{0,400}?\}\s*\)/g)].map((m) => m[0]); + expect(calls.length).toBeGreaterThan(0); + for (const call of calls) expect(call).not.toContain('body: JSON.stringify'); + }); + + it('hides every agent mode and says why when the container cannot be read', () => { + // Offering claude on a container that is not running is a click that can + // only fail, with the reason visible nowhere. + // Brace-matched, not a character window: slicing between two call sites + // silently yields '' when the second one appears ABOVE the first, and the + // assertion then passes over nothing. That has bitten this file twice. + const start = ui.indexOf('async _probeDockerCaseModes(activeCase, menu) {'); + expect(start).toBeGreaterThan(-1); + const open = ui.indexOf('{', start); + let depth = 0; + let fn = ''; + for (let i = open; i < ui.length; i++) { + if (ui[i] === '{') depth++; + else if (ui[i] === '}' && --depth === 0) { + fn = ui.slice(start, i + 1); + break; + } + } + expect(fn).toContain('_dockerCaseProbeError'); + expect(ui).toContain('_renderRunModeNotice'); + }); +}); + +describe('adopted container: claude as root', () => { + it('drops --dangerously-skip-permissions when the container runs as root', () => { + // Claude Code refuses the flag as root ("cannot be used with root/sudo + // privileges"), so keeping it kills the pane with a message only visible + // inside the container. Our base image runs a non-root user, which is why an + // owned container never hit this. + expect(defaultDockerCommandForMode('claude', true)).toBe('exec claude'); + expect(defaultDockerCommandForMode('claude', false)).toContain('--dangerously-skip-permissions'); + expect(defaultDockerCommandForMode('claude')).toContain('--dangerously-skip-permissions'); + }); + + it('leaves every other mode unchanged as root', () => { + for (const mode of ['codex', 'shell', 'pi'] as const) { + expect(defaultDockerCommandForMode(mode, true)).toBe(defaultDockerCommandForMode(mode, false)); + } + }); +}); + +describe('adopted container: the host is not required to have the CLI', () => { + const src = readFileSync(new URL('../src/tmux-manager.ts', import.meta.url), 'utf8'); + + it('skips every host CLI requirement for a docker session', () => { + // A docker session runs its CLI inside the container. Demanding it on the + // host threw, the catch fell back to a direct PTY, and that PTY tried to + // exec the CLI on the HOST — surfacing as a bare `execvp(3) failed` with + // nothing naming the real cause. + const guarded = src.match(/!cliRunsInContainer && mode === '/g) || []; + const unguarded = src.match(/\n if \(mode === '[a-z]+' && !cliDir\)/g) || []; + expect(guarded.length).toBeGreaterThanOrEqual(7); + expect(unguarded).toHaveLength(0); + }); + + 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;'); + }); +}); + +describe('adopted container: mutating verbs fail closed at the builder', () => { + const docker = toSessionDocker(HOST, caseFor(false)); + + it('refuses to build a stop command', () => { + expect(() => buildDockerStopCommand(docker)).toThrow(/does not own its lifecycle/); + }); + + it('refuses to build a remove command', () => { + expect(() => buildDockerRemoveCommand(docker)).toThrow(/does not own its lifecycle/); + }); + + it('refuses to remove the container', async () => { + await expect(removeDockerContainer(docker)).rejects.toThrow(/does not own its lifecycle/); + }); + + it('still allows killing THIS session in-container tmux, never the container', () => { + const kill = buildDockerKillCommand({ docker, sessionId: 'abcdef12-0000-0000-0000-000000000000' }); + expect(kill).toContain('tmux'); + expect(kill).toContain('kill-session'); + expect(kill).not.toContain('docker stop'); + expect(kill).not.toContain('docker rm'); + }); + + it('still permits every verb for an owned container', () => { + const ownedDocker = toSessionDocker(HOST, caseFor(undefined)); + expect(buildDockerStopCommand(ownedDocker)).toContain('stop -t 10'); + expect(buildDockerRemoveCommand(ownedDocker)).toContain('rm -f'); + }); +}); + +describe('adopted container: the Add Case panel id contract', () => { + // The modal's load/save contract is getElementById by fixed id, so a renamed or + // dropped id stops the control working with no error anywhere. Static guard in + // the style of app-settings-structure / session-options-structure. + const html = readFileSync(new URL('../src/web/public/index.html', import.meta.url), 'utf8'); + const ui = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf8'); + const css = readFileSync(new URL('../src/web/public/styles.css', import.meta.url), 'utf8'); + + it('ships every id session-ui.js reads back', () => { + for (const id of ['dockerAdoptExisting', 'dockerContainerName', 'dockerAdoptCheckBtn']) { + expect(html).toContain(`id="${id}"`); + expect(ui).toContain(`'${id}'`); + } + }); + + it('routes adoption to the endpoint that never creates a container', () => { + expect(ui).toContain('/api/cases/docker-adopt'); + expect(ui).toContain('/api/docker-cases/adopt-preflight'); + // The create path must survive untouched beside it. + expect(ui).toContain('/api/cases/docker-link'); + }); + + it('hides the adopt-only row until the toggle is on, so the panel is unchanged by default', () => { + expect(css).toContain('#createCaseModal .docker-adopt-only'); + expect(css).toMatch(/#createCaseModal \.docker-adopt-only \{\s*display: none/); + expect(css).toContain("#createCaseModal[data-docker-adopt='1'] .docker-adopt-only"); + }); + + it('marks the create-time rows so adoption hides the fields it never uses', () => { + // image / network / advanced describe a `docker create` adoption never runs. + expect(html.match(/docker-create-only/g)?.length).toBeGreaterThanOrEqual(3); + expect(css).toContain("#createCaseModal[data-docker-adopt='1'] .docker-create-only"); + }); +}); + +describe('adopted container: run modes come from the CONTAINER, not the host', () => { + const ui = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf8'); + /** Slice the method BODY. Anchored on the definition, not a call site: the + * menu opener calls _loadRunModeHistory() ABOVE this definition, so slicing + * between call sites silently yields an empty string and passes nothing. */ + /** + * The method BODY, delimited by brace depth rather than a character budget. + * A fixed window silently truncates the moment the method grows — which is + * exactly what happened twice: a comment added above the assertion pushed the + * asserted line past the cutoff and CI failed on a test that was still true. + */ + const refreshFn = (src) => { + const start = src.indexOf('_refreshRunModeAvailability(menu) {'); + expect(start).toBeGreaterThan(-1); + const open = src.indexOf('{', start); + let depth = 0; + for (let i = open; i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}' && --depth === 0) return src.slice(start, i + 1); + } + throw new Error('unbalanced braces in _refreshRunModeAvailability'); + }; + + it('gates a docker case on availableModes instead of host CLI probes', () => { + // The sandbox host had codex but no claude while the adopted container had + // claude and no codex; gating on the host hid the only mode that worked. + const fn = refreshFn(ui); + expect(fn).toContain("location === 'docker'"); + expect(fn).toContain('availableModes'); + // Non-docker cases must keep the original host probe (#201). + expect(fn).toContain('this.isCliAvailable(mode)'); + }); + + it('leaves an owned container ungated when nothing was probed', () => { + // Our base image ships every CLI, so an absent list means "unknown", and + // treating unknown as "nothing available" would empty the menu. + expect(refreshFn(ui)).toMatch(/containerModes \?[^:]*:\s*true/); + }); +}); + +describe('adopted container: both path fields get a folder picker', () => { + const html = readFileSync(new URL('../src/web/public/index.html', import.meta.url), 'utf8'); + const ui = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf8'); + const picker = readFileSync(new URL('../src/web/public/keyboard-accessory.js', import.meta.url), 'utf8'); + + it('wires a Browse button to each of the two paths', () => { + expect(html).toContain('app.openDockerWorkspacePathPicker()'); + expect(html).toContain('app.openDockerWorkdirPicker()'); + // Same markup Link Existing uses, so the two look and behave alike. + expect(html.match(/path-input-browse/g)?.length).toBeGreaterThanOrEqual(3); + }); + + it('browses the CONTAINER for the container workdir, not the host', () => { + // For an adopted container nothing is mounted at a matching host path, so a + // host listing would be a different filesystem — and typing this field blind + // is what makes the launch fail with an OCI chdir error. + const fn = ui.slice(ui.indexOf('openDockerWorkdirPicker()'), ui.indexOf('async linkRemoteCase()')); + expect(fn).toContain('/api/docker-cases/browse'); + expect(fn).not.toContain('/api/filesystem/browse'); + expect(fn).toContain('fetchListing'); + }); + + it('keeps the host picker for the host workspace path', () => { + const fn = ui.slice(ui.indexOf('openDockerWorkspacePathPicker()'), ui.indexOf('openDockerWorkdirPicker()')); + expect(fn).toContain('PathPicker.open'); + expect(fn).not.toContain('fetchListing'); + }); + + it('reuses one PathPicker via an optional source rather than forking it', () => { + expect(picker).toContain('this._options.fetchListing'); + expect(picker).toContain('/api/filesystem/browse'); + }); +}); + +describe('adopted container: drift is not evaluated', () => { + it('reports no drift rather than demanding a recreate we may not perform', async () => { + // An adopted container carries no codeman.confighash label, so a real + // comparison would always report drift and the launch gate would 409 forever. + const status = await checkDockerConfigDrift(toSessionDocker(HOST, caseFor(false))); + expect(status.drifted).toBe(false); + }); +}); diff --git a/test/file-picker-root-home.test.ts b/test/file-picker-root-home.test.ts new file mode 100644 index 00000000..c726237f --- /dev/null +++ b/test/file-picker-root-home.test.ts @@ -0,0 +1,53 @@ +/** + * @fileoverview The picker must offer a root when Codeman runs as root. + * + * `/root` is a DEFAULT blocked tree in the attachment guard, and Codeman running + * as root — containers, plenty of servers — makes `homedir()` exactly `/root`. + * The picker's own allowlisted Home root was therefore blocked by the guard, + * every other candidate lives under it or does not exist, and the endpoint + * answered 403 "No filesystem browse roots are available" with nothing the user + * could open. The fix drops only the trees that would swallow a configured root + * whole; `isSensitivePath` still guards what is inside. + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { isBlockedAttachmentPath, isUnderTree } from '../src/config/attachment-guard.js'; + +const TREES = ['/root', '/etc']; + +/** Mirror of pickerBlockedTrees in file-routes.ts. */ +const narrow = (trees: readonly string[], roots: readonly string[]) => + roots.length === 0 ? trees : trees.filter((t) => !roots.some((r) => isUnderTree(r, t))); + +describe('file picker roots when the server runs as root', () => { + it('drops the tree that would swallow the configured Home root', () => { + expect(narrow(TREES, ['/root'])).toEqual(['/etc']); + }); + + it('keeps trees that hold no configured root', () => { + expect(narrow(TREES, ['/home/alice'])).toEqual(['/root', '/etc']); + expect(narrow(TREES, [])).toEqual(['/root', '/etc']); + }); + + it('also frees a root nested under the blocked tree', () => { + // ~/codeman-cases is /root/codeman-cases when running as root. + expect(narrow(TREES, ['/root/codeman-cases'])).toEqual(['/etc']); + }); + + it('still refuses secrets inside the freed tree', () => { + const trees = narrow(TREES, ['/root']); + for (const p of ['/root/.ssh/id_rsa', '/root/.aws/credentials', '/root/app/.env']) { + expect(isBlockedAttachmentPath(p, trees)).toBe(true); + } + // …while ordinary files under it become reachable, which is the point. + expect(isBlockedAttachmentPath('/root/projects/readme.md', trees)).toBe(false); + }); + + it('navigation reuses the same narrowed list the roots were chosen with', () => { + // Handing the raw trees to navigation would admit a root and then refuse + // every path inside it — a picker that opens and then does nothing. + const src = readFileSync(new URL('../src/web/routes/file-routes.ts', import.meta.url), 'utf8'); + expect(src.match(/pickerBlockedTrees\(/g)?.length).toBeGreaterThanOrEqual(3); + expect(src).not.toMatch(/blockedTrees:\s*guard\.blockedTrees/); + }); +}); diff --git a/test/foreign-tmux.test.ts b/test/foreign-tmux.test.ts new file mode 100644 index 00000000..b11216f8 --- /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 '