@tanstack/ai-sandbox 0.2.4 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/agents-file.js +53 -34
- package/dist/esm/agents-file.js.map +1 -1
- package/dist/esm/align.d.ts +121 -0
- package/dist/esm/align.js +197 -0
- package/dist/esm/align.js.map +1 -0
- package/dist/esm/approvals.js +63 -29
- package/dist/esm/approvals.js.map +1 -1
- package/dist/esm/attach-preflight.d.ts +85 -0
- package/dist/esm/attach-preflight.js +189 -0
- package/dist/esm/attach-preflight.js.map +1 -0
- package/dist/esm/bootstrap.js +103 -117
- package/dist/esm/bootstrap.js.map +1 -1
- package/dist/esm/bridge-events.js +96 -71
- package/dist/esm/bridge-events.js.map +1 -1
- package/dist/esm/capabilities.d.ts +0 -5
- package/dist/esm/capabilities.js +32 -28
- package/dist/esm/capabilities.js.map +1 -1
- package/dist/esm/chunk-identity.d.ts +52 -0
- package/dist/esm/chunk-identity.js +102 -0
- package/dist/esm/chunk-identity.js.map +1 -0
- package/dist/esm/claim.d.ts +187 -0
- package/dist/esm/claim.js +349 -0
- package/dist/esm/claim.js.map +1 -0
- package/dist/esm/contracts.d.ts +13 -0
- package/dist/esm/driver.d.ts +83 -0
- package/dist/esm/driver.js +138 -0
- package/dist/esm/driver.js.map +1 -0
- package/dist/esm/durability.d.ts +263 -0
- package/dist/esm/durability.js +230 -0
- package/dist/esm/durability.js.map +1 -0
- package/dist/esm/errors.js +28 -24
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/file-diff.js +151 -135
- package/dist/esm/file-diff.js.map +1 -1
- package/dist/esm/git-exec.js +51 -62
- package/dist/esm/git-exec.js.map +1 -1
- package/dist/esm/harness-cwd.js +24 -19
- package/dist/esm/harness-cwd.js.map +1 -1
- package/dist/esm/index.d.ts +30 -8
- package/dist/esm/index.js +23 -91
- package/dist/esm/instance-store.d.ts +88 -0
- package/dist/esm/instance-store.js +67 -0
- package/dist/esm/instance-store.js.map +1 -0
- package/dist/esm/journal-bytes.d.ts +67 -0
- package/dist/esm/journal-bytes.js +110 -0
- package/dist/esm/journal-bytes.js.map +1 -0
- package/dist/esm/journal-reader.d.ts +66 -0
- package/dist/esm/journal-reader.js +228 -0
- package/dist/esm/journal-reader.js.map +1 -0
- package/dist/esm/journal-sweep.d.ts +113 -0
- package/dist/esm/journal-sweep.js +309 -0
- package/dist/esm/journal-sweep.js.map +1 -0
- package/dist/esm/journal.d.ts +542 -0
- package/dist/esm/journal.js +679 -0
- package/dist/esm/journal.js.map +1 -0
- package/dist/esm/key.js +36 -33
- package/dist/esm/key.js.map +1 -1
- package/dist/esm/middleware.d.ts +50 -2
- package/dist/esm/middleware.js +335 -208
- package/dist/esm/middleware.js.map +1 -1
- package/dist/esm/ngrok.js +75 -49
- package/dist/esm/ngrok.js.map +1 -1
- package/dist/esm/policy.js +43 -34
- package/dist/esm/policy.js.map +1 -1
- package/dist/esm/projection.js +16 -8
- package/dist/esm/projection.js.map +1 -1
- package/dist/esm/reap.d.ts +238 -0
- package/dist/esm/reap.js +355 -0
- package/dist/esm/reap.js.map +1 -0
- package/dist/esm/reclaim.d.ts +84 -0
- package/dist/esm/reclaim.js +106 -0
- package/dist/esm/reclaim.js.map +1 -0
- package/dist/esm/remote-tools.js +73 -62
- package/dist/esm/remote-tools.js.map +1 -1
- package/dist/esm/run.d.ts +93 -25
- package/dist/esm/run.js +274 -79
- package/dist/esm/run.js.map +1 -1
- package/dist/esm/runner.d.ts +119 -2
- package/dist/esm/runner.js +270 -51
- package/dist/esm/runner.js.map +1 -1
- package/dist/esm/sandbox.d.ts +3 -2
- package/dist/esm/sandbox.js +139 -123
- package/dist/esm/sandbox.js.map +1 -1
- package/dist/esm/secrets.js +39 -47
- package/dist/esm/secrets.js.map +1 -1
- package/dist/esm/setup-plan.js +22 -14
- package/dist/esm/setup-plan.js.map +1 -1
- package/dist/esm/shell.d.ts +8 -0
- package/dist/esm/shell.js +197 -158
- package/dist/esm/shell.js.map +1 -1
- package/dist/esm/testkit/conformance.d.ts +16 -0
- package/dist/esm/testkit/conformance.js +97 -0
- package/dist/esm/testkit/conformance.js.map +1 -0
- package/dist/esm/testkit/durable-run-fields-conformance.d.ts +4 -0
- package/dist/esm/testkit/durable-run-fields-conformance.js +95 -0
- package/dist/esm/testkit/durable-run-fields-conformance.js.map +1 -0
- package/dist/esm/testkit/journal-conformance.d.ts +51 -0
- package/dist/esm/testkit/journal-conformance.js +378 -0
- package/dist/esm/testkit/journal-conformance.js.map +1 -0
- package/dist/esm/testkit/reaper-conformance.d.ts +37 -0
- package/dist/esm/testkit/reaper-conformance.js +847 -0
- package/dist/esm/testkit/reaper-conformance.js.map +1 -0
- package/dist/esm/testkit/shell-spawn.d.ts +2 -0
- package/dist/esm/testkit/shell-spawn.js +60 -0
- package/dist/esm/testkit/shell-spawn.js.map +1 -0
- package/dist/esm/testkit/takeover-conformance.d.ts +24 -0
- package/dist/esm/testkit/takeover-conformance.js +685 -0
- package/dist/esm/testkit/takeover-conformance.js.map +1 -0
- package/dist/esm/tool-bridge.js +227 -180
- package/dist/esm/tool-bridge.js.map +1 -1
- package/dist/esm/tool-history.d.ts +62 -0
- package/dist/esm/tool-history.js +171 -0
- package/dist/esm/tool-history.js.map +1 -0
- package/dist/esm/watch.js +310 -236
- package/dist/esm/watch.js.map +1 -1
- package/dist/esm/workspace.d.ts +1 -1
- package/dist/esm/workspace.js +49 -28
- package/dist/esm/workspace.js.map +1 -1
- package/package.json +16 -6
- package/skills/ai-sandbox/SKILL.md +658 -20
- package/src/align.ts +297 -0
- package/src/attach-preflight.ts +292 -0
- package/src/capabilities.ts +4 -13
- package/src/chunk-identity.ts +154 -0
- package/src/claim.ts +479 -0
- package/src/contracts.ts +13 -0
- package/src/driver.ts +205 -0
- package/src/durability.ts +380 -0
- package/src/index.ts +212 -27
- package/src/instance-store.ts +122 -0
- package/src/journal-bytes.ts +136 -0
- package/src/journal-reader.ts +359 -0
- package/src/journal-sweep.ts +406 -0
- package/src/journal.ts +875 -0
- package/src/middleware.ts +470 -30
- package/src/reap.ts +723 -0
- package/src/reclaim.ts +191 -0
- package/src/run.ts +365 -75
- package/src/runner.ts +347 -3
- package/src/sandbox.ts +38 -8
- package/src/shell.ts +106 -38
- package/src/testkit/conformance.ts +117 -0
- package/src/testkit/durable-run-fields-conformance.ts +147 -0
- package/src/testkit/journal-conformance.ts +676 -0
- package/src/testkit/reaper-conformance.ts +1201 -0
- package/src/testkit/shell-spawn.ts +67 -0
- package/src/testkit/takeover-conformance.ts +1040 -0
- package/src/tool-history.ts +245 -0
- package/src/workspace.ts +1 -1
- package/dist/esm/index.js.map +0 -1
- package/dist/esm/run-log.d.ts +0 -81
- package/dist/esm/run-log.js +0 -107
- package/dist/esm/run-log.js.map +0 -1
- package/dist/esm/store.d.ts +0 -53
- package/dist/esm/store.js +0 -34
- package/dist/esm/store.js.map +0 -1
- package/src/run-log.ts +0 -224
- package/src/store.ts +0 -83
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reaper-conformance.js","names":[],"sources":["../../../src/testkit/reaper-conformance.ts"],"sourcesContent":["/**\n * Provider conformance for the two unattended sweeps: `pruneJournals`\n * (`journal-sweep.ts`) and `reapDetachedRuns` (`reap.ts`), against a REAL\n * sandbox.\n *\n * WHY THIS EXISTS SEPARATELY FROM THE UNIT TESTS. Both sweeps are almost\n * entirely *shell* — `ls -1`, `stat -c '%Y %n'`, `rm -f`, `tail -c -N | base64`\n * — composed as strings by `journal.ts` and executed by a provider. The unit\n * suites drive fakes: an `exec` that answers from a scripted table, a\n * filesystem that is a `Map`. A fake cannot be wrong about `stat` the way a\n * BusyBox actually is, and on this feature that gap has already produced four\n * defects that every unit test passed (see `takeover-conformance.ts`'s module\n * doc for the roster). So the four properties the sweeps rest on are asserted\n * here through a real shell against real files:\n *\n * 1. **A deletion really deletes, and a keep really keeps.** Asserted with\n * `test -f` through the provider's shell, NEVER `handle.fs.exists`: on\n * local-process the two resolve `/tmp` differently, so an `fs` probe answers\n * about a path the journal was never written to (`journal.ts` rule 3). A\n * sweep that \"succeeded\" while deleting nothing passes an `fs` probe.\n * 2. **The age gate's self-witness works on THIS shell.** `journalMtimeListCommand`\n * passes the directory as `stat`'s own first operand precisely because\n * BusyBox exits 1 with EMPTY stdout on an unrecognised flag, and an empty\n * parse read as an empty directory would delete every live run's journal. The\n * docker provider's image is `alpine:3` — BusyBox 1.37, where `find -newermt`\n * and `find -printf` are unrecognised — so the docker matrix is the authority\n * on this case, not the local-process one (on Windows local-process execs\n * through git-bash, whose `find`/`stat` are GNU-flavoured).\n * 3. **The reaper never drives a live run.** The `'producing'` case asserts\n * ABSENCE — nothing appended, `close()` not called, not one `runs.update`,\n * `detachedSince` intact — because that is the shape of the defect\n * `probeRunExit` exists to prevent: entering `pipeToRunLog` to \"check\" writes\n * a terminal status and drops the run out of `listReclaimable` forever.\n * 4. **A shell-hostile runId cannot become a shell-hostile command.** The encode\n * → journal → follow → `ls` → decode → `rm` round trip runs on a runId\n * containing `/`, a space, `;`, `$( )` and an embedded `touch`, with a canary\n * file asserted absent. An ENCODING bug here is arbitrary command execution\n * inside the sandbox, not a cosmetic defect.\n *\n * **What the canary proves, exactly, and what it does not.** It detects a\n * runId reaching the shell WITHOUT `encodeRunId` — that is the mutation it\n * bites on, and it bites hard: `journaledCommand`, `journalFollowCommand`,\n * `journalExitProbeCommand`, `journalStderrReadCommand` and\n * `journalCleanupCommand` all interpolate the path, so the `;touch` executes\n * and the canary appears. It is BLIND to the loss of `journal.ts`'s\n * `shellQuote`, the second and independent layer. Measured: with `shellQuote`\n * reduced to the identity while `encodeRunId` stays, the redirect target\n * becomes `>> /tmp/…/rp-a_3btouch_20_2ftmp…ndjson` — a single shell word of\n * `[A-Za-z0-9._/-]`, because the encoder already removed every character a\n * shell can act on — so no canary fires and NOTHING in this suite, or in any\n * other real-provider suite, changes. Do not read a green run here as licence\n * to \"simplify\" `shellQuote` away.\n *\n * The quoting is pinned instead by exact-string unit tests in\n * `packages/ai-sandbox/tests/journal.test.ts`, which compare each composed\n * command to a literal containing the quotes. By name, one per command:\n * `journaledCommand` — \"redirects stdout to the journal, stderr to its own\n * file, and appends the exit sentinel\" plus \"quotes an adversarial runId so it\n * cannot inject shell metacharacters\"; `journalFollowCommand` — \"translates a\n * 0-based consumed-byte count into tail -c +N (1-based)\";\n * `journalReadCommand` — \"the bounded read drops -f and keeps the base64\n * frame, so a poll cannot hang\"; `journalExistsCommand` — \"probes through the\n * shell, never through fs.*\"; `journalStderrReadCommand` — \"reads a BOUNDED\n * tail of the sidecar, base64-framed, stderr silenced\";\n * `journalCleanupCommand`, `journalMtimeListCommand` and\n * `journalExitProbeCommand` — the first `it` under each of their `describe`s.\n * Those are the tests that go red on a dropped `shellQuote`; keep them exact.\n *\n * A provider that cannot satisfy the contract MUST declare `unsupported.reason`.\n * As in the journal and takeover suites there is deliberately no silent-skip\n * path: a conformance case that quietly returns prints as a pass, which is how\n * an unimplemented capability ships green.\n *\n * EVERY WAIT IN THIS FILE IS BOUNDED, and every journal directory is unique per\n * case — see {@link caseDir}. This suite DELETES FILES, and\n * `DEFAULT_JOURNAL_DIR` is a fixed absolute path shared with every other test\n * and, on local-process, with a developer's real runs.\n *\n * Vitest is an OPTIONAL peer dependency: this module is imported only from test\n * files, which already run under Vitest.\n */\nimport { randomUUID } from 'node:crypto'\nimport { describe, expect, it } from 'vitest'\nimport { EventType, InMemoryRunStore } from '@tanstack/ai'\nimport { InMemoryLockStore } from '@tanstack/ai/locks'\nimport {\n EXIT_SENTINEL_KEY,\n decodeJournalRunId,\n exitSentinelLine,\n journalCleanupCommand,\n journalExistsCommand,\n journalListCommand,\n journalMtimeListCommand,\n journalPaths,\n journalReadCommand,\n journalStderrReadCommand,\n journaledCommand,\n parseJournalMtimeListing,\n} from '../journal'\nimport { journalReadStrategy, readJournal } from '../journal-reader'\nimport { pruneJournals } from '../journal-sweep'\nimport { probeRunExit, reapDetachedRuns } from '../reap'\nimport { readJournalNdjson } from '../runner'\nimport { chunkFingerprint, createRunScopedIdGen } from '../chunk-identity'\nimport { waitForJournal } from './journal-conformance'\nimport type { JournalPaths } from '../journal'\nimport type { SandboxHandle } from '../contracts'\nimport type { RunStore, StreamChunk, StreamDurability } from '@tanstack/ai'\n\nexport interface ReaperConformanceConfig {\n /** Provider name, used in the describe title. */\n name: string\n /** Create a live sandbox plus its teardown. */\n createHandle: () => Promise<{\n handle: SandboxHandle\n dispose: () => Promise<void>\n }>\n /**\n * Declare that this provider cannot support the sweeps, with the reason.\n * Registers a skipped case whose title carries the reason — a NAMED skip,\n * visible in the reporter. Omit it and the suite runs.\n */\n unsupported?: { reason: string }\n /**\n * Declare that this provider's reads take the POLL strategy rather than the\n * FOLLOW one — i.e. `journalReadStrategy` answers `'poll'` for its handles.\n *\n * Only the FOLLOW half of the shell-hostile-runId case depends on it, so this\n * does not skip a case; it names itself in that case's title and the follow\n * read is omitted. The declaration is checked against the live handle there, in\n * both directions, so it cannot quietly remove coverage from a provider that\n * can in fact follow.\n */\n followUnsupported?: { reason: string }\n}\n\n/** Poll interval handed to providers that cannot follow a growing file. */\nconst POLL_INTERVAL_MS = 50\n\n/**\n * Quiescence window for the reaper's first append. Short because the agent in\n * these cases has provably stopped (the suite waited for its sentinel) — the\n * gate still runs, it just does not need to wait 5s to observe nothing.\n */\nconst FENCE_QUIET_MS = 25\n\n/**\n * Bound on a real journal read, so a reader that delivers nothing FAILS instead\n * of parking CI.\n *\n * Never an assertion, and deliberately far above anything a healthy read needs\n * (measured: 10–18s for the follow cases on both providers). Every use site\n * pairs it with a `backstopped: false` witness, so a read the CLOCK ended fails\n * naming this backstop rather than as a downstream transcript mismatch — which\n * means this number can be raised freely and must never be the thing a case is\n * tuned against.\n */\nconst READ_BACKSTOP_MS = 90_000\n\n/** Long enough that nothing in this suite is ever classified as expired. */\nconst NEVER_EXPIRES_MS = 60 * 60 * 1000\n\n/**\n * A journal directory nothing else on the machine writes to, created fresh for\n * EVERY case.\n *\n * Not `DEFAULT_JOURNAL_DIR`, and not even one directory per suite. Both sweeps\n * under test enumerate a whole directory and then DELETE from it, so a shared\n * directory would let one case's leftovers become another's input — and on\n * local-process the sandbox shell shares the host's real `/tmp`, where\n * `DEFAULT_JOURNAL_DIR` holds a developer's actual runs.\n */\nfunction caseDir(): string {\n return `/tmp/tanstack-reaper-conformance-${randomUUID()}`\n}\n\n/**\n * Unique per run, and it must be: `journalPaths` derives the filename from the\n * runId and the journal is append-only, so a reused id appends BEHIND the\n * previous run's `{\"__exit\":N}` sentinel and the new run appears to emit nothing\n * at all (see `journal.ts`).\n */\nfunction uniqueRunId(label: string): string {\n return `rp-${label}-${randomUUID()}`\n}\n\n/**\n * Single-quote a shell word, POSIX-style — the same rule `journal.ts`'s private\n * `shellQuote` applies.\n *\n * Duplicated rather than exported from production code on purpose: this exists\n * only for this suite's `rm -rf` teardown, which is not a production operation\n * and must not become one by growing an export for it.\n */\nfunction quote(value: string): string {\n return `'${value.replaceAll(\"'\", `'\\\\''`)}'`\n}\n\n/** Remove a case's journal directory and everything in it. Best effort. */\nasync function removeDir(handle: SandboxHandle, dir: string): Promise<void> {\n try {\n await handle.process.exec(`rm -rf ${quote(dir)}`)\n } catch {\n // The sandbox may already be gone, and on docker it is about to be. Nothing\n // under test depends on the directory being absent afterwards — the cases\n // that DO assert deletion assert it directly, per file.\n }\n}\n\n/**\n * Does `path` exist, according to the SANDBOX'S SHELL?\n *\n * `journalExistsCommand` rather than `handle.fs.exists`, for any path and not\n * just a journal: `journal.ts` rule 3 — on local-process `fs.*` resolves `/tmp`\n * under the sandbox root while a shell redirect hits the host's real `/tmp`, so\n * an `fs` probe would answer about a different file and every deletion\n * assertion in this suite would pass vacuously.\n */\nasync function fileExists(\n handle: SandboxHandle,\n path: string,\n): Promise<boolean> {\n const probe = await handle.process.exec(\n // Only `journal` is read by the probe, and its parameter is typed\n // `Pick<JournalPaths, 'journal'>` for exactly this reason: an arbitrary path\n // has no run behind it, so there is no nonce or sidecar to invent.\n journalExistsCommand({ journal: path }),\n )\n return probe.exitCode === 0\n}\n\n/** Filename as `ls -1` reports it, for a path inside `dir`. */\nfunction basename(dir: string, path: string): string {\n return path.slice(dir.length + 1)\n}\n\n/**\n * A real agent: a shell command printing one NDJSON line per delta, then\n * exiting.\n *\n * `printf '%s\\n' a b c` reuses the format for every operand on GNU coreutils\n * and on BusyBox alike, so this needs no loop. The JSON contains only double\n * quotes, so it is safe inside the POSIX single-quoted words this builds.\n */\nfunction emitLines(deltas: Array<string>): string {\n return `printf '%s\\\\n' ${deltas.map((delta) => `'{\"delta\":\"${delta}\"}'`).join(' ')}`\n}\n\n/**\n * Run a journaled agent to completion, so the `{\"__exit\":N}` sentinel is in the\n * journal by the time this resolves.\n *\n * `exec`, not `spawn`: `exec` waits, and a bounded wait is the only kind this\n * suite allows. (`SpawnHandle.wait()` is also not safe to call after the fact on\n * every provider — see `journal-conformance.ts`.)\n */\nasync function runAgent(\n handle: SandboxHandle,\n paths: JournalPaths,\n deltas: Array<string>,\n): Promise<void> {\n await handle.process.exec(journaledCommand(emitLines(deltas), paths))\n}\n\n/** `ls -1` output as a list of names. */\nasync function listNames(\n handle: SandboxHandle,\n dir: string,\n): Promise<Array<string>> {\n const listing = await handle.process.exec(journalListCommand(dir))\n return listing.stdout\n .split('\\n')\n .map((line) => line.trim())\n .filter((line) => line !== '')\n}\n\n/** Decode the base64 frame a bounded journal read produces. */\nfunction decodeJournalRead(stdout: string): string {\n return Buffer.from(stdout.replace(/\\s+/g, ''), 'base64').toString('utf8')\n}\n\n/**\n * The `stat -c '%Y %n'` listing for `dir`, plus the raw stdout so a case can\n * assert the WITNESS LINE itself rather than only its parsed consequence.\n */\nasync function mtimeListing(\n handle: SandboxHandle,\n dir: string,\n): Promise<{ stdout: string; entries: Map<string, number> }> {\n const probe = await handle.process.exec(journalMtimeListCommand(dir))\n const parsed = parseJournalMtimeListing(probe.stdout, dir)\n if (parsed.kind !== 'listed') {\n throw new Error(\n `reaper conformance: the mtime listing for ${dir} came back unavailable — ` +\n `stat -c '%Y %n' produced no witness line. stdout: ${JSON.stringify(probe.stdout)}`,\n )\n }\n return {\n stdout: probe.stdout,\n entries: new Map(\n parsed.entries.map((entry) => [entry.name, entry.mtimeMs]),\n ),\n }\n}\n\n/**\n * Is there a `<seconds> <dir>` line — `stat`'s report on its own first operand?\n *\n * That line, not the exit status, is the evidence the mechanism ran: BusyBox\n * exits 1 both for an EMPTY directory (whose unexpanded glob it cannot stat) and\n * for an unrecognised flag, and only the witness distinguishes them.\n */\nfunction hasWitnessLine(stdout: string, dir: string): boolean {\n return stdout\n .split('\\n')\n .some((line) => /^\\d+ (?<path>.+)$/.exec(line.trim())?.[1] === dir)\n}\n\n/** Read one file's mtime out of a listing, loudly when it is missing. */\nfunction mtimeOf(entries: Map<string, number>, name: string): number {\n const mtimeMs = entries.get(name)\n if (mtimeMs === undefined) {\n throw new Error(\n `reaper conformance: ${name} has no mtime in the stat listing, so the age gate cannot be exercised`,\n )\n }\n return mtimeMs\n}\n\n/**\n * An in-process event log with real accumulated state, plus the two facts the\n * reaper assertions need: what was appended, and how many times `close()` ran.\n *\n * `close()` is the load-bearing counter. `pipeToRunLog` ALWAYS calls it, so a\n * reaper that entered the pipe to find out whether a run finished would show up\n * here as `closes() === 1` — which ends every attached client's stream — even if\n * it happened to append nothing.\n */\ninterface ConformanceLog {\n log: StreamDurability\n stored: () => Array<StreamChunk>\n closes: () => number\n}\n\nfunction conformanceLog(): ConformanceLog {\n const entries: Array<{ offset: string; chunk: StreamChunk }> = []\n let closes = 0\n return {\n log: {\n resumeFrom: () => null,\n append: (chunks) =>\n Promise.resolve(\n chunks.map((chunk) => {\n const offset = `reap:${entries.length}`\n entries.push({ offset, chunk })\n return offset\n }),\n ),\n // Nothing here tails the log — every assertion reads the appended\n // transcript, and a `read` would park until `close()` (see `align.ts`).\n read: () => (async function* empty() {})(),\n close: () => {\n closes += 1\n return Promise.resolve()\n },\n snapshot: () => Promise.resolve(entries.map((entry) => ({ ...entry }))),\n },\n stored: () => entries.map((entry) => entry.chunk),\n closes: () => closes,\n }\n}\n\n/**\n * A `RunStore` that counts its MUTATIONS, so the leave-alone case can assert\n * that a producing run's record was not written at all.\n *\n * \"Status still `'running'`\" is too weak on its own: `driverEpoch` is bumped by\n * `withRunClaim` before any status is written, so a reaper that claimed a live\n * run and then bailed would still read as `'running'`. Counting `update` sees\n * that; reading the status does not.\n */\ninterface CountingRunStore {\n runs: RunStore\n updates: () => number\n}\n\nfunction countingRunStore(inner: InMemoryRunStore): CountingRunStore {\n let updates = 0\n return {\n runs: {\n createOrResume: (...args) => inner.createOrResume(...args),\n update: (...args) => {\n updates += 1\n return inner.update(...args)\n },\n get: (...args) => inner.get(...args),\n listByThread: (...args) => inner.listByThread(...args),\n listReclaimable: (...args) => inner.listReclaimable(...args),\n findActiveRun: (...args) => inner.findActiveRun(...args),\n },\n updates: () => updates,\n }\n}\n\n/** The event a journal line translates into. `timestamp` is excluded from `chunkFingerprint`. */\nfunction contentChunk(messageId: string, delta: string): StreamChunk {\n return {\n type: EventType.TEXT_MESSAGE_CONTENT,\n messageId,\n delta,\n timestamp: Date.now(),\n }\n}\n\n/**\n * Narrow one parsed journal line into its chunk.\n *\n * Fields are validated and the chunk REBUILT from them rather than asserted into\n * shape: a cast would let a provider that mangles the bytes reach\n * `chunkFingerprint` as a structurally invalid chunk and fail somewhere\n * unrelated.\n */\nfunction toChunk(\n runId: string,\n messageId: string,\n value: unknown,\n): StreamChunk {\n if (typeof value !== 'object' || value === null || !('delta' in value)) {\n throw new Error(\n `reaper conformance: run ${runId} journal line is not an agent event: ${JSON.stringify(value)}`,\n )\n }\n const delta = value.delta\n if (typeof delta !== 'string') {\n throw new Error(\n `reaper conformance: run ${runId} journal line has a non-string delta: ${JSON.stringify(value)}`,\n )\n }\n return contentChunk(messageId, delta)\n}\n\n/** Deterministic translator: re-reading the journal reproduces the same chunks. */\nasync function* translate(\n runId: string,\n lines: AsyncIterable<unknown>,\n): AsyncIterable<StreamChunk> {\n const messageId = createRunScopedIdGen(runId)()\n for await (const line of lines) yield toChunk(runId, messageId, line)\n}\n\n/** A comparable transcript: each chunk reduced to its fingerprint. */\nfunction transcript(chunks: Array<StreamChunk>): Array<string> {\n return chunks.map(chunkFingerprint)\n}\n\n/** The chunks a run over `deltas` must deliver, exactly once and in order. */\nfunction expectedTranscript(\n runId: string,\n deltas: Array<string>,\n): Array<StreamChunk> {\n const messageId = createRunScopedIdGen(runId)()\n return deltas.map((delta) => contentChunk(messageId, delta))\n}\n\n/**\n * The reaper's `drive`: read the run's journal from byte 0 and translate it.\n *\n * The read is bounded independently of `signal` so a journal that stops growing\n * fails the case instead of hanging CI.\n *\n * Returns the drive alongside `backstopped()`, the causal witness for\n * {@link READ_BACKSTOP_MS}: the case must assert it is `false` before its\n * transcript assertions, so a read the CLOCK ended fails naming the backstop\n * instead of as a truncated-transcript diff.\n */\nfunction driveFromJournal(\n handle: SandboxHandle,\n dir: string,\n): {\n drive: (input: {\n runId: string\n threadId: string\n signal: AbortSignal\n }) => AsyncIterable<StreamChunk>\n /** True if any read this drive started was ended by the backstop clock. */\n backstopped: () => boolean\n} {\n // One entry per `drive` invocation, so a sweep that drives more than one run\n // cannot hide a backstopped read behind a healthy one.\n const backstops: Array<AbortSignal> = []\n return {\n drive: ({ runId, signal }) => {\n // Not the assertion — see {@link READ_BACKSTOP_MS}. `backstopped()` is what\n // proves the clock was not what ended the read.\n const backstop = AbortSignal.timeout(READ_BACKSTOP_MS)\n backstops.push(backstop)\n return translate(\n runId,\n readJournalNdjson(handle, {\n signal: AbortSignal.any([signal, backstop]),\n journal: { runId, dir, pollIntervalMs: POLL_INTERVAL_MS },\n }),\n )\n },\n backstopped: () => backstops.some((s) => s.aborted),\n }\n}\n\n/** A `'running'`, DETACHED record — the shape `listReclaimable` selects on. */\nasync function detachedRun(\n store: RunStore,\n runId: string,\n threadId: string,\n detachedSince: number,\n): Promise<void> {\n await store.createOrResume({ runId, threadId, startedAt: Date.now() })\n await store.update(runId, { detachedSince })\n}\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms))\n}\n\n/**\n * Assert `createHandle` satisfies the sweep conformance contract. Each `it` gets\n * a fresh sandbox via `createHandle`/`dispose`, a fresh journal directory, and\n * unique runIds, so no case can observe another's files.\n */\nexport function runReaperConformance(config: ReaperConformanceConfig): void {\n describe(`reaper conformance — ${config.name}`, () => {\n if (config.unsupported) {\n it.skip(`unsupported: ${config.unsupported.reason}`, () => {\n expect(true).toBe(true)\n })\n return\n }\n\n // -----------------------------------------------------------------------\n // 1. `pruneJournals` against a real filesystem.\n // -----------------------------------------------------------------------\n it(\n \"deletes a terminal run's journal AND its .err sidecar, while a running run's journal survives the same sweep\",\n { timeout: 60_000 },\n async () => {\n const { handle, dispose } = await config.createHandle()\n const dir = caseDir()\n const terminalId = uniqueRunId('terminal')\n const liveId = uniqueRunId('live')\n const terminal = journalPaths(terminalId, dir)\n const live = journalPaths(liveId, dir)\n try {\n await runAgent(handle, terminal, ['1'])\n await runAgent(handle, live, ['1'])\n // Premise: all four files really exist before the sweep, otherwise\n // \"deleted\" below would be indistinguishable from \"never written\".\n expect({\n terminalJournal: await fileExists(handle, terminal.journal),\n terminalSidecar: await fileExists(handle, terminal.stderr),\n liveJournal: await fileExists(handle, live.journal),\n }).toEqual({\n terminalJournal: true,\n terminalSidecar: true,\n liveJournal: true,\n })\n\n const runs = new InMemoryRunStore()\n await runs.createOrResume({\n runId: terminalId,\n threadId: `${terminalId}-t`,\n startedAt: Date.now(),\n })\n await runs.update(terminalId, {\n status: 'completed',\n finishedAt: Date.now(),\n })\n await runs.createOrResume({\n runId: liveId,\n threadId: `${liveId}-t`,\n startedAt: Date.now(),\n })\n\n const result = await pruneJournals({ handle, runs, dir })\n expect(result.deleted).toEqual([terminalId])\n expect(result.failures).toEqual([])\n expect(result.kept).toEqual([\n { runId: liveId, names: expect.any(Array), reason: 'non-terminal' },\n ])\n\n // The files, through the shell. A sweep that reported a deletion it did\n // not perform passes every assertion above and fails here.\n expect({\n terminalJournal: await fileExists(handle, terminal.journal),\n terminalSidecar: await fileExists(handle, terminal.stderr),\n liveJournal: await fileExists(handle, live.journal),\n liveSidecar: await fileExists(handle, live.stderr),\n }).toEqual({\n terminalJournal: false,\n terminalSidecar: false,\n liveJournal: true,\n liveSidecar: true,\n })\n } finally {\n await removeDir(handle, dir)\n await dispose()\n }\n },\n )\n\n it(\n 'sweeps the same terminal run twice without a failure, and rm -f of an already-absent journal exits 0',\n { timeout: 60_000 },\n async () => {\n const { handle, dispose } = await config.createHandle()\n const dir = caseDir()\n const runId = uniqueRunId('twice')\n const paths = journalPaths(runId, dir)\n try {\n await runAgent(handle, paths, ['1'])\n const runs = new InMemoryRunStore()\n await runs.createOrResume({\n runId,\n threadId: `${runId}-t`,\n startedAt: Date.now(),\n })\n await runs.update(runId, {\n status: 'completed',\n finishedAt: Date.now(),\n })\n\n const first = await pruneJournals({ handle, runs, dir })\n expect(first.deleted).toEqual([runId])\n\n // The second sweep sees an empty directory. It must report nothing to\n // do rather than a failure — a cron runs this every tick forever.\n const second = await pruneJournals({ handle, runs, dir })\n expect({\n listed: second.listed,\n runIds: second.runIds,\n deleted: second.deleted,\n kept: second.kept,\n failures: second.failures,\n }).toEqual({\n listed: 0,\n runIds: 0,\n deleted: [],\n kept: [],\n failures: [],\n })\n\n // And the `rm -f` the sweep issues is itself idempotent on this shell.\n // Asserted directly because the sweep folds a non-zero `rm` into\n // `kept: 'delete-failed'` and would therefore hide it as a keep.\n const rerun = await handle.process.exec(journalCleanupCommand(paths))\n expect(rerun.exitCode).toBe(0)\n } finally {\n await removeDir(handle, dir)\n await dispose()\n }\n },\n )\n\n it(\n 'leaves a filename it cannot decode alone, while still sweeping the terminal run beside it',\n { timeout: 60_000 },\n async () => {\n const { handle, dispose } = await config.createHandle()\n const dir = caseDir()\n const runId = uniqueRunId('undecodable')\n const paths = journalPaths(runId, dir)\n // `_1.` is not a two-hex-digit escape, so this name is `malformed` — the\n // shape a truncated or foreign file has. `decodeJournalRunId` must refuse\n // it, and the sweep must keep it WITHOUT asking the store, because a\n // plausible-but-wrong runId could answer `terminal` for someone else.\n const strayName = 'reaper-conformance-stray_1.ndjson'\n const strayPath = `${dir}/${strayName}`\n try {\n await runAgent(handle, paths, ['1'])\n await handle.process.exec(\n `printf 'not a journal\\\\n' >> ${quote(strayPath)}`,\n )\n expect(await fileExists(handle, strayPath)).toBe(true)\n expect(decodeJournalRunId(strayName).kind).toBe('malformed')\n\n const runs = new InMemoryRunStore()\n await runs.createOrResume({\n runId,\n threadId: `${runId}-t`,\n startedAt: Date.now(),\n })\n await runs.update(runId, {\n status: 'completed',\n finishedAt: Date.now(),\n })\n\n const result = await pruneJournals({ handle, runs, dir })\n expect(result.deleted).toEqual([runId])\n expect(result.kept).toEqual([\n { names: [strayName], reason: 'undecodable-name' },\n ])\n expect(result.failures).toEqual([])\n expect({\n strayKept: await fileExists(handle, strayPath),\n journalDeleted: !(await fileExists(handle, paths.journal)),\n }).toEqual({ strayKept: true, journalDeleted: true })\n } finally {\n await removeDir(handle, dir)\n await dispose()\n }\n },\n )\n\n // -----------------------------------------------------------------------\n // 2. The age gate on a real shell.\n // -----------------------------------------------------------------------\n it(\n \"emits stat's self-witness line for a populated directory, so the age gate is usable\",\n { timeout: 60_000 },\n async () => {\n const { handle, dispose } = await config.createHandle()\n const dir = caseDir()\n const runId = uniqueRunId('witness')\n const paths = journalPaths(runId, dir)\n try {\n await runAgent(handle, paths, ['1'])\n const listing = await mtimeListing(handle, dir)\n // The witness is what makes \"no files\" distinguishable from \"the\n // mechanism is unavailable\". On BusyBox 1.37 — the docker provider's\n // `alpine:3` — `find -newermt`/`-printf` are unrecognised and exit 1\n // with empty stdout, which is exactly why the design is a witness line\n // rather than a `find` and an exit code.\n expect(hasWitnessLine(listing.stdout, dir)).toBe(true)\n expect([...listing.entries.keys()].sort()).toEqual(\n [basename(dir, paths.journal), basename(dir, paths.stderr)].sort(),\n )\n // Real epoch times, not the parser's zeroes: a `%Y` the shell did not\n // expand would parse as no entry at all, and a `stat` that printed\n // something else would land far from now.\n for (const mtimeMs of listing.entries.values()) {\n expect(Math.abs(Date.now() - mtimeMs)).toBeLessThan(120_000)\n }\n } finally {\n await removeDir(handle, dir)\n await dispose()\n }\n },\n )\n\n it(\n 'reports an EMPTY journal directory as witness-only rather than unavailable',\n { timeout: 60_000 },\n async () => {\n const { handle, dispose } = await config.createHandle()\n const dir = caseDir()\n try {\n await handle.process.exec(`mkdir -p ${quote(dir)}`)\n const listing = await mtimeListing(handle, dir)\n expect(hasWitnessLine(listing.stdout, dir)).toBe(true)\n expect([...listing.entries.keys()]).toEqual([])\n\n // And the sweep agrees: an empty directory is a LISTED age gate, not an\n // unavailable one. `'unavailable'` here would silently disable orphan\n // expiry forever on this provider.\n const result = await pruneJournals({\n handle,\n runs: new InMemoryRunStore(),\n dir,\n })\n expect({\n listed: result.listed,\n ageGate: result.ageGate,\n deleted: result.deleted,\n failures: result.failures,\n }).toEqual({\n listed: 0,\n ageGate: 'listed',\n deleted: [],\n failures: [],\n })\n } finally {\n await removeDir(handle, dir)\n await dispose()\n }\n },\n )\n\n it(\n 'keeps an orphan younger than orphanTtlMs and sweeps the older one, in the same pass',\n { timeout: 120_000 },\n async () => {\n const { handle, dispose } = await config.createHandle()\n const dir = caseDir()\n const olderId = uniqueRunId('older')\n const newerId = uniqueRunId('newer')\n const older = journalPaths(olderId, dir)\n const newer = journalPaths(newerId, dir)\n try {\n await runAgent(handle, older, ['1'])\n // `stat -c '%Y'` is second-granular, so the two runs must be more than\n // one second apart for their ages to be distinguishable at all.\n await sleep(2_500)\n await runAgent(handle, newer, ['1'])\n\n // The cutoff is computed from the REAL mtimes the real shell reported,\n // not from a fabricated timestamp: that is the whole point of running\n // this against a provider. `pruneJournals` keeps when the NEWEST of a\n // run's files is strictly newer than the cutoff, so placing the cutoff\n // between the two runs must expire exactly one of them.\n const listing = await mtimeListing(handle, dir)\n const newestOf = (paths: JournalPaths): number =>\n Math.max(\n mtimeOf(listing.entries, basename(dir, paths.journal)),\n mtimeOf(listing.entries, basename(dir, paths.stderr)),\n )\n const olderMtime = newestOf(older)\n const newerMtime = newestOf(newer)\n expect(newerMtime - olderMtime).toBeGreaterThanOrEqual(1_000)\n\n const now = Date.now()\n const cutoff = olderMtime + Math.floor((newerMtime - olderMtime) / 2)\n // NEITHER run is in the store, so both take the orphan arm and only the\n // age gate decides between them.\n const result = await pruneJournals({\n handle,\n runs: new InMemoryRunStore(),\n dir,\n now,\n orphanTtlMs: now - cutoff,\n })\n expect(result.ageGate).toBe('listed')\n expect(result.deleted).toEqual([olderId])\n expect(result.kept).toEqual([\n {\n runId: newerId,\n names: expect.any(Array),\n reason: 'orphan-too-recent',\n },\n ])\n expect(result.failures).toEqual([])\n expect({\n olderJournal: await fileExists(handle, older.journal),\n olderSidecar: await fileExists(handle, older.stderr),\n newerJournal: await fileExists(handle, newer.journal),\n newerSidecar: await fileExists(handle, newer.stderr),\n }).toEqual({\n olderJournal: false,\n olderSidecar: false,\n newerJournal: true,\n newerSidecar: true,\n })\n } finally {\n await removeDir(handle, dir)\n await dispose()\n }\n },\n )\n\n // -----------------------------------------------------------------------\n // 3. `reapDetachedRuns` end to end.\n // -----------------------------------------------------------------------\n it(\n 'finalizes a detached run whose agent reached its sentinel, and its transcript lands',\n { timeout: 120_000 },\n async () => {\n const { handle, dispose } = await config.createHandle()\n const dir = caseDir()\n const runId = uniqueRunId('finalize')\n const threadId = `${runId}-t`\n const deltas = ['1', '2', '3']\n const paths = journalPaths(runId, dir)\n try {\n const runs = new InMemoryRunStore()\n const detachedSince = Date.now()\n await detachedRun(runs, runId, threadId, detachedSince)\n await runAgent(handle, paths, deltas)\n\n // The probe, on its own, before any sweep: this read is what makes the\n // reaper safe, and it must answer from the JOURNAL rather than from the\n // delivery log (which a detached run's dead host stopped appending to).\n expect(await probeRunExit({ handle, runId, dir })).toEqual({\n state: 'finished',\n exitCode: 0,\n })\n\n const log = conformanceLog()\n const journalDrive = driveFromJournal(handle, dir)\n const result = await reapDetachedRuns({\n runs,\n locks: new InMemoryLockStore(),\n durability: () => log.log,\n hasFinished: (record) =>\n probeRunExit({ handle, runId: record.runId, dir }),\n drive: journalDrive.drive,\n now: Date.now(),\n detachedRunTtlMs: NEVER_EXPIRES_MS,\n fenceQuietMs: FENCE_QUIET_MS,\n })\n\n // The causal witness, before anything downstream — see\n // {@link READ_BACKSTOP_MS}. The reaper's read ends at the sentinel; if\n // the clock ended it instead, the transcript below is short and the\n // failure must name the backstop rather than a missing chunk.\n expect({ backstopped: journalDrive.backstopped() }).toEqual({\n backstopped: false,\n })\n expect({\n considered: result.considered,\n probed: result.probed,\n finalized: result.outcomes.finalized,\n }).toEqual({ considered: 1, probed: 1, finalized: 1 })\n expect(result.runs).toEqual([\n { runId, outcome: 'finalized', status: 'completed', exitCode: 0 },\n ])\n // The transcript, element for element — the reaper's whole purpose is\n // that the run a nobody watched still ends up saved.\n expect(transcript(log.stored())).toEqual(\n transcript(expectedTranscript(runId, deltas)),\n )\n const record = await runs.get(runId)\n expect(record?.status).toBe('completed')\n // NEVER CLEARED: `detachedSince` is what the next sweep selects on, and\n // clearing it would reset the TTL on every pass.\n expect(record?.detachedSince).toBe(detachedSince)\n } finally {\n await removeDir(handle, dir)\n await dispose()\n }\n },\n )\n\n it(\n 'reports a still-producing detached run as producing and leaves it completely untouched',\n { timeout: 120_000 },\n async () => {\n const { handle, dispose } = await config.createHandle()\n const dir = caseDir()\n const runId = uniqueRunId('producing')\n const threadId = `${runId}-t`\n const paths = journalPaths(runId, dir)\n const store = countingRunStore(new InMemoryRunStore())\n // A REAL agent that has written a line and is genuinely still alive: no\n // sentinel can be in the journal, and driving it would truncate a healthy\n // run's transcript at line one.\n const agent = await handle.process.spawn(\n journaledCommand(`${emitLines(['1'])}; sleep 30`, paths),\n )\n try {\n const detachedSince = Date.now()\n await detachedRun(store.runs, runId, threadId, detachedSince)\n await waitForJournal(handle, paths)\n const read = await handle.process.exec(journalReadCommand(paths, 0))\n const text = decodeJournalRead(read.stdout)\n // Producing, provably: the first line is there and the sentinel is not.\n expect(text).toContain('{\"delta\":\"1\"}')\n expect(text).not.toContain('__exit')\n expect(await probeRunExit({ handle, runId, dir })).toEqual({\n state: 'producing',\n })\n\n const log = conformanceLog()\n let driveCalled = false\n let durabilityCalls = 0\n const result = await reapDetachedRuns({\n runs: store.runs,\n locks: new InMemoryLockStore(),\n durability: () => {\n durabilityCalls += 1\n return log.log\n },\n hasFinished: (record) =>\n probeRunExit({ handle, runId: record.runId, dir }),\n drive: () => {\n driveCalled = true\n return (async function* never() {})()\n },\n now: Date.now(),\n detachedRunTtlMs: NEVER_EXPIRES_MS,\n fenceQuietMs: FENCE_QUIET_MS,\n })\n\n expect(result.runs).toEqual([{ runId, outcome: 'producing' }])\n expect({\n considered: result.considered,\n probed: result.probed,\n producing: result.outcomes.producing,\n finalized: result.outcomes.finalized,\n expired: result.outcomes.expired,\n failed: result.outcomes.failed,\n }).toEqual({\n considered: 1,\n probed: 1,\n producing: 1,\n finalized: 0,\n expired: 0,\n failed: 0,\n })\n\n // ABSENCE, asserted in one object so a regression names which\n // guarantee broke instead of failing on whichever line came first.\n // Every one of these is a way the pre-`probeRunExit` design destroyed a\n // live run: an append duplicates its prefix, a `close()` ends every\n // attached client's stream, an `update` writes `'completed'` and drops\n // the run out of `listReclaimable` forever, and a moved\n // `detachedSince` restarts its TTL.\n const record = await store.runs.get(runId)\n expect({\n driveCalled,\n durabilityCalls,\n appended: log.stored().length,\n closes: log.closes(),\n updatesAfterSetup: store.updates() - 1,\n status: record?.status,\n detachedSince: record?.detachedSince,\n driverEpoch: record?.driverEpoch,\n }).toEqual({\n driveCalled: false,\n durabilityCalls: 0,\n appended: 0,\n closes: 0,\n updatesAfterSetup: 0,\n status: 'running',\n detachedSince,\n driverEpoch: undefined,\n })\n } finally {\n // The agent outlives the sweep on purpose; reap it here so no `sleep`\n // survives the case.\n try {\n await agent.kill()\n } catch {\n // Already gone, or a provider whose sandbox teardown covers it.\n }\n await removeDir(handle, dir)\n await dispose()\n }\n },\n )\n\n // -----------------------------------------------------------------------\n // 4. A shell-hostile runId, end to end. SECURITY-RELEVANT.\n // -----------------------------------------------------------------------\n it(\n 'round-trips a shell-hostile runId through encode, journal, follow, sidecar read, list, decode and delete without executing any of it' +\n (config.followUnsupported === undefined\n ? ''\n : ` (follow read omitted: ${config.followUnsupported.reason})`),\n { timeout: 120_000 },\n async () => {\n const { handle, dispose } = await config.createHandle()\n const dir = caseDir()\n const nonce = randomUUID().slice(0, 8)\n // The canary lives OUTSIDE `dir` so the teardown `rm -rf` cannot be what\n // makes the final assertion pass.\n const canary = `/tmp/rp-pwn-${nonce}`\n // `/` would escape the directory, the space would split the word, `;` and\n // `$( )` would start new commands, and the `touch` is a real payload with\n // an observable effect. Every one of these must survive as DATA.\n //\n // THE ORDER OF THE PAYLOAD IS DELIBERATE and was measured: the `;touch`\n // comes BEFORE the space and the `/`. With raw interpolation, the\n // journaled command's redirect target is one word, so a payload whose\n // space precedes the `;` (`rp-a b;touch …`) makes the mangled command a\n // SYNTAX ERROR — the injected `touch` never runs and the canary below\n // would be decoration that can never fire. With the `;` first, the\n // vulnerable form parses as a command LIST and the payload really\n // executes (verified against a hand-composed unquoted, unencoded command\n // on this provider: canary present). So the canary is a live detector.\n const runId = `rp-a;touch ${canary};b c/d$(x)-${nonce}`\n const threadId = `${runId}-t`\n const paths = journalPaths(runId, dir)\n const journalName = basename(dir, paths.journal)\n try {\n // This case's agent writes to STDERR as well, so the sidecar read\n // below has real bytes to compare against: an empty sidecar is also\n // what a `journalStderrReadCommand` that read the wrong path (or\n // nothing at all) would return, and that read is the only coverage\n // that command has anywhere.\n await handle.process.exec(\n journaledCommand(\n `${emitLines(['1'])}; printf 'boom\\\\n' 1>&2`,\n paths,\n ),\n )\n // THE SECURITY ASSERTION, and deliberately the FIRST one: nothing the\n // runId contains was executed. It is stated before the cheaper\n // structural checks below on purpose — a defect that reintroduces raw\n // interpolation would also fail the filename shape, and a case that\n // short-circuited there would never prove this probe is live rather\n // than decorative. Re-asserted after the delete, because the sweep\n // composes a DIFFERENT command (`rm -f`) from the same id.\n expect(await fileExists(handle, canary)).toBe(false)\n expect(await probeRunExit({ handle, runId, dir })).toEqual({\n state: 'finished',\n exitCode: 0,\n })\n // The encoding is what bought that: the filename carries no character\n // a shell can act on, and it stays inside the journal directory.\n expect(journalName).toMatch(/^[A-Za-z0-9._-]+\\.ndjson$/)\n expect(paths.journal.startsWith(`${dir}/`)).toBe(true)\n\n // THE FOLLOW PATH, against this same hostile id.\n //\n // `journalFollowCommand` is the WORST command in the set under a\n // dropped `encodeRunId`: it interpolates the journal path THREE times\n // (`mkdir -p`, `: >> path`, `tail -c +N -f path`) and joins its prep\n // steps with `;` rather than `&&`, so `: >> /tmp/dir/rp-a;touch\n // <canary>;…` is a complete redirect followed by a command LIST — the\n // payload runs on EVERY attach, and a failing prep step does not stop\n // it. Nothing else reaches this command with a hostile runId: the\n // reaper's own probes are all bounded reads, and the takeover suite,\n // the only other real-provider consumer of the follow path, builds\n // alnum-only ids. So it is exercised here, where the hostile id and a\n // live canary already exist, for the cost of one read.\n //\n // The strategy is FORCED rather than capability-derived so this is the\n // follow command and not the bounded one, and the declaration is\n // checked against the live handle in both directions — a config that\n // does not describe the provider must fail rather than silently drop\n // this read.\n expect(journalReadStrategy(handle)).toBe(\n config.followUnsupported === undefined ? 'follow' : 'poll',\n )\n if (config.followUnsupported === undefined) {\n const followed: Array<string> = []\n // A backstop, so a reader that delivers nothing fails instead of\n // parking CI — a `tail -f` never ends on its own, so this read has no\n // other floor. Not the assertion — `backstopped` below proves it was\n // not what ended the loop.\n const backstop = AbortSignal.timeout(READ_BACKSTOP_MS)\n for await (const line of readJournal(handle, {\n paths,\n fromByte: 0,\n strategy: 'follow',\n signal: backstop,\n })) {\n followed.push(line.line)\n // The agent has already reached its sentinel, so this arrives; a\n // `tail -f` never ends on its own.\n if (line.line.includes(EXIT_SENTINEL_KEY)) break\n }\n // The causal witness, first: the loop must end on the sentinel\n // `break`, not on the clock. A backstopped follow read otherwise\n // reports as a one-element-vs-two array diff that says nothing about\n // why.\n expect({ backstopped: backstop.aborted }).toEqual({\n backstopped: false,\n })\n expect(followed).toEqual([\n '{\"delta\":\"1\"}',\n exitSentinelLine(paths, 0),\n ])\n expect(await fileExists(handle, canary)).toBe(false)\n }\n\n // The stderr SIDECAR read, which no other conformance case reaches at\n // all. Same hostile id, same canary, one `exec`.\n const sidecar = await handle.process.exec(\n journalStderrReadCommand(paths),\n )\n expect(decodeJournalRead(sidecar.stdout)).toBe('boom\\n')\n expect(await fileExists(handle, canary)).toBe(false)\n\n // encode → journal → list → decode: the sweep's actual path back to a\n // runId, over a real `ls -1`.\n const names = await listNames(handle, dir)\n expect(names.sort()).toEqual(\n [journalName, basename(dir, paths.stderr)].sort(),\n )\n expect(decodeJournalRunId(journalName)).toEqual({\n kind: 'runId',\n runId,\n })\n\n const runs = new InMemoryRunStore()\n await runs.createOrResume({ runId, threadId, startedAt: Date.now() })\n await runs.update(runId, {\n status: 'completed',\n finishedAt: Date.now(),\n })\n const result = await pruneJournals({ handle, runs, dir })\n expect(result.deleted).toEqual([runId])\n expect(result.failures).toEqual([])\n expect({\n journalDeleted: !(await fileExists(handle, paths.journal)),\n sidecarDeleted: !(await fileExists(handle, paths.stderr)),\n canaryAbsent: !(await fileExists(handle, canary)),\n }).toEqual({\n journalDeleted: true,\n sidecarDeleted: true,\n canaryAbsent: true,\n })\n } finally {\n await handle.process\n .exec(`rm -f ${quote(canary)}`)\n .catch(() => undefined)\n await removeDir(handle, dir)\n await dispose()\n }\n },\n )\n })\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
//#region src/testkit/shell-spawn.ts
|
|
2
|
+
function makeFakeShellSpawn() {
|
|
3
|
+
const queue = [];
|
|
4
|
+
const waiters = [];
|
|
5
|
+
let done = false;
|
|
6
|
+
function emit(chunk) {
|
|
7
|
+
const waiter = waiters.shift();
|
|
8
|
+
if (waiter !== void 0) waiter({
|
|
9
|
+
value: chunk,
|
|
10
|
+
done: false
|
|
11
|
+
});
|
|
12
|
+
else queue.push(chunk);
|
|
13
|
+
}
|
|
14
|
+
const stdout = { [Symbol.asyncIterator]() {
|
|
15
|
+
return { next() {
|
|
16
|
+
const queued = queue.shift();
|
|
17
|
+
if (queued !== void 0) return Promise.resolve({
|
|
18
|
+
value: queued,
|
|
19
|
+
done: false
|
|
20
|
+
});
|
|
21
|
+
if (done) return Promise.resolve({
|
|
22
|
+
value: "",
|
|
23
|
+
done: true
|
|
24
|
+
});
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
waiters.push(resolve);
|
|
27
|
+
});
|
|
28
|
+
} };
|
|
29
|
+
} };
|
|
30
|
+
let counter = 0;
|
|
31
|
+
return {
|
|
32
|
+
pid: 1,
|
|
33
|
+
stdout,
|
|
34
|
+
stderr: (async function* empty() {})(),
|
|
35
|
+
stdin: {
|
|
36
|
+
write: (data) => {
|
|
37
|
+
const sentinel = `__BSSH_${counter}__`;
|
|
38
|
+
counter += 1;
|
|
39
|
+
if (data.startsWith("pwd;")) emit("/workspace\n");
|
|
40
|
+
emit(`${sentinel} 0\n`);
|
|
41
|
+
return Promise.resolve();
|
|
42
|
+
},
|
|
43
|
+
end: () => {
|
|
44
|
+
done = true;
|
|
45
|
+
for (const waiter of waiters) waiter({
|
|
46
|
+
value: "",
|
|
47
|
+
done: true
|
|
48
|
+
});
|
|
49
|
+
waiters.length = 0;
|
|
50
|
+
return Promise.resolve();
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
wait: () => Promise.resolve(0),
|
|
54
|
+
kill: () => Promise.resolve()
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
//#endregion
|
|
58
|
+
export { makeFakeShellSpawn };
|
|
59
|
+
|
|
60
|
+
//# sourceMappingURL=shell-spawn.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shell-spawn.js","names":[],"sources":["../../../src/testkit/shell-spawn.ts"],"sourcesContent":["/**\n * A minimal sentinel-driven fake `sh` for driving the persistent bootstrap\n * shell (see `../shell.ts`). Every command succeeds; `pwd` answers\n * `/workspace`, `export -p` answers an empty env, so `forkState()` resolves.\n *\n * Shipped from the `@tanstack/ai-sandbox/testkit` subpath so provider authors\n * (local-process, Docker, and third-party providers outside this repo) can\n * verify their `process.spawn` wiring against the journal contract without\n * reimplementing this fake themselves.\n */\nimport type { SpawnHandle } from '../contracts'\n\nexport function makeFakeShellSpawn(): SpawnHandle {\n const queue: Array<string> = []\n const waiters: Array<(result: IteratorResult<string>) => void> = []\n let done = false\n\n function emit(chunk: string): void {\n const waiter = waiters.shift()\n if (waiter !== undefined) {\n waiter({ value: chunk, done: false })\n } else {\n queue.push(chunk)\n }\n }\n\n const stdout: AsyncIterable<string> = {\n [Symbol.asyncIterator](): AsyncIterator<string> {\n return {\n next(): Promise<IteratorResult<string>> {\n const queued = queue.shift()\n if (queued !== undefined) {\n return Promise.resolve({ value: queued, done: false })\n }\n if (done) return Promise.resolve({ value: '', done: true })\n return new Promise<IteratorResult<string>>((resolve) => {\n waiters.push(resolve)\n })\n },\n }\n },\n }\n\n let counter = 0\n return {\n pid: 1,\n stdout,\n stderr: (async function* empty() {})(),\n stdin: {\n write: (data: string) => {\n const sentinel = `__BSSH_${counter}__`\n counter += 1\n if (data.startsWith('pwd;')) emit('/workspace\\n')\n emit(`${sentinel} 0\\n`)\n return Promise.resolve()\n },\n end: () => {\n done = true\n for (const waiter of waiters) waiter({ value: '', done: true })\n waiters.length = 0\n return Promise.resolve()\n },\n },\n wait: () => Promise.resolve(0),\n kill: () => Promise.resolve(),\n }\n}\n"],"mappings":";AAYA,SAAgB,qBAAkC;CAChD,MAAM,QAAuB,CAAC;CAC9B,MAAM,UAA2D,CAAC;CAClE,IAAI,OAAO;CAEX,SAAS,KAAK,OAAqB;EACjC,MAAM,SAAS,QAAQ,MAAM;EAC7B,IAAI,WAAW,KAAA,GACb,OAAO;GAAE,OAAO;GAAO,MAAM;EAAM,CAAC;OAEpC,MAAM,KAAK,KAAK;CAEpB;CAEA,MAAM,SAAgC,EACpC,CAAC,OAAO,iBAAwC;EAC9C,OAAO,EACL,OAAwC;GACtC,MAAM,SAAS,MAAM,MAAM;GAC3B,IAAI,WAAW,KAAA,GACb,OAAO,QAAQ,QAAQ;IAAE,OAAO;IAAQ,MAAM;GAAM,CAAC;GAEvD,IAAI,MAAM,OAAO,QAAQ,QAAQ;IAAE,OAAO;IAAI,MAAM;GAAK,CAAC;GAC1D,OAAO,IAAI,SAAiC,YAAY;IACtD,QAAQ,KAAK,OAAO;GACtB,CAAC;EACH,EACF;CACF,EACF;CAEA,IAAI,UAAU;CACd,OAAO;EACL,KAAK;EACL;EACA,SAAS,gBAAgB,QAAQ,CAAC,EAAA,CAAG;EACrC,OAAO;GACL,QAAQ,SAAiB;IACvB,MAAM,WAAW,UAAU,QAAQ;IACnC,WAAW;IACX,IAAI,KAAK,WAAW,MAAM,GAAG,KAAK,cAAc;IAChD,KAAK,GAAG,SAAS,KAAK;IACtB,OAAO,QAAQ,QAAQ;GACzB;GACA,WAAW;IACT,OAAO;IACP,KAAK,MAAM,UAAU,SAAS,OAAO;KAAE,OAAO;KAAI,MAAM;IAAK,CAAC;IAC9D,QAAQ,SAAS;IACjB,OAAO,QAAQ,QAAQ;GACzB;EACF;EACA,YAAY,QAAQ,QAAQ,CAAC;EAC7B,YAAY,QAAQ,QAAQ;CAC9B;AACF"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { SandboxHandle } from '../contracts.js';
|
|
2
|
+
export interface TakeoverConformanceConfig {
|
|
3
|
+
/** Provider name, used in the describe title. */
|
|
4
|
+
name: string;
|
|
5
|
+
/** Create a live sandbox plus its teardown. */
|
|
6
|
+
createHandle: () => Promise<{
|
|
7
|
+
handle: SandboxHandle;
|
|
8
|
+
dispose: () => Promise<void>;
|
|
9
|
+
}>;
|
|
10
|
+
/**
|
|
11
|
+
* Declare that this provider cannot support takeover, with the reason.
|
|
12
|
+
* Registers a skipped case whose title carries the reason — a NAMED skip,
|
|
13
|
+
* visible in the reporter. Omit it and the suite runs.
|
|
14
|
+
*/
|
|
15
|
+
unsupported?: {
|
|
16
|
+
reason: string;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Assert `createHandle` satisfies the takeover conformance contract. Each `it`
|
|
21
|
+
* gets a fresh sandbox via `createHandle`/`dispose`, and a unique `runId`, so no
|
|
22
|
+
* case can observe another's journal.
|
|
23
|
+
*/
|
|
24
|
+
export declare function runTakeoverConformance(config: TakeoverConformanceConfig): void;
|