/** * Parent-side sandbox management for Davis-style workflows. * * Spawns a sandbox child process, sends the user script via stdin, * and communicates via line-delimited JSON over stdin/stdout. * * The child runs in a restricted vm within a separate Node process * (optionally with --permission for OS-level isolation). */ import { spawn, type ChildProcess } from "node:child_process"; import { createInterface, type Interface as ReadlineInterface } from "node:readline"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { randomBytes } from "node:crypto"; import type { ChildMessage, ParentMessage, WorkflowMeta, IpcAuthContext, } from "./types.ts"; import { IPC_DELIMITER, IPC_TOKEN_BYTES } from "./types.ts"; import { generateIpcToken, tokensMatch } from "./ipc.ts"; // ── Path Resolution ──────────────────────────────────────────────────────────── const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const SANDBOX_CHILD_PATH = join(__dirname, "sandbox-child.cjs"); // ── Types ────────────────────────────────────────────────────────────────────── export interface SandboxSession { /** The child process */ process: ChildProcess; /** Send a message to the child (agent result) */ send: (msg: ParentMessage) => void; /** Async iterator of messages from child */ messages: AsyncIterable; /** Kill the child process */ kill: () => void; /** Cleanup resources */ dispose: () => void; } export interface SpawnSandboxOptions { /** Timeout for the entire workflow (ms) */ timeoutMs?: number; /** JSON-compatible workflow arguments exposed as global `args`. */ workflowArgs?: Record; /** Whether to use --permission flag (Node 23+) */ usePermission?: boolean; } // ── Spawn ────────────────────────────────────────────────────────────────────── /** * Spawn a sandbox child process and set up bidirectional IPC. * * Returns a SandboxSession with: * - send(): send agent results back to child * - messages: async iterable of child messages * - kill(): terminate child */ export function spawnSandboxChild( script: string, token: string, options: SpawnSandboxOptions = {}, ): SandboxSession { const encodedArgs = Buffer.from( JSON.stringify(options.workflowArgs ?? {}), "utf8", ).toString("base64url"); const nodeArgs = [SANDBOX_CHILD_PATH, token, encodedArgs]; // Add --permission if requested and available if (options.usePermission !== false) { // Check Node version for --permission support (v23+) const nodeVersion = process.versions.node; const major = parseInt(nodeVersion.split(".")[0], 10); if (major >= 23) { nodeArgs.unshift("--permission"); } } const child = spawn(process.execPath, nodeArgs, { stdio: ["pipe", "pipe", "pipe"], // stdin, stdout, stderr windowsHide: true, }); // Set up stdout reader (child → parent) const stdoutRl = createInterface({ input: child.stdout!, output: undefined, terminal: false, }); // Set up stderr reader (for early errors) const stderrRl = createInterface({ input: child.stderr!, output: undefined, terminal: false, }); // Message queue for async iteration const messageQueue: ChildMessage[] = []; let messageResolve: ((value: IteratorResult) => void) | null = null; let childDone = false; let childError: Error | null = null; let timeoutId: ReturnType | undefined; let forceKillTimer: ReturnType | undefined; function enqueueMessage(msg: ChildMessage): void { if (messageResolve) { const resolve = messageResolve; messageResolve = null; resolve({ value: msg, done: false }); } else { messageQueue.push(msg); } } function closeQueue(): void { if (timeoutId) clearTimeout(timeoutId); if (forceKillTimer) clearTimeout(forceKillTimer); childDone = true; if (messageResolve) { const resolve = messageResolve; messageResolve = null; resolve({ value: undefined as any, done: true }); } } // Handle stdout lines — verify token on child messages stdoutRl.on("line", (line: string) => { try { const raw = JSON.parse(line); // Require token on child messages (except delimiter) for authenticated IPC if (raw.token !== token) { // Drop unauthenticated messages return; } const msg = raw as ChildMessage; enqueueMessage(msg); if (msg.type === "complete" || msg.type === "error") { closeQueue(); } } catch { // Skip malformed lines (could be raw output) } }); // Handle stderr (unexpected errors) — also verify token stderrRl.on("line", (line: string) => { try { const raw = JSON.parse(line); if (raw.token !== token) return; const msg = raw as ChildMessage; if (msg.type === "error") { enqueueMessage(msg); closeQueue(); } } catch { // Raw stderr — log? ignore in sandbox } }); // Handle child exit child.on("exit", (code, signal) => { if (!childDone) { if (code !== 0) { enqueueMessage({ type: "error", message: `Sandbox child exited with code ${code}${signal ? ` (signal ${signal})` : ""}`, }); } else if (!childError) { enqueueMessage({ type: "complete", meta: undefined }); } closeQueue(); } stdoutRl.close(); stderrRl.close(); }); child.on("error", (err) => { childError = err; if (!childDone) { enqueueMessage({ type: "error", message: err.message }); closeQueue(); } }); // Send script to child via stdin child.stdin!.write(script.replace(/\r\n/g, "\n")); child.stdin!.write("\n" + IPC_DELIMITER + "\n"); // Function to send parent messages to child (token-authenticated) function sendToChild(msg: ParentMessage): void { if (child.stdin && !child.stdin.destroyed) { child.stdin.write(JSON.stringify({ ...msg, token }) + "\n"); } } // Kill function function kill(): void { try { if (child.stdin && !child.stdin.destroyed) { child.stdin.end(); } child.kill("SIGTERM"); // Force kill after grace period forceKillTimer = setTimeout(() => { try { child.kill("SIGKILL"); } catch {} }, 2000); } catch {} } function dispose(): void { stdoutRl.close(); stderrRl.close(); try { if (child.stdin && !child.stdin.destroyed) { child.stdin.end(); } } catch {} kill(); } // Async iterator const messages: AsyncIterable = { [Symbol.asyncIterator]() { return { async next(): Promise> { if (childDone && messageQueue.length === 0) { return { value: undefined as any, done: true }; } if (messageQueue.length > 0) { return { value: messageQueue.shift()!, done: false }; } return new Promise>((resolve) => { messageResolve = resolve; }); }, }; }, }; // Apply overall timeout const timeoutMs = options.timeoutMs ?? 600_000; timeoutId = setTimeout(() => { if (!childDone) { enqueueMessage({ type: "error", message: "Workflow timed out" }); closeQueue(); kill(); } }, timeoutMs); return { process: child, send: sendToChild, messages, kill, dispose, }; } // ── Token Generation ──────────────────────────────────────────────────────────── export { generateIpcToken } from "./ipc.ts";