feat(docker): attach a case to an already-running container

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.
This commit is contained in:
d fei
2026-08-29 21:02:19 -07:00
parent 23fae0c5af
commit 15eebde832
7 changed files with 604 additions and 34 deletions
+154 -3
View File
@@ -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-<name>` 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<SessionDocker, 'owned'>): 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<SessionDocker, 'engine' | 'context' | 'daemonHost' | 'containerName' | 'configHash'>
docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost' | 'containerName' | 'configHash' | 'owned'>
): Promise<DockerDriftStatus> {
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<SessionDocker, 'engine' | 'context' | 'daemonHost' | 'containerName'>
docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost' | 'containerName' | 'owned'>
): Promise<void> {
// 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 <mode>`). */
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<SessionDocker, 'engine' | 'context' | 'daemonHost' | 'containerName'>,
modes: SessionMode[] = []
): Promise<AdoptedContainerProbe> {
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 });