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.

857 lines (856 loc) 32.5 kB
import { decodeJournalRunId, exitSentinelLine, journalCleanupCommand, journalExistsCommand, journalListCommand, journalMtimeListCommand, journalPaths, journalReadCommand, journalStderrReadCommand, journaledCommand, parseJournalMtimeListing } from "../journal.js"; import { chunkFingerprint, createRunScopedIdGen } from "../chunk-identity.js"; import { journalReadStrategy, readJournal } from "../journal-reader.js"; import { readJournalNdjson } from "../runner.js"; import { pruneJournals } from "../journal-sweep.js"; import { probeRunExit, reapDetachedRuns } from "../reap.js"; import { waitForJournal } from "./journal-conformance.js"; import { EventType, InMemoryRunStore } from "@tanstack/ai"; import { InMemoryLockStore } from "@tanstack/ai/locks"; import { randomUUID } from "node:crypto"; import { describe, expect, it } from "vitest"; //#region src/testkit/reaper-conformance.ts /** * Provider conformance for the two unattended sweeps: `pruneJournals` * (`journal-sweep.ts`) and `reapDetachedRuns` (`reap.ts`), against a REAL * sandbox. * * WHY THIS EXISTS SEPARATELY FROM THE UNIT TESTS. Both sweeps are almost * entirely *shell* — `ls -1`, `stat -c '%Y %n'`, `rm -f`, `tail -c -N | base64` * — composed as strings by `journal.ts` and executed by a provider. The unit * suites drive fakes: an `exec` that answers from a scripted table, a * filesystem that is a `Map`. A fake cannot be wrong about `stat` the way a * BusyBox actually is, and on this feature that gap has already produced four * defects that every unit test passed (see `takeover-conformance.ts`'s module * doc for the roster). So the four properties the sweeps rest on are asserted * here through a real shell against real files: * * 1. **A deletion really deletes, and a keep really keeps.** Asserted with * `test -f` through the provider's shell, NEVER `handle.fs.exists`: on * local-process the two resolve `/tmp` differently, so an `fs` probe answers * about a path the journal was never written to (`journal.ts` rule 3). A * sweep that "succeeded" while deleting nothing passes an `fs` probe. * 2. **The age gate's self-witness works on THIS shell.** `journalMtimeListCommand` * passes the directory as `stat`'s own first operand precisely because * BusyBox exits 1 with EMPTY stdout on an unrecognised flag, and an empty * parse read as an empty directory would delete every live run's journal. The * docker provider's image is `alpine:3` — BusyBox 1.37, where `find -newermt` * and `find -printf` are unrecognised — so the docker matrix is the authority * on this case, not the local-process one (on Windows local-process execs * through git-bash, whose `find`/`stat` are GNU-flavoured). * 3. **The reaper never drives a live run.** The `'producing'` case asserts * ABSENCE — nothing appended, `close()` not called, not one `runs.update`, * `detachedSince` intact — because that is the shape of the defect * `probeRunExit` exists to prevent: entering `pipeToRunLog` to "check" writes * a terminal status and drops the run out of `listReclaimable` forever. * 4. **A shell-hostile runId cannot become a shell-hostile command.** The encode * → journal → follow → `ls` → decode → `rm` round trip runs on a runId * containing `/`, a space, `;`, `$( )` and an embedded `touch`, with a canary * file asserted absent. An ENCODING bug here is arbitrary command execution * inside the sandbox, not a cosmetic defect. * * **What the canary proves, exactly, and what it does not.** It detects a * runId reaching the shell WITHOUT `encodeRunId` — that is the mutation it * bites on, and it bites hard: `journaledCommand`, `journalFollowCommand`, * `journalExitProbeCommand`, `journalStderrReadCommand` and * `journalCleanupCommand` all interpolate the path, so the `;touch` executes * and the canary appears. It is BLIND to the loss of `journal.ts`'s * `shellQuote`, the second and independent layer. Measured: with `shellQuote` * reduced to the identity while `encodeRunId` stays, the redirect target * becomes `>> /tmp/…/rp-a_3btouch_20_2ftmp…ndjson` — a single shell word of * `[A-Za-z0-9._/-]`, because the encoder already removed every character a * shell can act on — so no canary fires and NOTHING in this suite, or in any * other real-provider suite, changes. Do not read a green run here as licence * to "simplify" `shellQuote` away. * * The quoting is pinned instead by exact-string unit tests in * `packages/ai-sandbox/tests/journal.test.ts`, which compare each composed * command to a literal containing the quotes. By name, one per command: * `journaledCommand` — "redirects stdout to the journal, stderr to its own * file, and appends the exit sentinel" plus "quotes an adversarial runId so it * cannot inject shell metacharacters"; `journalFollowCommand` — "translates a * 0-based consumed-byte count into tail -c +N (1-based)"; * `journalReadCommand` — "the bounded read drops -f and keeps the base64 * frame, so a poll cannot hang"; `journalExistsCommand` — "probes through the * shell, never through fs.*"; `journalStderrReadCommand` — "reads a BOUNDED * tail of the sidecar, base64-framed, stderr silenced"; * `journalCleanupCommand`, `journalMtimeListCommand` and * `journalExitProbeCommand` — the first `it` under each of their `describe`s. * Those are the tests that go red on a dropped `shellQuote`; keep them exact. * * A provider that cannot satisfy the contract MUST declare `unsupported.reason`. * As in the journal and takeover suites 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. * * EVERY WAIT IN THIS FILE IS BOUNDED, and every journal directory is unique per * case — see {@link caseDir}. This suite DELETES FILES, and * `DEFAULT_JOURNAL_DIR` is a fixed absolute path shared with every other test * and, on local-process, with a developer's real runs. * * Vitest is an OPTIONAL peer dependency: this module is imported only from test * files, which already run under Vitest. */ /** Poll interval handed to providers that cannot follow a growing file. */ var POLL_INTERVAL_MS = 50; /** * Quiescence window for the reaper's first append. Short because the agent in * these cases has provably stopped (the suite waited for its sentinel) — the * gate still runs, it just does not need to wait 5s to observe nothing. */ var FENCE_QUIET_MS = 25; /** * Bound on a real 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). Every use site * pairs it with a `backstopped: false` witness, so a read the CLOCK ended fails * naming this backstop rather than as a downstream transcript mismatch — which * means this number can be raised freely and must never be the thing a case is * tuned against. */ var READ_BACKSTOP_MS = 9e4; /** Long enough that nothing in this suite is ever classified as expired. */ var NEVER_EXPIRES_MS = 36e5; /** * A journal directory nothing else on the machine writes to, created fresh for * EVERY case. * * Not `DEFAULT_JOURNAL_DIR`, and not even one directory per suite. Both sweeps * under test enumerate a whole directory and then DELETE from it, so a shared * directory would let one case's leftovers become another's input — and on * local-process the sandbox shell shares the host's real `/tmp`, where * `DEFAULT_JOURNAL_DIR` holds a developer's actual runs. */ function caseDir() { return `/tmp/tanstack-reaper-conformance-${randomUUID()}`; } /** * Unique per run, and it must be: `journalPaths` derives the filename from the * runId and the journal is append-only, so a reused id appends BEHIND the * previous run's `{"__exit":N}` sentinel and the new run appears to emit nothing * at all (see `journal.ts`). */ function uniqueRunId(label) { return `rp-${label}-${randomUUID()}`; } /** * Single-quote a shell word, POSIX-style — the same rule `journal.ts`'s private * `shellQuote` applies. * * Duplicated rather than exported from production code on purpose: this exists * only for this suite's `rm -rf` teardown, which is not a production operation * and must not become one by growing an export for it. */ function quote(value) { return `'${value.replaceAll("'", `'\\''`)}'`; } /** Remove a case's journal directory and everything in it. Best effort. */ async function removeDir(handle, dir) { try { await handle.process.exec(`rm -rf ${quote(dir)}`); } catch {} } /** * Does `path` exist, according to the SANDBOX'S SHELL? * * `journalExistsCommand` rather than `handle.fs.exists`, for any path and not * just a journal: `journal.ts` rule 3 — on local-process `fs.*` resolves `/tmp` * under the sandbox root while a shell redirect hits the host's real `/tmp`, so * an `fs` probe would answer about a different file and every deletion * assertion in this suite would pass vacuously. */ async function fileExists(handle, path) { return (await handle.process.exec(journalExistsCommand({ journal: path }))).exitCode === 0; } /** Filename as `ls -1` reports it, for a path inside `dir`. */ function basename(dir, path) { return path.slice(dir.length + 1); } /** * A real agent: a shell command printing one NDJSON line per delta, then * exiting. * * `printf '%s\n' a b c` reuses the format for every operand on GNU coreutils * and on BusyBox alike, so this needs no loop. The JSON contains only double * quotes, so it is safe inside the POSIX single-quoted words this builds. */ function emitLines(deltas) { return `printf '%s\\n' ${deltas.map((delta) => `'{"delta":"${delta}"}'`).join(" ")}`; } /** * Run a journaled agent to completion, so the `{"__exit":N}` sentinel is in the * journal by the time this resolves. * * `exec`, not `spawn`: `exec` waits, and a bounded wait is the only kind this * suite allows. (`SpawnHandle.wait()` is also not safe to call after the fact on * every provider — see `journal-conformance.ts`.) */ async function runAgent(handle, paths, deltas) { await handle.process.exec(journaledCommand(emitLines(deltas), paths)); } /** `ls -1` output as a list of names. */ async function listNames(handle, dir) { return (await handle.process.exec(journalListCommand(dir))).stdout.split("\n").map((line) => line.trim()).filter((line) => line !== ""); } /** Decode the base64 frame a bounded journal read produces. */ function decodeJournalRead(stdout) { return Buffer.from(stdout.replace(/\s+/g, ""), "base64").toString("utf8"); } /** * The `stat -c '%Y %n'` listing for `dir`, plus the raw stdout so a case can * assert the WITNESS LINE itself rather than only its parsed consequence. */ async function mtimeListing(handle, dir) { const probe = await handle.process.exec(journalMtimeListCommand(dir)); const parsed = parseJournalMtimeListing(probe.stdout, dir); if (parsed.kind !== "listed") throw new Error(`reaper conformance: the mtime listing for ${dir} came back unavailable — stat -c '%Y %n' produced no witness line. stdout: ${JSON.stringify(probe.stdout)}`); return { stdout: probe.stdout, entries: new Map(parsed.entries.map((entry) => [entry.name, entry.mtimeMs])) }; } /** * Is there a `<seconds> <dir>` line — `stat`'s report on its own first operand? * * That line, not the exit status, is the evidence the mechanism ran: BusyBox * exits 1 both for an EMPTY directory (whose unexpanded glob it cannot stat) and * for an unrecognised flag, and only the witness distinguishes them. */ function hasWitnessLine(stdout, dir) { return stdout.split("\n").some((line) => /^\d+ (?<path>.+)$/.exec(line.trim())?.[1] === dir); } /** Read one file's mtime out of a listing, loudly when it is missing. */ function mtimeOf(entries, name) { const mtimeMs = entries.get(name); if (mtimeMs === void 0) throw new Error(`reaper conformance: ${name} has no mtime in the stat listing, so the age gate cannot be exercised`); return mtimeMs; } function conformanceLog() { const entries = []; let closes = 0; return { log: { resumeFrom: () => null, append: (chunks) => Promise.resolve(chunks.map((chunk) => { const offset = `reap:${entries.length}`; entries.push({ offset, chunk }); return offset; })), read: () => (async function* empty() {})(), close: () => { closes += 1; return Promise.resolve(); }, snapshot: () => Promise.resolve(entries.map((entry) => ({ ...entry }))) }, stored: () => entries.map((entry) => entry.chunk), closes: () => closes }; } function countingRunStore(inner) { let updates = 0; return { runs: { createOrResume: (...args) => inner.createOrResume(...args), update: (...args) => { updates += 1; return inner.update(...args); }, get: (...args) => inner.get(...args), listByThread: (...args) => inner.listByThread(...args), listReclaimable: (...args) => inner.listReclaimable(...args), findActiveRun: (...args) => inner.findActiveRun(...args) }, updates: () => updates }; } /** The event a journal line translates into. `timestamp` is excluded from `chunkFingerprint`. */ function contentChunk(messageId, delta) { return { type: EventType.TEXT_MESSAGE_CONTENT, messageId, delta, timestamp: Date.now() }; } /** * Narrow one parsed journal line into its chunk. * * Fields are validated and the chunk REBUILT from them rather than asserted into * shape: a cast would let a provider that mangles the bytes reach * `chunkFingerprint` as a structurally invalid chunk and fail somewhere * unrelated. */ function toChunk(runId, messageId, value) { if (typeof value !== "object" || value === null || !("delta" in value)) throw new Error(`reaper conformance: run ${runId} journal line is not an agent event: ${JSON.stringify(value)}`); const delta = value.delta; if (typeof delta !== "string") throw new Error(`reaper conformance: run ${runId} journal line has a non-string delta: ${JSON.stringify(value)}`); return contentChunk(messageId, delta); } /** Deterministic translator: re-reading the journal reproduces the same chunks. */ async function* translate(runId, lines) { const messageId = createRunScopedIdGen(runId)(); for await (const line of lines) yield toChunk(runId, messageId, line); } /** A comparable transcript: each chunk reduced to its fingerprint. */ function transcript(chunks) { return chunks.map(chunkFingerprint); } /** The chunks a run over `deltas` must deliver, exactly once and in order. */ function expectedTranscript(runId, deltas) { const messageId = createRunScopedIdGen(runId)(); return deltas.map((delta) => contentChunk(messageId, delta)); } /** * The reaper's `drive`: read the run's journal from byte 0 and translate it. * * The read is bounded independently of `signal` so a journal that stops growing * fails the case instead of hanging CI. * * Returns the drive alongside `backstopped()`, the causal witness for * {@link READ_BACKSTOP_MS}: the case must assert it is `false` before its * transcript assertions, so a read the CLOCK ended fails naming the backstop * instead of as a truncated-transcript diff. */ function driveFromJournal(handle, dir) { const backstops = []; return { drive: ({ runId, signal }) => { const backstop = AbortSignal.timeout(READ_BACKSTOP_MS); backstops.push(backstop); return translate(runId, readJournalNdjson(handle, { signal: AbortSignal.any([signal, backstop]), journal: { runId, dir, pollIntervalMs: POLL_INTERVAL_MS } })); }, backstopped: () => backstops.some((s) => s.aborted) }; } /** A `'running'`, DETACHED record — the shape `listReclaimable` selects on. */ async function detachedRun(store, runId, threadId, detachedSince) { await store.createOrResume({ runId, threadId, startedAt: Date.now() }); await store.update(runId, { detachedSince }); } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Assert `createHandle` satisfies the sweep conformance contract. Each `it` gets * a fresh sandbox via `createHandle`/`dispose`, a fresh journal directory, and * unique runIds, so no case can observe another's files. */ function runReaperConformance(config) { describe(`reaper conformance — ${config.name}`, () => { if (config.unsupported) { it.skip(`unsupported: ${config.unsupported.reason}`, () => { expect(true).toBe(true); }); return; } it("deletes a terminal run's journal AND its .err sidecar, while a running run's journal survives the same sweep", { timeout: 6e4 }, async () => { const { handle, dispose } = await config.createHandle(); const dir = caseDir(); const terminalId = uniqueRunId("terminal"); const liveId = uniqueRunId("live"); const terminal = journalPaths(terminalId, dir); const live = journalPaths(liveId, dir); try { await runAgent(handle, terminal, ["1"]); await runAgent(handle, live, ["1"]); expect({ terminalJournal: await fileExists(handle, terminal.journal), terminalSidecar: await fileExists(handle, terminal.stderr), liveJournal: await fileExists(handle, live.journal) }).toEqual({ terminalJournal: true, terminalSidecar: true, liveJournal: true }); const runs = new InMemoryRunStore(); await runs.createOrResume({ runId: terminalId, threadId: `${terminalId}-t`, startedAt: Date.now() }); await runs.update(terminalId, { status: "completed", finishedAt: Date.now() }); await runs.createOrResume({ runId: liveId, threadId: `${liveId}-t`, startedAt: Date.now() }); const result = await pruneJournals({ handle, runs, dir }); expect(result.deleted).toEqual([terminalId]); expect(result.failures).toEqual([]); expect(result.kept).toEqual([{ runId: liveId, names: expect.any(Array), reason: "non-terminal" }]); expect({ terminalJournal: await fileExists(handle, terminal.journal), terminalSidecar: await fileExists(handle, terminal.stderr), liveJournal: await fileExists(handle, live.journal), liveSidecar: await fileExists(handle, live.stderr) }).toEqual({ terminalJournal: false, terminalSidecar: false, liveJournal: true, liveSidecar: true }); } finally { await removeDir(handle, dir); await dispose(); } }); it("sweeps the same terminal run twice without a failure, and rm -f of an already-absent journal exits 0", { timeout: 6e4 }, async () => { const { handle, dispose } = await config.createHandle(); const dir = caseDir(); const runId = uniqueRunId("twice"); const paths = journalPaths(runId, dir); try { await runAgent(handle, paths, ["1"]); const runs = new InMemoryRunStore(); await runs.createOrResume({ runId, threadId: `${runId}-t`, startedAt: Date.now() }); await runs.update(runId, { status: "completed", finishedAt: Date.now() }); const first = await pruneJournals({ handle, runs, dir }); expect(first.deleted).toEqual([runId]); const second = await pruneJournals({ handle, runs, dir }); expect({ listed: second.listed, runIds: second.runIds, deleted: second.deleted, kept: second.kept, failures: second.failures }).toEqual({ listed: 0, runIds: 0, deleted: [], kept: [], failures: [] }); const rerun = await handle.process.exec(journalCleanupCommand(paths)); expect(rerun.exitCode).toBe(0); } finally { await removeDir(handle, dir); await dispose(); } }); it("leaves a filename it cannot decode alone, while still sweeping the terminal run beside it", { timeout: 6e4 }, async () => { const { handle, dispose } = await config.createHandle(); const dir = caseDir(); const runId = uniqueRunId("undecodable"); const paths = journalPaths(runId, dir); const strayName = "reaper-conformance-stray_1.ndjson"; const strayPath = `${dir}/${strayName}`; try { await runAgent(handle, paths, ["1"]); await handle.process.exec(`printf 'not a journal\\n' >> ${quote(strayPath)}`); expect(await fileExists(handle, strayPath)).toBe(true); expect(decodeJournalRunId(strayName).kind).toBe("malformed"); const runs = new InMemoryRunStore(); await runs.createOrResume({ runId, threadId: `${runId}-t`, startedAt: Date.now() }); await runs.update(runId, { status: "completed", finishedAt: Date.now() }); const result = await pruneJournals({ handle, runs, dir }); expect(result.deleted).toEqual([runId]); expect(result.kept).toEqual([{ names: [strayName], reason: "undecodable-name" }]); expect(result.failures).toEqual([]); expect({ strayKept: await fileExists(handle, strayPath), journalDeleted: !await fileExists(handle, paths.journal) }).toEqual({ strayKept: true, journalDeleted: true }); } finally { await removeDir(handle, dir); await dispose(); } }); const mtimeSkip = config.mtimeListUnsupported; const itMtime = (title, timeout, fn) => it(mtimeSkip ? `${title} (unsupported: ${mtimeSkip.reason})` : title, { timeout, skip: Boolean(mtimeSkip) }, fn); itMtime("emits stat's self-witness line for a populated directory, so the age gate is usable", 6e4, async () => { const { handle, dispose } = await config.createHandle(); const dir = caseDir(); const runId = uniqueRunId("witness"); const paths = journalPaths(runId, dir); try { await runAgent(handle, paths, ["1"]); const listing = await mtimeListing(handle, dir); expect(hasWitnessLine(listing.stdout, dir)).toBe(true); expect([...listing.entries.keys()].sort()).toEqual([basename(dir, paths.journal), basename(dir, paths.stderr)].sort()); for (const mtimeMs of listing.entries.values()) expect(Math.abs(Date.now() - mtimeMs)).toBeLessThan(12e4); } finally { await removeDir(handle, dir); await dispose(); } }); itMtime("reports an EMPTY journal directory as witness-only rather than unavailable", 6e4, async () => { const { handle, dispose } = await config.createHandle(); const dir = caseDir(); try { await handle.process.exec(`mkdir -p ${quote(dir)}`); const listing = await mtimeListing(handle, dir); expect(hasWitnessLine(listing.stdout, dir)).toBe(true); expect([...listing.entries.keys()]).toEqual([]); const result = await pruneJournals({ handle, runs: new InMemoryRunStore(), dir }); expect({ listed: result.listed, ageGate: result.ageGate, deleted: result.deleted, failures: result.failures }).toEqual({ listed: 0, ageGate: "listed", deleted: [], failures: [] }); } finally { await removeDir(handle, dir); await dispose(); } }); itMtime("keeps an orphan younger than orphanTtlMs and sweeps the older one, in the same pass", 12e4, async () => { const { handle, dispose } = await config.createHandle(); const dir = caseDir(); const olderId = uniqueRunId("older"); const newerId = uniqueRunId("newer"); const older = journalPaths(olderId, dir); const newer = journalPaths(newerId, dir); try { await runAgent(handle, older, ["1"]); await sleep(2500); await runAgent(handle, newer, ["1"]); const listing = await mtimeListing(handle, dir); const newestOf = (paths) => Math.max(mtimeOf(listing.entries, basename(dir, paths.journal)), mtimeOf(listing.entries, basename(dir, paths.stderr))); const olderMtime = newestOf(older); const newerMtime = newestOf(newer); expect(newerMtime - olderMtime).toBeGreaterThanOrEqual(1e3); const now = Date.now(); const cutoff = olderMtime + Math.floor((newerMtime - olderMtime) / 2); const result = await pruneJournals({ handle, runs: new InMemoryRunStore(), dir, now, orphanTtlMs: now - cutoff }); expect(result.ageGate).toBe("listed"); expect(result.deleted).toEqual([olderId]); expect(result.kept).toEqual([{ runId: newerId, names: expect.any(Array), reason: "orphan-too-recent" }]); expect(result.failures).toEqual([]); expect({ olderJournal: await fileExists(handle, older.journal), olderSidecar: await fileExists(handle, older.stderr), newerJournal: await fileExists(handle, newer.journal), newerSidecar: await fileExists(handle, newer.stderr) }).toEqual({ olderJournal: false, olderSidecar: false, newerJournal: true, newerSidecar: true }); } finally { await removeDir(handle, dir); await dispose(); } }); it("finalizes a detached run whose agent reached its sentinel, and its transcript lands", { timeout: 12e4 }, async () => { const { handle, dispose } = await config.createHandle(); const dir = caseDir(); const runId = uniqueRunId("finalize"); const threadId = `${runId}-t`; const deltas = [ "1", "2", "3" ]; const paths = journalPaths(runId, dir); try { const runs = new InMemoryRunStore(); const detachedSince = Date.now(); await detachedRun(runs, runId, threadId, detachedSince); await runAgent(handle, paths, deltas); expect(await probeRunExit({ handle, runId, dir })).toEqual({ state: "finished", exitCode: 0 }); const log = conformanceLog(); const journalDrive = driveFromJournal(handle, dir); const result = await reapDetachedRuns({ runs, locks: new InMemoryLockStore(), durability: () => log.log, hasFinished: (record) => probeRunExit({ handle, runId: record.runId, dir }), drive: journalDrive.drive, now: Date.now(), detachedRunTtlMs: NEVER_EXPIRES_MS, fenceQuietMs: FENCE_QUIET_MS }); expect({ backstopped: journalDrive.backstopped() }).toEqual({ backstopped: false }); expect({ considered: result.considered, probed: result.probed, finalized: result.outcomes.finalized }).toEqual({ considered: 1, probed: 1, finalized: 1 }); expect(result.runs).toEqual([{ runId, outcome: "finalized", status: "completed", exitCode: 0 }]); expect(transcript(log.stored())).toEqual(transcript(expectedTranscript(runId, deltas))); const record = await runs.get(runId); expect(record?.status).toBe("completed"); expect(record?.detachedSince).toBe(detachedSince); } finally { await removeDir(handle, dir); await dispose(); } }); it("reports a still-producing detached run as producing and leaves it completely untouched", { timeout: 12e4 }, async () => { const { handle, dispose } = await config.createHandle(); const dir = caseDir(); const runId = uniqueRunId("producing"); const threadId = `${runId}-t`; const paths = journalPaths(runId, dir); const store = countingRunStore(new InMemoryRunStore()); const agent = await handle.process.spawn(journaledCommand(`${emitLines(["1"])}; sleep 30`, paths)); try { const detachedSince = Date.now(); await detachedRun(store.runs, runId, threadId, detachedSince); await waitForJournal(handle, paths); const text = decodeJournalRead((await handle.process.exec(journalReadCommand(paths, 0))).stdout); expect(text).toContain("{\"delta\":\"1\"}"); expect(text).not.toContain("__exit"); expect(await probeRunExit({ handle, runId, dir })).toEqual({ state: "producing" }); const log = conformanceLog(); let driveCalled = false; let durabilityCalls = 0; const result = await reapDetachedRuns({ runs: store.runs, locks: new InMemoryLockStore(), durability: () => { durabilityCalls += 1; return log.log; }, hasFinished: (record) => probeRunExit({ handle, runId: record.runId, dir }), drive: () => { driveCalled = true; return (async function* never() {})(); }, now: Date.now(), detachedRunTtlMs: NEVER_EXPIRES_MS, fenceQuietMs: FENCE_QUIET_MS }); expect(result.runs).toEqual([{ runId, outcome: "producing" }]); expect({ considered: result.considered, probed: result.probed, producing: result.outcomes.producing, finalized: result.outcomes.finalized, expired: result.outcomes.expired, failed: result.outcomes.failed }).toEqual({ considered: 1, probed: 1, producing: 1, finalized: 0, expired: 0, failed: 0 }); const record = await store.runs.get(runId); expect({ driveCalled, durabilityCalls, appended: log.stored().length, closes: log.closes(), updatesAfterSetup: store.updates() - 1, status: record?.status, detachedSince: record?.detachedSince, driverEpoch: record?.driverEpoch }).toEqual({ driveCalled: false, durabilityCalls: 0, appended: 0, closes: 0, updatesAfterSetup: 0, status: "running", detachedSince, driverEpoch: void 0 }); } finally { try { await agent.kill(); } catch {} await removeDir(handle, dir); await dispose(); } }); it("round-trips a shell-hostile runId through encode, journal, follow, sidecar read, list, decode and delete without executing any of it" + (config.followUnsupported === void 0 ? "" : ` (follow read omitted: ${config.followUnsupported.reason})`), { timeout: 12e4 }, async () => { const { handle, dispose } = await config.createHandle(); const dir = caseDir(); const nonce = randomUUID().slice(0, 8); const canary = `/tmp/rp-pwn-${nonce}`; const runId = `rp-a;touch ${canary};b c/d$(x)-${nonce}`; const threadId = `${runId}-t`; const paths = journalPaths(runId, dir); const journalName = basename(dir, paths.journal); try { await handle.process.exec(journaledCommand(`${emitLines(["1"])}; printf 'boom\\n' 1>&2`, paths)); expect(await fileExists(handle, canary)).toBe(false); expect(await probeRunExit({ handle, runId, dir })).toEqual({ state: "finished", exitCode: 0 }); expect(journalName).toMatch(/^[A-Za-z0-9._-]+\.ndjson$/); expect(paths.journal.startsWith(`${dir}/`)).toBe(true); expect(journalReadStrategy(handle)).toBe(config.followUnsupported === void 0 ? "follow" : "poll"); if (config.followUnsupported === void 0) { const followed = []; const backstop = AbortSignal.timeout(READ_BACKSTOP_MS); for await (const line of readJournal(handle, { paths, fromByte: 0, strategy: "follow", signal: backstop })) { followed.push(line.line); if (line.line.includes("__exit")) break; } expect({ backstopped: backstop.aborted }).toEqual({ backstopped: false }); expect(followed).toEqual(["{\"delta\":\"1\"}", exitSentinelLine(paths, 0)]); expect(await fileExists(handle, canary)).toBe(false); } const sidecar = await handle.process.exec(journalStderrReadCommand(paths)); expect(decodeJournalRead(sidecar.stdout)).toBe("boom\n"); expect(await fileExists(handle, canary)).toBe(false); const names = await listNames(handle, dir); expect(names.sort()).toEqual([journalName, basename(dir, paths.stderr)].sort()); expect(decodeJournalRunId(journalName)).toEqual({ kind: "runId", runId }); const runs = new InMemoryRunStore(); await runs.createOrResume({ runId, threadId, startedAt: Date.now() }); await runs.update(runId, { status: "completed", finishedAt: Date.now() }); const result = await pruneJournals({ handle, runs, dir }); expect(result.deleted).toEqual([runId]); expect(result.failures).toEqual([]); expect({ journalDeleted: !await fileExists(handle, paths.journal), sidecarDeleted: !await fileExists(handle, paths.stderr), canaryAbsent: !await fileExists(handle, canary) }).toEqual({ journalDeleted: true, sidecarDeleted: true, canaryAbsent: true }); } finally { await handle.process.exec(`rm -f ${quote(canary)}`).catch(() => void 0); await removeDir(handle, dir); await dispose(); } }); }); } //#endregion export { runReaperConformance }; //# sourceMappingURL=reaper-conformance.js.map