feat(docker): export/import (move a container to another machine) + boot reaper

- src/docker-export.ts: full-image export (pause-consistent commit + save|stream +
  workspace tar + manifest -> one .codeman-container.tgz) and workspace-only; import
  validates manifest + per-member sha256, traversal-guards the workspace tar, docker
  load + quarantine re-tag (never overwrites a local tag). Bounded by
  runWithConversionLimit; free-space precheck; docker rmi in finally; sealed
  containers refuse full-image export.
- routes: POST /api/docker-cases/:name/export (background + SSE), GET/DELETE
  /api/docker-exports, GET download, POST /api/docker-cases/import (-> new host+case)
- instance-scoped boot reaper (docker-hosts.reapOrphanedDockerContainers) wired after
  restoreMuxSessions; never touches another instance's containers
- SSE docker:exportComplete/exportFailed/importComplete (both registries)
- fix: stream pipeline in saveImageToTar so the bundle isn't truncated

VERIFIED end-to-end on real docker: full export -> 326MB valid bundle -> delete
case -> import -> new container runs from the quarantined image with the workspace
file AND the in-image change both restored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-07-19 17:38:01 +02:00
parent 814362b67b
commit 39ef17b6af
8 changed files with 788 additions and 3 deletions
+53
View File
@@ -545,6 +545,59 @@ export async function checkDockerTmuxAvailable(
}
}
/**
* Instance-scoped boot reaper: `docker rm -f` any MANAGED container that belongs
* to THIS instance (by the `codeman.instance` label) but whose case is no longer
* in `docker-cases.json`. The instance scoping is what stops a beta from reaping
* prod's containers (the cross-instance hazard). No-op under VITEST. Best-effort.
*/
export async function reapOrphanedDockerContainers(
configDir: string,
instance: string,
engine: DockerEngine = 'docker'
): Promise<string[]> {
if (IS_TEST_MODE) return [];
const bin = engine === 'podman' ? 'podman' : 'docker';
let rows: Array<{ name: string; inst: string }> = [];
try {
const { stdout } = await execFileAsync(
bin,
[
'ps',
'-a',
'--filter',
'label=codeman.managed=1',
'--format',
'{{.Names}}\t{{index .Labels "codeman.instance"}}',
],
{ timeout: DOCKER_PROBE_TIMEOUT_MS }
);
rows = stdout
.split('\n')
.filter(Boolean)
.map((line) => {
const [name, inst = ''] = line.split('\t');
return { name, inst };
});
} catch {
return []; // daemon down / engine absent — nothing to reap
}
const cases = await readDockerCases(configDir);
const expected = new Set(cases.map((c) => c.container ?? dockerContainerName(c.name)));
const reaped: string[] = [];
for (const { name, inst } of rows) {
if (inst !== instance) continue; // only THIS instance's containers
if (expected.has(name)) continue; // still referenced by a live case
try {
await execFileAsync(bin, ['rm', '-f', name], { timeout: DOCKER_PROBE_TIMEOUT_MS });
reaped.push(name);
} catch {
/* best-effort */
}
}
return reaped;
}
/**
* Read the IN-CONTAINER Claude CLI version (`docker exec <container> claude
* --version`). Feeds Session.cliVersion for docker sessions (the LOCAL claude