From 15eebde83213b1b5def51c453b7b027bb684fe80 Mon Sep 17 00:00:00 2001 From: d fei Date: Sat, 29 Aug 2026 21:02:19 -0700 Subject: [PATCH 01/15] feat(docker): attach a case to an already-running container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docker cases could only run in a container Codeman created itself. Attaching to one the user already built and runs means Codeman must leave that container's lifecycle completely alone, which the launch chain could not do: it was `image inspect` -> `inspect || create` -> `start` -> `exec`. Adds `DockerCase.owned`, mirroring the `owned:false` contract remote-SSH already uses for attached sessions. Absent (every existing case) means owned, so current behaviour is byte-identical. `false` means the container belongs to the user and Codeman may only exec into it. The launch chain for an attached container only looks, then execs: no image gate (the image is theirs), no create, and no `start` — starting a container we do not own is the very mutation attaching promises not to perform. A missing or stopped container fails closed with an actionable message instead. Credential seeding is skipped too: those copies read from create-time read-only mounts that do not exist here, and writing host credentials into someone's container is not ours to do, so its CLIs must already be authenticated inside it. Four fail-closed guards. buildDockerStopCommand and buildDockerRemoveCommand throw during pure string construction, so no caller bug can turn into a `docker stop`/`rm` on a container we do not own; removeDockerContainer refuses again at the lowest layer; drift reports "none" for an attached container, which carries no `codeman.confighash` label and would otherwise always look drifted and 409 the launch gate forever; and the orphan reaper skips attached containers through a check deliberately independent of the two conditions already covering them. `owned` is applied AFTER the config hash is computed. dockerConfigHash takes an explicit field list, so ownership can never shift an existing case's hash — if it did, every pre-existing case would trip the drift gate at once, and the remedy the UI offers is "recreate the container". Adds POST /api/cases/docker-adopt and a read-only POST /api/docker-cases/adopt-preflight. The preflight refuses at LINK time rather than at session launch, where the only ways out would be a dead pane or starting a container we do not own. Tests assert the negative guarantee directly — that create, start, stop, rm, restart and kill are absent from the generated commands while `docker exec -it` and `new-session -A` remain — since it cannot be observed by using the feature. --- src/docker-hosts.ts | 157 +++++++++++++++++++++++- src/tmux-manager.ts | 53 +++++++- src/types/session.ts | 24 ++++ src/web/routes/case-routes.ts | 135 ++++++++++++++++++++- src/web/routes/session-routes.ts | 57 ++++++--- src/web/schemas.ts | 44 +++++++ test/docker-adopted-container.test.ts | 168 ++++++++++++++++++++++++++ 7 files changed, 604 insertions(+), 34 deletions(-) create mode 100644 test/docker-adopted-container.test.ts diff --git a/src/docker-hosts.ts b/src/docker-hosts.ts index e2a924865..b49502a59 100644 --- a/src/docker-hosts.ts +++ b/src/docker-hosts.ts @@ -55,6 +55,21 @@ 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', + 'shell', +] as const satisfies readonly SessionMode[]; + /** 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. */ @@ -251,7 +266,22 @@ export function toSessionDocker(host: DockerHost, dockerCase: DockerCase): Sessi extraCreateArgs: host.extraCreateArgs, extraExecArgs: host.extraExecArgs, }; - return { ...base, configHash: dockerConfigHash(base) }; + // `owned` is deliberately applied AFTER the hash: dockerConfigHash() picks an + // explicit field list, so ownership can never shift an existing case's hash and + // mass-trip the drift gate. + const session: SessionDocker = { ...base, configHash: dockerConfigHash(base) }; + if (dockerCase.owned === false) session.owned = false; + return session; +} + +/** + * An ADOPTED container is one the user built and runs themselves. Codeman may + * only exec into it; it must never create, start, stop, restart or remove it. + * Every lifecycle branch routes through this one predicate so a new call site + * cannot silently opt out. + */ +export function isAdoptedContainer(docker: Pick): boolean { + return docker.owned === false; } // ========== Shell escaping ========== @@ -713,9 +743,15 @@ export interface DockerDriftStatus { * daemon down) means there is nothing to drift. No-op under VITEST. */ export async function checkDockerConfigDrift( - docker: Pick + docker: Pick ): Promise { if (IS_TEST_MODE) return { exists: false, running: false, drifted: false }; + // An ADOPTED container carries no `codeman.confighash` label — it was never + // created from our config — so every comparison would report drift and the + // launch gate would demand a recreate we are not allowed to perform. Ownership + // of its configuration belongs to the user; report "no drift" and never offer + // to rebuild it. + if (isAdoptedContainer(docker)) return { exists: true, running: false, drifted: false }; const argv = dockerEngineArgv(docker); try { const { stdout } = await execFileAsync( @@ -743,8 +779,15 @@ export async function checkDockerConfigDrift( * case's lastClaudeSessionId. No-op under VITEST. */ export async function removeDockerContainer( - docker: Pick + docker: Pick ): Promise { + // Fail CLOSED at the lowest layer: an adopted container is the user's, and no + // caller — recreate-on-drift, case delete, a future teardown — may remove it. + if (isAdoptedContainer(docker)) { + throw new Error( + `Refusing to remove adopted container "${docker.containerName}": Codeman does not own its lifecycle.` + ); + } if (IS_TEST_MODE) return; const argv = dockerEngineArgv(docker); await execFileAsync(argv[0], [...argv.slice(1), 'rm', '-f', docker.containerName], { timeout: 30_000 }); @@ -990,6 +1033,105 @@ 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[]; + error?: string; +} + +/** + * 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[] = [] +): Promise { + if (IS_TEST_MODE) { + return { ok: true, exists: true, running: true, tmuxPath: '/usr/bin/tmux', availableModes: modes }; + } + 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 probes = ['tmux', ...modes.filter((m) => m !== 'shell')]; + const script = probes.map((bin) => `command -v ${bin} >/dev/null 2>&1 && echo ${bin}`).join('; '); + 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)`, + }; + } + return { + ok: true, + exists: true, + running: true, + image, + tmuxPath: 'tmux', + availableModes: modes.filter((m) => m === 'shell' || found.has(m)), + }; + } 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}` }; + } +} + /** * Resolve the host's IP on the default docker bridge (the address a container * reaches as `host.docker.internal`), so the server can bind a hooks-only listener @@ -1052,9 +1194,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/tmux-manager.ts b/src/tmux-manager.ts index 5088793df..3e0c96cb5 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -1275,7 +1275,15 @@ export interface DockerLaunchOptions { export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string { const { mode, docker, sessionId, resumeSessionId, createContext, execEnv, execEnvNames, seedCopies } = opts; const base = buildDockerBaseArgs(docker).join(' '); - const createArgs = buildDockerCreateArgs(createContext).join(' '); + // ADOPTED container (docker.owned === false): the user built it and runs it, so + // this chain may only LOOK and then exec. No image check (the image is theirs), + // no create, and above all no `start` — starting a container we do not own is + // exactly the lifecycle mutation adoption promises never to perform. A missing + // or stopped container fails closed with an actionable message instead. + const adopted = docker.owned === false; + // Built lazily: an adopted case has no meaningful create-config, so computing + // create args for it would demand a context the adopt path never assembles. + const createArgs = adopted ? '' : buildDockerCreateArgs(createContext).join(' '); const name = shellescape(docker.containerName); const workdir = shellescape(docker.containerWorkdir); const image = shellescape(docker.image); @@ -1318,16 +1326,33 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string { ); const startFailMsg = shellescape(`Codeman: container ${docker.containerName} failed to start (docker daemon down?)`); - const imageCheck = `${base} image inspect ${image} >/dev/null 2>&1 || { echo ${imageMissingMsg}; exit 1; }`; + const notFoundMsg = shellescape( + `Codeman: container ${docker.containerName} not found. Adopted containers are never created by Codeman — start it yourself, then reopen this session.` + ); + const notRunningMsg = shellescape( + `Codeman: container ${docker.containerName} is not running. Codeman never starts a container it does not own — start it yourself, then reopen this session.` + ); + + const imageCheck = adopted + ? '' + : `${base} image inspect ${image} >/dev/null 2>&1 || { echo ${imageMissingMsg}; exit 1; }`; // create-if-missing (idempotent): reconnect / boot recovery re-runs this exact chain. - const ensure = `${base} inspect ${name} >/dev/null 2>&1 || ${base} ${createArgs}`; - const start = `${base} start ${name} >/dev/null 2>&1 || { echo ${startFailMsg}; exit 1; }`; + const ensure = adopted + ? `${base} inspect ${name} >/dev/null 2>&1 || { echo ${notFoundMsg}; exit 1; }` + : `${base} inspect ${name} >/dev/null 2>&1 || ${base} ${createArgs}`; + const start = adopted + ? `[ "$(${base} inspect -f '{{.State.Running}}' ${name} 2>/dev/null)" = true ] || { echo ${notRunningMsg}; exit 1; }` + : `${base} start ${name} >/dev/null 2>&1 || { echo ${startFailMsg}; exit 1; }`; // Seed writable credential config from read-only host mounts ONCE per container // (guarded by [ -e ] so reconnects never clobber in-container config; `cp -a` for // whole-dir credential seeds). mkdir -p the parent so a file seed works even when // no sibling share-mount pre-created the dir. Paths are fixed CONTAINER_HOME // constants (no shell metachars), so the whole inner command is shell-quoted once. - const seedSteps = (seedCopies ?? []).map((s) => { + // An ADOPTED container gets NO seed copies: those read from create-time + // read-only mounts that do not exist here, and writing host credentials into a + // container the user owns is a mutation adoption does not permit. Its CLIs must + // already be authenticated inside it. + const seedSteps = (adopted ? [] : (seedCopies ?? [])).map((s) => { const cp = s.recursive ? 'cp -a' : 'cp'; const parent = s.to.slice(0, s.to.lastIndexOf('/')); return `mkdir -p ${parent} 2>/dev/null; [ -e ${s.to} ] || ${cp} ${s.from} ${s.to} 2>/dev/null || true`; @@ -1335,7 +1360,7 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string { const innerCmd = seedSteps.length ? `${seedSteps.join(' ; ')} ; ${tmuxInvocation}` : tmuxInvocation; const execCmd = `exec ${base} exec -it --workdir ${workdir} ${execEnvFlags.join(' ')} ${name} sh -lc ${shellescape(innerCmd)}`; - return [imageCheck, ensure, start, execCmd].join(' ; '); + return [imageCheck, ensure, start, execCmd].filter(Boolean).join(' ; '); } /** @@ -1351,13 +1376,29 @@ export function buildDockerKillCommand(options: { docker: SessionDocker; session return `${base} exec ${shellescape(docker.containerName)} tmux -L ${DOCKER_TMUX_SOCKET} kill-session -t ${shellescape(dkrName)}`; } +/** + * Guard for the two builders that mutate CONTAINER lifecycle. They are pure + * string builders, so refusing here means an adopted container cannot even have + * a stop/remove command constructed for it — there is no shape of caller bug + * that turns into a `docker stop`/`rm` on something we do not own. + */ +function assertOwnedContainer(docker: SessionDocker, action: string): void { + if (docker.owned === false) { + throw new Error( + `Refusing to ${action} adopted container "${docker.containerName}": Codeman does not own its lifecycle.` + ); + } +} + /** Explicit container stop (frees RAM/CPU; conversation resumes on next launch via --resume). */ export function buildDockerStopCommand(docker: SessionDocker): string { + assertOwnedContainer(docker, 'stop'); return `${buildDockerBaseArgs(docker).join(' ')} stop -t 10 ${shellescape(docker.containerName)}`; } /** Explicit container removal (case-delete). Destroys in-image state; bind mounts survive. */ export function buildDockerRemoveCommand(docker: SessionDocker): string { + assertOwnedContainer(docker, 'remove'); return `${buildDockerBaseArgs(docker).join(' ')} rm -f ${shellescape(docker.containerName)}`; } diff --git a/src/types/session.ts b/src/types/session.ts index 8e429459d..7187dae4d 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -243,6 +243,24 @@ export interface DockerCase { containerWorkdir?: string; /** Container name (default codeman-case-). */ container?: string; + /** + * Whether THIS Codeman created the container (mirror of `SessionRemote.owned`). + * + * - `true` (default for cases Codeman linked/quick-created): we own the + * container; drift may recreate it, case-delete may `docker rm -f` it, and + * the launch chain may create + start it. + * - `false` (ADOPTED: an already-running container the user built and runs + * themselves): Codeman must never create, start, stop, restart or remove it. + * The launch chain fails closed when the container is missing or not running + * instead of touching its lifecycle, drift is not evaluated (there is no + * `codeman.confighash` label to compare), and no credential seed is copied + * into its HOME. Only the in-container tmux session is ever created or + * killed — exactly the `owned:false` remote-SSH contract. + * + * Absent is treated as owned (cases persisted before this field existed were + * all created by us). + */ + owned?: boolean; /** Last captured Claude conversation id, replayed via --resume on a fresh launch. */ lastClaudeSessionId?: string; } @@ -275,6 +293,12 @@ export interface SessionDocker { extraExecArgs?: string[]; /** Stable hash of the drift-relevant create args (recreate-on-drift detection). */ configHash?: string; + /** + * Mirror of `DockerCase.owned`, flattened onto the live session so every + * lifecycle decision (launch chain, drift, stop, remove) can see it without + * re-reading docker-cases.json. Absent = owned. See `DockerCase.owned`. + */ + owned?: boolean; } /** diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index 978ac9418..49c94e2e0 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,8 @@ import { RemoteCaseLinkSchema, RemoteHostSchema, DockerCaseLinkSchema, + DockerCaseAdoptSchema, + DockerAdoptPreflightSchema, DockerHostSchema, DockerExportSchema, DockerImportSchema, @@ -66,6 +68,8 @@ import { DEFAULT_AGENT_IMAGE, dockerContainerName, dockerDisplayPath, + probeAdoptableContainer, + DOCKER_ADOPT_PROBE_MODES, readDockerCases, readDockerHosts, removeDockerContainer, @@ -73,6 +77,7 @@ import { writeDockerCases, writeDockerHosts, } from '../../docker-hosts.js'; +import type { AdoptedContainerProbe } from '../../docker-hosts.js'; import { buildDockerRemoveCommand } from '../../tmux-manager.js'; import { checkRemoteTmuxAvailable, @@ -771,6 +776,106 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config } ); + /** + * ADOPT an already-running container (`owned: false`). The mirror of the + * remote-SSH attach path: Codeman execs into a container the user built and + * runs, and never creates, starts, stops, restarts or removes it. + * + * Everything here is read-only toward the container. The preflight refuses at + * LINK time — missing, stopped, or no tmux inside — because the alternative is + * failing at session launch, where the only ways out would be a dead pane or + * starting a container we do not own. There is no image gate and no + * `ensureCaseImage`: adoption never runs `docker create`, so the container's + * image is the user's business. + */ + app.post( + '/api/cases/docker-adopt', + async (req): Promise> => { + const dockerCase = { + ...parseBody(DockerCaseAdoptSchema, req.body), + type: 'docker' as const, + owner: ownerFor(req), + owned: false as const, + }; + const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId); + if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found'); + + const linkedCases = await readLinkedCases(); + const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR); + if ( + dockerCases.some((item) => item.name === dockerCase.name) || + linkedCases[dockerCase.name] || + existsSync(join(resolveCasesDir(getAuthUser(req)), dockerCase.name)) + ) { + return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists'); + } + // Two cases must never share one adopted container: session close kills the + // in-container tmux by session id, but a shared adoption would let one case's + // teardown and another's launch race over the same tmux server. + const container = dockerCase.container; + if (dockerCases.some((item) => (item.container ?? dockerContainerName(item.name)) === container)) { + return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, `Container "${container}" is already linked to a case`); + } + + if (!isWorkingDirAllowed(getAuthUser(req), dockerCase.hostWorkspacePath)) { + return createErrorResponse(ApiErrorCode.FORBIDDEN, 'hostWorkspacePath is outside your workspace'); + } + // The workspace must ALREADY exist: it mirrors a path inside a container we + // did not create, so silently mkdir-ing it would invent a host directory that + // does not correspond to whatever is actually mounted there. + if (!existsSync(dockerCase.hostWorkspacePath)) { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + 'hostWorkspacePath does not exist. Adoption mirrors an existing container, so point this at the real host directory already mounted into it.' + ); + } + + const availability = await checkDockerAvailable(host.engine); + if (!availability.ok) { + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + availability.error || 'docker daemon is not available' + ); + } + const probe = await probeAdoptableContainer(toSessionDocker(host, dockerCase), [...DOCKER_ADOPT_PROBE_MODES]); + if (!probe.ok) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, probe.error || 'container is not adoptable'); + } + + await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, dockerCase]); + ctx.broadcast(SseEvent.CaseLinked, { + name: dockerCase.name, + path: dockerCase.hostWorkspacePath, + type: 'docker', + }); + return { + success: true, + data: { case: dockerCase, 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. + */ + 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] + ); + 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. @@ -1010,9 +1115,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config engine: result.manifest.engine, image: result.importedImage ?? result.manifest.image, network: (['bridge', 'none', 'custom'].includes(result.manifest.network) ? result.manifest.network : 'bridge') as - | 'bridge' - | 'none' - | 'custom', + 'bridge' | 'none' | 'custom', }; await writeDockerHosts( CODEMAN_CONFIG_DIR, @@ -1042,8 +1145,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 +1271,13 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config ); // Best-effort `docker rm -f` the per-case container (case-delete is the // explicit teardown that removes it; the bind-mounted workspace survives). - const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId); + // An ADOPTED container is skipped entirely: unlinking the case must leave + // the user's own container running and untouched. The seed file is skipped + // with it — adoption never wrote one. + const host = + dockerCase.owned === false + ? undefined + : (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId); if (host) { const sessionDocker = toSessionDocker(host, dockerCase); try { diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 3e7c258f4..36ccea31e 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -127,6 +127,7 @@ import { import { checkDockerAvailable, checkDockerConfigDrift, + probeAdoptableContainer, checkDockerTmuxAvailable, ensureAgentBaseImage, DEFAULT_AGENT_IMAGE, @@ -2957,25 +2958,43 @@ 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'); + } + if (mode !== 'shell' && !probe.availableModes?.includes(mode)) { + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + `"${mode}" is not installed in container "${sessionDocker.containerName}". Adoption never modifies the container — install it inside, or pick another mode.` + ); + } + } else { + // Ensure the base image exists, auto-building the default image on first use so + // it is never a blocker. Dedup'd with any build kicked off at case-create, so + // this awaits the SAME in-flight build rather than starting a second one. + const ensured = await ensureAgentBaseImage(sessionDocker, sessionDocker.image, { + onProgress: (line) => ctx.broadcast(SseEvent.DockerImageBuildProgress, { name: dockerCase.name, line }), + }); + if (!ensured.ok) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, ensured.error || 'base image not available'); + } + if (ensured.built) { + ctx.broadcast(SseEvent.DockerImageBuildComplete, { name: dockerCase.name, image: sessionDocker.image }); + } + // tmux is a hard prerequisite (the in-container tmux makes reconnect durable). + // Skip the extra container-run probe for our OWN default image (the baked + // Dockerfile always contains tmux); still verify a custom image. + if (sessionDocker.image !== DEFAULT_AGENT_IMAGE) { + const tmuxCheck = await checkDockerTmuxAvailable(sessionDocker); + if (!tmuxCheck.ok) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'base image is missing tmux'); + } } } diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 1641e61f4..c58603881 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -792,6 +792,50 @@ 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'), +}); + export const DockerExportSchema = z.object({ mode: z.enum(['full', 'workspace']).optional(), }); diff --git a/test/docker-adopted-container.test.ts b/test/docker-adopted-container.test.ts new file mode 100644 index 000000000..9561bf499 --- /dev/null +++ b/test/docker-adopted-container.test.ts @@ -0,0 +1,168 @@ +/** + * @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 { + toSessionDocker, + isAdoptedContainer, + removeDockerContainer, + checkDockerConfigDrift, + dockerConfigHash, +} from '../src/docker-hosts.js'; +import { + buildDockerLaunchCommand, + buildDockerStopCommand, + buildDockerRemoveCommand, + buildDockerKillCommand, +} from '../src/tmux-manager.js'; +import type { DockerCase, DockerHost, SessionDocker } from '../src/types.js'; + +const HOST: DockerHost = { id: 'h1', label: 'local', engine: 'docker', image: 'codeman/agent:base' }; + +function caseFor(owned: boolean | undefined): DockerCase { + return { + name: 'adopted', + type: 'docker', + hostId: 'h1', + hostWorkspacePath: '/srv/work', + container: 'my-own-container', + ...(owned === undefined ? {} : { owned }), + }; +} + +function launchFor(docker: SessionDocker): string { + return buildDockerLaunchCommand({ + mode: 'codex', + docker, + sessionId: '11111111-2222-3333-4444-555555555555', + createContext: { + docker, + sessionId: '11111111-2222-3333-4444-555555555555', + instance: 'default', + userArgs: ['--user', '1000:0'], + credentialMounts: [], + extraMounts: [], + envCreate: { HOME: '/home/agent' }, + addHostGateway: true, + gatewayAlias: 'host.docker.internal', + }, + execEnv: { TERM: 'xterm-256color' }, + execEnvNames: [], + seedCopies: [{ from: '/seed/creds.json', to: '/home/agent/.claude/.credentials.json' }], + }); +} + +describe('adopted container: ownership plumbing', () => { + it('carries owned:false from the case onto the live session metadata', () => { + expect(toSessionDocker(HOST, caseFor(false)).owned).toBe(false); + expect(isAdoptedContainer(toSessionDocker(HOST, caseFor(false)))).toBe(true); + }); + + it('treats an absent flag as owned, so existing cases are unchanged', () => { + const docker = toSessionDocker(HOST, caseFor(undefined)); + expect(docker.owned).toBeUndefined(); + expect(isAdoptedContainer(docker)).toBe(false); + }); + + it('keeps ownership OUT of the config hash so adoption cannot mass-trip drift', () => { + // A drift-hash that moved with `owned` would flag every pre-existing case the + // moment this field shipped, and the remedy the UI offers is "recreate". + const owned = toSessionDocker(HOST, caseFor(undefined)); + const adopted = toSessionDocker(HOST, caseFor(false)); + expect(adopted.configHash).toBe(owned.configHash); + expect(dockerConfigHash({ ...owned, owned: false } as never)).toBe(owned.configHash); + }); +}); + +describe('adopted container: the launch chain never mutates lifecycle', () => { + const adopted = launchFor(toSessionDocker(HOST, caseFor(false))); + const owned = launchFor(toSessionDocker(HOST, caseFor(undefined))); + + it('never creates the container', () => { + expect(owned).toContain('docker create'); + expect(adopted).not.toContain('docker create'); + }); + + it('never starts the container', () => { + expect(owned).toContain('docker start'); + expect(adopted).not.toContain('docker start'); + }); + + it('never stops or removes the container', () => { + for (const verb of ['docker stop', 'docker rm', 'docker restart', 'docker kill']) { + expect(adopted).not.toContain(verb); + } + }); + + it('fails closed when the container is missing instead of creating it', () => { + expect(adopted).toContain('docker inspect'); + expect(adopted).toMatch(/not found.*start it yourself/i); + }); + + it('fails closed when the container is stopped instead of starting it', () => { + expect(adopted).toMatch(/\{\{\.State\.Running\}\}/); + expect(adopted).toMatch(/not running.*never starts a container it does not own/i); + }); + + it('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: 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: 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); + }); +}); From bc55b6b0daa352929b08e626f1aee91d68459616 Mon Sep 17 00:00:00 2001 From: d fei Date: Sat, 29 Aug 2026 21:02:19 -0700 Subject: [PATCH 02/15] feat(docker): add the attach-an-existing-container panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Docker tab gains an "Attach to an existing container" toggle. Ticking it swaps the create-time fields (image, network, advanced) — which describe a `docker create` attaching never runs — for the container name, and routes the submit to the adopt endpoint. Reuses the existing linkDockerCase flow end to end: only the final call differs. The docker-host upsert still applies, since it is what resolves the engine/context/daemon for `docker exec`; its create-time fields are simply never read for an attached case. --- src/web/public/index.html | 15 ++++- src/web/public/session-ui.js | 83 +++++++++++++++++++++++++-- src/web/public/styles.css | 26 +++++++++ test/docker-adopted-container.test.ts | 36 ++++++++++++ 4 files changed, 153 insertions(+), 7 deletions(-) diff --git a/src/web/public/index.html b/src/web/public/index.html index d9491d452..bb5757b04 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2831,6 +2831,15 @@

