Files
Moltbot/src/agents/sandbox/docker.ts
Alessandro Rodi f257818ea5 fix(sandbox): prevent Windows PATH from poisoning docker exec (#13873)
* fix(sandbox): prevent Windows PATH from poisoning docker exec shell lookup

On Windows hosts, `buildDockerExecArgs` passes the host PATH env var
(containing Windows paths like `C:\Windows\System32`) to `docker exec -e
PATH=...`. Docker uses this PATH to resolve the executable argument
(`sh`), which fails because Windows paths don't exist in the Linux
container — producing `exec: "sh": executable file not found in $PATH`.

Two changes:
- Skip PATH in the `-e` env loop (it's already handled separately via
  OPENCLAW_PREPEND_PATH + shell export)
- Use absolute `/bin/sh` instead of bare `sh` to eliminate PATH
  dependency entirely

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: add braces around continue to satisfy linter

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(test): update assertion to match /bin/sh in buildDockerExecArgs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 16:17:33 -06:00

567 lines
17 KiB
TypeScript

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";
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: true,
});
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 { 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";
import type { SandboxConfig, SandboxDockerConfig, SandboxWorkspaceAccess } from "./types.js";
import { validateSandboxSecurity } from "./validate-sandbox-security.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;
}) {
// 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 ?? {});
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(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);
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}`,
);
}
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;
}