import { spawn } from "node:child_process"; import type { SandboxConfig, SandboxDockerConfig, SandboxWorkspaceAccess } from "./types.js"; import { formatCliCommand } from "../../cli/command-format.js"; import { defaultRuntime } from "../../runtime.js"; import { computeSandboxConfigHash } from "./config-hash.js"; import { DEFAULT_SANDBOX_IMAGE, SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js"; import { readRegistry, updateRegistry } from "./registry.js"; import { resolveSandboxAgentId, resolveSandboxScopeKey, slugifySessionKey } from "./shared.js"; const HOT_CONTAINER_WINDOW_MS = 5 * 60 * 1000; 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, }); if (result.code === 0) { return true; } const stderr = result.stderr.trim(); if (stderr.includes("No such image")) { return false; } throw new Error(`Failed to inspect sandbox image: ${stderr}`); } 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; configHash?: string; }) { const createdAtMs = params.createdAtMs ?? Date.now(); const args = ["create", "--name", params.name]; args.push("--label", "openclaw.sandbox=1"); args.push("--label", `openclaw.sessionKey=${params.scopeKey}`); args.push("--label", `openclaw.createdAtMs=${createdAtMs}`); if (params.configHash) { args.push("--label", `openclaw.configHash=${params.configHash}`); } 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; configHash?: string; }) { const { name, cfg, workspaceDir, scopeKey } = params; await ensureDockerImage(cfg.image); const args = buildSandboxCreateArgs({ name, cfg, scopeKey, configHash: params.configHash, }); 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]); } } async function readContainerConfigHash(containerName: string): Promise { const readLabel = async (label: string) => { const result = await execDocker( ["inspect", "-f", `{{ index .Config.Labels "${label}" }}`, containerName], { allowFailure: true }, ); if (result.code !== 0) { return null; } const raw = result.stdout.trim(); if (!raw || raw === "") { return null; } return raw; }; return await readLabel("openclaw.configHash"); } function formatSandboxRecreateHint(params: { scope: SandboxConfig["scope"]; sessionKey: string }) { if (params.scope === "session") { return formatCliCommand(`openclaw sandbox recreate --session ${params.sessionKey}`); } if (params.scope === "agent") { const agentId = resolveSandboxAgentId(params.sessionKey) ?? "main"; return formatCliCommand(`openclaw sandbox recreate --agent ${agentId}`); } return formatCliCommand("openclaw sandbox recreate --all"); } 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 expectedHash = computeSandboxConfigHash({ docker: params.cfg.docker, workspaceAccess: params.cfg.workspaceAccess, workspaceDir: params.workspaceDir, agentWorkspaceDir: params.agentWorkspaceDir, }); const now = Date.now(); const state = await dockerContainerState(containerName); let hasContainer = state.exists; let running = state.running; let currentHash: string | null = null; let hashMismatch = false; let registryEntry: | { lastUsedAtMs: number; configHash?: string; } | undefined; if (hasContainer) { const registry = await readRegistry(); registryEntry = registry.entries.find((entry) => entry.containerName === containerName); currentHash = await readContainerConfigHash(containerName); if (!currentHash) { currentHash = registryEntry?.configHash ?? null; } hashMismatch = !currentHash || currentHash !== expectedHash; if (hashMismatch) { const lastUsedAtMs = registryEntry?.lastUsedAtMs; const isHot = running && (typeof lastUsedAtMs !== "number" || now - lastUsedAtMs < HOT_CONTAINER_WINDOW_MS); if (isHot) { const hint = formatSandboxRecreateHint({ scope: params.cfg.scope, sessionKey: scopeKey }); defaultRuntime.log( `Sandbox config changed for ${containerName} (recently used). Recreate to apply: ${hint}`, ); } else { await execDocker(["rm", "-f", containerName], { allowFailure: true }); hasContainer = false; running = false; } } } if (!hasContainer) { await createSandboxContainer({ name: containerName, cfg: params.cfg.docker, workspaceDir: params.workspaceDir, workspaceAccess: params.cfg.workspaceAccess, agentWorkspaceDir: params.agentWorkspaceDir, scopeKey, configHash: expectedHash, }); } else if (!running) { await execDocker(["start", containerName]); } await updateRegistry({ containerName, sessionKey: scopeKey, createdAtMs: now, lastUsedAtMs: now, image: params.cfg.docker.image, configHash: hashMismatch && running ? (currentHash ?? undefined) : expectedHash, }); return containerName; }