/** * Docker sandbox provider — wraps DockerLifecycle into a SandboxProvider. * * Usage: * import { docker } from "sandcastle/sandboxes/docker"; * await run({ agent: claudeCode("claude-opus-4-6"), sandbox: docker() }); */ import { execFile, execFileSync, spawn, type StdioOptions, } from "node:child_process"; import { randomUUID } from "node:crypto"; import { createInterface } from "node:readline"; import { Effect } from "effect"; import { startContainer, removeContainer } from "../DockerLifecycle.js"; import { createBindMountSandboxProvider, type SandboxProvider, type BindMountCreateOptions, type BindMountSandboxHandle, type ExecResult, type InteractiveExecOptions, } from "../SandboxProvider.js"; import type { MountConfig } from "../MountConfig.js"; import { defaultImageName, resolveUserMounts } from "../mountUtils.js"; export interface DockerOptions { /** Docker image name (default: derived from repo directory name). */ readonly imageName?: string; /** * Additional host directories to bind-mount into the sandbox. * * Each entry specifies a `hostPath` (tilde-expanded) and `sandboxPath`. * If `hostPath` does not exist, sandbox creation fails with a clear error. */ readonly mounts?: readonly MountConfig[]; /** Environment variables injected by this provider. Merged at launch time with env resolver and agent provider env. */ readonly env?: Record; /** * Docker network(s) to attach the container to. * * - `"my-network"` → `--network my-network` * - `["net1", "net2"]` → `--network net1 --network net2` * * When omitted, Docker's default bridge network is used. */ readonly network?: string | readonly string[]; } /** * Create a Docker sandbox provider. * * The returned provider creates Docker containers with bind-mounts * for the worktree and git directories. */ export const docker = (options?: DockerOptions): SandboxProvider => { const configuredImageName = options?.imageName; const sandboxHomedir = "/home/agent"; const userMounts = options?.mounts ? resolveUserMounts(options.mounts, sandboxHomedir) : []; return createBindMountSandboxProvider({ name: "docker", env: options?.env, sandboxHomedir, create: async ( createOptions: BindMountCreateOptions, ): Promise => { const containerName = `sandcastle-${randomUUID()}`; const worktreePath = createOptions.mounts.find( (m) => m.hostPath === createOptions.worktreePath, )?.sandboxPath ?? "/home/agent/workspace"; // Build volume mount strings (internal mounts + user-provided mounts) const allMounts = [...createOptions.mounts, ...userMounts]; const volumeMounts = allMounts.map((m) => { const base = `${m.hostPath}:${m.sandboxPath}`; return m.readonly ? `${base}:ro` : base; }); // Resolve image name const imageName = configuredImageName ?? defaultImageName(createOptions.hostRepoPath); const hostUid = process.getuid?.() ?? 1000; const hostGid = process.getgid?.() ?? 1000; // Start container await Effect.runPromise( startContainer( containerName, imageName, { ...createOptions.env, HOME: "/home/agent", }, { volumeMounts, workdir: worktreePath, user: `${hostUid}:${hostGid}`, network: options?.network, }, ), ); // Set up signal handlers for cleanup const onExit = () => { try { execFileSync("docker", ["rm", "-f", containerName], { stdio: "ignore", }); } catch { /* best-effort */ } }; const onSignal = () => { onExit(); process.exit(1); }; process.on("exit", onExit); process.on("SIGINT", onSignal); process.on("SIGTERM", onSignal); const handle: BindMountSandboxHandle = { worktreePath, exec: ( command: string, opts?: { onLine?: (line: string) => void; cwd?: string; sudo?: boolean; stdin?: string; }, ): Promise => { const effectiveCommand = opts?.sudo ? `sudo ${command}` : command; const args = ["exec"]; if (opts?.stdin !== undefined) args.push("-i"); if (opts?.cwd) args.push("-w", opts.cwd); args.push(containerName, "sh", "-c", effectiveCommand); return new Promise((resolve, reject) => { const proc = spawn("docker", args, { stdio: [ opts?.stdin !== undefined ? "pipe" : "ignore", "pipe", "pipe", ], }); if (opts?.stdin !== undefined) { proc.stdin!.write(opts.stdin); proc.stdin!.end(); } const stdoutChunks: string[] = []; const stderrChunks: string[] = []; if (opts?.onLine) { const onLine = opts.onLine; const rl = createInterface({ input: proc.stdout! }); rl.on("line", (line) => { stdoutChunks.push(line); onLine(line); }); } else { proc.stdout!.on("data", (chunk: Buffer) => { stdoutChunks.push(chunk.toString()); }); } proc.stderr!.on("data", (chunk: Buffer) => { stderrChunks.push(chunk.toString()); }); proc.on("error", (error) => { reject(new Error(`docker exec failed: ${error.message}`)); }); proc.on("close", (code) => { resolve({ stdout: stdoutChunks.join(opts?.onLine ? "\n" : ""), stderr: stderrChunks.join(""), exitCode: code ?? 0, }); }); }); }, interactiveExec: ( args: string[], opts: InteractiveExecOptions, ): Promise<{ exitCode: number }> => { return new Promise((resolve, reject) => { const dockerArgs = ["exec"]; // Allocate a pseudo-terminal when stdin looks like a TTY if ( "isTTY" in opts.stdin && (opts.stdin as { isTTY?: boolean }).isTTY ) { dockerArgs.push("-it"); } else { dockerArgs.push("-i"); } if (opts.cwd) dockerArgs.push("-w", opts.cwd); dockerArgs.push(containerName, ...args); const proc = spawn("docker", dockerArgs, { stdio: [opts.stdin, opts.stdout, opts.stderr] as StdioOptions, }); proc.on("error", (error: Error) => { reject(new Error(`docker exec failed: ${error.message}`)); }); proc.on("close", (code: number | null) => { resolve({ exitCode: code ?? 0 }); }); }); }, copyFileIn: (hostPath: string, sandboxPath: string): Promise => new Promise((resolve, reject) => { execFile( "docker", ["cp", hostPath, `${containerName}:${sandboxPath}`], (error) => { if (error) { reject(new Error(`docker cp (in) failed: ${error.message}`)); } else { resolve(); } }, ); }), copyFileOut: (sandboxPath: string, hostPath: string): Promise => new Promise((resolve, reject) => { execFile( "docker", ["cp", `${containerName}:${sandboxPath}`, hostPath], (error) => { if (error) { reject(new Error(`docker cp (out) failed: ${error.message}`)); } else { resolve(); } }, ); }), close: async (): Promise => { process.removeListener("exit", onExit); process.removeListener("SIGINT", onSignal); process.removeListener("SIGTERM", onSignal); await Effect.runPromise(removeContainer(containerName)); }, }; return handle; }, }); }; // Re-export for backwards compatibility export { defaultImageName };