import { spawn } from "node:child_process"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { materializeWindowsSpawnProgram, resolveWindowsSpawnProgram, } from "../../plugin-sdk/windows-spawn.js"; import { sanitizeEnvVars } from "./sanitize-env-vars.js"; import type { EnvSanitizationOptions } from "./sanitize-env-vars.js"; type ExecDockerRawOptions = { allowFailure?: boolean; input?: Buffer | string; signal?: AbortSignal; }; export type ExecDockerRawResult = { stdout: Buffer; stderr: Buffer; code: number; }; type ExecDockerRawError = Error & { code: number; stdout: Buffer; stderr: Buffer; }; function createAbortError(): Error { const err = new Error("Aborted"); err.name = "AbortError"; return err; } type DockerSpawnRuntime = { platform: NodeJS.Platform; env: NodeJS.ProcessEnv; execPath: string; }; const DEFAULT_DOCKER_SPAWN_RUNTIME: DockerSpawnRuntime = { platform: process.platform, env: process.env, execPath: process.execPath, }; export function resolveDockerSpawnInvocation( args: string[], runtime: DockerSpawnRuntime = DEFAULT_DOCKER_SPAWN_RUNTIME, ): { command: string; args: string[]; shell?: boolean; windowsHide?: boolean } { const program = resolveWindowsSpawnProgram({ command: "docker", platform: runtime.platform, env: runtime.env, execPath: runtime.execPath, packageName: "docker", allowShellFallback: false, }); const resolved = materializeWindowsSpawnProgram(program, args); return { command: resolved.command, args: resolved.argv, shell: resolved.shell, windowsHide: resolved.windowsHide, }; } export function execDockerRaw( args: string[], opts?: ExecDockerRawOptions, ): Promise { return new Promise((resolve, reject) => { const spawnInvocation = resolveDockerSpawnInvocation(args); const child = spawn(spawnInvocation.command, spawnInvocation.args, { stdio: ["pipe", "pipe", "pipe"], shell: spawnInvocation.shell, windowsHide: spawnInvocation.windowsHide, }); const stdoutChunks: Buffer[] = []; const stderrChunks: Buffer[] = []; let aborted = false; const signal = opts?.signal; const handleAbort = () => { if (aborted) { return; } aborted = true; child.kill("SIGTERM"); }; if (signal) { if (signal.aborted) { handleAbort(); } else { signal.addEventListener("abort", handleAbort); } } child.stdout?.on("data", (chunk) => { stdoutChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); }); child.stderr?.on("data", (chunk) => { stderrChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); }); child.on("error", (error) => { if (signal) { signal.removeEventListener("abort", handleAbort); } if ( error && typeof error === "object" && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT" ) { const friendly = Object.assign( new Error( 'Sandbox mode requires Docker, but the "docker" command was not found in PATH. Install Docker (and ensure "docker" is available), or set `agents.defaults.sandbox.mode=off` to disable sandboxing.', ), { code: "INVALID_CONFIG", cause: error }, ); reject(friendly); return; } reject(error); }); child.on("close", (code) => { if (signal) { signal.removeEventListener("abort", handleAbort); } const stdout = Buffer.concat(stdoutChunks); const stderr = Buffer.concat(stderrChunks); if (aborted || signal?.aborted) { reject(createAbortError()); return; } const exitCode = code ?? 0; if (exitCode !== 0 && !opts?.allowFailure) { const message = stderr.length > 0 ? stderr.toString("utf8").trim() : ""; const error: ExecDockerRawError = Object.assign( new Error(message || `docker ${args.join(" ")} failed`), { code: exitCode, stdout, stderr, }, ); reject(error); return; } resolve({ stdout, stderr, code: exitCode }); }); const stdin = child.stdin; if (stdin) { if (opts?.input !== undefined) { stdin.end(opts.input); } else { stdin.end(); } } }); } import { formatCliCommand } from "../../cli/command-format.js"; import { markOpenClawExecEnv } from "../../infra/openclaw-exec-env.js"; import { defaultRuntime } from "../../runtime.js"; import { computeSandboxConfigHash } from "./config-hash.js"; import { DEFAULT_SANDBOX_IMAGE } from "./constants.js"; import { readRegistry, updateRegistry } from "./registry.js"; import { resolveSandboxAgentId, resolveSandboxScopeKey, slugifySessionKey } from "./shared.js"; import type { SandboxConfig, SandboxDockerConfig, SandboxWorkspaceAccess } from "./types.js"; import { validateSandboxSecurity } from "./validate-sandbox-security.js"; import { appendWorkspaceMountArgs } from "./workspace-mounts.js"; const log = createSubsystemLogger("docker"); const HOT_CONTAINER_WINDOW_MS = 5 * 60 * 1000; export type ExecDockerOptions = ExecDockerRawOptions; export async function execDocker(args: string[], opts?: ExecDockerOptions) { const result = await execDockerRaw(args, opts); return { stdout: result.stdout.toString("utf8"), stderr: result.stderr.toString("utf8"), code: result.code, }; } export async function readDockerContainerLabel( containerName: string, label: string, ): Promise { 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; } export async function readDockerContainerEnvVar( containerName: string, envVar: string, ): Promise { const result = await execDocker( ["inspect", "-f", "{{range .Config.Env}}{{println .}}{{end}}", containerName], { allowFailure: true }, ); if (result.code !== 0) { return null; } for (const line of result.stdout.split(/\r?\n/)) { if (line.startsWith(`${envVar}=`)) { return line.slice(envVar.length + 1); } } return null; } 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; includeBinds?: boolean; bindSourceRoots?: string[]; allowSourcesOutsideAllowedRoots?: boolean; allowReservedContainerTargets?: boolean; allowContainerNamespaceJoin?: boolean; envSanitizationOptions?: EnvSanitizationOptions; }) { // Runtime security validation: blocks dangerous bind mounts, network modes, and profiles. validateSandboxSecurity({ ...params.cfg, allowedSourceRoots: params.bindSourceRoots, allowSourcesOutsideAllowedRoots: params.allowSourcesOutsideAllowedRoots ?? params.cfg.dangerouslyAllowExternalBindSources === true, allowReservedContainerTargets: params.allowReservedContainerTargets ?? params.cfg.dangerouslyAllowReservedContainerTargets === true, dangerouslyAllowContainerNamespaceJoin: params.allowContainerNamespaceJoin ?? params.cfg.dangerouslyAllowContainerNamespaceJoin === true, }); 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); } const envSanitization = sanitizeEnvVars(params.cfg.env ?? {}, params.envSanitizationOptions); if (envSanitization.blocked.length > 0) { log.warn(`Blocked sensitive environment variables: ${envSanitization.blocked.join(", ")}`); } if (envSanitization.warnings.length > 0) { log.warn(`Suspicious environment variables: ${envSanitization.warnings.join(", ")}`); } for (const [key, value] of Object.entries(markOpenClawExecEnv(envSanitization.allowed))) { args.push("--env", `${key}=${value}`); } 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 ?? {})) { const formatted = formatUlimitValue(name, value); if (formatted) { args.push("--ulimit", formatted); } } if (params.includeBinds !== false && params.cfg.binds?.length) { for (const bind of params.cfg.binds) { args.push("-v", bind); } } return args; } function appendCustomBinds(args: string[], cfg: SandboxDockerConfig): void { if (!cfg.binds?.length) { return; } for (const bind of cfg.binds) { args.push("-v", bind); } } 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, includeBinds: false, bindSourceRoots: [workspaceDir, params.agentWorkspaceDir], }); args.push("--workdir", cfg.workdir); appendWorkspaceMountArgs({ args, workspaceDir, agentWorkspaceDir: params.agentWorkspaceDir, workdir: cfg.workdir, workspaceAccess: params.workspaceAccess, }); appendCustomBinds(args, cfg); args.push(cfg.image, "sleep", "infinity"); await execDocker(args); await execDocker(["start", name]); if (cfg.setupCommand?.trim()) { await execDocker(["exec", "-i", name, "/bin/sh", "-lc", cfg.setupCommand]); } } async function readContainerConfigHash(containerName: string): Promise { return await readDockerContainerLabel(containerName, "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; }