diff --git a/src/docker-export.ts b/src/docker-export.ts new file mode 100644 index 00000000..bb5549c0 --- /dev/null +++ b/src/docker-export.ts @@ -0,0 +1,416 @@ +/** + * @fileoverview Docker case export / import: move a container (toolchain + any + * in-image changes) PLUS its workspace to another machine as one portable + * `.codeman-container.tgz`, and restore it. + * + * A full-image export = `docker commit` the running container to an image -> + * `docker save` that image -> tar the bind-mounted workspace -> a manifest, all + * bundled into one gzip tarball. A workspace-only export skips the image (fast, + * files-only). Import validates the manifest + per-member checksums, extracts the + * workspace with a path-traversal guard, `docker load`s the image and RE-TAGS it + * into a quarantined namespace (never overwriting a local tag), and hands the + * caller enough to recreate a hardened case on the destination. + * + * Safety (all from the design critic): pause the container spanning the workspace + * tar AND the commit so the two artifacts are mutually consistent; a free-space + * precheck (a full docker graph wedges EVERY session on the host); `docker rmi` + * the intermediate image in a finally; sealed containers refuse a full-image + * export (an in-container login would ride the committed layer); import rejects + * absolute / `..` tar members and checksum mismatches. Bounded by + * runWithConversionLimit so N exports cannot fork-bomb the host. + * + * @module docker-export + */ + +import { createReadStream, createWriteStream, existsSync, mkdirSync } from 'node:fs'; +import fs from 'node:fs/promises'; +import { join, basename } from 'node:path'; +import { createHash } from 'node:crypto'; +import { spawn } from 'node:child_process'; +import { pipeline } from 'node:stream/promises'; +import type { DockerEngine, SessionDocker } from './types.js'; +import { runWithConversionLimit } from './document-conversion-limiter.js'; + +const IS_TEST_MODE = !!process.env.VITEST; + +/** Refuse to export when the target filesystem has less than this free (a full graph wedges the daemon). */ +export const DOCKER_EXPORT_MIN_FREE_BYTES = 2 * 1024 * 1024 * 1024; // 2 GiB + +/** Manifest schema version (bump on any breaking field change). */ +export const DOCKER_EXPORT_SCHEMA = 1; + +export type DockerExportMode = 'full' | 'workspace'; + +export interface DockerExportManifest { + schemaVersion: number; + caseName: string; + mode: DockerExportMode; + engine: DockerEngine; + image: string; + containerWorkdir: string; + network: string; + createdAt: number; + codemanVersion: string; + mountCredentials: boolean; + /** True when the bundle provably carries no credentials (convenient-mode workspace, or a full image whose creds were bind-mounted and thus never committed). */ + secretFree: boolean; + /** sha256 of each bundle member that is present. */ + checksums: { image?: string; workspace?: string }; +} + +// ========== Pure helpers (unit-tested) ========== + +/** Raw argv prefix for the engine (NO shell escaping — used with spawn). */ +export function dockerArgv(docker: Pick): string[] { + const argv: string[] = [docker.engine === 'podman' ? 'podman' : 'docker']; + if (docker.context) argv.push('--context', docker.context); + if (docker.daemonHost) argv.push('-H', docker.daemonHost); + return argv; +} + +/** Portable bundle filename for a case export. */ +export function exportBundleName(caseName: string, timestamp: number, mode: DockerExportMode): string { + const suffix = mode === 'workspace' ? 'workspace' : 'container'; + return `${caseName}-${timestamp}.codeman-${suffix}.tgz`; +} + +/** Quarantined image tag for an imported bundle (never overwrites a local tag). */ +export function importedImageTag(caseName: string, timestamp: number): string { + return `codeman/imported-${caseName}:${timestamp}`; +} + +/** Intermediate commit tag for a full-image export (unique per export, rmi'd in finally). */ +export function exportImageTag(caseName: string, timestamp: number): string { + return `codeman/export-${caseName}:${timestamp}`; +} + +/** + * Reject a tar member path that would escape the extraction root (absolute path + * or a `..` component). The import-side traversal guard. + */ +export function isSafeTarMember(member: string): boolean { + const trimmed = member.trim(); + if (!trimmed || trimmed === './') return true; + if (trimmed.startsWith('/')) return false; + // Normalize separators and check each component. + return !trimmed.split('/').some((part) => part === '..'); +} + +/** Parse the image id/ref from `docker load` output ("Loaded image: x" / "Loaded image ID: sha256:..."). */ +export function parseLoadedImageRef(loadOutput: string): string | null { + const idMatch = loadOutput.match(/Loaded image ID:\s*(sha256:[0-9a-f]+)/i); + if (idMatch) return idMatch[1]; + const refMatch = loadOutput.match(/Loaded image:\s*(\S+)/i); + if (refMatch) return refMatch[1]; + return null; +} + +// ========== IO helpers ========== + +function run( + cmd: string, + args: string[], + opts: { timeout?: number } = {} +): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + let timer: NodeJS.Timeout | undefined; + if (opts.timeout) { + timer = setTimeout(() => { + child.kill('SIGKILL'); + reject(new Error(`${cmd} timed out after ${opts.timeout}ms`)); + }, opts.timeout); + } + child.stdout.on('data', (d) => (stdout += d)); + child.stderr.on('data', (d) => (stderr += d)); + child.on('error', (err) => { + if (timer) clearTimeout(timer); + reject(err); + }); + child.on('close', (code) => { + if (timer) clearTimeout(timer); + if (code === 0) resolve({ stdout, stderr }); + else reject(new Error(`${cmd} ${args.join(' ')} exited ${code}: ${stderr.trim()}`)); + }); + }); +} + +/** + * Stream `docker save ` stdout to a raw tar file (no shell, no double-gzip). + * Uses stream `pipeline` so completion means the write stream is FULLY flushed to + * disk (a naive child 'close' resolves before the last chunks land, truncating the + * file — a real bug caught in end-to-end testing), AND waits for a clean exit code. + */ +async function saveImageToTar(argv: string[], tag: string, outPath: string): Promise { + const child = spawn(argv[0], [...argv.slice(1), 'save', tag], { stdio: ['ignore', 'pipe', 'pipe'] }); + let stderr = ''; + child.stderr.on('data', (d) => (stderr += d)); + const exited = new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', (code) => + code === 0 ? resolve() : reject(new Error(`docker save exited ${code}: ${stderr.trim()}`)) + ); + }); + // pipeline resolves only after the destination has fully flushed. + await Promise.all([pipeline(child.stdout, createWriteStream(outPath)), exited]); +} + +async function sha256File(path: string): Promise { + return new Promise((resolve, reject) => { + const hash = createHash('sha256'); + const stream = createReadStream(path); + stream.on('data', (d) => hash.update(d)); + stream.on('error', reject); + stream.on('end', () => resolve(hash.digest('hex'))); + }); +} + +async function freeBytes(path: string): Promise { + try { + const stat = await fs.statfs(path); + return Number(stat.bavail) * Number(stat.bsize); + } catch { + return Number.POSITIVE_INFINITY; // statfs unsupported — don't block + } +} + +async function isContainerRunning(argv: string[], container: string): Promise { + try { + const { stdout } = await run(argv[0], [...argv.slice(1), 'inspect', '-f', '{{.State.Running}}', container], { + timeout: 15_000, + }); + return stdout.trim() === 'true'; + } catch { + return false; + } +} + +export interface ExportResult { + bundlePath: string; + manifest: DockerExportManifest; + sizeBytes: number; +} + +/** + * Export a docker case to a portable bundle. Bounded by runWithConversionLimit. + * `full` mode commits + saves the image AND tars the workspace; `workspace` mode + * tars just the workspace. The container is paused across the artifact capture so + * image and workspace are mutually consistent. + */ +export async function exportDockerCase(params: { + docker: SessionDocker; + caseName: string; + timestamp: number; + exportsDir: string; + mode: DockerExportMode; + codemanVersion: string; +}): Promise { + const { docker, caseName, timestamp, exportsDir, mode, codemanVersion } = params; + + if (mode === 'full' && !docker.mountCredentials) { + throw new Error( + 'full-image export is refused for a sealed (mountCredentials:false) container: an in-container login would ride the committed image layer. Use a workspace-only export.' + ); + } + + if (IS_TEST_MODE) { + // No real docker/tar under vitest — return a deterministic stub. + const manifest: DockerExportManifest = { + schemaVersion: DOCKER_EXPORT_SCHEMA, + caseName, + mode, + engine: docker.engine, + image: docker.image, + containerWorkdir: docker.containerWorkdir, + network: docker.network, + createdAt: timestamp, + codemanVersion, + mountCredentials: docker.mountCredentials, + secretFree: true, + checksums: {}, + }; + return { bundlePath: join(exportsDir, exportBundleName(caseName, timestamp, mode)), manifest, sizeBytes: 0 }; + } + + return runWithConversionLimit(async () => { + if (!existsSync(exportsDir)) mkdirSync(exportsDir, { recursive: true }); + + const free = await freeBytes(exportsDir); + if (free < DOCKER_EXPORT_MIN_FREE_BYTES) { + throw new Error( + `not enough free space to export (need >= ${Math.round(DOCKER_EXPORT_MIN_FREE_BYTES / 1e9)}GB, have ${Math.round(free / 1e9)}GB). A full docker graph wedges every session on the host.` + ); + } + + const argv = dockerArgv(docker); + const bundlePath = join(exportsDir, exportBundleName(caseName, timestamp, mode)); + const stageDir = join(exportsDir, `.stage-${caseName}-${timestamp}`); + mkdirSync(stageDir, { recursive: true }); + const wasRunning = await isContainerRunning(argv, docker.containerName); + let commitTag: string | undefined; + + try { + if (wasRunning) { + await run(argv[0], [...argv.slice(1), 'pause', docker.containerName], { timeout: 30_000 }).catch(() => {}); + } + + const checksums: DockerExportManifest['checksums'] = {}; + + if (mode === 'full') { + commitTag = exportImageTag(caseName, timestamp); + // Blank instance-specific committed env so the image carries no stale host refs. + await run( + argv[0], + [ + ...argv.slice(1), + 'commit', + '-c', + 'ENV CODEMAN_API_URL=', + '-c', + 'ENV CODEMAN_HOOK_SECRET_FILE=', + docker.containerName, + commitTag, + ], + { timeout: 300_000 } + ); + const imageTar = join(stageDir, 'image.tar'); + await saveImageToTar(argv, commitTag, imageTar); + checksums.image = await sha256File(imageTar); + } + + const workspaceTar = join(stageDir, 'workspace.tar'); + await run('tar', ['-cf', workspaceTar, '-C', docker.hostWorkspacePath, '.'], { timeout: 300_000 }); + checksums.workspace = await sha256File(workspaceTar); + + const manifest: DockerExportManifest = { + schemaVersion: DOCKER_EXPORT_SCHEMA, + caseName, + mode, + engine: docker.engine, + image: docker.image, + containerWorkdir: docker.containerWorkdir, + network: docker.network, + createdAt: timestamp, + codemanVersion, + mountCredentials: docker.mountCredentials, + // Convenient mode keeps creds on bind mounts (never committed), so the bundle is secret-free. + secretFree: docker.mountCredentials, + checksums, + }; + await fs.writeFile(join(stageDir, 'manifest.json'), JSON.stringify(manifest, null, 2)); + + const members = + mode === 'full' ? ['manifest.json', 'image.tar', 'workspace.tar'] : ['manifest.json', 'workspace.tar']; + await run('tar', ['-czf', bundlePath, '-C', stageDir, ...members], { timeout: 300_000 }); + + const stat = await fs.stat(bundlePath); + return { bundlePath, manifest, sizeBytes: stat.size }; + } finally { + // Always remove the intermediate image + stage dir, and unpause. + if (commitTag) { + await run(argv[0], [...argv.slice(1), 'rmi', commitTag], { timeout: 60_000 }).catch(() => {}); + } + await fs.rm(stageDir, { recursive: true, force: true }).catch(() => {}); + if (wasRunning) { + await run(argv[0], [...argv.slice(1), 'unpause', docker.containerName], { timeout: 30_000 }).catch(() => {}); + } + } + }); +} + +export interface ImportResult { + manifest: DockerExportManifest; + /** Quarantined image ref the destination case should use (full mode only). */ + importedImage?: string; + /** Directory the workspace was extracted into. */ + workspacePath: string; +} + +/** + * Import a bundle produced by exportDockerCase: validate the manifest + per-member + * checksums, extract the workspace (traversal-guarded) into destWorkspace, and, in + * full mode, `docker load` the image and re-tag it into a quarantined namespace. + */ +export async function importDockerBundle(params: { + bundlePath: string; + destWorkspace: string; + engine: DockerEngine; + timestamp: number; +}): Promise { + const { bundlePath, destWorkspace, engine, timestamp } = params; + const argv: string[] = [engine === 'podman' ? 'podman' : 'docker']; + + if (IS_TEST_MODE) { + const raw = await fs.readFile(bundlePath, 'utf-8').catch(() => '{}'); + return { manifest: JSON.parse(raw) as DockerExportManifest, workspacePath: destWorkspace }; + } + + const stageDir = `${destWorkspace}.import-stage-${timestamp}`; + mkdirSync(stageDir, { recursive: true }); + try { + await run('tar', ['-xzf', bundlePath, '-C', stageDir], { timeout: 300_000 }); + + const manifestRaw = await fs.readFile(join(stageDir, 'manifest.json'), 'utf-8'); + const manifest = JSON.parse(manifestRaw) as DockerExportManifest; + if (manifest.schemaVersion !== DOCKER_EXPORT_SCHEMA) { + throw new Error(`unsupported export schema version ${manifest.schemaVersion} (expected ${DOCKER_EXPORT_SCHEMA})`); + } + + // Integrity: verify checksums before trusting any member. + const workspaceTar = join(stageDir, 'workspace.tar'); + if (manifest.checksums.workspace) { + const actual = await sha256File(workspaceTar); + if (actual !== manifest.checksums.workspace) + throw new Error('workspace checksum mismatch (corrupt or tampered bundle)'); + } + + // Traversal guard: reject absolute / `..` members before extraction. + const { stdout: memberList } = await run('tar', ['-tf', workspaceTar], { timeout: 60_000 }); + for (const member of memberList.split('\n').filter(Boolean)) { + if (!isSafeTarMember(member)) throw new Error(`unsafe path in workspace archive: ${member}`); + } + mkdirSync(destWorkspace, { recursive: true }); + await run('tar', ['--no-same-owner', '-xf', workspaceTar, '-C', destWorkspace], { timeout: 300_000 }); + + let importedImage: string | undefined; + if (manifest.mode === 'full') { + const imageTar = join(stageDir, 'image.tar'); + if (manifest.checksums.image) { + const actual = await sha256File(imageTar); + if (actual !== manifest.checksums.image) + throw new Error('image checksum mismatch (corrupt or tampered bundle)'); + } + const { stdout } = await run(argv[0], [...argv.slice(1), 'load', '-i', imageTar], { timeout: 300_000 }); + const loadedRef = parseLoadedImageRef(stdout); + if (!loadedRef) throw new Error('could not determine loaded image ref'); + // Quarantine: re-tag by the loaded ref/id, never trusting the bundle's original tag. + importedImage = importedImageTag(manifest.caseName, timestamp); + await run(argv[0], [...argv.slice(1), 'tag', loadedRef, importedImage], { timeout: 60_000 }); + } + + return { manifest, importedImage, workspacePath: destWorkspace }; + } finally { + await fs.rm(stageDir, { recursive: true, force: true }).catch(() => {}); + } +} + +/** List export bundles in the exports dir (newest first), with size + mtime. */ +export async function listDockerExports( + exportsDir: string +): Promise> { + if (!existsSync(exportsDir)) return []; + const entries = await fs.readdir(exportsDir).catch(() => [] as string[]); + const out: Array<{ name: string; sizeBytes: number; mtimeMs: number }> = []; + for (const name of entries) { + if (!name.endsWith('.tgz')) continue; + try { + const stat = await fs.stat(join(exportsDir, name)); + out.push({ name: basename(name), sizeBytes: stat.size, mtimeMs: stat.mtimeMs }); + } catch { + /* skip */ + } + } + return out.sort((a, b) => b.mtimeMs - a.mtimeMs); +} diff --git a/src/docker-hosts.ts b/src/docker-hosts.ts index 517f22ef..7c3cddd1 100644 --- a/src/docker-hosts.ts +++ b/src/docker-hosts.ts @@ -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 { + 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 claude * --version`). Feeds Session.cliVersion for docker sessions (the LOCAL claude diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 1751bfe3..4924c31e 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -474,6 +474,9 @@ const SSE_EVENTS = { CASE_LINKED: 'case:linked', CASE_DELETED: 'case:deleted', CASE_ORDER_CHANGED: 'case:order-changed', + DOCKER_EXPORT_COMPLETE: 'docker:exportComplete', + DOCKER_EXPORT_FAILED: 'docker:exportFailed', + DOCKER_IMPORT_COMPLETE: 'docker:importComplete', }; // ═══════════════════════════════════════════════════════════════ diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index 0d551028..66ff1ada 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -5,10 +5,11 @@ */ import { FastifyInstance } from 'fastify'; -import { existsSync, mkdirSync, writeFileSync, readdirSync } from 'node:fs'; +import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync, createReadStream } from 'node:fs'; import { exec } from 'node:child_process'; import fs from 'node:fs/promises'; -import { join, resolve } from 'node:path'; +import { join, resolve, basename } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { homedir } from 'node:os'; import type { ApiResponse, CaseInfo } from '../../types.js'; import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js'; @@ -20,7 +21,10 @@ import { RemoteHostSchema, DockerCaseLinkSchema, DockerHostSchema, + DockerExportSchema, + DockerImportSchema, } from '../schemas.js'; +import { exportDockerCase, importDockerBundle, listDockerExports, exportBundleName } from '../../docker-export.js'; import { generateClaudeMd } from '../../templates/claude-md.js'; import { writeHooksConfig } from '../../hooks-config.js'; import { CASES_DIR, SETTINGS_PATH, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.js'; @@ -51,6 +55,17 @@ import { const LINKED_CASES_FILE = dataPath('linked-cases.json'); const CODEMAN_CONFIG_DIR = getDataDir(); const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/; +const DOCKER_EXPORTS_DIR = dataPath('docker-exports'); + +/** App version for export manifests (best-effort read of package.json). */ +const APP_VERSION = (() => { + try { + const pkgPath = fileURLToPath(new URL('../../../package.json', import.meta.url)); + return (JSON.parse(readFileSync(pkgPath, 'utf-8')).version as string) || 'unknown'; + } catch { + return 'unknown'; + } +})(); /** Read and parse linked-cases.json, returning empty object on missing/invalid file. */ async function readLinkedCases(): Promise> { @@ -376,6 +391,136 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config } ); + // ========== Docker export / import ========== + + // Export a docker case to a portable bundle. Runs in the BACKGROUND (a full image + // save can take minutes) and broadcasts docker:exportComplete / docker:exportFailed. + app.post('/api/docker-cases/:name/export', async (req): Promise> => { + const { name } = req.params as { name: string }; + const { mode = 'full' } = parseBody(DockerExportSchema, req.body ?? {}); + const dockerCase = (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === name); + if (!dockerCase) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker case not found'); + 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); + if (mode === 'full' && !sessionDocker.mountCredentials) { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + 'full-image export is refused for a sealed container (its in-container login would ride the committed layer). Use a workspace-only export.' + ); + } + + const timestamp = Date.now(); + const bundle = exportBundleName(name, timestamp, mode); + // Fire-and-forget: the client watches for the SSE completion event. + void exportDockerCase({ + docker: sessionDocker, + caseName: name, + timestamp, + exportsDir: DOCKER_EXPORTS_DIR, + mode, + codemanVersion: APP_VERSION, + }) + .then((result) => { + ctx.broadcast(SseEvent.DockerExportComplete, { + name, + bundle: basename(result.bundlePath), + sizeBytes: result.sizeBytes, + mode, + }); + }) + .catch((err) => { + ctx.broadcast(SseEvent.DockerExportFailed, { name, mode, error: getErrorMessage(err) }); + }); + + return { success: true, data: { started: true, bundle } }; + }); + + app.get('/api/docker-exports', async (): Promise> => { + return { success: true, data: { exports: await listDockerExports(DOCKER_EXPORTS_DIR) } }; + }); + + // Download an export bundle (filename resolved WITHIN the exports dir — no traversal). + app.get('/api/docker-exports/:filename', async (req, reply) => { + const { filename } = req.params as { filename: string }; + if (!/^[a-zA-Z0-9._-]+\.tgz$/.test(filename)) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid bundle filename'); + } + const full = join(DOCKER_EXPORTS_DIR, filename); + if (!existsSync(full)) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Export not found'); + reply.header('Content-Type', 'application/gzip'); + reply.header('Content-Disposition', `attachment; filename="${filename}"`); + reply.header('X-Content-Type-Options', 'nosniff'); + return reply.send(createReadStream(full)); + }); + + app.delete('/api/docker-exports/:filename', async (req): Promise> => { + const { filename } = req.params as { filename: string }; + if (!/^[a-zA-Z0-9._-]+\.tgz$/.test(filename)) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid bundle filename'); + } + const full = join(DOCKER_EXPORTS_DIR, filename); + if (!existsSync(full)) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Export not found'); + await fs.rm(full, { force: true }); + return { success: true, data: { filename } }; + }); + + // Import a bundle (already present in the exports dir) into a NEW docker case. + app.post('/api/docker-cases/import', async (req): Promise> => { + const { bundle, newCaseName, destWorkspacePath } = parseBody(DockerImportSchema, req.body); + const bundlePath = join(DOCKER_EXPORTS_DIR, bundle); + if (!existsSync(bundlePath)) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Bundle not found in exports dir'); + + // Name-collision guard across ALL case kinds. + const linkedCases = await readLinkedCases(); + const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR); + if ( + dockerCases.some((item) => item.name === newCaseName) || + linkedCases[newCaseName] || + existsSync(join(CASES_DIR, newCaseName)) + ) { + return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists'); + } + + const timestamp = Date.now(); + let result; + try { + result = await importDockerBundle({ bundlePath, destWorkspace: destWorkspacePath, engine: 'docker', timestamp }); + } catch (err) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Import failed: ${getErrorMessage(err)}`); + } + + // Create a dedicated docker host pointing at the quarantined imported image + // (full mode) or the manifest's base image (workspace-only). + const hostId = `imported-${newCaseName}`; + const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR); + if (!hosts.some((h) => h.id === hostId)) { + await writeDockerHosts(CODEMAN_CONFIG_DIR, [ + ...hosts, + { + id: hostId, + label: `Imported: ${newCaseName}`, + 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', + }, + ]); + } + const newCase = { + name: newCaseName, + type: 'docker' as const, + hostId, + hostWorkspacePath: destWorkspacePath, + containerWorkdir: result.manifest.containerWorkdir, + }; + await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, newCase]); + ctx.broadcast(SseEvent.DockerImportComplete, { name: newCaseName, path: destWorkspacePath, type: 'docker' }); + return { success: true, data: { case: newCase } }; + }); + // Link an existing folder as a case app.post('/api/cases/link', async (req): Promise> => { const { name, path: folderPath } = parseBody(LinkCaseSchema, req.body, 'Invalid request body'); diff --git a/src/web/schemas.ts b/src/web/schemas.ts index dead6075..9ff80786 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -465,6 +465,26 @@ export const DockerCaseLinkSchema = z.object({ .optional(), }); +export const DockerExportSchema = z.object({ + mode: z.enum(['full', 'workspace']).optional(), +}); + +export const DockerImportSchema = z.object({ + // A bare filename resolved WITHIN the exports dir (never an arbitrary path). + bundle: z + .string() + .min(1) + .max(300) + .regex(/^[a-zA-Z0-9._-]+\.tgz$/, 'Invalid bundle filename'), + newCaseName: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format'), + destWorkspacePath: z + .string() + .min(1) + .max(2000) + .regex(/^\//, 'Destination path must be absolute') + .regex(NO_SHELL_META, 'Invalid characters in destination path'), +}); + // ========== Quick Start ========== /** diff --git a/src/web/server.ts b/src/web/server.ts index 56e951ff..c0eebe40 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -40,7 +40,7 @@ import { existsSync, mkdirSync, readFileSync, chmodSync, rmSync, statSync } from import fs from 'node:fs/promises'; import { execSync } from 'node:child_process'; import { hostname as getHostname } from 'node:os'; -import { dataPath } from '../config/instance.js'; +import { dataPath, getDataDir, CODEMAN_INSTANCE } from '../config/instance.js'; import { getHookSecret } from '../config/hook-secret.js'; import { EventEmitter } from 'node:events'; import { Session, isExternalCliMode, type BackgroundTask } from '../session.js'; @@ -1941,6 +1941,20 @@ export class WebServer extends EventEmitter { // CRITICAL: Skip in test mode to prevent tests from picking up user sessions if (!this.testMode) { await this.restoreMuxSessions(); + + // Instance-scoped reaper: after restore, `docker rm -f` managed containers of + // THIS instance whose case is gone from docker-cases.json (best-effort, never + // touches another instance's containers). Runs after restore so containers + // still referenced by a restored session are preserved. + void import('../docker-hosts.js') + .then(({ reapOrphanedDockerContainers }) => reapOrphanedDockerContainers(getDataDir(), CODEMAN_INSTANCE)) + .then((reaped) => { + if (reaped.length > 0) + console.log(`[Docker] reaped ${reaped.length} orphaned container(s): ${reaped.join(', ')}`); + }) + .catch(() => { + /* best-effort — daemon may be absent */ + }); } // Clean up stale sessions from state file that don't have active mux sessions diff --git a/src/web/sse-events.ts b/src/web/sse-events.ts index 6fc419d6..1e664c75 100644 --- a/src/web/sse-events.ts +++ b/src/web/sse-events.ts @@ -370,6 +370,14 @@ export const CaseDeleted = 'case:deleted' as const; /** Case ordering changed. */ export const CaseOrderChanged = 'case:order-changed' as const; +// ─── Docker cases ──────────────────────────────────────────────────────────── +/** A docker case export bundle finished writing. */ +export const DockerExportComplete = 'docker:exportComplete' as const; +/** A docker case export failed. */ +export const DockerExportFailed = 'docker:exportFailed' as const; +/** A docker bundle was imported into a new case. */ +export const DockerImportComplete = 'docker:importComplete' as const; + // ─── Namespace Re-export ───────────────────────────────────────────────────── /** @@ -551,4 +559,9 @@ export const SseEvent = { CaseLinked, CaseDeleted, CaseOrderChanged, + + // Docker cases + DockerExportComplete, + DockerExportFailed, + DockerImportComplete, } as const; diff --git a/test/docker-export.test.ts b/test/docker-export.test.ts new file mode 100644 index 00000000..9c03edad --- /dev/null +++ b/test/docker-export.test.ts @@ -0,0 +1,121 @@ +/** + * Unit tests for the pure docker export/import helpers (src/docker-export.ts). + * The IO paths no-op under VITEST; these cover the naming, tar-traversal guard, + * load-output parsing, and the sealed-mode refusal. + */ +import { describe, it, expect } from 'vitest'; +import { + dockerArgv, + exportBundleName, + exportImageTag, + importedImageTag, + isSafeTarMember, + parseLoadedImageRef, + exportDockerCase, + DOCKER_EXPORT_SCHEMA, +} from '../src/docker-export.js'; +import { toSessionDocker } from '../src/docker-hosts.js'; +import type { DockerCase, DockerHost } from '../src/types.js'; + +const HOST: DockerHost = { id: 'local', label: 'Local', image: 'codeman/agent:base' }; +const CASE: DockerCase = { + name: 'myproj', + type: 'docker', + hostId: 'local', + hostWorkspacePath: '/home/arkon/cases/myproj', +}; + +describe('dockerArgv', () => { + it('is raw (unescaped) argv for spawn', () => { + expect(dockerArgv({ engine: 'docker' })).toEqual(['docker']); + expect(dockerArgv({ engine: 'podman', context: 'ctx', daemonHost: 'ssh://h' })).toEqual([ + 'podman', + '--context', + 'ctx', + '-H', + 'ssh://h', + ]); + }); +}); + +describe('bundle / tag naming', () => { + it('names bundles by case + timestamp + mode', () => { + expect(exportBundleName('myproj', 1234, 'full')).toBe('myproj-1234.codeman-container.tgz'); + expect(exportBundleName('myproj', 1234, 'workspace')).toBe('myproj-1234.codeman-workspace.tgz'); + }); + it('quarantines imported images and tags export intermediates uniquely', () => { + expect(importedImageTag('myproj', 99)).toBe('codeman/imported-myproj:99'); + expect(exportImageTag('myproj', 99)).toBe('codeman/export-myproj:99'); + }); +}); + +describe('isSafeTarMember (import traversal guard)', () => { + it('accepts normal relative members', () => { + expect(isSafeTarMember('./')).toBe(true); + expect(isSafeTarMember('src/index.ts')).toBe(true); + expect(isSafeTarMember('./a/b/c.txt')).toBe(true); + }); + it('rejects absolute and parent-escaping members', () => { + expect(isSafeTarMember('/etc/passwd')).toBe(false); + expect(isSafeTarMember('../outside')).toBe(false); + expect(isSafeTarMember('a/../../b')).toBe(false); + expect(isSafeTarMember('./../../x')).toBe(false); + }); +}); + +describe('parseLoadedImageRef', () => { + it('parses "Loaded image ID: sha256:..."', () => { + expect(parseLoadedImageRef('Loaded image ID: sha256:abc123def')).toBe('sha256:abc123def'); + }); + it('parses "Loaded image: repo:tag"', () => { + expect(parseLoadedImageRef('Loaded image: codeman/export-x:1234')).toBe('codeman/export-x:1234'); + }); + it('returns null on unrecognized output', () => { + expect(parseLoadedImageRef('some other text')).toBeNull(); + }); +}); + +describe('exportDockerCase (VITEST stub)', () => { + it('returns a deterministic stub manifest without touching docker', async () => { + const docker = toSessionDocker(HOST, CASE); + const res = await exportDockerCase({ + docker, + caseName: 'myproj', + timestamp: 42, + exportsDir: '/tmp/exports', + mode: 'full', + codemanVersion: '9.9.9', + }); + expect(res.manifest.schemaVersion).toBe(DOCKER_EXPORT_SCHEMA); + expect(res.manifest.caseName).toBe('myproj'); + expect(res.manifest.mode).toBe('full'); + expect(res.bundlePath).toBe('/tmp/exports/myproj-42.codeman-container.tgz'); + }); + + it('refuses a full-image export for a sealed container', async () => { + const docker = toSessionDocker({ ...HOST, mountCredentials: false }, CASE); + await expect( + exportDockerCase({ + docker, + caseName: 'myproj', + timestamp: 42, + exportsDir: '/tmp/exports', + mode: 'full', + codemanVersion: '9.9.9', + }) + ).rejects.toThrow(/sealed/); + }); + + it('allows a workspace-only export for a sealed container', async () => { + const docker = toSessionDocker({ ...HOST, mountCredentials: false }, CASE); + const res = await exportDockerCase({ + docker, + caseName: 'myproj', + timestamp: 42, + exportsDir: '/tmp/exports', + mode: 'workspace', + codemanVersion: '9.9.9', + }); + expect(res.manifest.mode).toBe('workspace'); + }); +});