import { spawn } from "node:child_process"; import { DEFAULT_SANDBOX_IMAGE, SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js"; import { updateRegistry } from "./registry.js"; import { resolveSandboxScopeKey, slugifySessionKey } from "./shared.js"; import type { SandboxConfig, SandboxDockerConfig, SandboxWorkspaceAccess } from "./types.js"; export function execDocker(args: string[], opts?: { allowFailure?: boolean }) { return new Promise<{ stdout: string; stderr: string; code: number }>((resolve, reject) => { const child = spawn("docker", args, { stdio: ["ignore", "pipe", "pipe"], }); let stdout = ""; let stderr = ""; child.stdout?.on("data", (chunk) => { stdout += chunk.toString(); }); child.stderr?.on("data", (chunk) => { stderr += chunk.toString(); }); child.on("close", (code) => { const exitCode = code ?? 0; if (exitCode !== 0 && !opts?.allowFailure) { reject(new Error(stderr.trim() || `docker ${args.join(" ")} failed`)); return; } resolve({ stdout, stderr, code: exitCode }); }); }); } export async function readDockerPort(containerName: string, port: number) { const result = await execDocker(["port", containerName, `${port}/tcp`], { allowFailure: true, }); if (result.code !== 0) return null; const line = result.stdout.trim().split(/\r?\n/)[0] ?? ""; const match = line.match(/:(\d+)\s*$/); if (!match) return null; const mapped = Number.parseInt(match[1] ?? "", 10); return Number.isFinite(mapped) ? mapped : null; } async function dockerImageExists(image: string) { const result = await execDocker(["image", "inspect", image], { allowFailure: true, }); return result.code === 0; } export async function ensureDockerImage(image: string) { const exists = await dockerImageExists(image); if (exists) return; if (image === DEFAULT_SANDBOX_IMAGE) { await execDocker(["pull", "debian:bookworm-slim"]); await execDocker(["tag", "debian:bookworm-slim", DEFAULT_SANDBOX_IMAGE]); return; } throw new Error(`Sandbox image not found: ${image}. Build or pull it first.`); } export async function dockerContainerState(name: string) { const result = await execDocker(["inspect", "-f", "{{.State.Running}}", name], { allowFailure: true, }); if (result.code !== 0) return { exists: false, running: false }; return { exists: true, running: result.stdout.trim() === "true" }; } function normalizeDockerLimit(value?: string | number) { if (value === undefined || value === null) return undefined; if (typeof value === "number") { return Number.isFinite(value) ? String(value) : undefined; } const trimmed = value.trim(); return trimmed ? trimmed : undefined; } function formatUlimitValue( name: string, value: string | number | { soft?: number; hard?: number }, ) { if (!name.trim()) return null; if (typeof value === "number" || typeof value === "string") { const raw = String(value).trim(); return raw ? `${name}=${raw}` : null; } const soft = typeof value.soft === "number" ? Math.max(0, value.soft) : undefined; const hard = typeof value.hard === "number" ? Math.max(0, value.hard) : undefined; if (soft === undefined && hard === undefined) return null; if (soft === undefined) return `${name}=${hard}`; if (hard === undefined) return `${name}=${soft}`; return `${name}=${soft}:${hard}`; } export function buildSandboxCreateArgs(params: { name: string; cfg: SandboxDockerConfig; scopeKey: string; createdAtMs?: number; labels?: Record; }) { const createdAtMs = params.createdAtMs ?? Date.now(); const args = ["create", "--name", params.name]; args.push("--label", "clawdbot.sandbox=1"); args.push("--label", `clawdbot.sessionKey=${params.scopeKey}`); args.push("--label", `clawdbot.createdAtMs=${createdAtMs}`); for (const [key, value] of Object.entries(params.labels ?? {})) { if (key && value) args.push("--label", `${key}=${value}`); } if (params.cfg.readOnlyRoot) args.push("--read-only"); for (const entry of params.cfg.tmpfs) { args.push("--tmpfs", entry); } if (params.cfg.network) args.push("--network", params.cfg.network); if (params.cfg.user) args.push("--user", params.cfg.user); for (const cap of params.cfg.capDrop) { args.push("--cap-drop", cap); } args.push("--security-opt", "no-new-privileges"); if (params.cfg.seccompProfile) { args.push("--security-opt", `seccomp=${params.cfg.seccompProfile}`); } if (params.cfg.apparmorProfile) { args.push("--security-opt", `apparmor=${params.cfg.apparmorProfile}`); } for (const entry of params.cfg.dns ?? []) { if (entry.trim()) args.push("--dns", entry); } for (const entry of params.cfg.extraHosts ?? []) { if (entry.trim()) args.push("--add-host", entry); } if (typeof params.cfg.pidsLimit === "number" && params.cfg.pidsLimit > 0) { args.push("--pids-limit", String(params.cfg.pidsLimit)); } const memory = normalizeDockerLimit(params.cfg.memory); if (memory) args.push("--memory", memory); const memorySwap = normalizeDockerLimit(params.cfg.memorySwap); if (memorySwap) args.push("--memory-swap", memorySwap); if (typeof params.cfg.cpus === "number" && params.cfg.cpus > 0) { args.push("--cpus", String(params.cfg.cpus)); } for (const [name, value] of Object.entries(params.cfg.ulimits ?? {}) as Array< [string, string | number | { soft?: number; hard?: number }] >) { const formatted = formatUlimitValue(name, value); if (formatted) args.push("--ulimit", formatted); } if (params.cfg.binds?.length) { for (const bind of params.cfg.binds) { args.push("-v", bind); } } return args; } async function createSandboxContainer(params: { name: string; cfg: SandboxDockerConfig; workspaceDir: string; workspaceAccess: SandboxWorkspaceAccess; agentWorkspaceDir: string; scopeKey: string; }) { const { name, cfg, workspaceDir, scopeKey } = params; await ensureDockerImage(cfg.image); const args = buildSandboxCreateArgs({ name, cfg, scopeKey, }); args.push("--workdir", cfg.workdir); const mainMountSuffix = params.workspaceAccess === "ro" && workspaceDir === params.agentWorkspaceDir ? ":ro" : ""; args.push("-v", `${workspaceDir}:${cfg.workdir}${mainMountSuffix}`); if (params.workspaceAccess !== "none" && workspaceDir !== params.agentWorkspaceDir) { const agentMountSuffix = params.workspaceAccess === "ro" ? ":ro" : ""; args.push( "-v", `${params.agentWorkspaceDir}:${SANDBOX_AGENT_WORKSPACE_MOUNT}${agentMountSuffix}`, ); } args.push(cfg.image, "sleep", "infinity"); await execDocker(args); await execDocker(["start", name]); if (cfg.setupCommand?.trim()) { await execDocker(["exec", "-i", name, "sh", "-lc", cfg.setupCommand]); } } export async function ensureSandboxContainer(params: { sessionKey: string; workspaceDir: string; agentWorkspaceDir: string; cfg: SandboxConfig; }) { const scopeKey = resolveSandboxScopeKey(params.cfg.scope, params.sessionKey); const slug = params.cfg.scope === "shared" ? "shared" : slugifySessionKey(scopeKey); const name = `${params.cfg.docker.containerPrefix}${slug}`; const containerName = name.slice(0, 63); const state = await dockerContainerState(containerName); if (!state.exists) { await createSandboxContainer({ name: containerName, cfg: params.cfg.docker, workspaceDir: params.workspaceDir, workspaceAccess: params.cfg.workspaceAccess, agentWorkspaceDir: params.agentWorkspaceDir, scopeKey, }); } else if (!state.running) { await execDocker(["start", containerName]); } const now = Date.now(); await updateRegistry({ containerName, sessionKey: scopeKey, createdAtMs: now, lastUsedAtMs: now, image: params.cfg.docker.image, }); return containerName; }