mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
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:
+154
-3
@@ -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. */
|
/** HOME inside the base image (the `agent` user). Cred mounts + hook-secret land under it. */
|
||||||
export const CONTAINER_HOME = '/home/agent';
|
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
|
/** 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
|
* 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. */
|
* already validated `^[a-zA-Z0-9_-]+$`, so `codeman-case-<name>` is always valid. */
|
||||||
@@ -252,7 +267,22 @@ export function toSessionDocker(host: DockerHost, dockerCase: DockerCase): Sessi
|
|||||||
extraCreateArgs: host.extraCreateArgs,
|
extraCreateArgs: host.extraCreateArgs,
|
||||||
extraExecArgs: host.extraExecArgs,
|
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 ==========
|
// ========== Shell escaping ==========
|
||||||
@@ -753,9 +783,15 @@ export interface DockerDriftStatus {
|
|||||||
* daemon down) means there is nothing to drift. No-op under VITEST.
|
* daemon down) means there is nothing to drift. No-op under VITEST.
|
||||||
*/
|
*/
|
||||||
export async function checkDockerConfigDrift(
|
export async function checkDockerConfigDrift(
|
||||||
docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost' | 'containerName' | 'configHash'>
|
docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost' | 'containerName' | 'configHash' | 'owned'>
|
||||||
): Promise<DockerDriftStatus> {
|
): Promise<DockerDriftStatus> {
|
||||||
if (IS_TEST_MODE) return { exists: false, running: false, drifted: false };
|
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);
|
const argv = dockerEngineArgv(docker);
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execFileAsync(
|
const { stdout } = await execFileAsync(
|
||||||
@@ -783,8 +819,15 @@ export async function checkDockerConfigDrift(
|
|||||||
* case's lastClaudeSessionId. No-op under VITEST.
|
* case's lastClaudeSessionId. No-op under VITEST.
|
||||||
*/
|
*/
|
||||||
export async function removeDockerContainer(
|
export async function removeDockerContainer(
|
||||||
docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost' | 'containerName'>
|
docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost' | 'containerName' | 'owned'>
|
||||||
): Promise<void> {
|
): 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;
|
if (IS_TEST_MODE) return;
|
||||||
const argv = dockerEngineArgv(docker);
|
const argv = dockerEngineArgv(docker);
|
||||||
await execFileAsync(argv[0], [...argv.slice(1), 'rm', '-f', docker.containerName], { timeout: 30_000 });
|
await execFileAsync(argv[0], [...argv.slice(1), 'rm', '-f', docker.containerName], { timeout: 30_000 });
|
||||||
@@ -1030,6 +1073,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
|
* 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
|
* reaches as `host.docker.internal`), so the server can bind a hooks-only listener
|
||||||
@@ -1092,9 +1234,18 @@ export async function reapOrphanedDockerContainers(
|
|||||||
}
|
}
|
||||||
const cases = await readDockerCases(configDir);
|
const cases = await readDockerCases(configDir);
|
||||||
const expected = new Set(cases.map((c) => c.container ?? dockerContainerName(c.name)));
|
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[] = [];
|
const reaped: string[] = [];
|
||||||
for (const { name, inst } of rows) {
|
for (const { name, inst } of rows) {
|
||||||
if (inst !== instance) continue; // only THIS instance's containers
|
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
|
if (expected.has(name)) continue; // still referenced by a live case
|
||||||
try {
|
try {
|
||||||
await execFileAsync(bin, ['rm', '-f', name], { timeout: DOCKER_PROBE_TIMEOUT_MS });
|
await execFileAsync(bin, ['rm', '-f', name], { timeout: DOCKER_PROBE_TIMEOUT_MS });
|
||||||
|
|||||||
+50
-6
@@ -1311,7 +1311,15 @@ export interface DockerLaunchOptions {
|
|||||||
export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
|
export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
|
||||||
const { mode, docker, sessionId, resumeSessionId, createContext, execEnv, execEnvNames, seedCopies } = opts;
|
const { mode, docker, sessionId, resumeSessionId, createContext, execEnv, execEnvNames, seedCopies } = opts;
|
||||||
const base = buildDockerBaseArgs(docker).join(' ');
|
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 name = shellescape(docker.containerName);
|
||||||
const workdir = shellescape(docker.containerWorkdir);
|
const workdir = shellescape(docker.containerWorkdir);
|
||||||
const image = shellescape(docker.image);
|
const image = shellescape(docker.image);
|
||||||
@@ -1354,7 +1362,16 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
|
|||||||
);
|
);
|
||||||
const startFailMsg = shellescape(`Codeman: container ${docker.containerName} failed to start (docker daemon down?)`);
|
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
|
// create-if-missing (idempotent): reconnect / boot recovery re-runs this exact
|
||||||
// chain. A daemon without swap accounting warns whenever --memory is present,
|
// chain. A daemon without swap accounting warns whenever --memory is present,
|
||||||
// even when --memory-swap is omitted. In compatibility mode, retain the memory
|
// even when --memory-swap is omitted. In compatibility mode, retain the memory
|
||||||
@@ -1371,14 +1388,25 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
|
|||||||
`elif ${base} inspect ${name} >/dev/null 2>&1; then ${removeCreateOutput}; ` +
|
`elif ${base} inspect ${name} >/dev/null 2>&1; then ${removeCreateOutput}; ` +
|
||||||
`else ${filteredCreateOutput} >&2; ${removeCreateOutput}; false; fi; }`
|
`else ${filteredCreateOutput} >&2; ${removeCreateOutput}; false; fi; }`
|
||||||
: `${base} ${createArgs}`;
|
: `${base} ${createArgs}`;
|
||||||
const ensure = `${base} inspect ${name} >/dev/null 2>&1 || ${createCommand}`;
|
// An ADOPTED container is never created and never started by Codeman: it
|
||||||
const start = `${base} start ${name} >/dev/null 2>&1 || { echo ${startFailMsg}; exit 1; }`;
|
// belongs to the user, so a missing or stopped one is an error to report, not
|
||||||
|
// a state to fix.
|
||||||
|
const ensure = adopted
|
||||||
|
? `${base} inspect ${name} >/dev/null 2>&1 || { echo ${notFoundMsg}; exit 1; }`
|
||||||
|
: `${base} inspect ${name} >/dev/null 2>&1 || ${createCommand}`;
|
||||||
|
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
|
// 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
|
// (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
|
// 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
|
// 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.
|
// 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 cp = s.recursive ? 'cp -a' : 'cp';
|
||||||
const parent = s.to.slice(0, s.to.lastIndexOf('/'));
|
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`;
|
return `mkdir -p ${parent} 2>/dev/null; [ -e ${s.to} ] || ${cp} ${s.from} ${s.to} 2>/dev/null || true`;
|
||||||
@@ -1386,7 +1414,7 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
|
|||||||
const innerCmd = seedSteps.length ? `${seedSteps.join(' ; ')} ; ${tmuxInvocation}` : tmuxInvocation;
|
const innerCmd = seedSteps.length ? `${seedSteps.join(' ; ')} ; ${tmuxInvocation}` : tmuxInvocation;
|
||||||
const execCmd = `exec ${base} exec -it --workdir ${workdir} ${execEnvFlags.join(' ')} ${name} sh -lc ${shellescape(innerCmd)}`;
|
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(' ; ');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1402,13 +1430,29 @@ export function buildDockerKillCommand(options: { docker: SessionDocker; session
|
|||||||
return `${base} exec ${shellescape(docker.containerName)} tmux -L ${DOCKER_TMUX_SOCKET} kill-session -t ${shellescape(dkrName)}`;
|
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). */
|
/** Explicit container stop (frees RAM/CPU; conversation resumes on next launch via --resume). */
|
||||||
export function buildDockerStopCommand(docker: SessionDocker): string {
|
export function buildDockerStopCommand(docker: SessionDocker): string {
|
||||||
|
assertOwnedContainer(docker, 'stop');
|
||||||
return `${buildDockerBaseArgs(docker).join(' ')} stop -t 10 ${shellescape(docker.containerName)}`;
|
return `${buildDockerBaseArgs(docker).join(' ')} stop -t 10 ${shellescape(docker.containerName)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Explicit container removal (case-delete). Destroys in-image state; bind mounts survive. */
|
/** Explicit container removal (case-delete). Destroys in-image state; bind mounts survive. */
|
||||||
export function buildDockerRemoveCommand(docker: SessionDocker): string {
|
export function buildDockerRemoveCommand(docker: SessionDocker): string {
|
||||||
|
assertOwnedContainer(docker, 'remove');
|
||||||
return `${buildDockerBaseArgs(docker).join(' ')} rm -f ${shellescape(docker.containerName)}`;
|
return `${buildDockerBaseArgs(docker).join(' ')} rm -f ${shellescape(docker.containerName)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -244,6 +244,24 @@ export interface DockerCase {
|
|||||||
containerWorkdir?: string;
|
containerWorkdir?: string;
|
||||||
/** Container name (default codeman-case-<slug>). */
|
/** Container name (default codeman-case-<slug>). */
|
||||||
container?: string;
|
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. */
|
/** Last captured Claude conversation id, replayed via --resume on a fresh launch. */
|
||||||
lastClaudeSessionId?: string;
|
lastClaudeSessionId?: string;
|
||||||
}
|
}
|
||||||
@@ -276,6 +294,12 @@ export interface SessionDocker {
|
|||||||
extraExecArgs?: string[];
|
extraExecArgs?: string[];
|
||||||
/** Stable hash of the drift-relevant create args (recreate-on-drift detection). */
|
/** Stable hash of the drift-relevant create args (recreate-on-drift detection). */
|
||||||
configHash?: string;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import fs from 'node:fs/promises';
|
|||||||
import { join, resolve, basename } from 'node:path';
|
import { join, resolve, basename } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { homedir } from 'node:os';
|
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 { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||||
import {
|
import {
|
||||||
CreateCaseSchema,
|
CreateCaseSchema,
|
||||||
@@ -24,6 +24,8 @@ import {
|
|||||||
RemoteCaseLinkSchema,
|
RemoteCaseLinkSchema,
|
||||||
RemoteHostSchema,
|
RemoteHostSchema,
|
||||||
DockerCaseLinkSchema,
|
DockerCaseLinkSchema,
|
||||||
|
DockerCaseAdoptSchema,
|
||||||
|
DockerAdoptPreflightSchema,
|
||||||
DockerHostSchema,
|
DockerHostSchema,
|
||||||
DockerExportSchema,
|
DockerExportSchema,
|
||||||
DockerImportSchema,
|
DockerImportSchema,
|
||||||
@@ -66,6 +68,8 @@ import {
|
|||||||
DEFAULT_AGENT_IMAGE,
|
DEFAULT_AGENT_IMAGE,
|
||||||
dockerContainerName,
|
dockerContainerName,
|
||||||
dockerDisplayPath,
|
dockerDisplayPath,
|
||||||
|
probeAdoptableContainer,
|
||||||
|
DOCKER_ADOPT_PROBE_MODES,
|
||||||
readDockerCases,
|
readDockerCases,
|
||||||
readDockerHosts,
|
readDockerHosts,
|
||||||
removeDockerContainer,
|
removeDockerContainer,
|
||||||
@@ -73,6 +77,7 @@ import {
|
|||||||
writeDockerCases,
|
writeDockerCases,
|
||||||
writeDockerHosts,
|
writeDockerHosts,
|
||||||
} from '../../docker-hosts.js';
|
} from '../../docker-hosts.js';
|
||||||
|
import type { AdoptedContainerProbe } from '../../docker-hosts.js';
|
||||||
import { buildDockerRemoveCommand } from '../../tmux-manager.js';
|
import { buildDockerRemoveCommand } from '../../tmux-manager.js';
|
||||||
import {
|
import {
|
||||||
checkRemoteTmuxAvailable,
|
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<ApiResponse<{ case: unknown; image?: string; availableModes?: SessionMode[] }>> => {
|
||||||
|
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<ApiResponse<AdoptedContainerProbe>> => {
|
||||||
|
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)
|
// 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
|
// 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.
|
// 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,
|
engine: result.manifest.engine,
|
||||||
image: result.importedImage ?? result.manifest.image,
|
image: result.importedImage ?? result.manifest.image,
|
||||||
network: (['bridge', 'none', 'custom'].includes(result.manifest.network) ? result.manifest.network : 'bridge') as
|
network: (['bridge', 'none', 'custom'].includes(result.manifest.network) ? result.manifest.network : 'bridge') as
|
||||||
| 'bridge'
|
'bridge' | 'none' | 'custom',
|
||||||
| 'none'
|
|
||||||
| 'custom',
|
|
||||||
};
|
};
|
||||||
await writeDockerHosts(
|
await writeDockerHosts(
|
||||||
CODEMAN_CONFIG_DIR,
|
CODEMAN_CONFIG_DIR,
|
||||||
@@ -1042,8 +1145,22 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
'/api/docker-cases/:name/recreate',
|
'/api/docker-cases/:name/recreate',
|
||||||
async (req): Promise<ApiResponse<{ name: string; container: string }>> => {
|
async (req): Promise<ApiResponse<{ name: string; container: string }>> => {
|
||||||
const { name } = req.params as { name: string };
|
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');
|
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);
|
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
|
||||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
|
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
|
||||||
const sessionDocker = toSessionDocker(host, dockerCase);
|
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
|
// Best-effort `docker rm -f` the per-case container (case-delete is the
|
||||||
// explicit teardown that removes it; the bind-mounted workspace survives).
|
// 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) {
|
if (host) {
|
||||||
const sessionDocker = toSessionDocker(host, dockerCase);
|
const sessionDocker = toSessionDocker(host, dockerCase);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
checkDockerAvailable,
|
checkDockerAvailable,
|
||||||
checkDockerConfigDrift,
|
checkDockerConfigDrift,
|
||||||
|
probeAdoptableContainer,
|
||||||
checkDockerTmuxAvailable,
|
checkDockerTmuxAvailable,
|
||||||
ensureAgentBaseImage,
|
ensureAgentBaseImage,
|
||||||
DEFAULT_AGENT_IMAGE,
|
DEFAULT_AGENT_IMAGE,
|
||||||
@@ -3039,25 +3040,43 @@ export function registerSessionRoutes(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const sessionDocker = toSessionDocker(host, dockerCase);
|
const sessionDocker = toSessionDocker(host, dockerCase);
|
||||||
// Ensure the base image exists, auto-building the default image on first use so
|
// An ADOPTED container skips every image-side gate: we never run `docker
|
||||||
// it is never a blocker. Dedup'd with any build kicked off at case-create, so
|
// create`, so the image is the user's business, and `ensureAgentBaseImage`
|
||||||
// this awaits the SAME in-flight build rather than starting a second one.
|
// would build/require an image that has nothing to do with their container.
|
||||||
const ensured = await ensureAgentBaseImage(sessionDocker, sessionDocker.image, {
|
// The prerequisite that DOES still hold is tmux inside it, so probe the live
|
||||||
onProgress: (line) => ctx.broadcast(SseEvent.DockerImageBuildProgress, { name: dockerCase.name, line }),
|
// container (not the image) and refuse before launch rather than dead-paning.
|
||||||
});
|
if (sessionDocker.owned === false) {
|
||||||
if (!ensured.ok) {
|
const probe = await probeAdoptableContainer(sessionDocker, [mode]);
|
||||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, ensured.error || 'base image not available');
|
if (!probe.ok) {
|
||||||
}
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, probe.error || 'container is not usable');
|
||||||
if (ensured.built) {
|
}
|
||||||
ctx.broadcast(SseEvent.DockerImageBuildComplete, { name: dockerCase.name, image: sessionDocker.image });
|
if (mode !== 'shell' && !probe.availableModes?.includes(mode)) {
|
||||||
}
|
return createErrorResponse(
|
||||||
// tmux is a hard prerequisite (the in-container tmux makes reconnect durable).
|
ApiErrorCode.OPERATION_FAILED,
|
||||||
// Skip the extra container-run probe for our OWN default image (the baked
|
`"${mode}" is not installed in container "${sessionDocker.containerName}". Adoption never modifies the container — install it inside, or pick another mode.`
|
||||||
// Dockerfile always contains tmux); still verify a custom image.
|
);
|
||||||
if (sessionDocker.image !== DEFAULT_AGENT_IMAGE) {
|
}
|
||||||
const tmuxCheck = await checkDockerTmuxAvailable(sessionDocker);
|
} else {
|
||||||
if (!tmuxCheck.ok) {
|
// Ensure the base image exists, auto-building the default image on first use so
|
||||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'base image is missing tmux');
|
// 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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -815,6 +815,50 @@ export const DockerCaseLinkSchema = z.object({
|
|||||||
.optional(),
|
.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({
|
export const DockerExportSchema = z.object({
|
||||||
mode: z.enum(['full', 'workspace']).optional(),
|
mode: z.enum(['full', 'workspace']).optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user