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.

381 lines (380 loc) 16.6 kB
import { exitSentinelLine, journalExistsCommand, journalPaths, journalReadCommand, journaledCommand } from "../journal.js"; import { journalReadStrategy, readJournal } from "../journal-reader.js"; import { randomUUID } from "node:crypto"; import { describe, expect, it } from "vitest"; //#region src/testkit/journal-conformance.ts /** * Provider conformance for the agent output journal. * * The journal design rests on two provider-level claims: a command string is * framed through a POSIX shell (so `>>` redirection works), and `tail -c +N -f` * is available. Both are asserted here against a real sandbox rather than * assumed from the audit. * * A provider that cannot satisfy them MUST declare `unsupported.reason`. There * is deliberately no silent-skip path: a conformance case that quietly returns * prints as a pass, which is how an unimplemented capability ships green. The * three FOLLOW cases obey the same rule through a second declaration, * {@link JournalConformanceConfig.followUnsupported} — see {@link itFollows} for * why the strategy has to be declared rather than detected at registration time, * and {@link expectDeclaredStrategy} for what keeps the declaration honest. * * THE THIRD FOLLOW CASE TESTS THE OTHER SIDE OF THE BOUNDARY, and it is here * because the first two do not. `killableProcesses` is what selects `'follow'` * over `'poll'`, and a wrong `true` means `tail -f` is spawned on the assumption * it can be reclaimed — leaking one follower per run when it cannot. The two * follow cases only ever asserted that the READER stops, which * `journal-reader.ts`'s `untilAborted` guarantees on its own by abandoning the * pipe the moment the signal fires. So both of them pass a provider whose * `kill()` is `() => Promise.resolve()`, and three of the four `true` * declarations in this repo were in fact false: Docker's `stream.destroy()` only * detached the client, local-process's `sh -c` forks so signalling the shell left * the command alive, and Vercel's `kill()` never called the SDK's real * `Command.kill` at all. Every one of them shipped green through this suite. * "kills the sandbox-side process, not just the host's view of it" is the case * that fails them — see its own comment for how it probes. * * Vitest is an OPTIONAL peer dependency: this module is imported only from test * files, which already run under Vitest. */ /** * Per-case timeout. Every case here spawns a real sandbox and a real agent. * * 180s, not the 60s this used to be, and it matches the ceiling * `takeover-conformance.ts` already gives its heaviest cases. It is the one * wall-clock number left in the file and it is deliberately far outside the range * any healthy run needs: a case here makes half a dozen provider round-trips, and * ONE `docker exec` on a loaded daemon has been measured at 9.6s (see * `takeover-conformance.ts`'s `countingExec`) and at 20–45s on a saturated one, so * a 60s budget put the timeout itself in the same load-sensitive class as the * assertions that were removed from these cases — measured going red on cases that * pass in 7–13s each on a quiet machine. * * This bound exists only so a genuine hang FAILS instead of parking CI; it is not * an assertion about speed, and nothing here should be tuned to sit near it. */ var CASE_TIMEOUT_MS = 18e4; /** * Register a case that only means anything on a provider whose reads FOLLOW. * * `journalReadStrategy` needs a live handle and a live handle needs the async * `createHandle`, so the strategy is not knowable when the cases are registered. * It is therefore DECLARED, and the declaration selects `it` or `it.skip` here. * * This exists because the alternative — checking the strategy inside the case and * returning early — is the silent-skip the module doc forbids. Such a case prints * `✓` with a duration and a title claiming a property was verified while every * real assertion in it (including the incremental-delivery handshake, which is * the entire reason the follow path exists) was skipped. A named `it.skip` prints * `↓` with the reason instead. */ function itFollows(config, title, fn) { const unsupported = config.followUnsupported; if (unsupported === void 0) { it(title, fn, CASE_TIMEOUT_MS); return; } it.skip(`${title} — follow strategy unsupported: ${unsupported.reason}`, fn, CASE_TIMEOUT_MS); } /** * Assert the live handle's read strategy is the one the config DECLARED. * * BOTH directions are defects, and neither is a skip. A provider that declared * `followUnsupported` but whose handles do follow silently loses the two cases it * could pass. One that declared nothing but polls would reach the follow * assertions and fail them for a reason unrelated to journaling — which is what * the previous `expect(handle.capabilities.killableProcesses).toBe(false)` branch * did to a provider with `backgroundProcesses: false, killableProcesses: true`. * Either way the config does not describe the provider, and that is worth * failing. */ function expectDeclaredStrategy(handle, config) { expect(journalReadStrategy(handle)).toBe(config.followUnsupported === void 0 ? "follow" : "poll"); } /** Decode the base64 frame a journal read command produces into raw text. */ function decodeJournalRead(stdout) { return Buffer.from(stdout.replace(/\s+/g, ""), "base64").toString("utf8"); } /** * Block until the run's journal file exists in the sandbox. * * Through the shell (`journalExistsCommand`), never `handle.fs.exists` — see * rule 3 in `../journal.ts`: on local-process the two resolve `/tmp` * differently, so an `fs` probe would report the wrong file. * * Exported for `./reaper-conformance.ts`, which needs the same bounded, * shell-only wait before probing a still-producing run. Internal to the testkit; * not part of the `./testkit` public surface. */ async function waitForJournal(handle, paths) { const deadline = Date.now() + 15e3; for (;;) { if ((await handle.process.exec(journalExistsCommand(paths))).exitCode === 0) return; if (Date.now() > deadline) throw new Error(`journal conformance: ${paths.journal} never appeared`); await sleep(100); } } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * An absolute path inside the sandbox that no other case, suite, or machine will * touch. * * Every character is in `[A-Za-z0-9./-]`, so these interpolate into the shell * commands below as a single word without quoting. `/tmp` and not the workspace: * on local-process a shell redirect reaches the host's real `/tmp` while * `handle.fs` resolves under the sandbox root (see rule 3 in `../journal.ts`), * and everything here is written AND read through the shell so the two never have * to agree. */ function noncePath(label) { return `/tmp/tanstack-journal-conformance-${label}-${randomUUID()}`; } /** Iteration cap on the kill probe's loop, so nothing can outlive the suite. */ var PROBE_MAX_TICKS = 600; /** * Bound on a journal read, so a reader that delivers nothing FAILS instead of * parking CI. * * Never an assertion, and deliberately far above anything a healthy read needs * (measured: 10–18s for the follow cases on both providers). Each case that uses * it proves its property some other way — a causal handshake, or * `backstop.aborted` — so this number can be raised freely and must never be the * thing a case is tuned against. */ var READ_BACKSTOP_MS = 9e4; /** * How long to let an asynchronous kill land before the quiet window opens. * * A kill is asynchronous on every provider here — Docker signals through a * second `exec`, local-process signals a process group and lets the OS reap — so * one more heartbeat tick immediately after `kill()` resolves is not a survivor. */ var KILL_SETTLE_MS = 5e3; /** * The quiet window: how long the heartbeat must stay frozen. * * This is NOT a load-sensitive bound, and the asymmetry is the point. A dead * process can never write again, so a slow or busy machine can only make this * window MORE reliable, never less — unlike a "must happen within Nms" ceiling, * which fails on load. Only a live survivor can end this window, and a live * survivor writes once a second. */ var HEARTBEAT_QUIET_MS = 6e3; /** * Byte count of `path`, according to the SANDBOX'S OWN shell, or `null` when it * cannot be read. * * `wc -c` through the shell, never `handle.fs`: on local-process the two resolve * `/tmp` differently (rule 3), so an `fs` probe would answer about a file the * sandbox never wrote and the growth below would look frozen from the first * sample — a vacuous pass. Parsed strictly rather than coerced, so a shell * diagnostic cannot become `NaN` and compare unequal to itself. */ async function fileSize(handle, path) { const text = (await handle.process.exec(`wc -c < ${path} 2>/dev/null`)).stdout.trim(); return /^\d+$/.test(text) ? Number(text) : null; } /** * Wait until `path` has grown to at least `bytes`, i.e. the probe process is * provably DOING WORK inside the sandbox, and answer whether it got there. * * Returning the observation rather than throwing keeps the verdict inside the * case's own `expect`: this is the "before" half of the assertion, and it is what * makes the "after" half a live detector instead of a formality. */ async function waitForTicks(handle, path, bytes) { const deadline = Date.now() + 3e4; for (;;) { const size = await fileSize(handle, path); if (size !== null && size >= bytes) return true; if (Date.now() > deadline) return false; await sleep(1e3); } } /** * Assert `createHandle` satisfies the journal conformance contract. Each `it` * gets a fresh sandbox via `createHandle`/`dispose`, so implementations may * share process state across calls without cross-test bleed only if * `createHandle` returns an isolated sandbox. */ function runJournalConformance(config) { describe(`journal conformance — ${config.name}`, () => { if (config.unsupported) { it.skip(`unsupported: ${config.unsupported.reason}`, () => { expect(true).toBe(true); }); return; } it("redirects a command's stdout into the journal and appends the exit sentinel", async () => { const { handle, dispose } = await config.createHandle(); try { expectDeclaredStrategy(handle, config); const paths = journalPaths(`conf-${Date.now()}`); const command = journaledCommand(`printf '{"a":1}\\n{"b":2}\\n'`, paths); const proc = await handle.process.spawn(command); expect(await proc.wait()).toBe(0); const text = decodeJournalRead((await handle.process.exec(journalReadCommand(paths, 0))).stdout); expect(text).toBe(`{"a":1}\n{"b":2}\n${exitSentinelLine(paths, 0)}\n`); } finally { await dispose(); } }, CASE_TIMEOUT_MS); it("records the agent's non-zero exit in the sentinel", async () => { const { handle, dispose } = await config.createHandle(); try { const paths = journalPaths(`conf-exit-${Date.now()}`); await (await handle.process.spawn(journaledCommand("exit 7", paths))).wait(); const text = decodeJournalRead((await handle.process.exec(journalReadCommand(paths, 0))).stdout); expect(text).toBe(`${exitSentinelLine(paths, 7)}\n`); } finally { await dispose(); } }, CASE_TIMEOUT_MS); it("keeps the agent's stderr out of the journal", async () => { const { handle, dispose } = await config.createHandle(); try { const paths = journalPaths(`conf-err-${Date.now()}`); await (await handle.process.spawn(journaledCommand(`printf '{"a":1}\\n'; printf 'a warning\\n' 1>&2`, paths))).wait(); const text = decodeJournalRead((await handle.process.exec(journalReadCommand(paths, 0))).stdout); expect(text).toBe(`{"a":1}\n${exitSentinelLine(paths, 0)}\n`); expect(text).not.toContain("a warning"); } finally { await dispose(); } }, CASE_TIMEOUT_MS); it("reads incrementally from a byte offset with absolute positions", async () => { const { handle, dispose } = await config.createHandle(); try { const paths = journalPaths(`conf-seek-${Date.now()}`); await (await handle.process.spawn(journaledCommand(`printf '{"a":1}\\n{"b":2}\\n'`, paths))).wait(); const all = []; for await (const line of readJournal(handle, { paths, fromByte: 0, strategy: "poll", pollIntervalMs: 0, signal: AbortSignal.timeout(READ_BACKSTOP_MS) })) { all.push(line); if (all.length === 3) break; } expect(all.map((l) => l.line)).toEqual([ "{\"a\":1}", "{\"b\":2}", exitSentinelLine(paths, 0) ]); const resumed = []; for await (const line of readJournal(handle, { paths, fromByte: all[0]?.endPosition ?? 0, strategy: "poll", pollIntervalMs: 0, signal: AbortSignal.timeout(READ_BACKSTOP_MS) })) { resumed.push(line); if (resumed.length === 2) break; } expect(resumed.map((l) => l.line)).toEqual(["{\"b\":2}", exitSentinelLine(paths, 0)]); expect(resumed[0]?.endPosition).toBe(all[1]?.endPosition); } finally { await dispose(); } }, CASE_TIMEOUT_MS); itFollows(config, "follows a journal that is still being written, delivering each line before the next is produced", async () => { expect.hasAssertions(); const { handle, dispose } = await config.createHandle(); const gate = noncePath("follow-gate"); try { expectDeclaredStrategy(handle, config); const paths = journalPaths(`conf-follow-${Date.now()}`); const agentCommand = `printf '{"a":1}\\n'; i=0; while [ ! -f ${gate} ]; do i=$((i+1)); if [ $i -gt 30 ]; then printf '{"gate":"never"}\\n'; break; fi; sleep 1; done; printf '{"b":2}\\n'`; handle.process.spawn(journaledCommand(agentCommand, paths)); await waitForJournal(handle, paths); expect((await handle.process.exec(`test -e ${gate}`)).exitCode).not.toBe(0); const seen = []; for await (const line of readJournal(handle, { paths, fromByte: 0, signal: AbortSignal.timeout(READ_BACKSTOP_MS) })) { seen.push(line.line); if (seen.length === 1) await handle.process.exec(`: >> ${gate}`); if (seen.length === 3) break; } expect(seen).toEqual([ "{\"a\":1}", "{\"b\":2}", exitSentinelLine(paths, 0) ]); } finally { await handle.process.exec(`: >> ${gate}`).catch(() => void 0); await dispose(); } }); itFollows(config, "stops a follow read when its signal aborts, without a consumer break", async () => { expect.hasAssertions(); const { handle, dispose } = await config.createHandle(); try { expectDeclaredStrategy(handle, config); const paths = journalPaths(`conf-abort-${Date.now()}`); const agent = await handle.process.spawn(journaledCommand(`printf '{"a":1}\\n'; sleep 30`, paths)); try { await waitForJournal(handle, paths); const seen = []; const stop = new AbortController(); const backstop = AbortSignal.timeout(READ_BACKSTOP_MS); for await (const line of readJournal(handle, { paths, fromByte: 0, signal: AbortSignal.any([stop.signal, backstop]) })) { seen.push(line.line); stop.abort(); } expect({ seen, backstopped: backstop.aborted }).toEqual({ seen: ["{\"a\":1}"], backstopped: false }); } finally { await agent.kill(); } } finally { await dispose(); } }); itFollows(config, "kills the sandbox-side process, not just the host's view of it", async () => { expect.hasAssertions(); const { handle, dispose } = await config.createHandle(); const heartbeat = noncePath("killprobe-hb"); const stop = noncePath("killprobe-stop"); try { expectDeclaredStrategy(handle, config); const probe = await handle.process.spawn(`( i=0; while [ ! -f ${stop} ] && [ $i -lt ${PROBE_MAX_TICKS} ]; do printf '.' >> ${heartbeat}; i=$((i+1)); sleep 1; done ) & wait`); const tickedBeforeKill = await waitForTicks(handle, heartbeat, 2); await probe.kill(); await sleep(KILL_SETTLE_MS); const atSettle = await fileSize(handle, heartbeat); await sleep(HEARTBEAT_QUIET_MS); const afterQuietWindow = await fileSize(handle, heartbeat); expect({ tickedBeforeKill, tickedAfterKill: atSettle === null || afterQuietWindow === null || atSettle !== afterQuietWindow }).toEqual({ tickedBeforeKill: true, tickedAfterKill: false }); } finally { await handle.process.exec(`: >> ${stop}; rm -f ${heartbeat}`).catch(() => void 0); await dispose(); } }); }); } //#endregion export { runJournalConformance, waitForJournal }; //# sourceMappingURL=journal-conformance.js.map