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.

444 lines (421 loc) 18.2 kB
/** * The reusable "run an agent CLI inside a sandbox and stream its events out" * primitive. Harness adapters (claude-code, codex, …) spawn their CLI via the * uniform {@link SandboxHandle} and consume newline-delimited JSON from stdout, * which they then translate into AG-UI StreamChunks. * * This is intentionally transport-minimal: a stdout NDJSON pipe. Multi-client * reconnect / replay belongs to the persistence/EventLog layer, not here. * * The `journal` option adds a second, opt-in transport: instead of holding the * agent's stdout pipe directly, the host redirects it into an append-only file * inside the sandbox and tails that file. See `journal.ts` for why (host death * cannot SIGPIPE the agent, and a later host can resume the same file from byte * 0). `spawnNdjson`'s signature and unjournaled behavior are unchanged; every * existing caller keeps working exactly as before. */ import { journalCleanupCommand, journalPaths, journalStderrReadCommand, journaledCommand, parseExitSentinel, } from './journal' import { readJournal } from './journal-reader' import { decodeBase64Stream } from './journal-bytes' import { awaitAttachableJournal } from './attach-preflight' import type { JournalPaths } from './journal' import type { ProcessOptions, SandboxHandle } from './contracts' import type { RunStore } from '@tanstack/ai' export interface SpawnNdjsonOptions extends ProcessOptions { /** * Called for each raw stdout line that is non-empty but fails JSON parsing * (e.g. a CLI banner). Defaults to ignoring it. Stderr is never parsed. */ onNonJsonLine?: (line: string) => void /** * Written to the process stdin (then stdin is closed) right after spawn — * e.g. the agent prompt for `claude -p`. Avoids putting the prompt in argv. */ input?: string /** * Route the agent's stdout through an in-sandbox journal rather than holding * its pipe directly. * * This is what makes a run survive host death: with nothing piped, there is * no reader whose disappearance can SIGPIPE the agent, and a later host reads * the same file from byte 0. Opt-in so every existing caller (and every * existing test) is unaffected. */ journal?: JournalOptions } /** Journaling configuration for {@link spawnNdjson}. */ export interface JournalOptions { /** Run id the journal path is derived from. Must match across hosts. */ runId: string /** Journal directory. Defaults to `/tmp/tanstack-runs`. */ dir?: string /** * Read an EXISTING journal instead of starting the agent. The read still * begins at byte 0 — the alignment step, not the reader, decides what has * already been delivered. */ attach?: boolean /** Poll interval for providers that cannot follow. */ pollIntervalMs?: number /** * Run record store, consulted ONLY on an attach and only when the journal is * absent, to tell "not written yet" from "will never be written" (see * `attach-preflight.ts`). Optional so an attach with no store wired keeps the * bounded wait while losing the unknown/terminal classification. */ runs?: RunStore /** * Bounded wait for a live run's journal to appear on an attach, AND — on every * path, attach or fresh — the bound on the read's first byte * (`ReadJournalOptions.firstByteTimeoutMs`). One knob for both because they * bound the same question from two sides: the preflight covers "the file does * not exist", the read covers "the file exists but nothing is writing to it", * and a caller that widens one always means to widen the other. * * Defaults to `DEFAULT_ATTACH_JOURNAL_WAIT_MS`. */ attachWaitMs?: number } type JournaledOptions = SpawnNdjsonOptions & { journal: JournalOptions } function isJournaled(options: SpawnNdjsonOptions): options is JournaledOptions { return options.journal !== undefined } function resolvePaths(options: JournaledOptions) { return journalPaths(options.journal.runId, options.journal.dir) } /** * Strip the runner-only options, leaving what `handle.process.spawn` accepts. * * `signal` is deliberately KEPT: on the UNJOURNALED path the host holds the * agent's stdout pipe, so a client disconnect should take the process down with * it. The journaled path must not forward it — see * {@link toJournaledSpawnOptions}. */ function toProcessOptions(options: SpawnNdjsonOptions): ProcessOptions { const { onNonJsonLine, input, journal, ...rest } = options void onNonJsonLine void input void journal return rest } /** * The journaled agent's spawn options: {@link toProcessOptions} MINUS `signal`. * * The request's signal must never reach the agent process on this path. Providers * DO act on it at spawn time — local-process registers it to `killTree` the * process group, and daytona and docker honor it too — so forwarding it means a * client disconnect kills the journaled agent. The agent then writes no exit * sentinel, and a successor host takes over a run that is already dead: the exact * opposite of the guarantee documented on {@link startJournaledAgent}, and of the * reason the journal is a file rather than a pipe. * * The signal is still honored for the READ. `readJournalNdjson` forwards it to * `readJournal` and `awaitAttachableJournal` on its own, so a disconnecting * client stops tailing immediately. Only the agent spawn outlives the request. */ function toJournaledSpawnOptions(options: JournaledOptions): ProcessOptions { const { signal, ...rest } = toProcessOptions(options) void signal return rest } /** Split a stream of arbitrary string chunks into complete lines. */ export async function* toLines( chunks: AsyncIterable<string>, ): AsyncIterable<string> { let buffer = '' for await (const chunk of chunks) { buffer += chunk let newlineIndex = buffer.indexOf('\n') while (newlineIndex !== -1) { const line = buffer.slice(0, newlineIndex) buffer = buffer.slice(newlineIndex + 1) yield line newlineIndex = buffer.indexOf('\n') } } if (buffer.length > 0) yield buffer } /** * Start the agent with its stdout (and the `{"__exit":N}` sentinel) redirected * into the journal, then return. * * Deliberately does NOT wait for the process and does NOT read its stdout: the * whole point of journaling is that the host holds no handle on the agent's * output, so a host that dies mid-run cannot take the agent down with it (no * pipe to SIGPIPE). The spawned process is left running in the sandbox; the * sentinel line the wrapper appends on exit is how anyone — this host or a * successor — learns it finished. Stdin is still written directly to the * spawned process, exactly as the unjournaled path does, since that transport * is unaffected by where stdout goes. */ export async function startJournaledAgent( handle: SandboxHandle, command: string, options: JournaledOptions, ): Promise<void> { const paths = resolvePaths(options) const proc = await handle.process.spawn( journaledCommand(command, paths), toJournaledSpawnOptions(options), ) if (options.input !== undefined) { await proc.stdin.write(options.input) await proc.stdin.end() } } /** Chars of stderr attached to a non-zero-exit error, on both paths. */ const STDERR_ERROR_CHARS = 1000 async function* singleValue(value: string): AsyncIterable<string> { yield value } /** * Read the tail of a run's stderr sidecar, for the error message only. * * Returns `''` on ANY failure — a provider whose `exec` rejects, a sidecar that * no longer exists, a base64 frame the provider truncated. The caller is on its * way to throwing the real failure (the agent's non-zero exit), and losing a * diagnostic suffix must never replace that error with a cleanup error. Decoding * is deliberately lossy: `journalStderrReadCommand` reads the LAST N bytes, so * byte 0 of the frame can sit mid-character. */ async function readStderrTail( handle: SandboxHandle, paths: JournalPaths, ): Promise<string> { try { const result = await handle.process.exec(journalStderrReadCommand(paths)) const decoder = new TextDecoder() let text = '' for await (const bytes of decodeBase64Stream(singleValue(result.stdout))) { text += decoder.decode(bytes, { stream: true }) } text += decoder.decode() return text.trim() } catch { return '' } } /** * Delete a terminal run's journal. Best effort by construction: see * {@link journalCleanupCommand} for why a failure here cannot be allowed to fail * a run that has already finished. */ async function cleanupJournal( handle: SandboxHandle, paths: JournalPaths, ): Promise<void> { try { await handle.process.exec(journalCleanupCommand(paths)) } catch { // The sandbox may already be gone, `/tmp` may be read-only, the provider's // `exec` may reject. Nothing about a completed run depends on the files // still existing OR on them being gone, so there is nothing to report. } } /** * Read a run's journal and yield each line parsed as JSON. * * Always reads from byte 0 — the alignment step (a later phase), not this * reader, decides what a client has already seen. Stops at the `{"__exit":N}` * sentinel, and throws for a non-zero N so the calling adapter's existing * `catch` turns it into a `RUN_ERROR`, the same observable outcome the * unjournaled path produces from a non-zero `wait()`. There is nothing to * `wait()` on here: the host holds a `tail`, not the agent process, so the * sentinel line IS the exit code. * * The sentinel is also what bounds journal growth: reaching it means the run is * terminal, and a terminal run's record is the event log, so both journal files * are deleted before this iterable finishes. The ordering below is load-bearing * and is asserted, not merely commented: * * - The sentinel is captured and the loop is `break`-ed, so the source's * `finally` kills the `tail` BEFORE the `rm` runs — the reader is stopped, then * its input is deleted, never the other way round. * - `exitCode` stays `undefined` if the journal stream ends without a sentinel. * NOTHING is deleted on that path either way — the run may be mid-flight and a * successor host may still need every byte — but the two causes are then * separated by `options.signal.aborted`: an aborted consumer returns quietly, * while a stream that died on its own (killed `tail`, destroyed sandbox, torn * pipe) THROWS. Returning for both is how a truncated read used to reach the * client as a normally-completing run. * - A non-zero sentinel deletes too. The run is terminal either way. * - The stderr sidecar is read BEFORE the deletion that destroys it, so a * non-zero exit carries up to {@link STDERR_ERROR_CHARS} chars of the agent's * own diagnostics, exactly as the unjournaled path below does. That closes the * "Known regression" this function used to document; the read is bounded and * failure-swallowing (see {@link readStderrTail}), so it cannot turn a run * failure into a cleanup failure. * * On an ATTACH (`journal.attach === true`) the read is preceded by * {@link awaitAttachableJournal}, which fails fast for a runId the store does not * know or has already terminalized and otherwise waits a BOUNDED time for a live * run's journal to appear. Without it, an attach to a runId with no journal * created an empty one (`journalFollowCommand` does that deliberately) and tailed * it forever — no sentinel, no error, no timeout. * * One case this does NOT bound: a run that reaches its sentinel while DETACHED * has no host reading it, so nothing observes the sentinel and nothing here * runs. Sweeping those is `pruneJournals`' job (`journal-sweep.ts`): it consults * the run store's status for each journal it finds and deletes only the terminal * ones, from a cron the application schedules rather than from a run. */ export async function* readJournalNdjson( handle: SandboxHandle, options: JournaledOptions, ): AsyncIterable<unknown> { const paths = resolvePaths(options) // ATTACH ONLY, and before the first read. `journalFollowCommand` CREATES the // journal it tails, so an attach for a runId that never had one would // otherwise create an empty file and tail it forever with no sentinel ever // arriving. A fresh run must not be gated: its journal is created by its own // `journaledCommand` spawn, which `spawnNdjson` has just issued. if (options.journal.attach === true) { await awaitAttachableJournal(handle, { paths, runId: options.journal.runId, ...(options.journal.runs === undefined ? {} : { runs: options.journal.runs }), ...(options.journal.attachWaitMs === undefined ? {} : { waitMs: options.journal.attachWaitMs }), ...(options.signal === undefined ? {} : { signal: options.signal }), }) } let exitCode: number | undefined for await (const { line } of readJournal(handle, { paths, fromByte: 0, runId: options.journal.runId, ...(options.signal === undefined ? {} : { signal: options.signal }), ...(options.journal.pollIntervalMs === undefined ? {} : { pollIntervalMs: options.journal.pollIntervalMs }), ...(options.journal.attachWaitMs === undefined ? {} : { firstByteTimeoutMs: options.journal.attachWaitMs }), })) { const trimmed = line.trim() if (trimmed === '') continue // The sentinel test comes FIRST and is nonce-checked, so an agent line that // merely looks like a sentinel is delivered as the event it is instead of // truncating the run (see `parseExitSentinel`). const sentinel = parseExitSentinel(trimmed, paths) if (sentinel !== null) { exitCode = sentinel break } let parsed: unknown try { parsed = JSON.parse(trimmed) } catch { options.onNonJsonLine?.(trimmed) continue } yield parsed } // The stream ended with no sentinel. Two very different causes share this // shape, and collapsing them is how a torn-down read used to be recorded as a // short but SUCCESSFUL run: // // - The CONSUMER aborted (lease lost, client gone, host shutting down). Not a // failure and not terminal: return, having deleted nothing, because a // successor host may still need every byte. `pipeToRunLog`'s post-loop check // turns this into `'aborted'`. // - The read DIED (the `tail` was killed, the sandbox was destroyed, the pipe // was torn down). The iterable ends without an error, so returning here made // the adapter emit a normally-completing but silently TRUNCATED run — in a // function whose documented job is to throw so the adapter converts it to a // `RUN_ERROR`. `signal.aborted` is what tells the two apart, and the throw // matches the shape the unjournaled path already uses for a bad exit. if (exitCode === undefined) { if (options.signal?.aborted === true) return throw new Error( `Agent journal stream for run ${options.journal.runId} ended without an exit sentinel ` + `(${paths.journal}). The run was NOT observed to finish: the tail was torn down, ` + `the sandbox went away, or the agent's shell died before writing its sentinel. ` + `Both journal files are left in place for a successor host.`, ) } const stderr = exitCode === 0 ? '' : await readStderrTail(handle, paths) await cleanupJournal(handle, paths) if (exitCode !== 0) { throw new Error( `Agent process exited with code ${exitCode}` + (stderr ? `: ${stderr.slice(0, STDERR_ERROR_CHARS)}` : ''), ) } } /** * Spawn `command` in the sandbox and yield each stdout line parsed as JSON. * * Without `options.journal`, behavior is byte-identical to before: resolves * the spawn handle's exit via `wait()` after stdout closes; a non-zero exit * with no events surfaced is the adapter's concern to detect. * * With `options.journal`, the agent's stdout is redirected into an in-sandbox * journal (unless `journal.attach` is set, meaning a run already in flight) * and then read back from byte 0 — one code path for a fresh run and an * attach, both going through {@link readJournalNdjson}. */ export async function* spawnNdjson( handle: SandboxHandle, command: string, options: SpawnNdjsonOptions = {}, ): AsyncIterable<unknown> { if (isJournaled(options)) { if (options.journal.attach !== true) { await startJournaledAgent(handle, command, options) } yield* readJournalNdjson(handle, options) return } const { onNonJsonLine, input, ...processOptions } = options const proc = await handle.process.spawn(command, processOptions) if (input !== undefined) { await proc.stdin.write(input) await proc.stdin.end() } // Drain stderr concurrently. A CLI that fails before producing stdout (a // broken install, an auth/permission refusal, …) prints to stderr and exits // non-zero; without this, stdout-only parsing yields nothing and the failure // vanishes. Capturing it lets us surface the cause below. const stderrChunks: Array<string> = [] const stderrDrained = (async () => { try { for await (const chunk of proc.stderr) stderrChunks.push(chunk) } catch { // stderr stream torn down — use whatever was captured } })() for await (const line of toLines(proc.stdout)) { const trimmed = line.trim() if (trimmed === '') continue let parsed: unknown try { parsed = JSON.parse(trimmed) } catch { onNonJsonLine?.(trimmed) continue } yield parsed } const exitCode = await proc.wait() await stderrDrained // A non-zero exit means the agent CLI itself failed. Throw so the adapter's // catch turns it into a RUN_ERROR the UI can show, instead of ending the // stream silently with no events. if (exitCode !== 0) { const stderr = stderrChunks.join('').trim() throw new Error( `Agent process exited with code ${exitCode}` + (stderr ? `: ${stderr.slice(0, STDERR_ERROR_CHARS)}` : ''), ) } }