Remote

Docker

Run the case inside a container: one per case, shared by all its sessions.

+
+ + On: Codeman only docker execs into a container you already built and run — it never creates, starts, stops or removes it. The CLIs must already be installed and logged in inside it. +
+
+ + + Must be running already. +
@@ -2846,12 +2855,12 @@

Docker

A reusable docker host profile. Reuse the same ID across cases to share settings.
-
+
Build it once with node scripts/build-agent-image.mjs. Contains node + claude/codex/gemini/opencode/agy/pi/grok/dsh + tmux.
-
+
-
+
Advanced container settings
diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 6de0507cd..24c1f49be 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -2334,6 +2334,14 @@ 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(); + this._syncDockerAdoptMode(); // Scroll-into-view on focus for mobile keyboard visibility modal.querySelectorAll('input[type="text"]').forEach(input => { if (!input._mobileScrollWired) { @@ -2890,10 +2898,64 @@ 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'); + }, + + /** + * 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 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', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hostId, container }), + }); + 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 image = document.getElementById('dockerImage').value.trim() || 'codeman/agent:base'; const network = document.getElementById('dockerNetwork').value; const memory = document.getElementById('dockerMemory').value.trim(); @@ -2914,9 +2976,15 @@ Object.assign(CodemanApp.prototype, { this.showToast('Workspace path must be absolute', 'error'); return; } + if (adopting && !container) { + this.showToast('Enter the name of the running container to attach to', 'error'); + return; + } try { - if (statusEl) statusEl.textContent = 'Checking docker daemon + base image...'; + if (statusEl) { + statusEl.textContent = adopting ? 'Inspecting the existing container...' : 'Checking docker daemon + base image...'; + } // omitted optionals sent as UNDEFINED (never null — Zod .optional() rejects null) const resources = {}; if (memory) resources.memory = memory; @@ -2947,16 +3015,23 @@ 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 } : { name, hostId, hostWorkspacePath }), }); const caseData = await caseRes.json(); if (caseData.success) { this.closeCreateCaseModal(); const caps = caseData.data?.capsEnforced === false ? ' (resource caps are advisory on this engine)' : ''; - this.showToast(`Docker case "${name}" linked${caps}`, 'success'); + const modes = (caseData.data?.availableModes || []).filter((m) => m !== 'shell'); + const found = adopting && modes.length ? ` — found ${modes.join(', ')}` : ''; + this.showToast(`Docker case "${name}" ${adopting ? 'attached' : 'linked'}${caps}${found}`, 'success'); await this.loadQuickStartCases(name); await this.saveLastUsedCase(name); } else { diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 8efb776eb..7ca4edc35 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -16601,6 +16601,32 @@ 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. */ +#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; diff --git a/test/docker-adopted-container.test.ts b/test/docker-adopted-container.test.ts index 9561bf499..cd15a443a 100644 --- a/test/docker-adopted-container.test.ts +++ b/test/docker-adopted-container.test.ts @@ -10,6 +10,7 @@ * Mirror of the `owned:false` remote-SSH contract (COD-105). */ import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; import { toSessionDocker, isAdoptedContainer, @@ -158,6 +159,41 @@ describe('adopted container: mutating verbs fail closed at the builder', () => { }); }); +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: 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 From c98a59d7090b5ee526fe1f71cf7fb4554d79764e Mon Sep 17 00:00:00 2001 From: d fei Date: Sat, 29 Aug 2026 21:02:19 -0700 Subject: [PATCH 03/15] fix(docker): verify the container workdir and end the probe with exit 0 Two defects that only a real container exposes. The probe chained `command -v X && echo X` with semicolons, and a script's exit status is its last command's. A container without the last probed CLI made the whole `sh -lc` exit 1, so a perfectly healthy container with tmux and claude was reported as "could not exec into the container". A missing CLI is data here, not failure, so the script now ends with `exit 0`. containerWorkdir defaulted to hostWorkspacePath. That default holds for an owned container only because the create-time bind mount puts the host directory at that exact path; attaching mounts nothing, so the two are independent facts. A host path absent inside the container makes `docker exec --workdir` fail with an OCI chdir error that surfaces in the pane as a bare "execvp failed". The preflight now proves the directory exists inside the container and refuses at link time. --- src/docker-hosts.ts | 40 ++++++++++++++++++++++++++++++++--- src/web/public/index.html | 5 +++++ src/web/public/session-ui.js | 10 +++++++-- src/web/routes/case-routes.ts | 13 ++++++++++-- src/web/schemas.ts | 8 +++++++ 5 files changed, 69 insertions(+), 7 deletions(-) diff --git a/src/docker-hosts.ts b/src/docker-hosts.ts index b49502a59..c039815d8 100644 --- a/src/docker-hosts.ts +++ b/src/docker-hosts.ts @@ -1044,6 +1044,8 @@ export interface AdoptedContainerProbe { tmuxPath?: string; /** Modes whose CLI resolved inside the container (`command -v `). */ availableModes?: SessionMode[]; + /** Whether the requested working directory exists INSIDE the container. */ + workdirExists?: boolean; error?: string; } @@ -1059,10 +1061,18 @@ export interface AdoptedContainerProbe { */ export async function probeAdoptableContainer( docker: Pick, - modes: SessionMode[] = [] + modes: SessionMode[] = [], + containerWorkdir?: string ): Promise { if (IS_TEST_MODE) { - return { ok: true, exists: true, running: true, tmuxPath: '/usr/bin/tmux', availableModes: modes }; + return { + ok: true, + exists: true, + running: true, + tmuxPath: '/usr/bin/tmux', + availableModes: modes, + workdirExists: true, + }; } const argv = dockerEngineArgv(docker); let running = false; @@ -1096,7 +1106,19 @@ export async function probeAdoptableContainer( // One exec resolves tmux plus every requested CLI, so adoption costs a single // round trip. Binaries are fixed mode names, never user input. const probes = ['tmux', ...modes.filter((m) => m !== 'shell')]; - const script = probes.map((bin) => `command -v ${bin} >/dev/null 2>&1 && echo ${bin}`).join('; '); + // `; 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__`); + const script = `${steps.join('; ')}; exit 0`; try { const { stdout } = await execFileAsync( argv[0], @@ -1118,6 +1140,17 @@ export async function probeAdoptableContainer( 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, @@ -1125,6 +1158,7 @@ export async function probeAdoptableContainer( image, tmuxPath: 'tmux', availableModes: modes.filter((m) => m === 'shell' || found.has(m)), + workdirExists, }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); diff --git a/src/web/public/index.html b/src/web/public/index.html index bb5757b04..4d54096a3 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2840,6 +2840,11 @@

