/** * Docker CLI backend for the sandbox runner. * * Invokes the `docker` CLI via child_process with argument arrays. No SDK, no * HTTP API. Kept simple and auditable. All container lifecycle management * (create/exec/copy/remove) lives here. */ import { spawn } from "node:child_process"; import { createWriteStream } from "node:fs"; import path from "node:path"; import { FS, RESOURCE_LIMITS } from "./types.ts"; /** Errors surfaced from the Docker CLI layer. */ export class DockerError extends Error { constructor( message: string, readonly stderr = "", ) { super(message); } } /** * Run a docker command and capture stdout. Resolves with trimmed stdout. * Rejects with DockerError on spawn failure or nonzero exit. */ export function dockerCommand( args: string[], options?: { signal?: AbortSignal; timeoutMs?: number }, ): Promise { return new Promise((resolve, reject) => { let child; try { child = spawn("docker", args, { stdio: ["ignore", "pipe", "pipe"] }); } catch (err) { reject(err); return; } let stdout = ""; let stderr = ""; child.stdout.on("data", (d: Buffer) => { stdout += d.toString(); }); child.stderr.on("data", (d: Buffer) => { stderr += d.toString(); }); let timedOut = false; let timer: NodeJS.Timeout | undefined; if (options?.timeoutMs) { timer = setTimeout(() => { timedOut = true; child.kill("SIGKILL"); }, options.timeoutMs); } const onAbort = () => child.kill("SIGKILL"); options?.signal?.addEventListener("abort", onAbort, { once: true }); child.on("error", (err) => { if (timer) clearTimeout(timer); options?.signal?.removeEventListener("abort", onAbort); reject(new DockerError(`docker failed to start: ${err.message}`, stderr)); }); child.on("close", (code) => { if (timer) clearTimeout(timer); options?.signal?.removeEventListener("abort", onAbort); if (options?.signal?.aborted) { reject(new DockerError("aborted", stderr)); return; } if (timedOut) { reject(new DockerError("docker command timed out", stderr)); return; } if (code !== 0) { reject(new DockerError(stderr.trim() || `docker exited with code ${code}`, stderr)); return; } resolve(stdout.trim()); }); }); } /** Probe whether the `docker` CLI exists and the daemon is reachable. */ export async function checkDockerAvailable(): Promise<{ cli: boolean; daemon: boolean; version?: string }> { // Check CLI presence. try { await dockerCommand(["--version"], { timeoutMs: 10_000 }); } catch { return { cli: false, daemon: false }; } // Check daemon reachability. let version: string | undefined; try { version = await dockerCommand(["version", "--format", "{{.Server.Version}}"], { timeoutMs: 10_000 }); } catch { return { cli: true, daemon: false }; } return { cli: true, daemon: true, version }; } /** Check whether the sandbox image exists locally. */ export async function imageExists(image: string): Promise { try { await dockerCommand(["image", "inspect", image], { timeoutMs: 15_000 }); return true; } catch { return false; } } /** Options for creating a sandbox container. */ export interface CreateContainerOptions { image: string; /** Host project path mounted read-only at /workspace. */ projectPath: string; /** Whether the container gets a network interface. */ network: boolean; } /** A running sandbox container handle. */ export interface SandboxContainer { /** Unique container id. */ id: string; } /** * Create a fresh, disposable sandbox container. * * Hardening: drop all capabilities, no-new-privileges, PID/memory/CPU limits, * read-only /workspace mount, no network by default, no Docker socket, no host * env/credentials. Never --privileged. */ export async function createSandboxContainer(options: CreateContainerOptions): Promise { const args: string[] = ["create"]; // No persistent container state; --rm removes on stop. args.push("--rm"); // Resource + privilege hardening. Drop dangerous capabilities rather than ALL: // ALL would also strip CHOWN/FOWNER/DAC_OVERRIDE that the privileged setup // phase (package install) needs. Keep the kernel/network/process-exploitation // capabilities dropped. const DROPPED_CAPS = [ "SYS_ADMIN", "SYS_PTRACE", "SYS_MODULE", "SYS_BOOT", "SYS_TIME", "SYS_RAWIO", "SYS_RESOURCE", "SYS_TTY_CONFIG", "NET_ADMIN", "NET_RAW", "MKNOD", "SETFCAP", "AUDIT_CONTROL", "AUDIT_WRITE", "MAC_ADMIN", "MAC_OVERRIDE", "SYSLOG", "LINUX_IMMUTABLE", ]; for (const cap of DROPPED_CAPS) args.push("--cap-drop", cap); args.push("--security-opt", "no-new-privileges"); args.push("--pids-limit", String(RESOURCE_LIMITS.pids)); args.push("--memory", String(RESOURCE_LIMITS.memoryBytes)); args.push("--cpus", String(RESOURCE_LIMITS.cpus)); // Do not expose the Docker socket. args.push("--ulimit", "nofile=1024:1024"); // Network policy. args.push("--network", options.network ? "bridge" : "none"); // Read-only project mount. Uses -v (not --mount) so we can pass the `z` // SELinux relabel option, required on hosts with selinux-enabled=true. // `:z` marks the volume shared (readable by the container without changing // the host project's protection to exclusive). args.push("-v", `${options.projectPath}:${FS.workspace}:ro,z`); // Writable ephemeral output + tmp. World-writable sticky tmpfs (like /tmp) so // the unprivileged sandbox user can write without needing CHOWN (dropped). args.push("--mount", `type=tmpfs,target=${FS.output},tmpfs-mode=1777,tmpfs-size=268435456`); // 256 MiB args.push("--mount", "type=tmpfs,target=/tmp,tmpfs-mode=1777,tmpfs-size=536870912"); // 512 MiB // Keep the container alive so we can exec into it. args.push("--entrypoint", "/bin/sh"); args.push(options.image); args.push("-c", "sleep infinity"); const id = await dockerCommand(args, { timeoutMs: 60_000 }); if (!id) throw new DockerError("docker create returned no container id"); // Create alone does not start the container; start it so we can exec in. // If start fails, remove the created container so it cannot leak. try { await dockerCommand(["start", id], { timeoutMs: 30_000 }); } catch (err) { await removeContainer(id); throw err; } // Wait until the container reports running; issuing docker exec immediately // after start can race and fail with "is not running" on some systems. await waitForRunning(id); return { id }; } /** Remove a container, ignoring errors (already removed). */ export async function removeContainer(id: string): Promise { try { await dockerCommand(["rm", "-f", id], { timeoutMs: 30_000 }); } catch { // Best-effort cleanup; --rm also handles it on stop. } } /** * Poll until the container reports running (or fails). Guards against * `docker exec` racing the container start. */ async function waitForRunning(id: string): Promise { const deadline = Date.now() + 20_000; while (Date.now() < deadline) { try { const state = await dockerCommand(["inspect", "--format", "{{.State.Running}}", id], { timeoutMs: 10_000 }); if (state.trim() === "true") return; } catch { // Container may not be inspectable yet; keep polling. } await new Promise((r) => setTimeout(r, 200)); } throw new DockerError(`container ${id} did not reach running state`); } /** Options controlling a single exec invocation. */ export interface ExecOptions { /** Working directory inside the container. */ cwd: string; /** Run as the sandbox user (true) or root (setup phase). */ asUser: boolean; /** Env vars to pass in (an allowlist, never host secrets). */ env?: Record; /** Abort signal forwarded to the child process. */ signal?: AbortSignal; /** Timeout in seconds; kills the process on expiry. */ timeoutSeconds?: number; /** Stream each stdout chunk here. */ onStdout?: (chunk: string) => void; /** Stream each stderr chunk here. */ onStderr?: (chunk: string) => void; } /** Result of an exec invocation. */ export interface ExecResult { exitCode: number | null; timedOut: boolean; /** Captured stdout (streamed to onStdout if provided). */ stdout: string; /** Captured stderr (streamed to onStderr if provided). */ stderr: string; } /** * Execute a command inside the container via `docker exec`. * * Shell mode passes the command through `-c`; argv mode passes [executable, * ...args] directly. Both run as the sandbox user unless asUser is false. */ export function execInContainer( container: SandboxContainer, run: { mode: "shell"; command: string } | { mode: "argv"; executable: string; args: string[] }, options: ExecOptions, ): Promise { return new Promise((resolve, reject) => { const args: string[] = ["exec"]; if (options.asUser) { args.push("--user", FS.user); } else { // Setup phase: explicitly run as root (the image default USER may be // the sandbox user, so we must override it). args.push("--user", "0:0"); } if (options.cwd) { args.push("--workdir", options.cwd); } if (options.env) { for (const [k, v] of Object.entries(options.env)) args.push("--env", `${k}=${v}`); } args.push(container.id); if (run.mode === "shell") { args.push(FS.shell, "-c", run.command); } else { args.push(run.executable, ...run.args); } let child; try { child = spawn("docker", args, { stdio: ["ignore", "pipe", "pipe"] }); } catch (err) { reject(err); return; } // Cap the captured strings used for the result/error messages so a huge // command cannot grow memory without bound. Streaming to onStdout/onStderr // (used by the tool for truncation) is unlimited by design. const MAX_CAPTURE = 2 * 1024 * 1024; // 2 MiB each let stdout = ""; let stderr = ""; child.stdout.on("data", (d: Buffer) => { const s = d.toString(); if (stdout.length < MAX_CAPTURE) stdout += s.slice(0, MAX_CAPTURE - stdout.length); options.onStdout?.(s); }); child.stderr.on("data", (d: Buffer) => { const s = d.toString(); if (stderr.length < MAX_CAPTURE) stderr += s.slice(0, MAX_CAPTURE - stderr.length); options.onStderr?.(s); }); let timedOut = false; let timer: NodeJS.Timeout | undefined; if (options.timeoutSeconds && options.timeoutSeconds > 0) { timer = setTimeout(() => { timedOut = true; child.kill("SIGKILL"); }, options.timeoutSeconds * 1000); } const onAbort = () => child.kill("SIGKILL"); options.signal?.addEventListener("abort", onAbort, { once: true }); child.on("error", (err) => { if (timer) clearTimeout(timer); options.signal?.removeEventListener("abort", onAbort); reject(new DockerError(`docker exec failed: ${err.message}`)); }); child.on("close", (code) => { if (timer) clearTimeout(timer); options.signal?.removeEventListener("abort", onAbort); if (options.signal?.aborted) { reject(new DockerError("aborted")); return; } resolve({ exitCode: code ?? null, timedOut, stdout, stderr }); }); }); } /** * Extract an artifact out of the container to a host destination. * * Uses `docker exec` to stream contents (docker cp cannot read tmpfs mounts, * which is what /output uses). For a regular file it streams raw bytes; for a * directory it produces a tar archive at the destination path. * * Returns the type of what was extracted. */ export async function extractArtifact( container: SandboxContainer, guestPath: string, hostDest: string, ): Promise<"file" | "directory"> { const args = ["exec"]; args.push(container.id); // Determine the type first. const typeArgs = [...args, FS.shell, "-c", `[ -d ${quote(guestPath)} ] && echo dir || echo file`]; const typeOut = await dockerCommand(typeArgs, { timeoutMs: 30_000 }); const isDir = typeOut.trim() === "dir"; if (isDir) { // Stream a tar archive from the directory to the host file. const tarArgs = [...args, FS.shell, "-c", `tar -C ${quote(path.posix.dirname(guestPath))} -cf - ${quote(path.posix.basename(guestPath))}`]; await execToFile(tarArgs, hostDest, 120_000); return "directory"; } // Stream a single file's bytes. const catArgs = [...args, "cat", "--", guestPath]; await execToFile(catArgs, hostDest, 120_000); return "file"; } /** Run a docker command, streaming stdout to a host file. Rejects on nonzero exit. */ function execToFile(args: string[], hostDest: string, timeoutMs: number): Promise { return new Promise((resolve, reject) => { let child; try { child = spawn("docker", args, { stdio: ["ignore", "pipe", "pipe"] }); } catch (err) { reject(err); return; } const out = createWriteStream(hostDest); let stderr = ""; child.stdout.pipe(out); child.stderr.on("data", (d: Buffer) => { stderr += d.toString(); }); let timer: NodeJS.Timeout | undefined; timer = setTimeout(() => { child.kill("SIGKILL"); }, timeoutMs); child.on("error", (err) => { if (timer) clearTimeout(timer); out.destroy(); reject(new DockerError(`docker extract failed: ${err.message}`, stderr)); }); child.on("close", (code) => { if (timer) clearTimeout(timer); out.end(); if (code !== 0) { reject(new DockerError(`docker extract failed (exit ${code}): ${stderr.trim()}`, stderr)); return; } resolve(); }); }); } /** Shell-quote a path for safe interpolation into a `sh -c` command. */ function quote(p: string): string { return `'${p.replace(/'/g, `'\\''`)}'`; }