From 15eebde83213b1b5def51c453b7b027bb684fe80 Mon Sep 17 00:00:00 2001 From: d fei Date: Sat, 29 Aug 2026 21:02:19 -0700 Subject: [PATCH] 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 e2a92486..b49502a5 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 5088793d..3e0c96cb 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 8e429459..7187dae4 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 978ac941..49c94e2e 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 3e7c258f..36ccea31 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 1641e61f..c5860388 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 00000000..9561bf49 --- /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); + }); +});