UNPKG

@tanstack/ai-sandbox

Version:

Provider-agnostic sandbox layer for TanStack AI — run harness adapters inside isolated sandboxes (defineSandbox, defineWorkspace, withSandbox) with a uniform SandboxHandle, workspace bootstrap, policy, and resumable lifecycle.

357 lines (325 loc) 13 kB
/** * Internal persistent bootstrap shell. * * Spawns a single `sh` process via {@link SandboxHandle.process.spawn} and * drives it over stdin/stdout with a sentinel-echo protocol. Commands run * sequentially inside the same shell so `cd`, exported variables, etc. persist * across calls — exactly the exec model the bootstrap setup plan needs. * * Providers WITHOUT a writable host→process stdin (`capabilities.writableStdin * === false`, e.g. Cloudflare / Daytona / Vercel) can't be driven over stdin, so * {@link createBootstrapShell} transparently falls back to an exec-backed shell * ({@link createExecBootstrapShell}) that threads `cwd`/env across `exec` calls * to reproduce the same persistent-shell semantics. * * This module is internal-only and must NOT be re-exported from * `packages/ai-sandbox/src/index.ts`. */ import type { SandboxHandle } from './contracts' /** * Parse the output of `export -p` (or `declare -x`) into a plain env map. * Shared by the stdin shell's `forkState` and the exec-backed shell. */ function parseExports(output: string): Record<string, string> { const env: Record<string, string> = {} for (const line of output.split('\n')) { const trimmed = line.trim() // Match `declare -x KEY=...` or `export KEY=...` forms. const match = /^(?:declare\s+-x\s+|export\s+)([A-Za-z_][A-Za-z0-9_]*)(?:="((?:[^"\\]|\\.)*)")?$/.exec( trimmed, ) if (match === null) continue const key = match[1] if (key === undefined) continue // Value may be absent for exported-but-unset vars; skip those. const raw = match[2] if (raw === undefined) continue // Unescape backslash-escaped chars inside double quotes. env[key] = raw.replace(/\\(.)/g, '$1') } return env } /** The surface the bootstrap engine uses. */ export interface BootstrapShell { /** Run a shell command and capture its stdout + exit code. */ run: (command: string) => Promise<{ exitCode: number; stdout: string }> /** * Snapshot the shell's current working directory and exported environment. * Used to fork parallel exec calls that inherit the serial shell's state. */ forkState: () => Promise<{ cwd: string; env: Record<string, string> }> /** End the shell session (closes stdin, kills the process). */ dispose: () => Promise<void> } /** Options for {@link createBootstrapShell}. */ export interface BootstrapShellOptions { /** Working directory to start the shell in (passed as ProcessOptions.cwd). */ cwd?: string /** * Belt-and-braces deadline for a single `run()` to see its sentinel. The * primary termination condition is the stdout stream ending (see * {@link createBootstrapShell}); this only catches a shell that is alive, * silent, and never going to answer. Generous by default because setup steps * legitimately run for a long time (`npm install`, image pulls). */ commandTimeoutMs?: number } /** Default {@link BootstrapShellOptions.commandTimeoutMs} — 30 minutes. */ const DEFAULT_COMMAND_TIMEOUT_MS = 30 * 60 * 1000 /** Race marker for the per-command deadline. A symbol cannot collide with a * literal stdout line (a line of text `'timeout'` would). */ const TIMED_OUT = Symbol('bootstrap-shell-timeout') /** * Spawn one `sh` process and return a {@link BootstrapShell} that drives it * via the sentinel-echo protocol. * * Protocol: for each `run(cmd)` call, we write * `<cmd>; printf "\n__BSSH_<N>__ $?\n"` to stdin, then read stdout lines * until we see a line matching `__BSSH_<N>__ <exitCode>`. Everything before * that line is the command's stdout; the trailing integer is the exit code. * The counter `N` is a module-level monotonic integer — no Date.now / random. */ export async function createBootstrapShell( handle: SandboxHandle, opts: BootstrapShellOptions = {}, ): Promise<BootstrapShell> { // Providers without a writable host→process stdin can't run the sentinel-echo // protocol below (it feeds commands over stdin), so use the exec-backed shell. if (!handle.capabilities.writableStdin) { return createExecBootstrapShell(handle, opts) } const proc = await handle.process.spawn('sh', { cwd: opts.cwd }) /* * We need to read stdout lines across multiple run() calls while keeping * the iterator open. Buffer chunks into lines manually. */ const lineBuffer: Array<string> = [] // `null` means "the stdout stream ended" — distinct from an empty line, which // `sh` emits constantly. Collapsing the two is what let a dead shell feed an // infinite supply of `''` into a sentinel-hunting loop. let pending: Array<(line: string | null) => void> = [] let streamDone = false let streamError: unknown /** Feed the stdout async-iterable into the shared line queue. */ async function drainStdout(): Promise<void> { let partial = '' try { for await (const chunk of proc.stdout) { partial += chunk const parts = partial.split('\n') // All but the last element are complete lines. for (let i = 0; i < parts.length - 1; i++) { const line = parts[i] as string const resolver = pending.shift() if (resolver !== undefined) { resolver(line) } else { lineBuffer.push(line) } } partial = parts[parts.length - 1] as string } // Flush any trailing partial line. if (partial.length > 0) { const line = partial const resolver = pending.shift() if (resolver !== undefined) { resolver(line) } else { lineBuffer.push(line) } } } catch (error) { // A throw while iterating stdout (transport reset, provider stream error) // must NOT leave waiters parked on a promise nobody resolves. Record it so // `run()` can name the cause, and fall through to the `finally` that // unblocks everyone. streamError = error } finally { streamDone = true // Unblock any remaining waiters with the end-of-stream marker. for (const resolver of pending) { resolver(null) } pending = [] } } /* * Start draining immediately; do NOT await — runs concurrently. The `try/catch` * inside `drainStdout` means this promise never rejects, so there is no * unhandled rejection while nothing is awaiting it, and `dispose()` can await * it unconditionally. */ const drainPromise = drainStdout() /** Read the next line from the shared queue, or `null` once stdout ended. */ function nextLine(): Promise<string | null> { const buffered = lineBuffer.shift() if (buffered !== undefined) { return Promise.resolve(buffered) } if (streamDone) { return Promise.resolve(null) } return new Promise<string | null>((resolve) => { pending.push(resolve) }) } let counter = 0 async function run( command: string, ): Promise<{ exitCode: number; stdout: string }> { const id = counter counter += 1 const sentinel = `__BSSH_${id}__` // Write the command followed by a sentinel printf to stdin. Merge the // command's stderr into stdout (`{ … ; } 2>&1`) so a failing setup step's // error text is captured and can be surfaced — otherwise only the exit code // is visible. `$?` after the group is still the command's own exit code. await proc.stdin.write( `{ ${command} ; } 2>&1; printf "\\n${sentinel} $?\\n"\n`, ) const outputLines: Array<string> = [] /* * Read lines until we find the sentinel — but the wait MUST be able to end * without one. `sh` can exit before it ever prints the sentinel (a missing * binary, an OOM kill, the provider reaping the sandbox mid-bootstrap), and * a loop whose only exit is the sentinel then spins on end-of-stream * forever, pushing into `outputLines` until the host process dies of memory * exhaustion. Two independent terminators: * 1. `nextLine()` yields `null` the moment stdout is done — the real fix, * it fires as soon as the shell is gone. * 2. A deadline, for a shell that stays alive and simply never answers. */ let timer: ReturnType<typeof setTimeout> | undefined const deadline = new Promise<typeof TIMED_OUT>((resolve) => { timer = setTimeout( () => resolve(TIMED_OUT), opts.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS, ) }) try { for (;;) { const line = await Promise.race([nextLine(), deadline]) if (line === TIMED_OUT) { throw new Error( `bootstrap shell: timed out after ${ opts.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS }ms waiting for the sentinel of command: ${command}`, ) } if (line === null) { throw new Error( `bootstrap shell: the shell exited before the sentinel was printed; command: ${command}`, streamError === undefined ? undefined : { cause: streamError }, ) } if (line.startsWith(`${sentinel} `)) { const codeStr = line.slice(sentinel.length + 1).trim() const exitCode = parseInt(codeStr, 10) return { exitCode: Number.isFinite(exitCode) ? exitCode : 1, stdout: outputLines.join('\n'), } } outputLines.push(line) } } finally { clearTimeout(timer) } } async function forkState(): Promise<{ cwd: string env: Record<string, string> }> { const pwdResult = await run('pwd') const cwd = pwdResult.stdout.trim() const exportResult = await run('export -p') return { cwd, env: parseExports(exportResult.stdout) } } async function dispose(): Promise<void> { await proc.stdin.end() await proc.kill() // Drain the stdout iterator to completion so there are no dangling promises. await drainPromise } return { run, forkState, dispose } } /** * Exec-backed {@link BootstrapShell} for providers WITHOUT a writable stdin. * * There is no persistent process to feed commands into, so persistence of `cd` * and exported variables is reproduced by threading state across discrete * {@link SandboxHandle.process.exec} calls: each `run()` executes the command in * the tracked cwd+env, then captures the resulting `pwd` and `export -p` (via * marker lines) so the NEXT command inherits any directory change or exports. */ export function createExecBootstrapShell( handle: SandboxHandle, opts: BootstrapShellOptions = {}, ): BootstrapShell { let cwd = opts.cwd ?? '/' let env: Record<string, string> = {} let counter = 0 async function run( command: string, ): Promise<{ exitCode: number; stdout: string }> { const id = counter counter += 1 const sentinel = `__BSSH_${id}__` // Run the command, then emit its exit code, cwd and exported env behind // marker lines so we can recover state even when the command itself fails // (no `set -e`). Capturing `$?` immediately after the command keeps the // reported exit code the command's own, not the trailing introspection's. const script = [ command, `__bssh_rc=$?`, `printf '\\n%s %s\\n' '${sentinel}' "$__bssh_rc"`, `printf '%s\\n' '${sentinel}_CWD'`, `pwd`, `printf '%s\\n' '${sentinel}_ENV'`, `export -p`, ].join('\n') const res = await handle.process.exec(script, { cwd, env }) const cmdOut: Array<string> = [] const cwdLines: Array<string> = [] const envLines: Array<string> = [] let exitCode = res.exitCode let phase: 'cmd' | 'await-cwd' | 'cwd' | 'env' = 'cmd' for (const line of res.stdout.split('\n')) { if (phase === 'cmd') { if (line.startsWith(`${sentinel} `)) { const parsed = parseInt(line.slice(sentinel.length + 1).trim(), 10) exitCode = Number.isFinite(parsed) ? parsed : res.exitCode phase = 'await-cwd' continue } cmdOut.push(line) } else if (phase === 'await-cwd') { if (line === `${sentinel}_CWD`) phase = 'cwd' } else if (phase === 'cwd') { if (line === `${sentinel}_ENV`) phase = 'env' else cwdLines.push(line) } else { envLines.push(line) } } // `pwd` prints a single line; the last non-empty one is the new cwd. const newCwd = cwdLines .map((l) => l.trim()) .filter(Boolean) .pop() if (newCwd) cwd = newCwd const newEnv = parseExports(envLines.join('\n')) if (Object.keys(newEnv).length > 0) env = newEnv return { exitCode, stdout: cmdOut.join('\n') } } function forkState(): Promise<{ cwd: string; env: Record<string, string> }> { return Promise.resolve({ cwd, env: { ...env } }) } function dispose(): Promise<void> { // Nothing to tear down — there is no persistent process. return Promise.resolve() } return { run, forkState, dispose } }