| 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<ExecDockerRawResult> { |
| return new Promise<ExecDockerRawResult>((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<string | null> { |
| 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 === "<no value>") { |
| return null; |
| } |
| return raw; |
| } |
|
|
| export async function readDockerContainerEnvVar( |
| containerName: string, |
| envVar: string, |
| ): Promise<string | null> { |
| 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<string, string>; |
| configHash?: string; |
| includeBinds?: boolean; |
| bindSourceRoots?: string[]; |
| allowSourcesOutsideAllowedRoots?: boolean; |
| allowReservedContainerTargets?: boolean; |
| allowContainerNamespaceJoin?: boolean; |
| envSanitizationOptions?: EnvSanitizationOptions; |
| }) { |
| |
| 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<string | null> { |
| 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; |
| } |
|
|