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 e78e0000..94a9903f 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', @@ -252,7 +282,80 @@ 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; +} + +/** + * Which existing case, if any, blocks adopting `container` at `containerWorkdir`. + * + * One container may back SEVERAL adopted cases, each pointing at a different + * directory inside it — that is the whole reason to adopt the same container + * twice, and it is safe because the in-container tmux session is named per + * SESSION (`dockerTmuxSessionName`, `codeman-dkr-`) and not per case, so a + * session teardown kills exactly one session and its siblings on the shared + * in-container tmux server are untouched. Nothing else reaches an adopted + * container's lifecycle either: stop/remove throw at the builder, recreate + * refuses `owned === false`, and the orphan reaper filters on the + * `codeman.managed=1` label that only Codeman-created containers carry. + * + * So the conflicts that remain are NOT about the tmux server: + * - `owned-case` the container backs a case Codeman CREATED, whose lifecycle + * it owns; a recreate or delete there would destroy the + * adopted case's container out from under it. + * - `other-owner` already adopted by a different user. Adoption hands out a + * shell inside someone else's container, so it stays scoped. + * - `duplicate` same container AND same directory: the second case would + * behave identically to the first, so name the first instead + * of silently creating a twin. A DIFFERENT directory is the + * supported case and returns null. + */ +export type AdoptContainerConflict = + | { kind: 'owned-case'; caseName: string } + | { kind: 'other-owner'; caseName: string } + | { kind: 'duplicate'; caseName: string } + | null; + +export function classifyAdoptContainerConflict(params: { + container: string; + /** Directory inside the container this adoption targets (already defaulted). */ + containerWorkdir: string; + existing: ReadonlyArray< + Pick + >; + /** Owner visibility test (canAccessOwned bound to the caller). */ + canAccess: (owner?: string) => boolean; +}): AdoptContainerConflict { + const { container, containerWorkdir, existing, canAccess } = params; + const sharing = existing.filter((item) => (item.container ?? dockerContainerName(item.name)) === container); + if (sharing.length === 0) return null; + + // `owned` is optional and an ABSENT flag means owned (legacy cases predate the + // field), so this must test `!== false` rather than truthiness. + const owned = sharing.find((item) => item.owned !== false); + if (owned) return { kind: 'owned-case', caseName: owned.name }; + + const foreign = sharing.find((item) => !canAccess(item.owner)); + if (foreign) return { kind: 'other-owner', caseName: foreign.name }; + + const twin = sharing.find((item) => (item.containerWorkdir ?? item.hostWorkspacePath) === containerWorkdir); + if (twin) return { kind: 'duplicate', caseName: twin.name }; + + return null; +} + +/** + * 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 ========== @@ -753,9 +856,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( @@ -783,8 +892,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 }); @@ -1030,6 +1146,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 @@ -1092,9 +1453,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/input-cjk.js b/src/web/public/input-cjk.js index d2bf72bf..4f5420b7 100644 --- a/src/web/public/input-cjk.js +++ b/src/web/public/input-cjk.js @@ -120,6 +120,41 @@ const CjkInput = (() => { c: '\x03', d: '\x04', l: '\x0c', z: '\x1a', a: '\x01', e: '\x05', }; + /** CSI final byte per navigation key, for the modifier-carrying forms below. */ + const CSI_NAV_FINAL = { + ArrowUp: 'A', + ArrowDown: 'B', + ArrowRight: 'C', + ArrowLeft: 'D', + End: 'F', + Home: 'H', + }; + + /** + * The `CSI 1 ; ` form for a Ctrl/Alt-modified navigation key, or + * null when this key is not one. + * + * A modified navigation key is a terminal COMMAND, not text editing — claude's + * own "Jump to bottom (ctrl+End)" is one. PASSTHROUGH_KEYS carries only the + * plain forms, so Ctrl+End used to fail in BOTH directions: with an empty + * field it was sent as a bare `\x1b[F` (the modifier silently dropped, so the + * CLI saw a plain End), and with any text in the field it was not forwarded at + * all and the browser's default moved the caret to the end of the composer, + * which is what the user sees as "the shortcut does something to the input box + * instead". + * + * ⚠️ Shift ALONE is deliberately excluded: Shift+arrow selects text inside the + * composer, which is a real editing gesture worth keeping local. Shift is still + * encoded when it accompanies Ctrl or Alt. + */ + function _modifiedNavSequence(e) { + const final = CSI_NAV_FINAL[e.key]; + if (!final) return null; + if (!e.ctrlKey && !e.altKey) return null; + const mod = 1 + (e.shiftKey ? 1 : 0) + (e.altKey ? 2 : 0) + (e.ctrlKey ? 4 : 0); + return `\x1b[1;${mod}${final}`; + } + function _strip(str) { return str.replace(/​/g, ''); } @@ -321,6 +356,17 @@ const CjkInput = (() => { return; } + // Ctrl/Alt-modified navigation keys go to the PTY REGARDLESS of whether + // the field has text: they are commands for the CLI, and the composer has + // no editing behaviour for them worth preserving (plain Home/End still + // edit locally through the table below). + const modNav = _modifiedNavSequence(e); + if (modNav) { + e.preventDefault(); + _send(modNav); + return; + } + // Arrow/function keys: forward to PTY when no real text if (PASSTHROUGH_KEYS[e.key] && _isEffectivelyEmpty()) { e.preventDefault(); 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 df647ae9..ee265fa9 100644 --- a/src/web/public/mobile-overview.js +++ b/src/web/public/mobile-overview.js @@ -432,6 +432,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 c9b0838d..e4509444 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -3836,3 +3836,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 b4d18479..ecec8da9 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); } @@ -477,10 +484,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', 'omp']) { 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", @@ -622,6 +654,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; @@ -2366,6 +2466,9 @@ Object.assign(CodemanApp.prototype, { 'remoteHostPort', 'remoteHostCodexCommand', 'remoteHostIdentityFile', + // ⚠️ Must be cleared with the rest: a password left in the field would be + // silently inherited by the NEXT host created from this form. + 'remoteHostPassword', 'remoteHostSocksProxy', 'remoteHostJumpHost', 'remoteHostExtraSshOptions', @@ -2387,6 +2490,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) { @@ -2862,6 +2987,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(); @@ -2872,6 +3052,8 @@ Object.assign(CodemanApp.prototype, { // COD-107 — port + advanced SSH connection options. const portRaw = document.getElementById('remoteHostPort').value.trim(); const identityFile = document.getElementById('remoteHostIdentityFile').value.trim(); + // Deliberately NOT trimmed: leading/trailing spaces can be part of a password. + const password = document.getElementById('remoteHostPassword').value; const socksProxy = document.getElementById('remoteHostSocksProxy').value.trim(); const jumpHost = document.getElementById('remoteHostJumpHost').value.trim(); const extraSshOptions = document.getElementById('remoteHostExtraSshOptions').value @@ -2908,6 +3090,7 @@ Object.assign(CodemanApp.prototype, { username, ...(port ? { port } : {}), ...(identityFile ? { identityFile } : {}), + ...(password ? { password } : {}), ...(socksProxy ? { socksProxy } : {}), ...(jumpHost ? { jumpHost } : {}), ...(extraSshOptions.length ? { extraSshOptions } : {}), @@ -2943,10 +3126,224 @@ 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(); + void this._loadDockerCloneOptions(); + } + }, + + /** + * 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. + */ + /** + * Fill the "Duplicate an Existing Case" picker with the ADOPTED docker cases. + * + * One adopted container can back several cases, each pointing at a different + * directory inside it (classifyAdoptContainerConflict) — but re-typing the + * container, host and workspace by hand for every directory is exactly the + * friction that makes the capability go unused. Picking a case here fills those + * three and leaves only the two fields that MUST differ: the case name and the + * container workdir. + * + * ⚠️ Adopted cases only (`docker.owned === false`). An owned container's + * lifecycle belongs to its one case — a second case on it would be destroyed + * out from under itself by that case's recreate or delete — and the server + * refuses it, so offering it here would only produce a confusing error. + */ + async _loadDockerCloneOptions() { + const select = document.getElementById('dockerAdoptCloneFrom'); + const row = document.getElementById('dockerAdoptCloneRow'); + if (!select || !row) return; + let cases = []; + try { + const res = await fetch('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/api/cases'); + const data = await res.json(); + cases = (Array.isArray(data) ? data : data?.data || []).filter( + (c) => c?.docker && c.docker.owned === false + ); + } catch { + cases = []; + } + select.textContent = ''; + const blank = document.createElement('option'); + blank.value = ''; + blank.textContent = 'Start from scratch'; + select.appendChild(blank); + for (const c of cases) { + const option = document.createElement('option'); + option.value = c.name; + // Server-supplied strings: textContent, never markup. + option.textContent = `${c.name} — ${c.docker.container}:${c.docker.containerWorkdir || c.docker.path}`; + option.dataset.container = c.docker.container; + option.dataset.hostId = c.docker.hostId; + option.dataset.path = c.docker.path; + option.dataset.workdir = c.docker.containerWorkdir || c.docker.path; + select.appendChild(option); + } + // Nothing to duplicate yet: an empty picker is noise on the first adoption. + row.hidden = cases.length === 0; + }, + + /** + * Apply the picked case: carry over what STAYS the same, clear what must not. + * + * The two cleared fields are the point of the feature — a duplicate that kept + * the original's name would be rejected as an existing case, and one that kept + * its container workdir would be rejected as an exact twin (both by the server, + * with a clear message, but a form that pre-fills a value it knows will be + * refused is just a trap). + */ + applyDockerCloneSource() { + const select = document.getElementById('dockerAdoptCloneFrom'); + const option = select?.selectedOptions?.[0]; + if (!option || !option.value) return; + const set = (id, value) => { + const el = document.getElementById(id); + if (el) el.value = value || ''; + }; + set('dockerContainerName', option.dataset.container); + set('dockerHostId', option.dataset.hostId); + set('dockerWorkspacePath', option.dataset.path); + // Pre-filled, NOT cleared: these two must differ from the source, but editing + // `/srv/app/api` into `/srv/app/web` beats retyping a long path, and the same + // goes for the name. What keeps a duplicate from being submitted unchanged is + // the guard below (dockerCloneGuard), which is a better trade than an empty + // field: the form stays a starting point instead of a blank form with three + // fields mysteriously filled in. + set('dockerCaseName', option.value); + set('dockerAdoptWorkdir', option.dataset.workdir); + // Remembered so the guard can tell "unchanged" from "happens to look similar". + select.dataset.appliedName = option.value; + select.dataset.appliedWorkdir = option.dataset.workdir || ''; + const workdir = document.getElementById('dockerAdoptWorkdir'); + workdir?.focus(); + // Caret at the end: the tail is the part that changes. + if (workdir) workdir.setSelectionRange(workdir.value.length, workdir.value.length); + }, + + /** + * Refuse a duplicate that still carries the source case's name or directory. + * + * Both are pre-filled so they can be EDITED, which means both can also be left + * alone by accident. The server refuses either (an existing case name, or an + * exact same-container-same-directory twin) with a clear message, but a + * round-trip to be told "you forgot to change the field you were looking at" is + * worse than saying so here, next to the field, before anything is sent. + * + * Returns the offending element, or null when the form is fine. + */ + dockerCloneGuard() { + const select = document.getElementById('dockerAdoptCloneFrom'); + if (!select || !select.value) return null; + const name = document.getElementById('dockerCaseName'); + const workdir = document.getElementById('dockerAdoptWorkdir'); + if (name && name.value.trim() === (select.dataset.appliedName || '')) { + return { el: name, message: `"${name.value.trim()}" is the case you copied from — give this one a new name.` }; + } + if (workdir && workdir.value.trim() === (select.dataset.appliedWorkdir || '')) { + return { + el: workdir, + message: 'Same container and same directory as the case you copied from — point this one at another directory.', + }; + } + return null; + }, + + 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(); @@ -2967,9 +3364,26 @@ 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; + } + // A duplicate that still carries the source's name or directory: say so here, + // beside the field, rather than sending a request certain to come back refused. + const cloneIssue = adopting ? this.dockerCloneGuard() : null; + if (cloneIssue) { + this.showToast(cloneIssue.message, 'error'); + const statusEl = document.getElementById('dockerLinkStatus'); + if (statusEl) statusEl.textContent = cloneIssue.message; + cloneIssue.el.focus(); + cloneIssue.el.select?.(); + 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; @@ -3000,16 +3414,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 { @@ -3126,6 +3551,8 @@ Object.assign(CodemanApp.prototype, { const username = document.getElementById('remoteHostUsername').value.trim(); const portRaw = document.getElementById('remoteHostPort').value.trim(); const identityFile = document.getElementById('remoteHostIdentityFile').value.trim(); + // Deliberately NOT trimmed: leading/trailing spaces can be part of a password. + const password = document.getElementById('remoteHostPassword').value; const socksProxy = document.getElementById('remoteHostSocksProxy').value.trim(); const jumpHost = document.getElementById('remoteHostJumpHost').value.trim(); const codexCommand = document.getElementById('remoteHostCodexCommand').value.trim(); @@ -3145,6 +3572,7 @@ Object.assign(CodemanApp.prototype, { username, ...(port ? { port } : {}), ...(identityFile ? { identityFile } : {}), + ...(password ? { password } : {}), ...(socksProxy ? { socksProxy } : {}), ...(jumpHost ? { jumpHost } : {}), ...(extraSshOptions.length ? { extraSshOptions } : {}), diff --git a/src/web/public/styles.css b/src/web/public/styles.css index f382677d..d77df96d 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -10321,14 +10321,19 @@ kbd { position: fixed; inset: 0; background: var(--modal-backdrop); - backdrop-filter: blur(6px); - -webkit-backdrop-filter: blur(6px); z-index: 5100; display: none; align-items: center; justify-content: center; } +/* Same stale-hit-test reasoning as .offline-overlay above: this one is also a + persistent full-screen fixed element, shown by adding `.visible`. */ +.file-preview-overlay.visible { + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} + .file-preview-overlay.visible { display: flex; } @@ -15022,9 +15027,26 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { padding-top: calc(20px + var(--safe-area-top)); padding-bottom: calc(20px + var(--safe-area-bottom)); background: rgba(6, 8, 12, 0.93); + overflow-y: auto; +} + +/* ⚠️ `backdrop-filter` is applied ONLY while the overlay is actually shown. + It promotes the element to its own compositing layer, and a full-screen + `position: fixed` layer that is created and then hidden has been observed to + leave a STALE HIT-TEST REGION behind in Chrome: the page keeps rendering + correctly while every pointer event over the viewport lands on nothing. + Symptom (reported on a long-lived tab against a remote server, where a + connection blip shows and then hides #offlineOverlay): the terminal stops + scrolling AND unrelated click-to-expand controls stop responding at the same + time, while a freshly opened tab is fine — and a console one-liner that only + READS layout (getComputedStyle + elementFromPoint, both of which force a + hit-test recompute) restores it. Two unrelated features dying together, and a + read-only command curing them, is what points at hit-testing rather than at + either feature. Keeping the property off the hidden state means the layer is + never created while invisible. */ +.offline-overlay:not([hidden]) { backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); - overflow-y: auto; } .offline-overlay[hidden] { @@ -16645,6 +16667,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; @@ -17580,3 +17640,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 bb83cc5a..34cd799c 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -180,13 +180,13 @@ // global so both terminal-ui.js (main terminal) and panels-ui.js (teammate terminals, // a separate IIFE) can read the current skin's palette. const CODEMAN_XTERM_THEMES = { - og: { background: '#0d0d0d', foreground: '#e0e0e0', cursor: '#e0e0e0', cursorAccent: '#0d0d0d', selection: 'rgba(255,255,255,0.3)', black: '#0d0d0d', red: '#ff6b6b', green: '#51cf66', yellow: '#ffd43b', blue: '#339af0', magenta: '#cc5de8', cyan: '#22b8cf', white: '#e0e0e0', brightBlack: '#495057', brightRed: '#ff8787', brightGreen: '#69db7c', brightYellow: '#ffe066', brightBlue: '#5c7cfa', brightMagenta: '#da77f2', brightCyan: '#66d9e8', brightWhite: '#ffffff' }, - 'daylight-green': { background: '#161b23', foreground: '#dfe6ef', cursor: '#2fd3aa', cursorAccent: '#161b23', selection: 'rgba(47,211,170,0.22)', black: '#161b23', red: '#ff8585', green: '#34d8a0', yellow: '#f0c25a', blue: '#5cc6e8', magenta: '#c79af2', cyan: '#2bcbbb', white: '#dfe6ef', brightBlack: '#5b6675', brightRed: '#ffa0a0', brightGreen: '#5fe6b8', brightYellow: '#ffd884', brightBlue: '#82d4ee', brightMagenta: '#d6b3f7', brightCyan: '#5ee0d4', brightWhite: '#f3f6fa' }, - 'daylight-blue': { background: '#161b23', foreground: '#dfe6ef', cursor: '#38b6f0', cursorAccent: '#161b23', selection: 'rgba(56,182,240,0.22)', black: '#161b23', red: '#ff8585', green: '#34d8a0', yellow: '#f0c25a', blue: '#5cc6e8', magenta: '#c79af2', cyan: '#2bcbbb', white: '#dfe6ef', brightBlack: '#5b6675', brightRed: '#ffa0a0', brightGreen: '#5fe6b8', brightYellow: '#ffd884', brightBlue: '#82d4ee', brightMagenta: '#d6b3f7', brightCyan: '#5ee0d4', brightWhite: '#f3f6fa' }, - 'paper-gray': { background: '#f6f8fa', foreground: '#1f2328', cursor: '#0969da', cursorAccent: '#ffffff', selection: 'rgba(9,105,218,0.2)', black: '#24292f', red: '#cf222e', green: '#1a7f37', yellow: '#9a6700', blue: '#0969da', magenta: '#8250df', cyan: '#1b7c83', white: '#59636e', brightBlack: '#6e7781', brightRed: '#a40e26', brightGreen: '#116329', brightYellow: '#7d4e00', brightBlue: '#0550ae', brightMagenta: '#6639ba', brightCyan: '#116b75', brightWhite: '#1f2328' }, - 'solarized-light': { background: '#fdf6e3', foreground: '#586e75', cursor: '#147ba3', cursorAccent: '#fdf6e3', selection: 'rgba(38,139,210,0.2)', black: '#eee8d5', red: '#dc322f', green: '#758600', yellow: '#9b7800', blue: '#147ba3', magenta: '#d33682', cyan: '#2a9189', white: '#073642', brightBlack: '#93a1a1', brightRed: '#cb4b16', brightGreen: '#657b83', brightYellow: '#586e75', brightBlue: '#268bd2', brightMagenta: '#6c71c4', brightCyan: '#2aa198', brightWhite: '#002b36' }, - 'catppuccin-latte': { background: '#eff1f5', foreground: '#4c4f69', cursor: '#1e66f5', cursorAccent: '#ffffff', selection: 'rgba(30,102,245,0.18)', black: '#5c5f77', red: '#d20f39', green: '#3b8f2b', yellow: '#a86605', blue: '#1e66f5', magenta: '#8839ef', cyan: '#177f86', white: '#6c6f85', brightBlack: '#7c7f93', brightRed: '#b50930', brightGreen: '#2f7622', brightYellow: '#8b5604', brightBlue: '#174fbf', brightMagenta: '#6f2bc5', brightCyan: '#116b71', brightWhite: '#4c4f69' }, - 'rose-pine-dawn': { background: '#faf4ed', foreground: '#575279', cursor: '#286983', cursorAccent: '#fffaf3', selection: 'rgba(40,105,131,0.2)', black: '#575279', red: '#b4637a', green: '#286983', yellow: '#96681f', blue: '#477f91', magenta: '#907aa9', cyan: '#3f7f8b', white: '#6e6a86', brightBlack: '#797593', brightRed: '#984d66', brightGreen: '#1f5266', brightYellow: '#7d5417', brightBlue: '#386b7c', brightMagenta: '#765f90', brightCyan: '#326b76', brightWhite: '#575279' }, + og: { background: '#0d0d0d', foreground: '#e0e0e0', cursor: '#e0e0e0', cursorAccent: '#0d0d0d', selectionBackground: 'rgba(255,255,255,0.3)', selectionInactiveBackground: 'rgba(255,255,255,0.165)', black: '#0d0d0d', red: '#ff6b6b', green: '#51cf66', yellow: '#ffd43b', blue: '#339af0', magenta: '#cc5de8', cyan: '#22b8cf', white: '#e0e0e0', brightBlack: '#495057', brightRed: '#ff8787', brightGreen: '#69db7c', brightYellow: '#ffe066', brightBlue: '#5c7cfa', brightMagenta: '#da77f2', brightCyan: '#66d9e8', brightWhite: '#ffffff' }, + 'daylight-green': { background: '#161b23', foreground: '#dfe6ef', cursor: '#2fd3aa', cursorAccent: '#161b23', selectionBackground: 'rgba(47,211,170,0.22)', selectionInactiveBackground: 'rgba(47,211,170,0.121)', black: '#161b23', red: '#ff8585', green: '#34d8a0', yellow: '#f0c25a', blue: '#5cc6e8', magenta: '#c79af2', cyan: '#2bcbbb', white: '#dfe6ef', brightBlack: '#5b6675', brightRed: '#ffa0a0', brightGreen: '#5fe6b8', brightYellow: '#ffd884', brightBlue: '#82d4ee', brightMagenta: '#d6b3f7', brightCyan: '#5ee0d4', brightWhite: '#f3f6fa' }, + 'daylight-blue': { background: '#161b23', foreground: '#dfe6ef', cursor: '#38b6f0', cursorAccent: '#161b23', selectionBackground: 'rgba(56,182,240,0.22)', selectionInactiveBackground: 'rgba(56,182,240,0.121)', black: '#161b23', red: '#ff8585', green: '#34d8a0', yellow: '#f0c25a', blue: '#5cc6e8', magenta: '#c79af2', cyan: '#2bcbbb', white: '#dfe6ef', brightBlack: '#5b6675', brightRed: '#ffa0a0', brightGreen: '#5fe6b8', brightYellow: '#ffd884', brightBlue: '#82d4ee', brightMagenta: '#d6b3f7', brightCyan: '#5ee0d4', brightWhite: '#f3f6fa' }, + 'paper-gray': { background: '#f6f8fa', foreground: '#1f2328', cursor: '#0969da', cursorAccent: '#ffffff', selectionBackground: 'rgba(9,105,218,0.2)', selectionInactiveBackground: 'rgba(9,105,218,0.11)', black: '#24292f', red: '#cf222e', green: '#1a7f37', yellow: '#9a6700', blue: '#0969da', magenta: '#8250df', cyan: '#1b7c83', white: '#59636e', brightBlack: '#6e7781', brightRed: '#a40e26', brightGreen: '#116329', brightYellow: '#7d4e00', brightBlue: '#0550ae', brightMagenta: '#6639ba', brightCyan: '#116b75', brightWhite: '#1f2328' }, + 'solarized-light': { background: '#fdf6e3', foreground: '#586e75', cursor: '#147ba3', cursorAccent: '#fdf6e3', selectionBackground: 'rgba(38,139,210,0.2)', selectionInactiveBackground: 'rgba(38,139,210,0.11)', black: '#eee8d5', red: '#dc322f', green: '#758600', yellow: '#9b7800', blue: '#147ba3', magenta: '#d33682', cyan: '#2a9189', white: '#073642', brightBlack: '#93a1a1', brightRed: '#cb4b16', brightGreen: '#657b83', brightYellow: '#586e75', brightBlue: '#268bd2', brightMagenta: '#6c71c4', brightCyan: '#2aa198', brightWhite: '#002b36' }, + 'catppuccin-latte': { background: '#eff1f5', foreground: '#4c4f69', cursor: '#1e66f5', cursorAccent: '#ffffff', selectionBackground: 'rgba(30,102,245,0.18)', selectionInactiveBackground: 'rgba(30,102,245,0.099)', black: '#5c5f77', red: '#d20f39', green: '#3b8f2b', yellow: '#a86605', blue: '#1e66f5', magenta: '#8839ef', cyan: '#177f86', white: '#6c6f85', brightBlack: '#7c7f93', brightRed: '#b50930', brightGreen: '#2f7622', brightYellow: '#8b5604', brightBlue: '#174fbf', brightMagenta: '#6f2bc5', brightCyan: '#116b71', brightWhite: '#4c4f69' }, + 'rose-pine-dawn': { background: '#faf4ed', foreground: '#575279', cursor: '#286983', cursorAccent: '#fffaf3', selectionBackground: 'rgba(40,105,131,0.2)', selectionInactiveBackground: 'rgba(40,105,131,0.11)', black: '#575279', red: '#b4637a', green: '#286983', yellow: '#96681f', blue: '#477f91', magenta: '#907aa9', cyan: '#3f7f8b', white: '#6e6a86', brightBlack: '#797593', brightRed: '#984d66', brightGreen: '#1f5266', brightYellow: '#7d5417', brightBlue: '#386b7c', brightMagenta: '#765f90', brightCyan: '#326b76', brightWhite: '#575279' }, }; const CODEMAN_LIGHT_SKINS = new Set(['paper-gray', 'solarized-light', 'catppuccin-latte', 'rose-pine-dawn']); function currentSkin() { @@ -285,6 +285,7 @@ Object.assign(CodemanApp.prototype, { const container = document.getElementById('terminalContainer'); this.terminal.open(container); this._installMobileTapMouseGuard(); + this._installShiftDragSelection(); this._installTouchSelectionFocusGuard(); // Let xterm's CompositionHelper own IME key events. In particular, a @@ -850,7 +851,25 @@ Object.assign(CodemanApp.prototype, { container.addEventListener('contextmenu', (ev) => { if (longPressTimer !== null || this._touchSelecting || this._touchSelectionActive) { ev.preventDefault(); + return; } + // Right-click COPIES the selection, the mintty/PuTTY convention, because + // the browser's own menu structurally cannot offer it here: xterm paints + // glyphs into a canvas, so a terminal selection is not a DOM selection + // and the native "Copy" item has nothing to act on (it is absent or + // inert). This is the second half of the habit users bring from a native + // terminal running a mouse-tracking TUI — Shift+drag to select (see + // _installShiftDragSelection), right-click to copy — and without it that + // gesture dead-ends after the selection is made. + // + // With NOTHING selected the native menu is left alone: it still carries + // the browser-level items (reload, inspect) and suppressing it there + // would take them away to offer nothing in return. + if (!this.terminal?.hasSelection?.()) return; + const selection = this.terminal.getSelection(); + if (!selection) return; + ev.preventDefault(); + void this.copyTerminalSelection(selection); }); container.addEventListener( @@ -1985,6 +2004,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 +2021,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 +2034,7 @@ Object.assign(CodemanApp.prototype, { hideWelcome() { this.hideMobileOverview?.(); this.hideHomeSessions?.(); + this.stopForeignPolling?.(); const overlay = document.getElementById('welcomeOverlay'); if (overlay) { overlay.classList.remove('visible'); @@ -4721,6 +4748,51 @@ Object.assign(CodemanApp.prototype, { this._sendSyntheticSgrTap(ev.clientX, ev.clientY); }, + /** + * Make Shift+drag START a selection instead of trying to extend one. + * + * In a native terminal running a mouse-tracking TUI (claude, codex), Shift is + * the "let me select text" modifier: it bypasses the app's mouse reporting so + * the emulator selects locally. Users bring that habit here, and here it did + * NOTHING — Shift+drag selected no text at all (measured). + * + * The reason is that the habit and xterm's Shift mean different things once + * the DECSETs are stripped. xterm reads Shift as "force selection" ONLY while + * the app actually has mouse tracking on; the server strips those DECSETs for + * claude/codex/gemini (isAltScreenStripMode), so xterm's mouseTrackingMode is + * permanently `none`, that branch is unreachable, and Shift instead falls into + * `_onIncrementalClick` — EXTEND an existing selection. Extending is a no-op + * when `selectionStart` is null, so the drag never anchors and no selection is + * ever built (this is why nothing gets cleared: there was nothing to clear). + * + * So plant the anchor xterm is missing. Runs in the CAPTURE phase on the + * `.xterm` root, an ancestor of the `.xterm-screen` element SelectionService + * binds to, so it lands before xterm's own mousedown; xterm's incremental + * handler then extends from our anchor and the drag behaves like a plain one. + * A Shift+drag with a selection ALREADY up is left alone — that is a genuine + * extend gesture and xterm already does it right. + */ + _installShiftDragSelection() { + const el = this.terminal?.element; + if (!el || el._codemanShiftDragInstalled) return; + el._codemanShiftDragInstalled = true; + el.addEventListener( + 'mousedown', + (ev) => { + if (!ev.isTrusted || ev.button !== 0 || !ev.shiftKey) return; + if (ev.altKey || ev.ctrlKey || ev.metaKey) return; + if (this.terminal?.hasSelection?.()) return; + const pos = this._clientPointToCell(ev.clientX, ev.clientY); + if (!pos) return; + // _clientPointToCell is 1-based and viewport-relative; select() takes a + // 0-based column and an ABSOLUTE buffer row. + const viewportY = this.terminal.buffer?.active?.viewportY ?? 0; + this.terminal.select(pos.col - 1, pos.row - 1 + viewportY, 0); + }, + true + ); + }, + _installMobileTapMouseGuard() { const el = this.terminal?.element; if (!el || el._codemanTapMouseGuardInstalled) return; diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index 978ac941..8b63f540 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,11 @@ import { DEFAULT_AGENT_IMAGE, dockerContainerName, dockerDisplayPath, + probeAdoptableContainer, + classifyAdoptContainerConflict, + listDockerContainers, + browseInContainer, + DOCKER_ADOPT_PROBE_MODES, readDockerCases, readDockerHosts, removeDockerContainer, @@ -73,9 +81,12 @@ import { writeDockerCases, writeDockerHosts, } from '../../docker-hosts.js'; +import type { AdoptedContainerProbe, DockerBrowseResult, DockerContainerInfo } from '../../docker-hosts.js'; import { buildDockerRemoveCommand } from '../../tmux-manager.js'; import { checkRemoteTmuxAvailable, + redactRemoteHost, + mergeRemoteHostSecret, listRemoteCodemanSessions, readRemoteCases, readRemoteHosts, @@ -290,7 +301,10 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config container, image: host.image, path: dockerCase.hostWorkspacePath, + containerWorkdir: dockerCase.containerWorkdir ?? dockerCase.hostWorkspacePath, + owned: dockerCase.owned !== false, network: host.network ?? 'bridge', + ...(dockerCase.availableModes ? { availableModes: dockerCase.availableModes } : {}), }, }; const existingIndex = cases.findIndex((item) => item.name === dockerCase.name); @@ -541,8 +555,11 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config // Hosts are machine-level infra config (ssh users/identity paths): non-admins get an // empty list in multi-user mode, matching the admin-only write side. No-op otherwise. + // ⚠️ redactRemoteHost: a host may now carry an SSH password, and it must never + // reach a browser. Callers get `passwordSet` instead, which is all a UI needs to + // render "saved — replace or clear". app.get('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/api/remote-hosts', async (req) => - isMultiUserMode() && !isAdmin(req) ? [] : readRemoteHosts(CODEMAN_CONFIG_DIR) + isMultiUserMode() && !isAdmin(req) ? [] : (await readRemoteHosts(CODEMAN_CONFIG_DIR)).map(redactRemoteHost) ); // Hosts are machine-level resources: only admins may define them in multi-user mode. @@ -580,7 +597,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Remote host already exists'); } await writeRemoteHosts(CODEMAN_CONFIG_DIR, [...hosts, host]); - return { success: true, data: { host } }; + return { success: true, data: { host: redactRemoteHost(host) } }; }); app.put('/api/remote-hosts/:id', async (req, reply): Promise> => { @@ -592,9 +609,13 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config const index = hosts.findIndex((item) => item.id === id); if (index === -1) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found'); const next = [...hosts]; - next[index] = host; + // ⚠️ The GET above redacts the password, so a host round-tripping through the + // UI arrives WITHOUT one; writing it straight back would silently erase the + // stored secret and the next launch would fall back to key auth and fail. An + // explicit empty string still clears it — see mergeRemoteHostSecret. + next[index] = mergeRemoteHostSecret(host, hosts[index]); await writeRemoteHosts(CODEMAN_CONFIG_DIR, next); - return { success: true, data: { host } }; + return { success: true, data: { host: redactRemoteHost(next[index]) } }; }); app.delete('/api/remote-hosts/:id', async (req, reply): Promise> => { @@ -771,6 +792,185 @@ 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'); + } + // One container may back SEVERAL adopted cases, each pointing at a different + // directory inside it. What still blocks it, and why, lives in + // classifyAdoptContainerConflict — note that none of it is about the shared + // in-container tmux server, which is safe precisely because sessions there + // are named per SESSION id (`codeman-dkr-`), never per case. + const container = dockerCase.container; + const conflict = classifyAdoptContainerConflict({ + container, + containerWorkdir: dockerCase.containerWorkdir ?? dockerCase.hostWorkspacePath, + existing: dockerCases, + canAccess: (owner) => canAccessOwned(getAuthUser(req), owner), + }); + if (conflict?.kind === 'owned-case') { + return createErrorResponse( + ApiErrorCode.ALREADY_EXISTS, + `Container "${container}" belongs to case "${conflict.caseName}", which Codeman created and whose lifecycle it manages. Adopt a container you started yourself, or open that case directly.` + ); + } + if (conflict?.kind === 'other-owner') { + return createErrorResponse( + ApiErrorCode.FORBIDDEN, + `Container "${container}" is already adopted by another user.` + ); + } + if (conflict?.kind === 'duplicate') { + return createErrorResponse( + ApiErrorCode.ALREADY_EXISTS, + `Case "${conflict.caseName}" already adopts "${container}" at that same directory. Point this one at another directory inside the container.` + ); + } + + 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 +1242,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 +1368,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 { @@ -1273,6 +1493,8 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config container, image: host.image, path: dockerCase.hostWorkspacePath, + containerWorkdir: dockerCase.containerWorkdir ?? dockerCase.hostWorkspacePath, + owned: dockerCase.owned !== false, network: host.network ?? 'bridge', }, }; 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('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/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 84584990..7e14afdc 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -29,6 +29,16 @@ import { type DeepSeekConfig, type OmpConfig, } 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 { @@ -130,6 +140,7 @@ import { import { checkDockerAvailable, checkDockerConfigDrift, + probeAdoptableContainer, checkDockerTmuxAvailable, ensureAgentBaseImage, DEFAULT_AGENT_IMAGE, @@ -1146,6 +1157,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) => { @@ -3039,25 +3193,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/routes/ws-routes.ts b/src/web/routes/ws-routes.ts index fc6e4c61..ed60f5d3 100644 --- a/src/web/routes/ws-routes.ts +++ b/src/web/routes/ws-routes.ts @@ -192,8 +192,31 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost // a duplicate: the input was lost for good. if (!delivered && cid && seq !== null) session.forgetInputSeq(cid, seq); } - if (delivered && seq !== null && socket.readyState === 1) { - socket.send(`{"t":"ia","seq":${seq}}`); + if (seq !== null && socket.readyState === 1) { + if (apply) { + if (delivered) socket.send(`{"t":"ia","seq":${seq}}`); + } else { + // REJECTED as a duplicate. ACK it — the client must still drop it + // from its durable queue — but say so, and hand back our watermark. + // + // A plain ACK here is indistinguishable from "applied", which is + // what made a client with a rolled-back counter unrecoverable: its + // seqs persist to localStorage on a DEBOUNCED write, so a tab killed + // between a send and that write comes back with a counter BELOW this + // watermark, every later keystroke lands at or under it, and each one + // is dropped-but-ACKed. The UI stays clean, nothing is delivered, and + // a reload restores the same stale counter. `last` is what lets the + // client lift itself out. + // ⚠️ Defensive: the session arrives through a structural port, and an + // implementation without this method must not take the whole input + // path down with it — a throw here aborts the message handler and the + // frame is never ACKed at all, which strands it in the client's queue. + const watermark = + typeof (session as { lastInputSeq?: (c: string) => number }).lastInputSeq === 'function' + ? (session as { lastInputSeq: (c: string) => number }).lastInputSeq(cid as string) + : seq; + socket.send(`{"t":"ia","seq":${seq},"dup":true,"last":${watermark}}`); + } } } else if ( msg.t === 'z' && diff --git a/src/web/schemas.ts b/src/web/schemas.ts index a78b9def..5ed35c09 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -650,6 +650,15 @@ export const RemoteHostSchema = z.object({ .max(100) .regex(/^[a-zA-Z0-9._-]+$/, 'Invalid SSH username'), port: z.number().int().min(1).max(65535).optional(), + // SSH password for hosts that accept no key. Opt-in, stored 0600, never + // returned by the API (redactRemoteHost) and never placed on a command line — + // it reaches ssh through `sshpass -e` / the SSHPASS environment variable. + // + // ⚠️ Deliberately NOT filtered by NO_SHELL_META like the path fields below: a + // password legitimately contains `$` and backticks, and unlike those it is never + // interpolated into a shell string. An EMPTY string is allowed on purpose — it is + // the "clear the stored password" gesture (see mergeRemoteHostSecret). + password: z.string().max(1024).optional(), // Identity (private-key) file PATH only — never key bytes. Reject shell // metacharacters ($, backtick) that survive into the `bash -c` launch layer. identityFile: z.string().min(1).max(4096).regex(NO_SHELL_META, 'Invalid identity file path').optional(), @@ -815,6 +824,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(), }); @@ -1742,3 +1819,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 0cf318d6..88a27fe4 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1558,13 +1558,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 '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/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); } @@ -2809,6 +2816,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-adopt-duplicate.test.ts b/test/docker-adopt-duplicate.test.ts new file mode 100644 index 00000000..10bb90f2 --- /dev/null +++ b/test/docker-adopt-duplicate.test.ts @@ -0,0 +1,135 @@ +/** + * @fileoverview "Duplicate an existing case" in the container-adoption form. + * + * The server already allows one ADOPTED container to back several cases, each + * pointing at a different directory inside it (classifyAdoptContainerConflict). + * Re-typing the container, host and workspace by hand for every directory is the + * friction that would leave that capability unused, so the form carries them over + * and clears only the two fields that MUST differ. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, it, expect } from 'vitest'; + +const html = readFileSync(resolve(import.meta.dirname, '../src/web/public/index.html'), 'utf8'); +const ui = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8'); +const routes = readFileSync(resolve(import.meta.dirname, '../src/web/routes/case-routes.ts'), 'utf8'); +const apiTypes = readFileSync(resolve(import.meta.dirname, '../src/types/api.ts'), 'utf8'); + +describe('the API exposes what the picker needs', () => { + it('reports each docker case s directory inside the container', () => { + // Without it the picker cannot show WHICH directory a case already uses, which + // is the one thing the user needs to see before choosing a different one. + expect(apiTypes).toMatch(/containerWorkdir\?: string;/); + expect(routes).toContain('containerWorkdir: dockerCase.containerWorkdir ?? dockerCase.hostWorkspacePath'); + }); + + it('reports whether the container is owned, on EVERY case-shaped response', () => { + // Two sites build a docker CaseInfo (the list and the single-case lookup); + // filling only one leaves the picker blind depending on which the UI read. + expect(routes.match(/owned: dockerCase\.owned !== false,/g) ?? []).toHaveLength(2); + }); + + it('treats an ABSENT owned flag as owned, so legacy cases are not offered', () => { + // `owned` is optional and predates this field; truthiness would read a legacy + // case as adopted and offer a duplicate the server then refuses. + expect(routes).toContain('dockerCase.owned !== false'); + }); +}); + +describe('the picker only offers what the server would accept', () => { + const fn = ui.slice(ui.indexOf('async _loadDockerCloneOptions()'), ui.indexOf('applyDockerCloneSource()')); + + it('filters to ADOPTED cases only', () => { + expect(fn).toMatch(/c\.docker\.owned === false/); + }); + + it('hides the row entirely when there is nothing to duplicate', () => { + expect(fn).toMatch(/row\.hidden = cases\.length === 0/); + }); + + it('builds options with textContent, never markup', () => { + // Case names and container names are user- and engine-supplied strings. + expect(fn).toContain('option.textContent ='); + expect(fn).not.toContain('innerHTML'); + }); +}); + +describe('applying a source fills every field, including the two that must differ', () => { + const fn = ui.slice(ui.indexOf('applyDockerCloneSource()'), ui.indexOf('dockerCloneGuard()')); + + it('carries over container, host and workspace', () => { + for (const id of ['dockerContainerName', 'dockerHostId', 'dockerWorkspacePath']) { + expect(fn).toContain(`set('${id}', option.dataset.`); + } + }); + + it('PRE-FILLS the case name and container workdir rather than clearing them', () => { + // Editing `/srv/app/api` into `/srv/app/web` beats retyping a long path, and a + // form with three fields mysteriously filled and two blank reads as broken. + // What stops an unchanged submit is the guard, not an empty field. + expect(fn).toContain("set('dockerCaseName', option.value)"); + expect(fn).toContain("set('dockerAdoptWorkdir', option.dataset.workdir)"); + }); + + it('remembers what it applied, so the guard can tell unchanged from similar', () => { + expect(fn).toContain('select.dataset.appliedName = option.value'); + expect(fn).toContain('select.dataset.appliedWorkdir ='); + }); + + it('focuses the workdir with the caret at the END, where the edit happens', () => { + expect(fn).toMatch(/setSelectionRange\(workdir\.value\.length, workdir\.value\.length\)/); + }); + + it('does nothing for the blank "start from scratch" option', () => { + expect(fn).toMatch(/if \(!option \|\| !option\.value\) return;/); + }); +}); + +describe('the guard refuses a duplicate that was never edited', () => { + const fn = ui.slice(ui.indexOf('dockerCloneGuard()'), ui.indexOf('dockerCloneGuard()') + 1400); + + it('flags an unchanged case name', () => { + expect(fn).toMatch(/appliedName/); + expect(fn).toContain('give this one a new name'); + }); + + it('flags an unchanged container workdir', () => { + expect(fn).toMatch(/appliedWorkdir/); + expect(fn).toContain('another directory'); + }); + + it('stays silent when no source was picked', () => { + // Typing a fresh adoption by hand must not be second-guessed. + expect(fn).toMatch(/if \(!select \|\| !select\.value\) return null;/); + }); + + it('runs BEFORE the request, and focuses the offending field', () => { + const submit = ui.slice(ui.indexOf('const cloneIssue'), ui.indexOf('const cloneIssue') + 500); + expect(submit).toContain('cloneIssue.el.focus()'); + expect(submit).toContain('return;'); + }); + + it('reports into a status element that actually exists', () => { + // A dead id would silently drop the explanation next to the field. + const submit = ui.slice(ui.indexOf('const cloneIssue'), ui.indexOf('const cloneIssue') + 500); + const id = /getElementById\('([^']+)'\)/.exec(submit)?.[1]; + expect(id).toBeTruthy(); + expect(html).toContain(`id="${id}"`); + }); +}); + +describe('the row is wired into the adoption panel', () => { + it('lives in the adopt-only block and starts hidden', () => { + expect(html).toMatch(/id="dockerAdoptCloneRow"[^>]*hidden/); + expect(html).toMatch(/class="form-row docker-adopt-only" id="dockerAdoptCloneRow"/); + }); + + it('loads its options whenever adopt mode turns on', () => { + // Slice from the DEFINITION, not the first call site. + const start = ui.indexOf('_syncDockerAdoptMode() {'); + expect(start).toBeGreaterThan(-1); + const sync = ui.slice(start, start + 900); + expect(sync).toContain('_loadDockerCloneOptions()'); + }); +}); diff --git a/test/docker-adopted-container.test.ts b/test/docker-adopted-container.test.ts new file mode 100644 index 00000000..4d03327c --- /dev/null +++ b/test/docker-adopted-container.test.ts @@ -0,0 +1,485 @@ +/** + * @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, + classifyAdoptContainerConflict, + dockerContainerName, +} 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: '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/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); + }); +}); + +describe('adopted container: one container may back several cases', () => { + const base = { + type: 'docker' as const, + hostId: 'h1', + hostWorkspacePath: '/srv/work', + }; + const mk = (over: Record) => ({ ...base, ...over }) as never; + const mine = () => true; + + it('allows a second adoption of the same container at a DIFFERENT directory', () => { + // The whole point of the feature: one container, two folders, two cases. + const conflict = classifyAdoptContainerConflict({ + container: 'devbox', + containerWorkdir: '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/app/api', + existing: [mk({ name: 'web', container: 'devbox', containerWorkdir: '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/app/web', owned: false })], + canAccess: mine, + }); + expect(conflict).toBeNull(); + }); + + it('refuses an exact twin (same container AND same directory) and names the first case', () => { + const conflict = classifyAdoptContainerConflict({ + container: 'devbox', + containerWorkdir: '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/app/web', + existing: [mk({ name: 'web', container: 'devbox', containerWorkdir: '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/app/web', owned: false })], + canAccess: mine, + }); + expect(conflict).toEqual({ kind: 'duplicate', caseName: 'web' }); + }); + + it('falls back to hostWorkspacePath when containerWorkdir is absent on either side', () => { + // containerWorkdir defaults to hostWorkspacePath, so an absent field on the + // stored case must compare equal to an incoming adoption that omits it too — + // otherwise the twin check silently stops firing for the default case. + const conflict = classifyAdoptContainerConflict({ + container: 'devbox', + containerWorkdir: '/srv/work', + existing: [mk({ name: 'web', container: 'devbox', owned: false })], + canAccess: mine, + }); + expect(conflict).toEqual({ kind: 'duplicate', caseName: 'web' }); + }); + + it('still refuses a container backing a case Codeman CREATED', () => { + // Codeman owns that container's lifecycle: a recreate or case-delete there + // would destroy the adopted case's container out from under it. + const conflict = classifyAdoptContainerConflict({ + container: 'codeman-case-web', + containerWorkdir: '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/app/api', + existing: [mk({ name: 'web', container: 'codeman-case-web', owned: true })], + canAccess: mine, + }); + expect(conflict).toEqual({ kind: 'owned-case', caseName: 'web' }); + }); + + it('treats an ABSENT owned flag as owned, so legacy cases keep the old refusal', () => { + const conflict = classifyAdoptContainerConflict({ + container: 'legacy', + containerWorkdir: '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/app/api', + existing: [mk({ name: 'old', container: 'legacy' })], + canAccess: mine, + }); + expect(conflict).toEqual({ kind: 'owned-case', caseName: 'old' }); + }); + + it('derives the container name from the case name when the field is absent', () => { + const conflict = classifyAdoptContainerConflict({ + container: dockerContainerName('web'), + containerWorkdir: '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/app/api', + existing: [mk({ name: 'web', owned: true })], + canAccess: mine, + }); + expect(conflict).toEqual({ kind: 'owned-case', caseName: 'web' }); + }); + + it('refuses a container another user already adopted', () => { + const conflict = classifyAdoptContainerConflict({ + container: 'devbox', + containerWorkdir: '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/app/api', + existing: [mk({ name: 'theirs', container: 'devbox', owned: false, owner: 'bob' })], + canAccess: (owner) => owner === 'alice', + }); + expect(conflict).toEqual({ kind: 'other-owner', caseName: 'theirs' }); + }); + + it('an owned case outranks a foreign adoption, so the message names the real blocker', () => { + const conflict = classifyAdoptContainerConflict({ + container: 'devbox', + containerWorkdir: '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/app/api', + existing: [ + mk({ name: 'theirs', container: 'devbox', owned: false, owner: 'bob' }), + mk({ name: 'built', container: 'devbox', owned: true }), + ], + canAccess: (owner) => owner === 'alice', + }); + expect(conflict).toEqual({ kind: 'owned-case', caseName: 'built' }); + }); + + it('leaves an unrelated container alone', () => { + expect( + classifyAdoptContainerConflict({ + container: 'fresh', + containerWorkdir: '/app', + existing: [mk({ name: 'web', container: 'devbox', owned: false })], + canAccess: mine, + }) + ).toBeNull(); + }); +}); 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 '