mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12: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. */
|
||||
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 });
|
||||
|
||||
+47
-6
@@ -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)}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -243,6 +243,24 @@ export interface DockerCase {
|
||||
containerWorkdir?: string;
|
||||
/** Container name (default codeman-case-<slug>). */
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<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)
|
||||
// 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<ApiResponse<{ name: string; container: 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');
|
||||
// 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 {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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