Docker

Must be running already.
+
+ + + A path that already exists inside the container. Adoption mounts nothing, so this need not match the host workspace path — leave blank to reuse it only if you mounted it there yourself. +
diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 24c1f49be..9da4990d8 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -2922,6 +2922,7 @@ Object.assign(CodemanApp.prototype, { 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.'; @@ -2933,7 +2934,7 @@ Object.assign(CodemanApp.prototype, { const probe = await this._apiJson('/api/docker-cases/adopt-preflight', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ hostId, container }), + body: JSON.stringify({ hostId, container, ...(containerWorkdir ? { containerWorkdir } : {}) }), }); if (!statusEl) return; if (!probe) { @@ -2956,6 +2957,7 @@ Object.assign(CodemanApp.prototype, { 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(); @@ -3023,7 +3025,11 @@ Object.assign(CodemanApp.prototype, { const caseRes = await fetch(adopting ? '/api/cases/docker-adopt' : '/api/cases/docker-link', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(adopting ? { name, hostId, hostWorkspacePath, container } : { 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) { diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index 49c94e2e0..6e5269959 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -837,7 +837,15 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config availability.error || 'docker daemon is not available' ); } - const probe = await probeAdoptableContainer(toSessionDocker(host, dockerCase), [...DOCKER_ADOPT_PROBE_MODES]); + // 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'); } @@ -871,7 +879,8 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config daemonHost: host.daemonHost, containerName: body.container, }, - [...DOCKER_ADOPT_PROBE_MODES] + [...DOCKER_ADOPT_PROBE_MODES], + body.containerWorkdir ); return { success: true, data: probe }; }); diff --git a/src/web/schemas.ts b/src/web/schemas.ts index c58603881..65ef13951 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -834,6 +834,14 @@ export const DockerAdoptPreflightSchema = z.object({ .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(), }); export const DockerExportSchema = z.object({ From bb45909169a2df78c77db18ece60f6f35d36e229 Mon Sep 17 00:00:00 2001 From: d fei Date: Sat, 29 Aug 2026 21:02:19 -0700 Subject: [PATCH 04/15] feat(docker): link to container attach from the Create New tab Attaching lived only on the Docker tab, but the place users look for anything container-shaped is the "Run in an isolated Docker container" checkbox on Create New. A feature nobody can find is a feature nobody has. Adds a one-click link there that switches to the Docker tab, turns the toggle on and focuses the container field. Reuses switchCaseModalTab and the existing sync helper; no new CSS. --- src/web/public/index.html | 1 + src/web/public/session-ui.js | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/web/public/index.html b/src/web/public/index.html index 4d54096a3..9734ec061 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2629,6 +2629,7 @@

Create New

Runs this case in a hardened, isolated container. The base image is built automatically on first use. Docker/Podman must be installed. + Already have a container running? — Codeman only docker execs in and never touches its lifecycle.
Container settings (optional, sensible defaults) diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 9da4990d8..8cedd6472 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -2341,6 +2341,8 @@ Object.assign(CodemanApp.prototype, { 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(); this._syncDockerAdoptMode(); // Scroll-into-view on focus for mobile keyboard visibility modal.querySelectorAll('input[type="text"]').forEach(input => { @@ -2913,6 +2915,20 @@ Object.assign(CodemanApp.prototype, { else modal.removeAttribute('data-docker-adopt'); }, + /** + * 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" From e2f750cb304f79e7a30b5acc9e4c2540bcf5aada Mon Sep 17 00:00:00 2001 From: d fei Date: Sat, 29 Aug 2026 21:02:19 -0700 Subject: [PATCH 05/15] i18n(docker): translate the attach panel, and unblock translation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new strings were English only. Adding entries surfaced a deeper problem: the translator matches whole text nodes and skips `code`/`pre`, so an inline `` mid-sentence splits a hint into fragments that can never match an entry — which is why the panel's existing "Build it once with ..." hint was never translated either. Drops the inline markup from the new hints so each is a single text node, then adds the zh-CN entries. The brand name goes through the existing {name} placeholder. Server-side error bodies are deliberately not added: the client receives them already interpolated with a concrete container name, so a template key could never match. --- src/web/public/i18n.js | 13 +++++++++++++ src/web/public/index.html | 6 +++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index fbdcc2962..f9d83ab89 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -713,6 +713,19 @@ 'Runs this case in a hardened, isolated container. The base image is built automatically on first use. Docker/Podman must be installed.': '在加固的隔离容器中运行此案例。首次使用时会自动构建基础镜像;必须安装 Docker/Podman。', 'Run in an isolated Docker container': '在隔离的 Docker 容器中运行', + 'Attach to an existing container': '接入已在运行的容器', + 'On: Codeman only runs docker exec into a container you already built and run — it never creates, starts, stops or removes it. The CLIs must already be installed and logged in inside it.': + '开启后,{name}只会 docker exec 进入你自己构建并运行的容器,绝不创建、启动、停止或删除它;容器内必须已安装并登录好相应 CLI。', + 'Container Name': '容器名称', + 'Must be running already.': '该容器必须已在运行。', + 'Check container': '检查容器', + 'Container Workdir': '容器内工作目录', + 'A path that already exists inside the container. Adoption mounts nothing, so this need not match the host workspace path.': + '容器内已存在的路径。接入不挂载任何目录,因此它不必与主机工作区路径相同。', + 'Already have a container running?': '已经有正在运行的容器?', + 'Attach to it instead': '改为接入该容器', + 'Codeman only runs docker exec into it and never touches its lifecycle.': + '{name}只会 docker exec 进入它,绝不触碰其生命周期。', 'Absolute HOST directory, bind-mounted into the container. Codeman scaffolds CLAUDE.md + hooks into it.': '绑定挂载到容器中的主机绝对目录;{name}会在其中生成 CLAUDE.md 和 hooks。', 'A reusable docker host profile. Reuse the same ID across cases to share settings.': diff --git a/src/web/public/index.html b/src/web/public/index.html index 9734ec061..7e1024e4d 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2629,7 +2629,7 @@

Create New

Runs this case in a hardened, isolated container. The base image is built automatically on first use. Docker/Podman must be installed. - Already have a container running? — Codeman only docker execs in and never touches its lifecycle. + Already have a container running? Codeman only runs docker exec into it and never touches its lifecycle.
Container settings (optional, sensible defaults) @@ -2834,7 +2834,7 @@

Docker

Run the case inside a container: one per case, shared by all its sessions.

- On: Codeman only docker execs into a container you already built and run — it never creates, starts, stops or removes it. The CLIs must already be installed and logged in inside it. + On: Codeman only runs docker exec into a container you already built and run — it never creates, starts, stops or removes it. The CLIs must already be installed and logged in inside it.
@@ -2844,7 +2844,7 @@

Docker

- A path that already exists inside the container. Adoption mounts nothing, so this need not match the host workspace path — leave blank to reuse it only if you mounted it there yourself. + A path that already exists inside the container. Adoption mounts nothing, so this need not match the host workspace path.
From 34c12ca18bc2b0827a7510517fb2c2fd33c9ec74 Mon Sep 17 00:00:00 2001 From: d fei Date: Sat, 29 Aug 2026 21:02:19 -0700 Subject: [PATCH 06/15] feat(docker): make the container field a picker you can also type into Typing a container name from memory is error-prone. The field becomes a native datalist: pick from the engine's containers, type to filter, or type a name that is not listed (the engine may be remote, or the container may not exist yet). A datalist gives all three natively, so no dropdown state machine is introduced. Adds listDockerContainers and GET /api/docker-hosts/:hostId/containers, following the listRemoteCodemanSessions discovery precedent: read-only and never throwing, so an unreachable daemon returns an empty list and the field degrades to plain text instead of erroring. Stopped containers stay in the list, sorted after running ones and labelled. Attaching does require a running container, but hiding stopped ones turns "my container is not in the list" into a dead end, while showing `Exited (137) 8 days ago` says exactly what to fix. --- src/docker-hosts.ts | 49 +++++++++++++++++++++++++++++++++++ src/web/public/i18n.js | 2 +- src/web/public/index.html | 5 ++-- src/web/public/session-ui.js | 40 ++++++++++++++++++++++++++++ src/web/routes/case-routes.ts | 24 ++++++++++++++++- 5 files changed, 116 insertions(+), 4 deletions(-) diff --git a/src/docker-hosts.ts b/src/docker-hosts.ts index c039815d8..7ad11346f 100644 --- a/src/docker-hosts.ts +++ b/src/docker-hosts.ts @@ -1049,6 +1049,55 @@ export interface AdoptedContainerProbe { 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 diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index f9d83ab89..30ee874dd 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -717,7 +717,7 @@ 'On: Codeman only runs docker exec into a container you already built and run — it never creates, starts, stops or removes it. The CLIs must already be installed and logged in inside it.': '开启后,{name}只会 docker exec 进入你自己构建并运行的容器,绝不创建、启动、停止或删除它;容器内必须已安装并登录好相应 CLI。', 'Container Name': '容器名称', - 'Must be running already.': '该容器必须已在运行。', + 'Pick from the running containers or type a name.': '从正在运行的容器中选择,或直接输入名称。', 'Check container': '检查容器', 'Container Workdir': '容器内工作目录', 'A path that already exists inside the container. Adoption mounts nothing, so this need not match the host workspace path.': diff --git a/src/web/public/index.html b/src/web/public/index.html index 7e1024e4d..f83a17d9f 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2838,8 +2838,9 @@

Docker

- - Must be running already. + + + Pick from the running containers or type a name.
diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 8cedd6472..2377c37de 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -2343,6 +2343,18 @@ Object.assign(CodemanApp.prototype, { 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 => { @@ -2913,6 +2925,34 @@ Object.assign(CodemanApp.prototype, { const adopting = document.getElementById('dockerAdoptExisting')?.checked; if (adopting) modal.setAttribute('data-docker-adopt', '1'); else modal.removeAttribute('data-docker-adopt'); + if (adopting) void this._loadDockerContainerOptions(); + }, + + /** + * Fill the container-name ``. A native datalist is deliberate: the + * field must accept a free-typed name (the engine may be remote, or the + * container may not exist yet when the form is filled), and datalist gives + * type-to-filter over the suggestions without a custom dropdown. + * + * Best-effort by design — the endpoint returns [] for an unreachable daemon, + * and an empty list simply leaves the field as plain text input. + */ + async _loadDockerContainerOptions() { + const list = document.getElementById('dockerContainerList'); + if (!list) return; + const hostId = document.getElementById('dockerHostId')?.value.trim() || 'local'; + if (list.dataset.loadedFor === hostId) return; // one fetch per host per open + const data = await this._apiJson(`/api/docker-hosts/${encodeURIComponent(hostId)}/containers`); + const containers = data?.containers || []; + list.textContent = ''; + for (const c of containers) { + const option = document.createElement('option'); + option.value = c.name; + // Engine-supplied strings: set as text, never as markup. + option.textContent = c.running ? `${c.image} · ${c.status}` : `${c.image} · ${c.status} (not running)`; + list.appendChild(option); + } + list.dataset.loadedFor = hostId; }, /** diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index 6e5269959..fc2079d3a 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -69,6 +69,7 @@ import { dockerContainerName, dockerDisplayPath, probeAdoptableContainer, + listDockerContainers, DOCKER_ADOPT_PROBE_MODES, readDockerCases, readDockerHosts, @@ -77,7 +78,7 @@ import { writeDockerCases, writeDockerHosts, } from '../../docker-hosts.js'; -import type { AdoptedContainerProbe } from '../../docker-hosts.js'; +import type { AdoptedContainerProbe, DockerContainerInfo } from '../../docker-hosts.js'; import { buildDockerRemoveCommand } from '../../tmux-manager.js'; import { checkRemoteTmuxAvailable, @@ -868,6 +869,27 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config * 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 } }; + } + ); + 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); From 2f83a37c6d69b7e25ffc3b81d914116f6df412fd Mon Sep 17 00:00:00 2001 From: d fei Date: Sat, 29 Aug 2026 21:02:19 -0700 Subject: [PATCH 07/15] feat(docker): take run-mode availability from the container The run-mode dropdown hides CLIs that are not installed on the HOST (#201). That is right for local sessions and wrong for a container case, whose agents run inside the container: a host with no claude installed hides the mode while the container ships one, which is exactly what happened on a real deployment. The adoption preflight already probes what the container has, so that result is persisted on the case and surfaced through CaseInfo. Docker cases gate on it; every other case keeps the host probe unchanged. An absent list reads as "do not gate" rather than "nothing available": an owned container runs our base image, which ships every CLI, and treating unknown as empty would leave the menu with Shell alone. --- src/types/api.ts | 7 +++++++ src/types/session.ts | 9 +++++++++ src/web/public/session-ui.js | 16 ++++++++++++++- src/web/routes/case-routes.ts | 13 +++++++++---- test/docker-adopted-container.test.ts | 28 +++++++++++++++++++++++++++ 5 files changed, 68 insertions(+), 5 deletions(-) diff --git a/src/types/api.ts b/src/types/api.ts index b63268af2..a57b27953 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -167,6 +167,13 @@ export interface CaseInfo { image?: string; path: string; network?: string; + /** + * CLIs available INSIDE the container. A container case runs its agents in + * the container, so HOST CLI availability says nothing about what it can + * run. Absent = unknown (an owned container runs our base image, which ships + * every CLI), which the UI reads as "do not gate". + */ + availableModes?: string[]; }; } diff --git a/src/types/session.ts b/src/types/session.ts index 7187dae4d..21e117b93 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -261,6 +261,15 @@ export interface DockerCase { * all created by us). */ owned?: boolean; + /** + * CLIs found INSIDE the container by the adoption preflight. A container case + * runs its agents in the container, so host CLI availability says nothing about + * what this case can run — the base image ships every CLI, and an adopted + * container ships whatever its owner installed. Absent = unknown (owned cases, + * or a case linked before this field existed), which callers read as "do not + * gate". + */ + availableModes?: SessionMode[]; /** Last captured Claude conversation id, replayed via --resume on a fresh launch. */ lastClaudeSessionId?: string; } diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 2377c37de..b43e42888 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -474,9 +474,23 @@ 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 containerModes = activeCase?.location === 'docker' ? activeCase.docker?.availableModes : null; for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']) { const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`); - if (btn) btn.style.display = this.isCliAvailable(mode) ? 'flex' : 'none'; + if (!btn) continue; + let available; + if (activeCase?.location === 'docker') available = containerModes ? containerModes.includes(mode) : true; + else available = this.isCliAvailable(mode); + btn.style.display = available ? 'flex' : 'none'; } // DeepSeek is the one mode whose availability has two halves: `dsh` can be // perfectly installed while no pane-capable profile exists, because DeepSeek diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index fc2079d3a..11ac46e75 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -297,6 +297,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config image: host.image, path: dockerCase.hostWorkspacePath, network: host.network ?? 'bridge', + ...(dockerCase.availableModes ? { availableModes: dockerCase.availableModes } : {}), }, }; const existingIndex = cases.findIndex((item) => item.name === dockerCase.name); @@ -851,15 +852,19 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config return createErrorResponse(ApiErrorCode.OPERATION_FAILED, probe.error || 'container is not adoptable'); } - await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, dockerCase]); + // 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: dockerCase.name, - path: dockerCase.hostWorkspacePath, + name: adoptedCase.name, + path: adoptedCase.hostWorkspacePath, type: 'docker', }); return { success: true, - data: { case: dockerCase, image: probe.image, availableModes: probe.availableModes }, + data: { case: adoptedCase, image: probe.image, availableModes: probe.availableModes }, }; } ); diff --git a/test/docker-adopted-container.test.ts b/test/docker-adopted-container.test.ts index cd15a443a..1c14463c9 100644 --- a/test/docker-adopted-container.test.ts +++ b/test/docker-adopted-container.test.ts @@ -194,6 +194,34 @@ describe('adopted container: the Add Case panel id contract', () => { }); }); +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. */ + const refreshFn = (src) => { + const start = src.indexOf('_refreshRunModeAvailability(menu) {'); + expect(start).toBeGreaterThan(-1); + return src.slice(start, start + 1600); + }; + + 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: 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 From 8b20f5b1f8aa004bfd484a5df89dd4a32c8b811b Mon Sep 17 00:00:00 2001 From: d fei Date: Sat, 29 Aug 2026 21:01:28 -0700 Subject: [PATCH 08/15] fix(docker): probe container CLIs by their real binary name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adoption preflight used the mode name as the binary name. claude, codex, opencode, gemini and pi happen to match, so it never showed — but antigravity ships as `agy` and deepseek as `dsh`, so a container that has either was reported as not having it, and the mode was silently dropped from the case. Adds a MODE_BINARIES map, single-sourced with defaultDockerCommandForMode, which launches those same binaries. Probing and result filtering share one `binaryFor` so the two cannot drift apart. --- src/docker-hosts.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/docker-hosts.ts b/src/docker-hosts.ts index 7ad11346f..d9d2de1be 100644 --- a/src/docker-hosts.ts +++ b/src/docker-hosts.ts @@ -67,9 +67,19 @@ export const DOCKER_ADOPT_PROBE_MODES = [ '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. */ @@ -1154,7 +1164,9 @@ export async function probeAdoptableContainer( } // One exec resolves tmux plus every requested CLI, so adoption costs a single // round trip. Binaries are fixed mode names, never user input. - const probes = ['tmux', ...modes.filter((m) => m !== 'shell')]; + 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. @@ -1206,7 +1218,7 @@ export async function probeAdoptableContainer( running: true, image, tmuxPath: 'tmux', - availableModes: modes.filter((m) => m === 'shell' || found.has(m)), + availableModes: modes.filter((m) => m === 'shell' || found.has(binaryFor(m))), workdirExists, }; } catch (err) { From 06e7cbe2860baa30c01eafde6610796e456226d4 Mon Sep 17 00:00:00 2001 From: d fei Date: Sat, 29 Aug 2026 21:19:17 -0700 Subject: [PATCH 09/15] fix(docker): probe the container's CLIs live instead of trusting attach time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Storing the container's CLIs on the case at attach time left two gaps: a case linked before that field existed has none at all, and a container's CLIs can be installed or removed long after it was linked. A real deployment hit the first one — the host had only codex, the container only claude, and with no stored list the menu still gated on the host and hid the mode that actually worked. The probe now runs when a container case is selected, reusing the existing adopt-preflight endpoint, so there is no new backend surface. Results are cached per case for the page's lifetime, since the menu opens often and the probe is a `docker exec` round trip; a concurrent probe for the same case is deduplicated with an in-flight marker. A failed probe leaves the cache empty, which the caller reads as "unknown" and therefore does not gate. Hiding every mode because one probe failed is worse than offering one that turns out to be missing, which the launch path already refuses with a specific message. The repaint only happens while the menu is still open, so a late answer cannot make the list jump under a user who already closed it. --- src/web/public/session-ui.js | 45 +++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index b43e42888..ebb9eec13 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -483,7 +483,14 @@ Object.assign(CodemanApp.prototype, { // 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 containerModes = activeCase?.location === 'docker' ? activeCase.docker?.availableModes : 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); for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']) { const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`); if (!btn) continue; @@ -633,6 +640,42 @@ Object.assign(CodemanApp.prototype, { } }, + /** + * 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 { + const probe = await this._apiJson('/api/docker-cases/adopt-preflight', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hostId, container }), + }); + if (probe?.ok && Array.isArray(probe.availableModes)) { + this._dockerCaseModes[name] = probe.availableModes; + // 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; From 3685ad85bc7a8dd97c8b7bfe53b9eacca7875847 Mon Sep 17 00:00:00 2001 From: d fei Date: Sat, 29 Aug 2026 23:31:28 -0700 Subject: [PATCH 10/15] fix(docker): stop requiring the CLI on the host for a container session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attaching a container, picking claude and hitting Run gave one line — `execvp(3) failed.: No such file or directory` — and the run-mode menu offered every mode. Three separate defects, found on a real deployment. TmuxManager.createSession resolved the CLI directory without distinguishing a docker session, so a host with no claude threw, the catch fell back to a direct PTY, and that PTY exec'd the CLI on the HOST. The failure surfaced as a bare execvp error naming nothing. A docker session runs its CLI inside the container; the host does not need it. All eight modes now sit behind a cliRunsInContainer guard, and whether the container has the CLI is settled by the adoption preflight or the image gate before launch. The running check used a bare double quote and command substitution. The whole chain is embedded in an outer `bash -c "…"`, so the unescaped quote closed that string early and the remainder was re-tokenized. It is now a `grep -qx` pipeline using only the single-quote form every other line in the builder already uses. Claude Code refuses --dangerously-skip-permissions as root. Our base image runs a non-root user, so an owned container never hit this; an adopted container's user belongs to its owner and is frequently root, and keeping the flag killed the pane with a message visible only inside the container. The preflight now reports runsAsRoot and the launch chain drops the flag for it. The menu also showed every mode because the container CLI probe only started when the menu opened. It is warmed when the case is selected instead. --- src/docker-hosts.ts | 18 ++++++++-- src/tmux-manager.ts | 38 +++++++++++++------- src/types/session.ts | 17 +++++---- src/web/public/session-ui.js | 7 ++++ src/web/routes/session-routes.ts | 3 ++ test/docker-adopted-container.test.ts | 52 ++++++++++++++++++++++++++- 6 files changed, 110 insertions(+), 25 deletions(-) diff --git a/src/docker-hosts.ts b/src/docker-hosts.ts index d9d2de1be..a6ce66ad2 100644 --- a/src/docker-hosts.ts +++ b/src/docker-hosts.ts @@ -160,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', @@ -1056,6 +1061,8 @@ export interface AdoptedContainerProbe { 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; } @@ -1179,6 +1186,10 @@ export async function probeAdoptableContainer( // --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( @@ -1220,6 +1231,7 @@ export async function probeAdoptableContainer( 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); diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 3e0c96cb5..f177d35a2 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -1290,7 +1290,8 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string { const dkrName = dockerTmuxSessionName(sessionId); const sid = sessionId.slice(0, 8); - let modeCommand = docker.commands?.[mode as DockerCommandMode] || defaultDockerCommandForMode(mode); + let modeCommand = + docker.commands?.[mode as DockerCommandMode] || defaultDockerCommandForMode(mode, !!docker.runsAsRoot); if (mode === 'claude') { modeCommand = claudeDockerPaneCommand(modeCommand, sessionId, resumeSessionId); } else if (resumeSessionId) { @@ -1327,10 +1328,10 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string { const startFailMsg = shellescape(`Codeman: container ${docker.containerName} failed to start (docker daemon down?)`); const notFoundMsg = shellescape( - `Codeman: container ${docker.containerName} not found. Adopted containers are never created by Codeman — start it yourself, then reopen this session.` + `Codeman: container ${docker.containerName} not found. Adopted containers are never created by Codeman - start it yourself, then reopen this session.` ); const notRunningMsg = shellescape( - `Codeman: container ${docker.containerName} is not running. Codeman never starts a container it does not own — start it yourself, then reopen this session.` + `Codeman: container ${docker.containerName} is not running. Codeman never starts a container it does not own - start it yourself, then reopen this session.` ); const imageCheck = adopted @@ -1340,8 +1341,13 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string { const ensure = adopted ? `${base} inspect ${name} >/dev/null 2>&1 || { echo ${notFoundMsg}; exit 1; }` : `${base} inspect ${name} >/dev/null 2>&1 || ${base} ${createArgs}`; + // ⚠️ No double quotes and no `$(…)` here. This whole chain is embedded in an + // outer `bash -c "…"`, so an unescaped `"` closes that string early, the rest + // is re-tokenized, and tmux fails to exec with a bare `execvp(3) failed`. A + // `grep -qx` pipeline reads the same answer using only the single-quoted form + // every other line in this builder already uses. const start = adopted - ? `[ "$(${base} inspect -f '{{.State.Running}}' ${name} 2>/dev/null)" = true ] || { echo ${notRunningMsg}; exit 1; }` + ? `${base} inspect -f ${shellescape('{{.State.Running}}')} ${name} 2>/dev/null | grep -qx true || { echo ${notRunningMsg}; exit 1; }` : `${base} start ${name} >/dev/null 2>&1 || { echo ${startFailMsg}; exit 1; }`; // Seed writable credential config from read-only host mounts ONCE per container // (guarded by [ -e ] so reconnects never clobber in-container config; `cp -a` for @@ -2115,29 +2121,37 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // from the resolvers (formatCliNotFoundMessage) so the error names WHERE it // looked — server PATH, login shell, checked directories — instead of just // asserting the CLI is missing (the classic systemd/launchd PATH trap). + // + // ⚠️ A DOCKER session runs its CLI INSIDE the container, so the host does not + // need it at all. Demanding it here threw for a host without the binary, 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: No such file or directory` + // with nothing pointing at the real cause. The container's own CLIs are + // verified by the adoption preflight / image gate before launch instead. const { pathExport, dir: cliDir } = this.buildPathExport(mode); - if (mode === 'claude' && !cliDir) { + const cliRunsInContainer = !!docker; + if (!cliRunsInContainer && mode === 'claude' && !cliDir) { throw new Error(getClaudeNotFoundMessage()); } - if (mode === 'opencode' && !cliDir) { + if (!cliRunsInContainer && mode === 'opencode' && !cliDir) { throw new Error(getOpenCodeNotFoundMessage()); } - if (mode === 'codex' && !cliDir) { + if (!cliRunsInContainer && mode === 'codex' && !cliDir) { throw new Error(getCodexNotFoundMessage()); } - if (mode === 'gemini' && !cliDir) { + if (!cliRunsInContainer && mode === 'gemini' && !cliDir) { throw new Error(getGeminiNotFoundMessage()); } - if (mode === 'antigravity' && !cliDir) { + if (!cliRunsInContainer && mode === 'antigravity' && !cliDir) { throw new Error(getAntigravityNotFoundMessage()); } - if (mode === 'pi' && !cliDir) { + if (!cliRunsInContainer && mode === 'pi' && !cliDir) { throw new Error(getPiNotFoundMessage()); } - if (mode === 'deepseek' && !cliDir) { + if (!cliRunsInContainer && mode === 'deepseek' && !cliDir) { throw new Error(getDeepSeekNotFoundMessage()); } - if (mode === 'grok' && !cliDir) { + if (!cliRunsInContainer && mode === 'grok' && !cliDir) { throw new Error(getGrokNotFoundMessage()); } diff --git a/src/types/session.ts b/src/types/session.ts index 21e117b93..d3fe45657 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -47,15 +47,7 @@ export type ClaudeMode = 'dangerously-skip-permissions' | 'auto' | 'normal' | 'a /** Session mode: which CLI backend a session runs */ export type SessionMode = - | 'claude' - | 'shell' - | 'opencode' - | 'codex' - | 'gemini' - | 'antigravity' - | 'pi' - | 'grok' - | 'deepseek'; + 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek'; export type RemoteCommandMode = Extract< SessionMode, @@ -302,6 +294,13 @@ export interface SessionDocker { extraExecArgs?: string[]; /** Stable hash of the drift-relevant create args (recreate-on-drift detection). */ configHash?: string; + /** + * Whether the container's exec user is root. Claude Code REFUSES + * `--dangerously-skip-permissions` as root, and an adopted container's user + * belongs to its owner, so the flag is omitted rather than letting the pane + * die with a message only visible inside the container. + */ + runsAsRoot?: boolean; /** * Mirror of `DockerCase.owned`, flattened onto the live session so every * lifecycle decision (launch chain, drift, stop, remove) can see it without diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index ebb9eec13..dc4503bd5 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); } diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 36ccea31e..3fc80c940 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -2968,6 +2968,9 @@ export function registerSessionRoutes( 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, diff --git a/test/docker-adopted-container.test.ts b/test/docker-adopted-container.test.ts index 1c14463c9..2d9c6c1b7 100644 --- a/test/docker-adopted-container.test.ts +++ b/test/docker-adopted-container.test.ts @@ -12,6 +12,7 @@ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; import { + defaultDockerCommandForMode, toSessionDocker, isAdoptedContainer, removeDockerContainer, @@ -113,6 +114,18 @@ describe('adopted container: the launch chain never mutates lifecycle', () => { 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'); @@ -129,6 +142,43 @@ describe('adopted container: the launch chain never mutates lifecycle', () => { }); }); +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 docker metadata the session already carries', () => { + expect(src).toContain('const cliRunsInContainer = !!docker;'); + }); +}); + describe('adopted container: mutating verbs fail closed at the builder', () => { const docker = toSessionDocker(HOST, caseFor(false)); @@ -202,7 +252,7 @@ describe('adopted container: run modes come from the CONTAINER, not the host', ( const refreshFn = (src) => { const start = src.indexOf('_refreshRunModeAvailability(menu) {'); expect(start).toBeGreaterThan(-1); - return src.slice(start, start + 1600); + return src.slice(start, start + 2000); }; it('gates a docker case on availableModes instead of host CLI probes', () => { From 23ab2e77fd01394a47b9c2370042cf2d46d9a3c8 Mon Sep 17 00:00:00 2001 From: d fei Date: Sat, 29 Aug 2026 23:31:28 -0700 Subject: [PATCH 11/15] fix(files): give the picker a root when the server runs as root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Link Existing's Browse did nothing: GET /api/filesystem/browse answered 403 "No filesystem browse roots are available". Two rules were fighting. /root is a default blocked tree in the attachment guard, and Codeman running as root — containers, plenty of servers — makes homedir() exactly /root, so the picker's own allowlisted Home root was blocked; the other candidates live under it or do not exist. The root list came out empty and there was nothing the user could open. The blocked trees exist to keep ~/.ssh and friends out of reach, not to seal off the user's own home. Only trees that would swallow a configured root whole are dropped now: /root goes when Home is it (or sits inside it), /etc holds no configured root and is untouched. Secrets stay protected — isSensitivePath independently matches .ssh/, .env and credentials* at any depth, and it is what the directory probe asks about. ⚠️ Navigation must reuse the same narrowed list the roots were chosen with. Handing the raw trees downstream admits a root and then refuses every path inside it, which reads as a picker that opens and does nothing. --- src/web/routes/file-routes.ts | 53 ++++++++++++++++++++++++++++-- test/file-picker-root-home.test.ts | 53 ++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 test/file-picker-root-home.test.ts diff --git a/src/web/routes/file-routes.ts b/src/web/routes/file-routes.ts index 20a567f3c..adffc9ddb 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/test/file-picker-root-home.test.ts b/test/file-picker-root-home.test.ts new file mode 100644 index 000000000..c726237f4 --- /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/); + }); +}); From 5452ad5c5ae314bec730a21688f3b74a443cdef4 Mon Sep 17 00:00:00 2001 From: d fei Date: Sat, 29 Aug 2026 23:31:28 -0700 Subject: [PATCH 12/15] feat(docker): add a folder picker to both path fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both paths in the adoption form had to be typed. Each gets a Browse button using the same path-input-group markup Link Existing uses, so the two look and behave alike. What they can browse differs, and that is the point. The host workspace path reuses the existing host picker. The container workdir cannot: an adopted container has nothing mounted at a matching host path, so a host listing would be a different filesystem — and getting this field wrong is the source of the opaque OCI chdir error at launch, which makes it the field that most needs to be clickable. Adds a read-only POST /api/docker-cases/browse: one `ls` through docker exec, no writes, no lifecycle, path shell-escaped like every other value. `ls -Ap` marks directories with a trailing slash and keeps names with spaces intact. PathPicker takes an optional fetchListing source rather than being forked: the container variant only swaps where the rows come from, and reuses the rendering, navigation, Up and Choose/Select unchanged. --- src/docker-hosts.ts | 54 ++++++++++++++++++++++++++ src/web/public/index.html | 10 ++++- src/web/public/keyboard-accessory.js | 10 +++-- src/web/public/session-ui.js | 56 +++++++++++++++++++++++++++ src/web/routes/case-routes.ts | 26 ++++++++++++- src/web/schemas.ts | 16 ++++++++ test/docker-adopted-container.test.ts | 34 ++++++++++++++++ 7 files changed, 200 insertions(+), 6 deletions(-) diff --git a/src/docker-hosts.ts b/src/docker-hosts.ts index a6ce66ad2..e2801b55e 100644 --- a/src/docker-hosts.ts +++ b/src/docker-hosts.ts @@ -1239,6 +1239,60 @@ export async function probeAdoptableContainer( } } +/** 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 diff --git a/src/web/public/index.html b/src/web/public/index.html index f83a17d9f..6df870b3d 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2844,7 +2844,10 @@

Docker

- +
+ + +
A path that already exists inside the container. Adoption mounts nothing, so this need not match the host workspace path.
@@ -2854,7 +2857,10 @@

Docker

- +
+ + +
Absolute HOST directory, bind-mounted into the container. Codeman scaffolds CLAUDE.md + hooks into it.
diff --git a/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js index 35b67c3e1..7ec9a890a 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/session-ui.js b/src/web/public/session-ui.js index dc4503bd5..15109b16d 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -2895,6 +2895,62 @@ 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', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ 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(); diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index 11ac46e75..5aa4a2f05 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -26,6 +26,7 @@ import { DockerCaseLinkSchema, DockerCaseAdoptSchema, DockerAdoptPreflightSchema, + DockerBrowseSchema, DockerHostSchema, DockerExportSchema, DockerImportSchema, @@ -70,6 +71,7 @@ import { dockerDisplayPath, probeAdoptableContainer, listDockerContainers, + browseInContainer, DOCKER_ADOPT_PROBE_MODES, readDockerCases, readDockerHosts, @@ -78,7 +80,7 @@ import { writeDockerCases, writeDockerHosts, } from '../../docker-hosts.js'; -import type { AdoptedContainerProbe, DockerContainerInfo } from '../../docker-hosts.js'; +import type { AdoptedContainerProbe, DockerBrowseResult, DockerContainerInfo } from '../../docker-hosts.js'; import { buildDockerRemoveCommand } from '../../tmux-manager.js'; import { checkRemoteTmuxAvailable, @@ -895,6 +897,28 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config } ); + /** + * 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); diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 65ef13951..ffffd388c 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -844,6 +844,22 @@ export const DockerAdoptPreflightSchema = z.object({ .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(), }); diff --git a/test/docker-adopted-container.test.ts b/test/docker-adopted-container.test.ts index 2d9c6c1b7..36f86780b 100644 --- a/test/docker-adopted-container.test.ts +++ b/test/docker-adopted-container.test.ts @@ -272,6 +272,40 @@ describe('adopted container: run modes come from the CONTAINER, not the host', ( }); }); +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 From 8e5e207386f18c820679659466b58664a61c2a5f Mon Sep 17 00:00:00 2001 From: d fei Date: Sat, 29 Aug 2026 23:31:28 -0700 Subject: [PATCH 13/15] fix(docker): send the probe body as an object, and explain an unreachable container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The run menu still offered every mode for an attached container. The browser's actual request showed why: POST /api/docker-cases/adopt-preflight -> 400 {"error":"Invalid input: expected object, received string"} _api serializes `body` and sets Content-Type itself, and three call sites each passed an already-stringified body, so it was encoded twice and the server saw a JSON string where it expects an object. curl was fine throughout, so nothing in the server logs pointed at it. Also fixes the design defect underneath: a failed probe fell through to "do not gate", which silently offered every mode. When the container has been recreated, is stopped, or the engine is unreachable, the user sees claude, clicks it, and it can only fail — with the reason visible nowhere. A failed probe now hides every agent mode (Shell needs no CLI and stays) and shows the server's own reason at the top of the menu. Two static guards switched from a character window to brace matching. They sliced between two call sites, and _loadRunModeHistory's call appears above its definition, so the slice came out empty and the assertion verified nothing — the same trap twice in one file. --- src/types/session.ts | 10 ++++- src/web/public/session-ui.js | 54 ++++++++++++++++++++++----- src/web/public/styles.css | 12 ++++++ src/web/routes/case-routes.ts | 4 +- src/web/routes/session-routes.ts | 15 +++++--- src/web/routes/system-routes.ts | 5 ++- test/docker-adopted-container.test.ts | 54 ++++++++++++++++++++++++++- 7 files changed, 132 insertions(+), 22 deletions(-) diff --git a/src/types/session.ts b/src/types/session.ts index d3fe45657..39910bfc9 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -47,7 +47,15 @@ export type ClaudeMode = 'dangerously-skip-permissions' | 'auto' | 'normal' | 'a /** Session mode: which CLI backend a session runs */ export type SessionMode = - 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek'; + | 'claude' + | 'shell' + | 'opencode' + | 'codex' + | 'gemini' + | 'antigravity' + | 'pi' + | 'grok' + | 'deepseek'; export type RemoteCommandMode = Extract< SessionMode, diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 15109b16d..11fd15a0f 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -498,14 +498,18 @@ Object.assign(CodemanApp.prototype, { ? this._dockerCaseModes?.[caseName] || activeCase.docker?.availableModes || null : null; if (isDocker && !this._dockerCaseModes?.[caseName]) void this._probeDockerCaseModes(activeCase, menu); + // An unreachable container hides every agent mode and explains why, instead + // of silently offering modes that cannot start. + const probeError = isDocker ? this._dockerCaseProbeError?.[caseName] : null; for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']) { const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`); if (!btn) continue; let available; - if (activeCase?.location === 'docker') available = containerModes ? containerModes.includes(mode) : true; + 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", @@ -647,6 +651,27 @@ 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 @@ -668,16 +693,27 @@ Object.assign(CodemanApp.prototype, { 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', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ hostId, container }), + body: { hostId, container }, }); if (probe?.ok && Array.isArray(probe.availableModes)) { this._dockerCaseModes[name] = probe.availableModes; - // Only repaint while the menu the user opened is still on screen. - if (menu?.classList.contains('active')) this._refreshRunModeAvailability(menu); - } + 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]; } @@ -2934,8 +2970,7 @@ Object.assign(CodemanApp.prototype, { fetchListing: async (path) => { const data = await this._apiJson('/api/docker-cases/browse', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ hostId, container, path: path || '/' }), + 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 }; @@ -3109,8 +3144,7 @@ Object.assign(CodemanApp.prototype, { // reason it failed, so the envelope is unwrapped by hand here. const probe = await this._apiJson('/api/docker-cases/adopt-preflight', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ hostId, container, ...(containerWorkdir ? { containerWorkdir } : {}) }), + body: { hostId, container, ...(containerWorkdir ? { containerWorkdir } : {}) }, }); if (!statusEl) return; if (!probe) { diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 7ca4edc35..50912082e 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -16608,6 +16608,18 @@ html[data-tab-orientation='vertical'] .home-sessions { 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; } diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index 5aa4a2f05..2024d9943 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -1175,7 +1175,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config engine: result.manifest.engine, image: result.importedImage ?? result.manifest.image, network: (['bridge', 'none', 'custom'].includes(result.manifest.network) ? result.manifest.network : 'bridge') as - 'bridge' | 'none' | 'custom', + | 'bridge' + | 'none' + | 'custom', }; await writeDockerHosts( CODEMAN_CONFIG_DIR, diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 3fc80c940..c50ddcb37 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -944,8 +944,9 @@ export function registerSessionRoutes( } } if (body.mode === 'antigravity') { - const { isAntigravityAvailable, getAntigravityNotFoundMessage } = - await import('../../utils/antigravity-cli-resolver.js'); + const { isAntigravityAvailable, getAntigravityNotFoundMessage } = await import( + '../../utils/antigravity-cli-resolver.js' + ); if (!isAntigravityAvailable()) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getAntigravityNotFoundMessage()); } @@ -3025,8 +3026,9 @@ export function registerSessionRoutes( // Check OpenCode availability if requested. Error text comes from the // resolver so it carries the resolution diagnostics; same for the modes below. if (mode === 'opencode') { - const { isOpenCodeAvailable, getOpenCodeNotFoundMessage } = - await import('../../utils/opencode-cli-resolver.js'); + const { isOpenCodeAvailable, getOpenCodeNotFoundMessage } = await import( + '../../utils/opencode-cli-resolver.js' + ); if (!isOpenCodeAvailable()) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getOpenCodeNotFoundMessage()); } @@ -3050,8 +3052,9 @@ export function registerSessionRoutes( // Check Antigravity availability if requested if (mode === 'antigravity') { - const { isAntigravityAvailable, getAntigravityNotFoundMessage } = - await import('../../utils/antigravity-cli-resolver.js'); + const { isAntigravityAvailable, getAntigravityNotFoundMessage } = await import( + '../../utils/antigravity-cli-resolver.js' + ); if (!isAntigravityAvailable()) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getAntigravityNotFoundMessage()); } diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index 4929e977e..a23fb286b 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -683,8 +683,9 @@ export function registerSystemRoutes( `Installing ${pkg} into profile "${profile}" failed: ${detail}` ); } - const { listDeepSeekProfiles, resolveDefaultDeepSeekProfile, isDeepSeekRunnable } = - await import('../../utils/deepseek-cli-resolver.js'); + const { listDeepSeekProfiles, resolveDefaultDeepSeekProfile, isDeepSeekRunnable } = await import( + '../../utils/deepseek-cli-resolver.js' + ); return { profile, package: pkg, diff --git a/test/docker-adopted-container.test.ts b/test/docker-adopted-container.test.ts index 36f86780b..62d26cfdc 100644 --- a/test/docker-adopted-container.test.ts +++ b/test/docker-adopted-container.test.ts @@ -123,7 +123,7 @@ describe('adopted container: the launch chain never mutates lifecycle', () => { expect(adopted).not.toContain('"'); expect(adopted).not.toContain('$('); // Every other line already quotes with the single-quote helper. - expect(adopted).toContain("grep -qx true"); + expect(adopted).toContain('grep -qx true'); }); it('skips the base-image gate, which describes an image adoption never uses', () => { @@ -142,6 +142,44 @@ describe('adopted container: the launch chain never mutates lifecycle', () => { }); }); +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 @@ -249,10 +287,22 @@ describe('adopted container: run modes come from the CONTAINER, not the host', ( /** 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); - return src.slice(start, start + 2000); + 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', () => { From 47ee49128c1a2320b5c6ba3020cf893acbc76fff Mon Sep 17 00:00:00 2001 From: d fei Date: Sat, 29 Aug 2026 23:49:04 -0700 Subject: [PATCH 14/15] style: match the prettier version the lockfile pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Format check failed twice, on different files each time, because three prettier versions were in play: package.json says ^3.4.0, package-lock pins 3.8.3 (CI runs npm ci, so that is the one CI uses), and the local node_modules had 3.9.6. Files formatted with 3.9.6 were then "fixed" with 3.4.2, pushing session-routes and system-routes onto a third style — every version change moved the failure to a different set of files. Line-break placement in `await import` and a union type only; no logic changes. --- src/web/routes/session-routes.ts | 15 ++++++--------- src/web/routes/system-routes.ts | 5 ++--- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index c50ddcb37..3fc80c940 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -944,9 +944,8 @@ export function registerSessionRoutes( } } if (body.mode === 'antigravity') { - const { isAntigravityAvailable, getAntigravityNotFoundMessage } = await import( - '../../utils/antigravity-cli-resolver.js' - ); + const { isAntigravityAvailable, getAntigravityNotFoundMessage } = + await import('../../utils/antigravity-cli-resolver.js'); if (!isAntigravityAvailable()) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getAntigravityNotFoundMessage()); } @@ -3026,9 +3025,8 @@ export function registerSessionRoutes( // Check OpenCode availability if requested. Error text comes from the // resolver so it carries the resolution diagnostics; same for the modes below. if (mode === 'opencode') { - const { isOpenCodeAvailable, getOpenCodeNotFoundMessage } = await import( - '../../utils/opencode-cli-resolver.js' - ); + const { isOpenCodeAvailable, getOpenCodeNotFoundMessage } = + await import('../../utils/opencode-cli-resolver.js'); if (!isOpenCodeAvailable()) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getOpenCodeNotFoundMessage()); } @@ -3052,9 +3050,8 @@ export function registerSessionRoutes( // Check Antigravity availability if requested if (mode === 'antigravity') { - const { isAntigravityAvailable, getAntigravityNotFoundMessage } = await import( - '../../utils/antigravity-cli-resolver.js' - ); + const { isAntigravityAvailable, getAntigravityNotFoundMessage } = + await import('../../utils/antigravity-cli-resolver.js'); if (!isAntigravityAvailable()) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getAntigravityNotFoundMessage()); } diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index a23fb286b..4929e977e 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -683,9 +683,8 @@ export function registerSystemRoutes( `Installing ${pkg} into profile "${profile}" failed: ${detail}` ); } - const { listDeepSeekProfiles, resolveDefaultDeepSeekProfile, isDeepSeekRunnable } = await import( - '../../utils/deepseek-cli-resolver.js' - ); + const { listDeepSeekProfiles, resolveDefaultDeepSeekProfile, isDeepSeekRunnable } = + await import('../../utils/deepseek-cli-resolver.js'); return { profile, package: pkg, From 5efc9c209fb899f165b7b5ee7f3c6ebd26f96a11 Mon Sep 17 00:00:00 2001 From: d fei Date: Mon, 31 Aug 2026 01:30:47 -0700 Subject: [PATCH 15/15] feat(adopt): adopt tmux sessions a human started, in all three locations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The home screen now lists tmux sessions Codeman did not create — a `claude` or `codex` someone started inside `tmux new -s work`, or just a shell — and one click turns one into a tab you can keep working in. Adoption is a FOURTH location overlay, structurally identical to remote-SSH and Docker, and deliberately NOT a new `SessionMode`: the outer session is still an ordinary `codeman-<8hex>` on this instance's own socket, and only what runs inside its pane differs. That indirection is what leaves the mux name allowlist, capture, input and recovery paths completely untouched, and it makes "detach, never kill" structural rather than a rule to remember — `killSession` can only ever reach the wrapper we created. One probe script, one parser and one classifier serve all three locations; only the transport differs (direct / `docker exec` / `ssh`). The mode comes from a bounded process-tree walk over the pane's descendants, because `#{pane_current_command}` is `node` for BOTH claude and codex. Anything not recognised is `shell`, which is also the honest answer for the case this was built for. Three things were settled by measurement rather than by reasoning, and each is recorded where the code would otherwise invite the wrong change: - A grouped session buys an independent `status off` and current window, but NOT an independent size. Measured on tmux 3.3a against a target held by a 200x49 client: a bare attach and a grouped session BOTH shrink it to 80x23; only `window-size largest` protects it, and that is a WINDOW option on a SHARED window which survives our detach — so it is deliberately not set, and attaching resizes like any second tmux client does. - View collection differs per location: a local client dies with its pane and ssh propagates SIGHUP, but a `docker exec` outlives its client, so the in-container view has to be collected explicitly or every adoption leaks one plus its exec process. - A foreign session name is chosen by someone else, and the local launch chain ends at `bash -c ${JSON.stringify(cmd)}`. `JSON.stringify` escapes `"` and `\` but not `$` or a backtick, and the outer shell substitutes before the inner single quotes apply. Names and socket paths therefore go through a character allowlist and are dropped during DISCOVERY, so an unusable candidate never gets an id for a caller to send. Capabilities degrade on "who launched this process": an adopted session has no hooks, no envOverrides, no effort, and a working directory that is merely the foreign pane's cwd — a path that need not exist on this host at all. So respawn, Ralph, the orchestrator, hook-backed waits and every watcher keyed on a local workingDir refuse or skip, and the close dialog no longer offers a "kill the session" option it cannot honour. --- src/config/foreign-tmux.ts | 38 ++ src/foreign-tmux-discovery.ts | Bin 0 -> 12487 bytes src/foreign-tmux.ts | 597 ++++++++++++++++++++++++++ src/mux-interface.ts | 12 + src/session.ts | 54 ++- src/tmux-manager.ts | 144 ++++++- src/types/foreign-tmux.ts | 102 +++++ src/types/index.ts | 1 + src/types/session.ts | 74 ++++ src/web/public/app.js | 36 +- src/web/public/foreign-sessions.js | 359 ++++++++++++++++ src/web/public/index.html | 15 +- src/web/public/mobile-overview.js | 10 + src/web/public/mobile.css | 12 + src/web/public/styles.css | 170 ++++++++ src/web/public/terminal-ui.js | 8 + src/web/routes/mux-routes.ts | 63 ++- src/web/routes/ralph-routes.ts | 11 + src/web/routes/respawn-routes.ts | 33 ++ src/web/routes/session-routes.ts | 153 +++++++ src/web/schemas.ts | 22 + src/web/server.ts | 21 +- src/web/session-wait-registry.ts | 17 + test/docker-adopted-container.test.ts | 11 +- test/foreign-tmux.test.ts | 314 ++++++++++++++ 25 files changed, 2248 insertions(+), 29 deletions(-) create mode 100644 src/config/foreign-tmux.ts create mode 100644 src/foreign-tmux-discovery.ts create mode 100644 src/foreign-tmux.ts create mode 100644 src/types/foreign-tmux.ts create mode 100644 src/web/public/foreign-sessions.js create mode 100644 test/foreign-tmux.test.ts diff --git a/src/config/foreign-tmux.ts b/src/config/foreign-tmux.ts new file mode 100644 index 000000000..e4c838c22 --- /dev/null +++ b/src/config/foreign-tmux.ts @@ -0,0 +1,38 @@ +/** + * @fileoverview Bounds for FOREIGN tmux discovery (sessions a human started + * outside Codeman). + * + * Two facts drive every number here. First, the number of tmux sockets and panes + * on a machine is NOT under Codeman's control — a discovery walk with no ceiling + * is an unbounded loop over data someone else produces, so sockets and panes are + * both hard-capped. Second, an ssh handshake is an order of magnitude slower than + * a local `exec`; reusing the shared 5s `EXEC_TIMEOUT_MS` would classify every + * remote host as unreachable, so the probe gets its own timeout. + * + * @module config/foreign-tmux + */ + +/** How often the browser re-polls `/api/mux/foreign` while the home screen is visible. */ +export const FOREIGN_POLL_INTERVAL_MS = 8000; + +/** + * Server-side cache TTL for a LOCAL scan. This, not the poll interval, is what + * bounds the real cost: N open tabs polling at 8s still trigger at most one scan + * per TTL. + */ +export const FOREIGN_CACHE_TTL_MS = 5000; + +/** Timeout for one probe invocation (local exec, `docker exec`, or one ssh). */ +export const FOREIGN_PROBE_TIMEOUT_MS = 12000; + +/** Max tmux sockets inspected per location, oldest-first by directory order. */ +export const FOREIGN_MAX_SOCKETS = 16; + +/** Max pane rows parsed from one probe. Panes past this are dropped, not errors. */ +export const FOREIGN_MAX_PANES = 400; + +/** Max process rows parsed from one probe's `ps` snapshot. */ +export const FOREIGN_MAX_PROCS = 4000; + +/** Max bytes of probe stdout kept. A runaway `ps` must not become a heap problem. */ +export const FOREIGN_PROBE_MAX_BYTES = 2 * 1024 * 1024; diff --git a/src/foreign-tmux-discovery.ts b/src/foreign-tmux-discovery.ts new file mode 100644 index 0000000000000000000000000000000000000000..ab3c10174a8770ffb84a65cc960138c866279587 GIT binary patch literal 12487 zcmdT~-E!N=mCm)EqBC)YphH5FyOcb(qmksXD*ADWnpCz_DiH#jBE}%V0YFh)jjHwy za_WRE11|W*Ey;b9jxKb0b(S7>#`Tfr6-hTOV%e*vyo}`&AZf$v+ z+NC*LV=A7MQ(PBz6|%q!neQjgpo5 zm>0{+OpB$d3xlC}x`U-DhpKvD(!5F&&@*w7*HN0=a$ur7F;QF=Rb{Gb8iE=L?q-?n z`O_k~u``?3CQEN^1qx=|I*z>C~OLht-YShG9k#9VWG z@)2EE+%_Y_eQU^|uA1m`IhvN@GV_2xFFl^F6+MYlE)6X*!_U;p!u|NZCRMUrY|O0TGm8Usk$ zJE#t_wOIx>L{8uUB2VX-r6yFF@r(20AOCiBe*9to@N6t68ovnA#0(ex@xUynX*?Cy z4^K`G&Xk;)MzHpRB<X6gTE&fwRD-(?E zosHkYkH&rOgass-7nBAMPS&wQTEQOR5mr5EW+rJ_)uB04$-`DDT6-7UIfX`_!LHSm z#NuKGCre=exmYy>0-Zw}S^5!c#4HN(s*;DX(MiPVVmRn^R9P`TOd&ZZJ#4uwqxl?q z!R*oD$qz>d=H~_MKzPBSp?fDg%(Zt>TxTl`h)0dD7ipHf(@61wF=8Z23HoC*gIscI zQT0QE^ja7HTtf0Q8&z=GWFRvUCo0TJcIXq0p{Y^O7Z*2EBZ3~S@)+JO8kMt9FE9XD z+%O*Z!6j9RBW*N^AIB;fSox3V-Vg>Rv(c?HZqiI)xh4Fxm?I$A68;NOGfIk1G8xOv zc$?vIxcyS?t+Wg;ocW)&Ea0J4B@wd7Niu>=7K$y7C07t!3;boJ%LG$MYA6Af0Jj z#yq3f0Y1f9gi}pcUh7eAkFhB~ImSnN?8Ag(X|Iv_y2KzFk1g`et3KUA4DR0y5F<9tXI(1y>XVX@7?1}vMqMdU?fLVJM7Tr z+Uj{Taim+N3|xAoXa_Dz>)+0nb?TEHpiVlz%4!P|M27SZyIUc8nb!_;z>xy~rT?lk zb@Mb#adQqwSCA-Vce;O|)adM?&2PiM9`cKM{ra`()#bwW%rC!iyJo@()?6K&{MAjd zRSoZVbX8@iAI?XhdJn&|InoD0+K%Zh>dEj=J%hk4N1|8O>C7U>?X0Q1)hs8?qPy=G zlZh?Yr?r-`^#BgG6_B1UO27z&NeTTuBAkS%BF&LH0hy9CiIAT{GnxXcB<6Yr+)0>7 zSlWO*`Zu5!124E$lu25u8o)B-Ql{4_bc=DK9v9kDr3r+Z3u;3;LX=>X-rkxla>X+( zAMX%U1DsO1+;M4{=k^d8$9`g!7d`*AcgeYA<1cpAT`sU^$9zcRkKa8dFfcu?IZU8l z{qrcgwwe5D4otu9y&-+1Vq3lSbGRRAq&?G!N?!l#mX0dw|5iH{fMyr_WtBa=gETmRI$r(PDEhGl|j+?t}v+L`;Bb-AqAr7&U2mVK5lWb7JR_}h{~vNRk6^e*p>=N9pIx~!7s)y?nxo6nsYw< z#t*fUe;DgIDO$Q*h{`AcfhRs7AEP9ZkK%IP{X60FwU4FiFwwb~gcb<~h9y!sUsTh; z%RWT~0Ha~|!KK;5B=FI4;3MTggxNWjA860iXW-+%X0iJnhM@#k|GgE>&J(I`Nyno6 zoOUBmXPM>+YIZll{qi59x(57bd86vk<-2aw&q5yo(InYLO6^YD(_tjDKYsh|w|+7T z2AEeX_mBhrqbnF6eRpGi%Caaw!iWI)+-}@*Z&8+THIK%Pc@Ra>;AtGrGk*VjO)1`G zB)_O0oy1^wI`_dFZ-Dq)eN0(xs#8dB$w^kj9%6+m1E)us4LfG$2H-%dN9k$Hrrm@( z97&(yagKt7vCOpy#25K#(QLCl8qE$6 zZv>Ob7qe?yzG8MoXlOF{8VUv;{RLAY9^_5@->a6|Vg4>lZ>CuKL3>^vS*($GvrfT- zvk`k%5|W8N&}%zC8nT&&Ku<)aF=;rFO$O%L#?e9=YlvH&xaS>`=!~(j+e2fgOFCjH zG8%HR0x#6`?A%3yo_z)~zB(2iIE)k^cEkh(lPIe!V9w`G%D6+z?n3H5L@ai5?~NB; zeQ}AT%ABnJiCW2qXmkeX`H1;*h9Qvk5fLB3M>~%pR&tlJ2p?)yY{a9MX4n}EfdRg2 zp!Hs-bJy_K5(w2O{2ubbtNZbEKPsF)ng?zJ6>;zdf_weO9Pd_GogA{-#d_aIdz_2J?T^Q1b}K^|AosVJ&MtpY65Ck%AS!*%bHNq0k>Ef@_yRRq z$R(1C%{M0wEP6~~h$6pf<<~ee(B~8|2GuP(>r3Drh5(2J6BL!Qd0Up)3#91h3Ov0? z#b7jN=y{M8+S>*&mn>p11-8|+d;W~ViW=Q#A|YznMy#K9UOKMF3L12w293I{1G7oP z$m=J&Akli>LqF(;lQd_qKM4A!aglX5)3~h375h3uk7jAUdim1qm*_%LeDWDWM7VY6 z9&)~eZ!suC=rHUI8JegY*8x+gWy1C|%MaEaxEetT>mq3rL(}{=%A}-rjwu<3{(oDb zdrm`=ePtLjQO>_gD%Ez%ubxZMSHRs(a}_axr&m&F5Q2+j*q-!YgO^cID63Z#FYKyP z^rgK)_r6TocYqKPFKh`y)+o=~_&dxeg$PV5b6;)Kure0s-2n$}Y{?>}`+=Q$SM`rH zAm|owdYj*%ecY7zP`&(}M7Tf5-?NeNMvSJ>Z9>dnDsxp3v(LEWIA+t435~2agQRhB z3DvKk3K5!ypgwoKI!tH?D9Mkbc_0+R8U6~UV(}N#koD*EvM&y+i=<1mfvnFGI_p!_ zK9Z&{?PJ#UavYC8bvgd4ogRkJu>FM+aLii}+@JuhRCf5{J`P!DZ!CM7bCw?NnmHnCqzLW#34rx6BoChR66<<2;I%|xZD{FKfev-2a8zc*w1M=Q2S|q`tNE+ zRGR)%+u0Ew-=a@QOEPcYnx5K7 *Dx(Vm6Y+b{ob0JpVE%_Sr7V0TRN=6aBcND zG}-aJ3YZa{3hm8lxOyTpgYk!R?H4>`bwpy;DqNUv^3O6{?u z!E`--dczc&6S?$7u`x&Ep(4dBo!EF4XK3zdIztP*ls3ysi*DYH@gA;_3pAJ~ZdZta z+fV{PJe%wFGv!#O*SHWwqecMZ0P~q0xneB2c-P;R?0G{TjtA{+ zMbPhuf_hxWMX*ATTF=J0h-mauu?gI!^NNOK)gT@PM;8t|)1L#iO3Wj+Pl8%(ttW7W zNC+_JmAs|MVH!%8c14|+^IzPh` z3)a}~#fHq_?bZ5Q4ZwM{6|{v<{Yj@Jh?IVB`@8di!`4 z(cE&65%a91Hr9OFW&YF~4)ENiHe?$goY)wTrI2S^?&K1kX$%>1#e?!u$>aME*wGTV zCGy&#DMFrU=cuZES`uCCbgo|op}JuDZ2#)(rKPW$64dYrrd>kdz=oU<&j`UK7V`}b z-W~ulxW`tzc-Nw!u;Q;CJzX=QEr*@ccG}55FW4ZWJF*Vn&6bQ1u8oR%Zfc`BlBTqf zwL#(f4Y;hnE($!$$_d`MRuP?60aBFj?H(QB1s9*1p>UM&j20j+38v{BZ|gGD=nP6z zK5)Y&g2Y(f)&YY{6>l5{WG&3gaovEw^ah%nYAn)(uwhe2N6qCuY>sqv4S%(CM!5Ls zG;z>3*F!r3C~jlhJu-8s7D=bP{$q#7J&)CPgoqU#3if8?9d2ljEubx0Z@lXFB@1tH z+efVDNJ?5%nQAegXJ}^#PYQ*dWSLBy`P2B(R_feiOZwA?YD`N6u3HjEILGUST9Bhr&g)rl#f-YQn6@Z=}+Zov1;yR93e9k}Aj9 zr0b?sL1}+(0QIwuD$Z3hN z@j`=3#XdB6d1|UOwcRiSrmN9@d6uHuwqsSe>8yWDxNFRxC#=TaCEQt0!W|4L*`qC* zl-WrZ)$Ah$bIEgae|rxAr1N3>?#e|M=vmEMbf4-eil@5($wEI#7pkG-7ov{?=WefS zgse;B&+s5$g#q-1)feH`cpV|HHoX@OZ}FUxy~C~p4WzdyQHI`yI~?gsObgues|&F6 zB!0j7R*ReLW|?YC^xM6SX@@T^^#Z2pLi++)*L+!ltkDi?+Hgq4!`)c$d9KwQBocPH VZihE;-G2OhS>Mi;vi@P~{{W ' + diff --git a/src/web/public/mobile-overview.js b/src/web/public/mobile-overview.js index c9c32bb44..ab7982122 100644 --- a/src/web/public/mobile-overview.js +++ b/src/web/public/mobile-overview.js @@ -431,6 +431,16 @@ Object.assign(CodemanApp.prototype, { ) ); + // Sessions a human opened outside Codeman. Its own container, rebuilt by the + // ONE renderer in foreign-sessions.js — the phone must not grow a second row + // builder that could describe the same session differently from the desktop. + const foreign = document.createElement('div'); + foreign.className = 'foreign-sessions mobile-foreign-sessions'; + foreign.id = 'mobileForeignSessions'; + foreign.hidden = true; + el.appendChild(foreign); + this.renderForeignSessions?.(foreign); + el.appendChild( this._buildMobileOverviewSection( 'Past sessions', diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index e0ddb53de..da66a27af 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -3816,3 +3816,15 @@ html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-close { transition: none; } } + +/* Foreign sessions block inside the phone overview (foreign-sessions.js). + The desktop block sits inside the welcome column; here it is a full-width + section between CURRENT and PAST, so it only needs the surrounding spacing — + every row style is shared with styles.css on purpose. */ +.mobile-foreign-sessions { + margin: 0.75rem 0.75rem 0; +} + +.mobile-foreign-sessions .foreign-list { + max-height: none; +} diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 50912082e..dc5a874e5 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -17574,3 +17574,173 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle transition: none; } } + +/* ═══════════════════════════════════════════════════════════════ + Foreign sessions — tmux sessions a human started outside Codeman + (foreign-sessions.js). Rendered on the welcome screen and, with the + same row builder, inside the phone overview. + + Colour vocabulary is deliberately the session-tab one: a mode dot on + the left, name over a dim meta line, action pinned right. A block that + invented its own language here would read as a different product. + ═══════════════════════════════════════════════════════════════ */ + +.welcome-foreign { + width: 100%; + margin-top: 0.75rem; +} + +.foreign-sessions { + display: flex; + flex-direction: column; + gap: 0.4rem; + text-align: left; +} + +/* `.foreign-sessions` is a flex container, so `[hidden]` needs re-asserting or + the module's only visibility lever does nothing (same trap as .home-sessions). */ +.foreign-sessions[hidden] { + display: none; +} + +.foreign-header { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.foreign-title { + font-size: 0.85rem; + color: var(--text-dim); + font-weight: 500; + text-align: left; +} + +.foreign-count { + font-size: 0.68rem; + color: var(--text-dim); + background: rgba(255, 255, 255, 0.05); + border-radius: 999px; + padding: 0.1rem 0.45rem; + white-space: nowrap; +} + +.foreign-scan-toggle { + margin-left: auto; + font-size: 0.68rem; + color: var(--text-dim); + background: transparent; + border: 1px solid var(--border); + border-radius: 999px; + padding: 0.12rem 0.5rem; + cursor: pointer; +} + +.foreign-scan-toggle[aria-pressed='true'] { + color: var(--session-blue, #4a9eff); + border-color: var(--session-blue, #4a9eff); +} + +.foreign-list { + display: flex; + flex-direction: column; + gap: 0.3rem; + max-height: min(40vh, 320px); + overflow-y: auto; +} + +.foreign-row { + display: flex; + align-items: center; + gap: 0.55rem; + padding: 0.4rem 0.55rem; + border: 1px solid var(--border); + border-radius: 6px; + background: rgba(255, 255, 255, 0.02); + min-width: 0; +} + +.foreign-row--open { + opacity: 0.62; +} + +.foreign-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex: 0 0 auto; + background: var(--text-muted, #888); +} + +.foreign-dot--claude { + background: #d97757; +} +.foreign-dot--codex { + background: #9b8cff; +} +.foreign-dot--shell { + background: #4caf7d; +} + +.foreign-row-body { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1 1 auto; +} + +.foreign-row-name { + font-size: 0.82rem; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.foreign-row-sub { + font-size: 0.68rem; + color: var(--text-dim); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.foreign-open-btn { + flex: 0 0 auto; + font-size: 0.7rem; + padding: 0.22rem 0.6rem; + border-radius: 5px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.04); + color: var(--text); + cursor: pointer; +} + +.foreign-open-btn:hover:not(:disabled) { + border-color: var(--session-blue, #4a9eff); + color: var(--session-blue, #4a9eff); +} + +.foreign-open-btn:disabled { + opacity: 0.55; + cursor: default; +} + +.foreign-empty { + font-size: 0.72rem; + color: var(--text-dim); + padding: 0.3rem 0.1rem; +} + +.foreign-notes { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.foreign-note { + font-size: 0.66rem; + color: var(--text-dim); + opacity: 0.85; + line-height: 1.35; +} diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 844513072..6e509288b 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -1985,6 +1985,9 @@ Object.assign(CodemanApp.prototype, { if (overlay) overlay.classList.remove('visible'); this.hideHomeSessions?.(); this.showMobileOverview(); + // The phone overview hosts the same list in its own container. + this.wireForeignSessions?.(); + this.startForeignPolling?.(); this._updateCjkInputState?.(); return; } @@ -1999,6 +2002,10 @@ Object.assign(CodemanApp.prototype, { // Open tabs down the left gutter. Self-gating: a window too narrow to hold // the column without overlapping the content leaves it hidden. this.showHomeSessions?.(); + // Sessions a human opened outside Codeman. Polls only while this screen is + // up (stopped in hideWelcome) — see foreign-sessions.js. + this.wireForeignSessions?.(); + this.startForeignPolling?.(); } // Home screen has no input target — hide the CJK textarea (activeSessionId // is null by the time we get here). Guarded: defined on the app object. @@ -2008,6 +2015,7 @@ Object.assign(CodemanApp.prototype, { hideWelcome() { this.hideMobileOverview?.(); this.hideHomeSessions?.(); + this.stopForeignPolling?.(); const overlay = document.getElementById('welcomeOverlay'); if (overlay) { overlay.classList.remove('visible'); diff --git a/src/web/routes/mux-routes.ts b/src/web/routes/mux-routes.ts index 12b22229e..b4e42ce92 100644 --- a/src/web/routes/mux-routes.ts +++ b/src/web/routes/mux-routes.ts @@ -1,6 +1,13 @@ /** * @fileoverview Mux (tmux) session management routes. - * Provides mux session listing, killing, reconciliation, and stats control. + * Provides mux session listing, killing, reconciliation, stats control, and + * discovery of FOREIGN tmux sessions (ones a human started outside Codeman). + * + * Discovery lives here rather than beside the adopt endpoint on purpose: like + * every other route in this file it exposes cross-user process state — other + * people's session names, commands and working directories — so it inherits the + * admin gate this file already applies. Adoption is a session CREATE and stays in + * `session-routes.ts`, where the owner, capacity and case-space gates live. */ import { FastifyInstance } from 'fastify'; @@ -8,6 +15,8 @@ import type { InfraPort } from '../ports/index.js'; import { STATS_COLLECTION_INTERVAL_MS } from '../../config/server-timing.js'; import { requireAdmin } from '../route-helpers.js'; import { isMultiUserMode } from '../../config/multiuser.js'; +import { discoverForeignSessions, readAllDockerCases, readAllRemoteHosts } from '../../foreign-tmux-discovery.js'; +import { FOREIGN_POLL_INTERVAL_MS } from '../../config/foreign-tmux.js'; export function registerMuxRoutes(app: FastifyInstance, ctx: InfraPort): void { app.get('/api/mux-sessions', async (req, reply) => { @@ -36,6 +45,58 @@ export function registerMuxRoutes(app: FastifyInstance, ctx: InfraPort): void { return result; }); + /** + * Foreign tmux sessions available for adoption. + * + * LOCAL results are always included and are TTL-cached, because the home screen + * polls this endpoint while it is open. DOCKER and REMOTE are opt-in per + * request (`?docker=1`, `?remote=1`): each costs one `docker exec` or one ssh + * per target, and having the home page fan those out on every load is the one + * cost this design refuses to pay. + * + * `adoptedBy` is filled from the live mux sessions, so a target Codeman already + * wraps renders as "open" rather than offering a second wrapper. + */ + app.get('/api/mux/foreign', async (req, reply) => { + if (isMultiUserMode() && !requireAdmin(req, reply)) return; + const q = (req.query ?? {}) as Record; + const wantDocker = q.docker === '1' || q.docker === 'true'; + const wantRemote = q.remote === '1' || q.remote === 'true'; + + // Read the registries either way: `canScanWide` tells the browser whether the + // expensive scan has anywhere to go. Without it the UI hides an empty block — + // and with it the toggle that is the ONLY way to populate that block, which on + // a host with containers but no local tmux sessions made the feature invisible. + const dockerCases = await readAllDockerCases(); + const remoteHosts = await readAllRemoteHosts(); + + const result = await discoverForeignSessions({ + local: true, + force: q.force === '1', + dockerCases: wantDocker ? dockerCases : undefined, + remoteHosts: wantRemote ? remoteHosts : undefined, + }); + + // Match on the (socket, session) pair rather than on our opaque candidate id: + // the id encodes a host key that a restored wrapper does not carry, while the + // pair is exactly what the wrapper stores and what it re-attaches to. + const wrapped = new Map(); + for (const m of ctx.mux.getSessions()) { + if (m.adopt) wrapped.set(`${m.adopt.socketPath}\u0000${m.adopt.targetSession}`, m.sessionId); + } + + return { + sessions: result.sessions.map((f) => ({ + ...f, + adoptedBy: wrapped.get(`${f.socketPath}\u0000${f.sessionName}`), + })), + scannedAt: result.scannedAt, + notes: result.notes, + pollIntervalMs: FOREIGN_POLL_INTERVAL_MS, + canScanWide: dockerCases.length > 0 || remoteHosts.length > 0, + }; + }); + app.post('/api/mux-sessions/stats/start', async (req, reply) => { // Multi-user: process-wide stats collection toggle → admin-only. if (isMultiUserMode() && !requireAdmin(req, reply)) return; diff --git a/src/web/routes/ralph-routes.ts b/src/web/routes/ralph-routes.ts index 24b08a485..d7c9e27e1 100644 --- a/src/web/routes/ralph-routes.ts +++ b/src/web/routes/ralph-routes.ts @@ -53,6 +53,17 @@ export function registerRalphRoutes( }; const session = findSessionOrFail(ctx, id, req); + // ⚠️ Adoption gate, kept SEPARATE from the external-CLI gate above: an adopted + // session can be `mode: 'claude'` and still be a process we never launched. + // Everything below drives the pane on the assumption Codeman owns what runs + // in it — sending `/clear`, killing and relaunching the agent — which against + // someone else's live session is destructive, not merely unsupported. + if (session.isAdopted) { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + 'The Ralph tracker is not available for adopted sessions: Codeman did not start this agent and must not drive its lifecycle' + ); + } // Ralph tracker is not supported for external-CLI sessions (opencode/codex) if (isExternalCliMode(session.mode)) { return createErrorResponse( diff --git a/src/web/routes/respawn-routes.ts b/src/web/routes/respawn-routes.ts index 56543997b..049028132 100644 --- a/src/web/routes/respawn-routes.ts +++ b/src/web/routes/respawn-routes.ts @@ -98,6 +98,17 @@ export function registerRespawnRoutes( } const session = findSessionOrFail(ctx, id, req); + // ⚠️ Adoption gate, kept SEPARATE from the external-CLI gate above: an adopted + // session can be `mode: 'claude'` and still be a process we never launched. + // Everything below drives the pane on the assumption Codeman owns what runs + // in it — sending `/clear`, killing and relaunching the agent — which against + // someone else's live session is destructive, not merely unsupported. + if (session.isAdopted) { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + 'Respawn is not available for adopted sessions: Codeman did not start this agent and must not drive its lifecycle' + ); + } // Respawn is not supported for external-CLI sessions (opencode/codex) if (isExternalCliMode(session.mode)) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Respawn is not supported for ${session.mode} sessions`); @@ -241,6 +252,17 @@ export function registerRespawnRoutes( return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy'); } + // ⚠️ Adoption gate, kept SEPARATE from the external-CLI gate above: an adopted + // session can be `mode: 'claude'` and still be a process we never launched. + // Everything below drives the pane on the assumption Codeman owns what runs + // in it — sending `/clear`, killing and relaunching the agent — which against + // someone else's live session is destructive, not merely unsupported. + if (session.isAdopted) { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + 'Respawn is not available for adopted sessions: Codeman did not start this agent and must not drive its lifecycle' + ); + } // Respawn is not supported for external-CLI sessions (opencode/codex) if (isExternalCliMode(session.mode)) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Respawn is not supported for ${session.mode} sessions`); @@ -310,6 +332,17 @@ export function registerRespawnRoutes( const body = reResult.data as { config?: Partial; durationMinutes?: number }; const session = findSessionOrFail(ctx, id, req); + // ⚠️ Adoption gate, kept SEPARATE from the external-CLI gate above: an adopted + // session can be `mode: 'claude'` and still be a process we never launched. + // Everything below drives the pane on the assumption Codeman owns what runs + // in it — sending `/clear`, killing and relaunching the agent — which against + // someone else's live session is destructive, not merely unsupported. + if (session.isAdopted) { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + 'Respawn is not available for adopted sessions: Codeman did not start this agent and must not drive its lifecycle' + ); + } // Respawn is not supported for external-CLI sessions (opencode/codex) if (isExternalCliMode(session.mode)) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Respawn is not supported for ${session.mode} sessions`); diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 3fc80c940..5cb4514d2 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -27,6 +27,16 @@ import { type GrokConfig, type DeepSeekConfig, } from '../../types.js'; +import { AdoptForeignSessionSchema } from '../schemas.js'; +import { + discoverForeignSessions, + invalidateForeignCache, + readAllDockerCases, + readAllRemoteHosts, +} from '../../foreign-tmux-discovery.js'; +import { foreignViewSessionName } from '../../foreign-tmux.js'; +import { requireAdmin } from '../route-helpers.js'; +import type { SessionAdopt } from '../../types/session.js'; import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode } from '../../session.js'; import { SseEvent } from '../sse-events.js'; import { @@ -1086,6 +1096,149 @@ export function registerSessionRoutes( return { session: lightState }; }); + // ========== Adopt a foreign tmux session ========== + + /** + * Wrap a tmux session a HUMAN started (local, in a container, or over ssh) in a + * Codeman session, so it appears as a tab and can be driven from the browser. + * + * Four things make this safe, and each is load-bearing: + * + * 1. **The body carries only an opaque id.** The socket path, session name and + * host are re-resolved by re-running discovery here. A browser therefore + * never supplies a fragment of the command we are about to run, which is the + * same rule that keeps docker-adopt and remote-attach injection-free. + * 2. **The candidate must still exist.** Discovery is re-run rather than cached, + * so a session that died between the listing and the click fails with a 404 + * instead of producing a wrapper attached to nothing. + * 3. **One wrapper per target.** Two wrappers on one foreign session would each + * create their own grouped view and each think they own the tab; the guard + * is here rather than in the button's in-flight lock, which only stops a + * double-click on one device. + * 4. **Admin-only under multi-user.** Discovery already is (it exposes other + * users' processes), and adopting someone's `shell` is arbitrary execution + * as the server account — which is exactly what the `can-bypass-permissions` + * grant gates elsewhere. The admin gate subsumes it, so there is deliberately + * no second grant check here. + */ + app.post('/api/sessions/adopt', async (req, reply) => { + if (isMultiUserMode() && !requireAdmin(req, reply)) return; + + const owner = ownerFor(req); + const capMsg = sessionCapacityMessage(ctx.sessions, owner); + if (capMsg) return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg); + + const body = parseBody(AdoptForeignSessionSchema, req.body, 'Invalid request body'); + + // Re-resolve rather than trust: point 1 and 2 above. + const found = await discoverForeignSessions({ + local: true, + force: true, + dockerCases: body.docker ? await readAllDockerCases() : undefined, + remoteHosts: body.remote ? await readAllRemoteHosts() : undefined, + }); + const target = found.sessions.find((f) => f.id === body.id); + if (!target) { + // ⚠️ "Not in the re-resolve" has two very different causes and they must not + // be reported as one. The session really being gone is the ordinary case; + // the OTHER case is a location we could not reach this time, which on a + // flaky link makes a perfectly live remote session read as deleted. Measured + // against a real VM whose ssh path dropped ~10% of connections: clicking + // Open failed with "no longer there" while the session was sitting right + // there. Discovery already knows which it was — it wrote a note — so say so. + const reach = found.notes.filter((n) => !/skipped/.test(n)); + return createErrorResponse( + ApiErrorCode.NOT_FOUND, + reach.length + ? `Could not reach it just now (${reach.join('; ')}). It may still be running — try again.` + : 'That tmux session is no longer there. Refresh the list and try again.' + ); + } + + // Point 3 — one wrapper per (socket, session). + const existing = ctx.mux + .getSessions() + .find((m) => m.adopt?.socketPath === target.socketPath && m.adopt?.targetSession === target.sessionName); + if (existing) { + const live = ctx.sessions.get(existing.sessionId); + if (live) return { session: ctx.getSessionStateWithRespawn(live), alreadyAdopted: true }; + } + + // Connection facts are copied onto the session rather than referenced by id: + // a wrapper restored after a server restart must be able to rebuild its + // command even if the host registry was edited in the meantime. + const adopt: SessionAdopt = { + location: target.location, + socketPath: target.socketPath, + targetSession: target.sessionName, + viewSession: '', + paneCurrentPath: target.workingDir, + }; + + if (target.location === 'docker') { + const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR); + const host = hosts.find((h) => h.id === target.hostId); + if (!target.containerName) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Container name missing for a docker candidate'); + } + adopt.docker = { + hostId: target.hostId ?? '', + label: target.hostLabel ?? target.containerName, + engine: host?.engine ?? 'docker', + containerName: target.containerName, + daemonHost: host?.daemonHost, + context: host?.context, + }; + } else if (target.location === 'remote') { + const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((h) => h.id === target.hostId); + if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found'); + adopt.remote = { + hostId: host.id, + label: host.label, + host: host.host, + username: host.username, + port: host.port, + identityFile: host.identityFile, + socksProxy: host.socksProxy, + jumpHost: host.jumpHost, + extraSshOptions: host.extraSshOptions, + }; + } + + const adoptHistoryConfig = await ctx.getTerminalHistoryConfig(); + + // ⚠️ `workingDir` for an adopted session is the FOREIGN pane's cwd, which may + // not exist on this host (a container path, a remote path). It is recorded as + // an observation for display; the wrapper pane is never `cd`'d into it, and + // the case-space confinement that guards a real workingDir does not apply + // because nothing is created there. + const session = new Session({ + workingDir: target.workingDir || process.cwd(), + mode: target.mode, + name: body.name || target.sessionName, + mux: ctx.mux, + useMux: true, + tmuxHistoryLimit: adoptHistoryConfig.tmuxHistoryLimit, + adopt, + owner, + parentSessionId: resolveParentSessionId(ctx, req, body.parentSessionId, owner), + }); + // The view session name is derived from the Codeman session id, so it can only + // be filled once the Session exists. + adopt.viewSession = foreignViewSessionName(session.id); + + await ctx.addSession(session); + ctx.store.incrementSessionsCreated(); + ctx.persistSessionState(session); + await ctx.setupSessionListeners(session); + getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name }); + invalidateForeignCache(); + + const lightState = ctx.getSessionStateWithRespawn(session); + ctx.broadcast(SseEvent.SessionCreated, lightState); + return { session: lightState, adopted: true }; + }); + // ========== Rename Session ========== app.put('/api/sessions/:id/name', async (req) => { diff --git a/src/web/schemas.ts b/src/web/schemas.ts index ffffd388c..7de99732a 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1784,3 +1784,25 @@ export const WebviewUpdateSchema = WebviewBaseSchema.partial(); /** POST /api/webviews/probe: reachability + framing check for the editor's Test button. */ export const WebviewProbeSchema = z.object({ url: webviewUrlSchema }); + +/** + * Adopt a FOREIGN tmux session (one a human started outside Codeman). + * + * ⚠️ The body carries ONLY the opaque candidate id from `GET /api/mux/foreign`. + * The socket path, session name and host are re-resolved server-side by re-running + * discovery, so a browser can never hand the launch chain a path or a session name + * to interpolate. That is the same discipline that keeps the docker-adopt and + * remote-attach paths free of caller-supplied command fragments. + */ +export const AdoptForeignSessionSchema = z + .object({ + id: z.string().min(1).max(64), + /** Optional tab name; defaults to the foreign session's own name. */ + name: z.string().max(128).optional(), + /** Include docker locations in the re-resolve (must match the listing call). */ + docker: z.boolean().optional(), + /** Include remote locations in the re-resolve. */ + remote: z.boolean().optional(), + parentSessionId: z.string().max(64).optional(), + }) + .strict(); diff --git a/src/web/server.ts b/src/web/server.ts index 8ef0b24ad..7055795cd 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1555,13 +1555,20 @@ export class WebServer extends EventEmitter { this.runSummaryTrackers.set(session.id, summaryTracker); summaryTracker.recordSessionStarted(session.mode, session.workingDir); - // Set working directory for Ralph tracker to auto-load @fix_plan.md (not supported for external CLIs) - if (!isExternalCliMode(session.mode)) { + // Set working directory for Ralph tracker to auto-load @fix_plan.md (not supported for external CLIs). + // ⚠️ Also skipped for an ADOPTED session, and for two reasons: Ralph is refused + // for one anyway, and its `workingDir` is the FOREIGN pane's cwd — a path that + // need not exist on this host at all. Watching it logged a caught ENOENT on + // every in-container adoption (`watch '/workspace/pythonserver'`), which is + // noise pointing at a real category error rather than a real failure. + if (!isExternalCliMode(session.mode) && !session.isAdopted) { session.ralphTracker.setWorkingDir(session.workingDir); } // Start watching for new images in this session's working directory (if enabled globally and per-session) - if ((await this.isImageWatcherEnabled()) && session.imageWatcherEnabled) { + if ((await this.isImageWatcherEnabled()) && session.imageWatcherEnabled && !session.isAdopted) { + // Same reason as the Ralph watcher above: an adopted session's workingDir is + // an observation about ANOTHER host's (or container's) filesystem. imageWatcher.watchSession(session.id, session.workingDir); } @@ -2805,6 +2812,14 @@ export class WebServer extends EventEmitter { // MuxSession.docker; state.json carries SessionState.docker), so recovery // rebuilds the `docker exec` launch instead of a broken local command. docker: muxSession.docker ?? savedState?.docker, + // Adoption metadata round-trips for the same reason remote/docker do, + // and one more: it is the ONLY thing that marks this session as + // wrapping a process Codeman never launched. Dropping it on recovery + // silently re-enabled respawn, Ralph and hook-backed waits against + // someone else's live tmux session after every server restart — + // measured, not hypothetical. The mux record is preferred because it + // is what `killSession`'s detach-not-kill guard already reads. + adopt: muxSession.adopt ?? savedState?.adopt, owner: recoveredOwner, // Tab lineage survives a restart. It is only decoration, so a parent // that did NOT come back is harmless: the frontend draws an edge only diff --git a/src/web/session-wait-registry.ts b/src/web/session-wait-registry.ts index 0592d9525..c5f97b5c8 100644 --- a/src/web/session-wait-registry.ts +++ b/src/web/session-wait-registry.ts @@ -189,6 +189,18 @@ export interface HookCapabilityOptions { * timeout on every turn. */ deepSeekBridgeUnreachable?: boolean; + /** + * True when the session is a WRAPPER around a tmux session a human started + * outside Codeman. + * + * This one overrides the mode entirely, and it has to: an adopted session can + * be `mode: 'claude'` and still have no hooks, because hooks are installed into + * a WORKSPACE at session-create time (`applyWorkspaceHooks`) and we never + * created this one. Answering from the mode there would promise `stop` and + * `blocked` for a process that can never post either — the exact + * infinite-wait-dressed-as-a-timeout this predicate exists to prevent. + */ + adopted?: boolean; } /** @@ -223,6 +235,9 @@ export interface HookCapabilityOptions { * function only about hook SIGNALS. */ export function hooksAvailableForMode(mode: SessionMode, options: HookCapabilityOptions = {}): boolean { + // Checked BEFORE the mode: adoption is about who launched the process, and no + // mode can vouch for a workspace Codeman never touched. See `adopted` above. + if (options.adopted) return false; if (mode === 'claude') return true; // `deepseek` earns this the same way `claude` does — by emitting DEFINITIVE // signals rather than having them inferred. The DeepSeek Harness terminal @@ -250,10 +265,12 @@ export function sessionHookOptions(session: { deepSeekStatusReporting?: boolean; docker?: unknown; remote?: unknown; + adopt?: unknown; }): HookCapabilityOptions { return { deepSeekStatusReporting: session.deepSeekStatusReporting, deepSeekBridgeUnreachable: Boolean(session.docker || session.remote), + adopted: Boolean(session.adopt), }; } diff --git a/test/docker-adopted-container.test.ts b/test/docker-adopted-container.test.ts index 62d26cfdc..a522532af 100644 --- a/test/docker-adopted-container.test.ts +++ b/test/docker-adopted-container.test.ts @@ -212,8 +212,15 @@ describe('adopted container: the host is not required to have the CLI', () => { expect(unguarded).toHaveLength(0); }); - it('derives the flag from the docker metadata the session already carries', () => { - expect(src).toContain('const cliRunsInContainer = !!docker;'); + it('derives the flag from the location metadata the session already carries', () => { + // Adoption joined the condition for the same reason docker is in it: an + // adopted session's CLI was started by a human in a process Codeman never + // spawned, so the host binary is irrelevant there too — and demanding it + // would reject adopting a claude that lives in a container, on an ssh host, + // or simply outside the server process's PATH (the systemd/launchd case). + // What the assertion still pins is that the flag comes from the session's + // OWN metadata rather than from anything ambient. + expect(src).toContain('const cliRunsInContainer = !!docker || !!adopt;'); }); }); diff --git a/test/foreign-tmux.test.ts b/test/foreign-tmux.test.ts new file mode 100644 index 000000000..b11216f85 --- /dev/null +++ b/test/foreign-tmux.test.ts @@ -0,0 +1,314 @@ +/** + * Foreign tmux adoption — the pure core. + * + * These pin the properties that were established by MEASUREMENT against a real + * tmux (3.3a) while the feature was built, and that a plausible-looking refactor + * would quietly undo. Each one has a comment naming what actually went wrong. + */ + +import { describe, it, expect } from 'vitest'; +import { + buildForeignProbeScript, + parseForeignProbeOutput, + classifyForeignPaneMode, + isCodemanOwnedPane, + foreignSessionId, + foreignViewSessionName, + isAdoptableSessionName, + isAdoptableSocketPath, + buildForeignAttachCommand, + buildForeignDockerAttachCommand, + buildForeignRemoteAttachCommand, + buildForeignTmuxInvocation, +} from '../src/foreign-tmux.js'; + +// A probe transcript in exactly the shape a real run produces. The pane rows use +// the LITERAL backslash-t that tmux's `-F` emits (verified on next-3.7 and 3.3a), +// while the socket line is space-separated because `sh`'s builtin `echo` expands +// a backslash-t to a real TAB — two different meanings for one escape, two lines +// apart, which is why the socket marker carries no separator at all. +const PROBE = [ + 'CMFS /tmp/tmux-0/default', + 'CMFP\\t/tmp/tmux-0/default\\t631\\t0\\t1\\t1788092494\\t1\\t%0\\tclaude\\twork\\t/srv/app', + 'CMFP\\t/tmp/tmux-0/default\\t900\\t0\\t1\\t1788092500\\t0\\t%1\\tbash\\tscratch\\t/home/me', + 'CMFP\\t/tmp/tmux-0/default\\t950\\t0\\t2\\t1788092600\\t0\\t%2\\tnode\\tcodex-work\\t/srv/app', + 'CMFQ', + ' 631 630 -bash', + ' 4056 631 claude --dangerously-skip-permissions', + ' 4104 4056 /usr/local/bin/ortg --repo /ortg mcp', + ' 900 630 -bash', + ' 950 630 node /opt/homebrew/bin/codex', +].join('\n'); + +describe('buildForeignProbeScript', () => { + it('contains no single quote — it is wrapped in one to cross ssh and docker exec', () => { + // The script is embedded as `ssh host '