@tanstack/ai-sandbox 0.3.1 → 0.3.3

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.
Files changed (42) hide show
  1. package/README.md +15 -5
  2. package/dist/esm/agents-file.d.ts +15 -0
  3. package/dist/esm/agents-file.js +47 -1
  4. package/dist/esm/agents-file.js.map +1 -1
  5. package/dist/esm/bootstrap.js +2 -1
  6. package/dist/esm/bootstrap.js.map +1 -1
  7. package/dist/esm/contracts.d.ts +12 -8
  8. package/dist/esm/git-exec.js +2 -0
  9. package/dist/esm/git-exec.js.map +1 -1
  10. package/dist/esm/index.d.ts +2 -1
  11. package/dist/esm/index.js +3 -3
  12. package/dist/esm/journal-sweep.js +3 -2
  13. package/dist/esm/journal-sweep.js.map +1 -1
  14. package/dist/esm/middleware.js +5 -2
  15. package/dist/esm/middleware.js.map +1 -1
  16. package/dist/esm/reap.js +2 -1
  17. package/dist/esm/reap.js.map +1 -1
  18. package/dist/esm/sandbox.d.ts +2 -0
  19. package/dist/esm/sandbox.js +19 -2
  20. package/dist/esm/sandbox.js.map +1 -1
  21. package/dist/esm/shell.js +5 -4
  22. package/dist/esm/shell.js.map +1 -1
  23. package/dist/esm/testkit/conformance.js +4 -2
  24. package/dist/esm/testkit/conformance.js.map +1 -1
  25. package/dist/esm/testkit/journal-conformance.js +6 -3
  26. package/dist/esm/testkit/journal-conformance.js.map +1 -1
  27. package/dist/esm/testkit/reaper-conformance.js +13 -8
  28. package/dist/esm/testkit/reaper-conformance.js.map +1 -1
  29. package/dist/esm/testkit/takeover-conformance.js +4 -3
  30. package/dist/esm/testkit/takeover-conformance.js.map +1 -1
  31. package/dist/esm/tool-bridge.js +1 -1
  32. package/dist/esm/tool-bridge.js.map +1 -1
  33. package/package.json +14 -4
  34. package/skills/ai-sandbox/SKILL.md +13 -8
  35. package/src/agents-file.ts +65 -0
  36. package/src/bootstrap.ts +5 -1
  37. package/src/contracts.ts +12 -8
  38. package/src/git-exec.ts +7 -0
  39. package/src/index.ts +2 -0
  40. package/src/middleware.ts +4 -1
  41. package/src/sandbox.ts +23 -0
  42. package/src/tool-bridge.ts +3 -1
@@ -1 +1 @@
1
- {"version":3,"file":"takeover-conformance.js","names":[],"sources":["../../../src/testkit/takeover-conformance.ts"],"sourcesContent":["/**\n * Provider conformance for TAKEOVER: a second driver picking up a run whose\n * first driver died, against a REAL sandbox.\n *\n * WHY THIS EXISTS SEPARATELY FROM THE UNIT TESTS. Every takeover unit test in\n * this package drives fakes — a scripted `spawn`, a `test -f` that answers from\n * a boolean, a log that is an array. Fakes model what we believe the shell and\n * the filesystem do, and on this feature that belief has been wrong three times:\n * `base64` delivers zero bytes on a live pipe, `tail -f` on a missing file exits\n * instead of waiting, and a provider's `kill` does not always reap a grandchild.\n * Each one passed every fake. So the four properties a takeover actually rests\n * on are asserted here through a provider's real `spawn`/`exec` against a real\n * journal file:\n *\n * 1. **The delivered sequence is the run's sequence, with no duplicated\n * prefix.** Asserted as a TRANSCRIPT, never as \"chunks arrived\": a takeover\n * that replays the whole journal and re-appends everything satisfies the weak\n * assertion while showing the user the entire run twice. That is the exact\n * failure `alignToStoredLog` exists to prevent, and the only assertion that\n * can see it is one that compares the stored log to the expected sequence\n * element for element.\n * 2. **The attach preflight decides, or fails, but never hangs.** It probes with\n * the provider's real `exec` (`test -f`), which is the layer where a fake's\n * assumptions break, and its three verdicts (`unknown-run`, `terminal-run`,\n * `journal-timeout`) plus the legitimate late-journal race are all timing\n * against a real filesystem.\n * 3. **The epoch fence and its latch hold under real concurrency.** Two drivers\n * reading one real journal at once: the second wins, the first appends\n * NOTHING — not even `pipeToRunLog`'s recovery `RUN_ERROR` — and cannot\n * terminalize the record out from under the live successor.\n * 4. **A terminal run's journal is deleted, and a later attach says so.** The\n * deletion is a real `rm` of real files, and the follow-up attach must report\n * `terminal-run` rather than tailing the file that `journalFollowCommand`\n * would helpfully re-create.\n *\n * WHAT IS REAL HERE. The provider (its `spawn`, `exec`, and shell), the journal\n * (a real NDJSON file the agent's stdout is redirected into), the agent (a real\n * process writing real lines with a real pause in the middle), the reader\n * (`readJournalNdjson`, including the follow/poll strategy split and the attach\n * preflight), the alignment (`alignedIfAttaching` over the real\n * `resolveSandboxDurability` output), the claim and BOTH fences\n * (`sandboxRunDriver`), and the run record (`InMemoryRunStore`). The event log is\n * in-process, exactly as the recommended `memoryStream` backend is.\n *\n * A provider that cannot satisfy the contract MUST declare `unsupported.reason`.\n * As in the journal suite there is deliberately no silent-skip path: a\n * conformance case that quietly returns prints as a pass, which is how an\n * unimplemented capability ships green.\n *\n * FOUND BY THIS SUITE, FIXED IN THE PROVIDER, STILL NOT ASSERTED HERE. On\n * local-process under Windows (git-bash `sh`), the follow read's `tail`\n * grandchild used to SURVIVE `proc.kill()`: `LocalProcessHandle.killTree` ran\n * `taskkill /PID <sh> /T /F` and returned as soon as `spawnSync` reported no\n * `error`. Two things were wrong. It never checked taskkill's exit status — and\n * that alone would not have caught it, because MSYS's fork emulation leaves the\n * `tail.exe` pointing at an intermediate shell that has already exited, so\n * `taskkill /T` (live parent links only) cannot reach it and still exits `0`.\n * Measured by counting `tail.exe` before and after a run: this suite leaked 4 per\n * run and the shipped journal suite 2, accumulating for the life of the machine.\n * It was a provider defect, not a takeover defect — every case here still\n * delivered the right transcript, because `untilAborted` (see\n * `journal-reader.ts`) stops honoring the pipe once the signal fires rather than\n * waiting for the kill, which is exactly why it never failed a test.\n * `killTree` now resolves the tree through MSYS's own process table and verifies\n * the survivors are gone (0 per run), covered in\n * `ai-sandbox-local-process/tests/kill-tree.test.ts`.\n * Deliberately still NOT asserted in this suite: a per-provider process census is\n * not portable (Docker's `tail` dies with its container), and a conformance case\n * that counted host processes would fail for reasons unrelated to takeover.\n *\n * EVERY WAIT IN THIS FILE IS BOUNDED. A hang stalls CI instead of failing it, so\n * each journal read carries a timeout signal, each poll loop carries a deadline\n * and a message naming what never happened, and each case carries an explicit\n * per-test timeout.\n *\n * Vitest is an OPTIONAL peer dependency: this module is imported only from test\n * files, which already run under Vitest.\n */\nimport { describe, expect, it } from 'vitest'\nimport { EventType, InMemoryRunStore } from '@tanstack/ai'\nimport { InMemoryLockStore } from '@tanstack/ai/locks'\nimport {\n journalCleanupCommand,\n journalExistsCommand,\n journalPaths,\n journaledCommand,\n} from '../journal'\nimport { readJournalNdjson, startJournaledAgent } from '../runner'\nimport {\n JournalAttachUnavailableError,\n awaitAttachableJournal,\n} from '../attach-preflight'\nimport {\n alignedIfAttaching,\n journalOptionsFor,\n resolveSandboxDurability,\n} from '../durability'\nimport { sandboxRunDriver } from '../driver'\nimport { fenceDurability, withRunClaim } from '../claim'\nimport { chunkFingerprint, createRunScopedIdGen } from '../chunk-identity'\nimport type { SandboxRunDurability } from '../durability'\nimport type { JournalOptions } from '../runner'\nimport type { SandboxHandle } from '../contracts'\nimport type { LockStore } from '@tanstack/ai/locks'\nimport type { RunStore, StreamChunk, StreamDurability } from '@tanstack/ai'\n\nexport interface TakeoverConformanceConfig {\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 takeover, 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\n/**\n * Journal directory for this suite, deliberately NOT\n * {@link DEFAULT_JOURNAL_DIR}: on local-process the sandbox shell shares the\n * host's real `/tmp`, so conformance runs must not write where an application's\n * runs live.\n */\nconst CONFORMANCE_JOURNAL_DIR = '/tmp/tanstack-takeover-conformance'\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 successor's first append. Short because the\n * predecessor in these cases has provably stopped (the suite sequenced it) —\n * the 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/**\n * Unique per case, and it must be: `journalPaths` derives the file name 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`). The counter covers two cases created inside the\n * same millisecond; the random suffix covers two suites sharing one `/tmp`.\n */\nlet caseCounter = 0\nfunction uniqueRunId(label: string): string {\n caseCounter += 1\n const suffix = Math.random().toString(36).slice(2, 8)\n return `tko-${label}-${Date.now()}-${caseCounter}-${suffix}`\n}\n\n/**\n * An in-process event log with real accumulated state, plus the two facts the\n * assertions need: what is stored (in append order) and how many times `close()`\n * ran.\n *\n * `snapshot()` returns fresh objects, per the `StreamDurability` contract, so a\n * caller cannot reach the stored log through the result.\n */\ninterface ConformanceLog {\n log: StreamDurability\n /** Stored chunks, in append order. The transcript under test. */\n stored: () => Array<StreamChunk>\n /** `close()` calls — proof that `close` is NOT fenced. */\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 = `conf:${entries.length}`\n entries.push({ offset, chunk })\n return offset\n }),\n ),\n // Nothing in this suite tails the log — every assertion reads the stored\n // transcript with `snapshot()`, which is also what `alignToStoredLog`\n // uses, 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 lock that grants every request immediately and never reports a loss.\n *\n * `InMemoryLockStore` SERIALIZES claims within one process, so a second attach\n * waits for the first to finish and the two drivers are never concurrent — which\n * means the epoch fence can never be observed there. `claim.ts` says exactly\n * that: in one process only layer 2, the `driverEpoch` fence, is provable. This\n * models a lease-less lock so the two drives overlap and layer 2 does the work.\n */\nconst permissiveLocks: LockStore = {\n withLock: (_key, fn) => fn(new AbortController().signal),\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 is REBUILT from them rather than asserted\n * into shape: a cast would let a provider that mangles the bytes (a folded\n * stderr diagnostic, a truncated line) reach `chunkFingerprint` as a\n * structurally invalid chunk and fail somewhere 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 `takeover 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 `takeover conformance: run ${runId} journal line has a non-string delta: ${JSON.stringify(value)}`,\n )\n }\n return contentChunk(messageId, delta)\n}\n\n/**\n * The translator. Deterministic by construction, which is what makes alignment\n * possible at all: the message id comes from {@link createRunScopedIdGen}, so\n * re-translating the same journal from byte 0 reproduces byte-identical chunks\n * (modulo `timestamp`, the one field `chunkFingerprint` excludes).\n */\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/**\n * A comparable transcript: each chunk reduced to its {@link chunkFingerprint}.\n *\n * The fingerprint, not the chunk object, and for the same reason alignment uses\n * it — `timestamp` is wall-clock and unreproducible, so a raw `toEqual` on\n * chunks would fail on the one field the feature deliberately ignores. Every\n * other field participates, so a duplicated prefix, a dropped chunk, or a\n * reordered one still fails.\n */\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 * A real agent: a shell command that prints one NDJSON line per delta, with an\n * optional real pause partway through, then exits.\n *\n * `printf '%s\\n' a b c` reuses the format for every operand on GNU coreutils and\n * on busybox alike, so this needs no loop. The JSON contains only double quotes,\n * so it is safe inside the POSIX single-quoted words this builds.\n */\nfunction agentCommand(deltas: Array<string>, pauseAfter: number): string {\n const line = (delta: string): string => `'{\"delta\":\"${delta}\"}'`\n const head = deltas.slice(0, pauseAfter)\n const tail = deltas.slice(pauseAfter)\n const parts = [`printf '%s\\\\n' ${head.map(line).join(' ')}`]\n if (tail.length > 0) {\n // A real sleep, so the takeover below happens while the agent is genuinely\n // still writing rather than against a finished file.\n parts.push('sleep 2', `printf '%s\\\\n' ${tail.map(line).join(' ')}`)\n }\n return parts.join('; ')\n}\n\n/** Resolve durability through the production resolver, fresh or attaching. */\nfunction durabilityFor(\n runs: RunStore,\n log: StreamDurability,\n attach: boolean,\n): SandboxRunDurability {\n const resolved = resolveSandboxDurability({\n runs,\n durability: {\n adapter: log,\n journal: CONFORMANCE_JOURNAL_DIR,\n attach,\n pollIntervalMs: POLL_INTERVAL_MS,\n },\n })\n if (resolved === undefined) {\n throw new Error(\n 'takeover conformance: resolveSandboxDurability returned undefined for a fully wired run',\n )\n }\n return resolved\n}\n\n/**\n * The reader's journal options for a resolved durability.\n *\n * `journalOptionsFor` answers `undefined` for a NON-durable run, which cannot\n * happen here — every run in this suite is fully wired. Narrowing it with a\n * thrown error rather than a non-null assertion keeps the impossible case loud\n * if the resolver's contract ever changes.\n */\nfunction journalOptions(\n durability: SandboxRunDurability,\n runId: string,\n): JournalOptions {\n const options = journalOptionsFor(durability, runId)\n if (options === undefined) {\n throw new Error(\n `takeover conformance: journalOptionsFor answered undefined for durable run ${runId}`,\n )\n }\n return options\n}\n\n/** A `'running'` record for `runId`, ready to be claimed. */\nasync function runningRun(\n runId: string,\n threadId: string,\n): Promise<InMemoryRunStore> {\n const runs = new InMemoryRunStore()\n await runs.createOrResume({ runId, threadId, startedAt: Date.now() })\n return runs\n}\n\n/**\n * Wrap a handle so the `process.exec` calls ONE operation makes can be counted.\n *\n * This is how the attach preflight's fail-fast cases are anchored, and the reason\n * they are not anchored on elapsed time. `awaitAttachableJournal` runs exactly one\n * `test -f` before it consults the run store, so a decision made from the record\n * costs one `exec` and a decision made by waiting costs one per\n * `probeIntervalMs`. The count separates those two behaviors exactly; elapsed time\n * does not, because a single `exec` is a provider round-trip whose latency the\n * suite does not control — a `docker exec` on a loaded daemon has been measured at\n * 9.6s, which fails a `< 4_000ms` bound while the preflight under test did\n * precisely the right thing. A timing bound that goes red on a busy machine\n * teaches people to ignore the suite.\n *\n * The spread copies the handle's own methods, so everything except `exec` is the\n * provider's; the wrapper delegates rather than reimplementing.\n */\nfunction countingExec(handle: SandboxHandle): {\n handle: SandboxHandle\n execs: () => number\n} {\n let execs = 0\n return {\n handle: {\n ...handle,\n process: {\n ...handle.process,\n exec: (command, options) => {\n execs += 1\n return handle.process.exec(command, options)\n },\n },\n },\n execs: () => execs,\n }\n}\n\n/** Poll `check` until it answers true, or fail with a message naming what never happened. */\nasync function waitUntil(\n check: () => Promise<boolean>,\n options: { timeoutMs: number; message: string },\n): Promise<void> {\n const deadline = Date.now() + options.timeoutMs\n for (;;) {\n if (await check()) return\n if (Date.now() > deadline) {\n throw new Error(\n `takeover conformance: ${options.message} within ${options.timeoutMs}ms`,\n )\n }\n await sleep(25)\n }\n}\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms))\n}\n\ninterface Gate {\n promise: Promise<void>\n open: () => void\n}\n\n/** A one-shot gate, for sequencing two concurrent drivers deterministically. */\nfunction gate(): Gate {\n let open = (): void => {}\n const promise = new Promise<void>((resolve) => {\n open = () => resolve()\n })\n return { promise, open }\n}\n\n/**\n * Build the driver a host would build for one run.\n *\n * `drive` is the real journal path: read the run's journal from byte 0 (through\n * the attach preflight when attaching), translate, and align against the stored\n * log — `alignedIfAttaching`, so alignment runs on an attach and only on an\n * attach.\n *\n * Returns the driver alongside `backstopped()`, the causal witness for\n * {@link READ_BACKSTOP_MS}: every case that drives this must assert it is\n * `false` before its transcript assertions, so a read the CLOCK ended fails\n * naming the backstop instead of as a truncated-transcript diff.\n */\nfunction driverFor(input: {\n handle: SandboxHandle\n runs: RunStore\n locks: LockStore\n log: StreamDurability\n runId: string\n attach: boolean\n /** Awaited before the FIRST translated chunk is yielded, never after. */\n beforeFirstChunk?: () => Promise<void>\n}): {\n driver: ReturnType<typeof sandboxRunDriver>\n /** True if any read this driver started was ended by the backstop clock. */\n backstopped: () => boolean\n} {\n const durability = durabilityFor(input.runs, input.log, input.attach)\n // One entry per `drive` invocation, so a re-drive cannot hide a backstopped\n // read behind a healthy one.\n const backstops: Array<AbortSignal> = []\n const driver = sandboxRunDriver({\n request: new Request(\n `http://takeover.local/attach?runId=${encodeURIComponent(input.runId)}&offset=-1`,\n ),\n runs: input.runs,\n locks: input.locks,\n durability: () => input.log,\n fenceQuietMs: FENCE_QUIET_MS,\n drive: ({ runId, signal }) => {\n // The read is bounded independently of `signal`: an `InMemoryLockStore`\n // hands out a signal it never aborts, so a journal that stops growing\n // would otherwise park this read forever and turn a broken takeover into a\n // hung CI job instead of a failing assertion.\n //\n // Not the assertion — see {@link READ_BACKSTOP_MS}. `backstopped()` below\n // is what proves the clock was not what ended the read.\n const backstop = AbortSignal.timeout(READ_BACKSTOP_MS)\n backstops.push(backstop)\n const bounded = AbortSignal.any([signal, backstop])\n const lines = readJournalNdjson(input.handle, {\n signal: bounded,\n journal: journalOptions(durability, runId),\n })\n const gated = input.beforeFirstChunk\n const source =\n gated === undefined\n ? lines\n : (async function* afterGate() {\n let first = true\n for await (const value of lines) {\n if (first) {\n first = false\n await gated()\n }\n yield value\n }\n })()\n return alignedIfAttaching(translate(runId, source), durability)\n },\n })\n return { driver, backstopped: () => backstops.some((s) => s.aborted) }\n}\n\n/** Exactly what core's `startRunDriver` does: claim, then pipe the drive. */\nfunction takeOver(\n driver: ReturnType<typeof sandboxRunDriver>,\n input: { runs: RunStore; runId: string; threadId: string },\n): Promise<unknown> {\n const { runs, runId, threadId } = input\n return driver.claim({ runs, locks: driver.locks, runId }, (claim) =>\n driver.pipe(driver.drive({ runId, threadId, signal: claim.signal }), {\n runId,\n threadId,\n signal: claim.signal,\n }),\n )\n}\n\n/** Best-effort removal of a case's journal files, through the shell (rule 3). */\nasync function cleanup(handle: SandboxHandle, runId: string): Promise<void> {\n try {\n await handle.process.exec(\n journalCleanupCommand(journalPaths(runId, CONFORMANCE_JOURNAL_DIR)),\n )\n } catch {\n // The sandbox may already be gone. Nothing under test depends on the files\n // being absent afterwards — the cases that DO assert deletion assert it\n // directly.\n }\n}\n\n/**\n * Assert `createHandle` satisfies the takeover conformance contract. Each `it`\n * gets a fresh sandbox via `createHandle`/`dispose`, and a unique `runId`, so no\n * case can observe another's journal.\n */\nexport function runTakeoverConformance(\n config: TakeoverConformanceConfig,\n): void {\n describe(`takeover 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. A real takeover, end to end.\n // ---------------------------------------------------------------------\n it(\n 'delivers the run sequence exactly once when a second driver takes over mid-stream',\n { timeout: 180_000 },\n async () => {\n const { handle, dispose } = await config.createHandle()\n const runId = uniqueRunId('e2e')\n const threadId = `${runId}-t`\n const deltas = ['1', '2', '3', '4', '5', '6']\n const prefixLength = 3\n const expected = expectedTranscript(runId, deltas)\n const runs = await runningRun(runId, threadId)\n const log = conformanceLog()\n try {\n const fresh = durabilityFor(runs, log.log, false)\n\n // THE HOST THAT DIES. A real claim, a real fence, a real journal read\n // of a real agent — and then it stops after `prefixLength` chunks\n // without closing the log and without terminalizing the record, which\n // is what a host vanishing looks like from the outside.\n const deliveredByFirst: Array<StreamChunk> = []\n // A backstop, so a reader that delivers nothing fails instead of\n // parking CI. Not the assertion — `backstopped` below proves it was not\n // what ended the loop.\n const firstBackstop = AbortSignal.timeout(READ_BACKSTOP_MS)\n await withRunClaim(\n { runs, locks: new InMemoryLockStore(), runId },\n async (claim) => {\n const fenced = fenceDurability(log.log, claim, { runs })\n await startJournaledAgent(\n handle,\n agentCommand(deltas, prefixLength),\n { journal: journalOptions(fresh, runId) },\n )\n const lines = readJournalNdjson(handle, {\n signal: firstBackstop,\n journal: journalOptions(fresh, runId),\n })\n for await (const chunk of translate(runId, lines)) {\n await fenced.append([chunk])\n deliveredByFirst.push(chunk)\n // Breaking ends the reader's `tail` before this host walks away;\n // the AGENT keeps running, which is the whole premise.\n if (deliveredByFirst.length === prefixLength) break\n }\n },\n )\n // The causal witness for the dying host's read: it must stop because\n // the consumer broke at `prefixLength`, not because the clock ran out.\n // A backstopped read here delivers a short prefix and the takeover the\n // case exists to exercise would start from the wrong offset.\n expect({ backstopped: firstBackstop.aborted }).toEqual({\n backstopped: false,\n })\n expect(transcript(deliveredByFirst)).toEqual(\n transcript(expected.slice(0, prefixLength)),\n )\n\n // THE SUCCESSOR. Same runId, same journal, a fresh claim.\n const successor = driverFor({\n handle,\n runs,\n locks: new InMemoryLockStore(),\n log: log.log,\n runId,\n attach: true,\n })\n const record = await takeOver(successor.driver, {\n runs,\n runId,\n threadId,\n })\n\n // THE CAUSAL WITNESS, first — see {@link READ_BACKSTOP_MS}. The\n // transcript assertions below can only speak about chunks that\n // arrived; this one says the successor's read ended because the\n // journal ended, not because the clock did. Without it a backstopped\n // read reports as a confusing short-transcript diff.\n expect({ backstopped: successor.backstopped() }).toEqual({\n backstopped: false,\n })\n\n // THE TRANSCRIPT, element for element. This is the assertion that can\n // see the failure the feature exists to prevent: a takeover that\n // replays the journal from byte 0 without aligning re-appends the\n // prefix, so `stored` would be 9 entries beginning `1,2,3,1,2,3,…` —\n // and the user would watch the first half of the run twice. \"Chunks\n // arrived\" passes against that; this does not.\n expect(transcript(log.stored())).toEqual(transcript(expected))\n // Stated separately so a failure reads as what it is rather than as a\n // 9-vs-6 array diff.\n expect(log.stored()).toHaveLength(deltas.length)\n expect(transcript(log.stored().slice(prefixLength))).toEqual(\n transcript(expected.slice(prefixLength)),\n )\n\n const finalRecord = await runs.get(runId)\n expect(finalRecord?.status).toBe('completed')\n // The successor's claim, not the predecessor's: a hardcoded epoch\n // would read 1 here and every takeover would be fenced out.\n expect(finalRecord?.driverEpoch).toBe(2)\n expect(record).not.toBeUndefined()\n } finally {\n await cleanup(handle, runId)\n await dispose()\n }\n },\n )\n\n // ---------------------------------------------------------------------\n // 2. The attach preflight, against a real filesystem.\n // ---------------------------------------------------------------------\n it(\n 'fails an attach to an unknown runId with unknown-run, without waiting it out',\n { timeout: 120_000 },\n async () => {\n const { handle, dispose } = await config.createHandle()\n const runId = uniqueRunId('unknown')\n try {\n expect.hasAssertions()\n const probes = countingExec(handle)\n const error = await awaitAttachableJournal(probes.handle, {\n paths: journalPaths(runId, CONFORMANCE_JOURNAL_DIR),\n runId,\n runs: new InMemoryRunStore(),\n // Generous on purpose: were the store verdict skipped, this would\n // poll for the full 8s and the probe count below would catch it.\n waitMs: 8_000,\n probeIntervalMs: POLL_INTERVAL_MS,\n }).then(\n () => null,\n (reason: unknown) => reason,\n )\n expect(error).toBeInstanceOf(JournalAttachUnavailableError)\n if (!(error instanceof JournalAttachUnavailableError)) return\n expect(error.reason).toBe('unknown-run')\n // Decided from the RECORD, not by waiting it out: one `test -f`, then\n // the store. A preflight that polled to the deadline would run ~80\n // probes here. See `countingExec` for why this is not a stopwatch.\n expect(probes.execs()).toBe(1)\n } finally {\n await dispose()\n }\n },\n )\n\n it(\n 'fails an attach to a terminal run whose journal is gone with terminal-run',\n { timeout: 120_000 },\n async () => {\n const { handle, dispose } = await config.createHandle()\n const runId = uniqueRunId('terminal')\n const threadId = `${runId}-t`\n try {\n expect.hasAssertions()\n const runs = await runningRun(runId, threadId)\n await runs.update(runId, { status: 'completed', finishedAt: 2 })\n const probes = countingExec(handle)\n const error = await awaitAttachableJournal(probes.handle, {\n paths: journalPaths(runId, CONFORMANCE_JOURNAL_DIR),\n runId,\n runs,\n waitMs: 8_000,\n probeIntervalMs: POLL_INTERVAL_MS,\n }).then(\n () => null,\n (reason: unknown) => reason,\n )\n expect(error).toBeInstanceOf(JournalAttachUnavailableError)\n if (!(error instanceof JournalAttachUnavailableError)) return\n expect(error.reason).toBe('terminal-run')\n // One `test -f`, then the record. Not a stopwatch — `countingExec`.\n expect(probes.execs()).toBe(1)\n } finally {\n await dispose()\n }\n },\n )\n\n it(\n 'waits for a live run whose journal appears late — the legitimate race',\n { timeout: 120_000 },\n async () => {\n const { handle, dispose } = await config.createHandle()\n const runId = uniqueRunId('race')\n const threadId = `${runId}-t`\n const paths = journalPaths(runId, CONFORMANCE_JOURNAL_DIR)\n try {\n const runs = await runningRun(runId, threadId)\n // A real driver writing its real first line ~400ms after the attach\n // starts probing. This is the NORMAL case — `journalFollowCommand`'s\n // `: >> file` exists for it — so failing fast here would reintroduce\n // the defect that fix cured.\n const writer = sleep(400).then(() =>\n handle.process.exec(\n journaledCommand(`printf '{\"delta\":\"1\"}\\\\n'`, paths),\n ),\n )\n try {\n await awaitAttachableJournal(handle, {\n paths,\n runId,\n runs,\n // Comfortably longer than the write above; the per-test timeout is\n // what turns a never-resolving wait into a failure.\n waitMs: 20_000,\n probeIntervalMs: POLL_INTERVAL_MS,\n })\n } finally {\n await writer\n }\n // Resolving at all is the assertion; this pins the premise that it\n // resolved because the file really is there now.\n expect(\n (await handle.process.exec(journalExistsCommand(paths))).exitCode,\n ).toBe(0)\n } finally {\n await cleanup(handle, runId)\n await dispose()\n }\n },\n )\n\n it(\n 'bounds the wait for a live run whose journal never appears, with journal-timeout',\n { timeout: 120_000 },\n async () => {\n const { handle, dispose } = await config.createHandle()\n const runId = uniqueRunId('timeout')\n const threadId = `${runId}-t`\n try {\n expect.hasAssertions()\n const runs = await runningRun(runId, threadId)\n const error = await awaitAttachableJournal(handle, {\n paths: journalPaths(runId, CONFORMANCE_JOURNAL_DIR),\n runId,\n runs,\n waitMs: 600,\n probeIntervalMs: POLL_INTERVAL_MS,\n }).then(\n () => null,\n (reason: unknown) => reason,\n )\n expect(error).toBeInstanceOf(JournalAttachUnavailableError)\n if (!(error instanceof JournalAttachUnavailableError)) return\n expect(error.reason).toBe('journal-timeout')\n // The three assertions above ARE the proof the bound was applied: an\n // unbounded wait never produces a `JournalAttachUnavailableError` at\n // all, and `'600ms'` in the message is the configured bound reported\n // back. No stopwatch assertion here on purpose — the case's own\n // `{ timeout: 120_000 }` already converts an unbounded wait into a\n // failure, and a wall-clock ceiling would red a CORRECT implementation\n // on a machine where one `docker exec` was measured at 95s.\n expect(error.message).toContain('600ms')\n } finally {\n await dispose()\n }\n },\n )\n\n // ---------------------------------------------------------------------\n // 3. The epoch fence and the shared latch, under real concurrency.\n // ---------------------------------------------------------------------\n it(\n 'lets the second of two concurrent drivers win, and the loser appends nothing at all',\n { timeout: 180_000 },\n async () => {\n const { handle, dispose } = await config.createHandle()\n const runId = uniqueRunId('fence')\n const threadId = `${runId}-t`\n const deltas = ['1', '2', '3']\n const expected = expectedTranscript(runId, deltas)\n try {\n const runs = await runningRun(runId, threadId)\n const log = conformanceLog()\n // One agent, one journal, two drivers reading it concurrently.\n await startJournaledAgent(\n handle,\n agentCommand(deltas, deltas.length),\n {\n journal: journalOptions(\n durabilityFor(runs, log.log, false),\n runId,\n ),\n },\n )\n\n // The LOSER: the original host, so it does not align (there is nothing\n // stored when it starts). Gated before its first chunk reaches the\n // log, which is where the fence has to catch it — after the successor\n // has claimed and finished. The gate sits INSIDE the source stream, so\n // the loser's alignment snapshot (were it attaching) and its first\n // append both happen after the release, exactly as a host paused by a\n // GC or a VM suspend would.\n const released = gate()\n const losingDriver = driverFor({\n handle,\n runs,\n locks: permissiveLocks,\n log: log.log,\n runId,\n attach: false,\n beforeFirstChunk: () => released.promise,\n })\n const loser = takeOver(losingDriver.driver, {\n runs,\n runId,\n threadId,\n })\n await waitUntil(\n async () => ((await runs.get(runId))?.driverEpoch ?? 0) >= 1,\n {\n timeoutMs: 30_000,\n message: `the first driver never claimed run ${runId}`,\n },\n )\n\n // The WINNER: claims at a higher epoch and drives the run to the end.\n const winner = driverFor({\n handle,\n runs,\n locks: permissiveLocks,\n log: log.log,\n runId,\n attach: true,\n })\n await takeOver(winner.driver, { runs, runId, threadId })\n // The causal witness, before the transcript — see\n // {@link READ_BACKSTOP_MS}. The winner drives the run to its sentinel,\n // so a clock-ended read here must say so rather than surface as a\n // missing chunk.\n expect({ backstopped: winner.backstopped() }).toEqual({\n backstopped: false,\n })\n expect(transcript(log.stored())).toEqual(transcript(expected))\n\n // Now let the superseded host try to write.\n released.open()\n await loser\n // The causal witness, and here it is load-bearing rather than merely\n // diagnostic: the loser's gate is awaited from INSIDE its read, so a\n // backstopped read would abandon the stream during the wait, the loser\n // would never attempt an append at all, and every \"nothing lands\"\n // assertion below would pass vacuously without the fence ever running.\n expect({ backstopped: losingDriver.backstopped() }).toEqual({\n backstopped: false,\n })\n\n // NOTHING lands — not the run's chunks a second time, and not\n // `pipeToRunLog`'s recovery `RUN_ERROR` either. That log belongs to the\n // WINNER: a terminal `RUN_ERROR` from a dead host would fail the stream\n // for every client attached to the live, healthy run.\n expect(transcript(log.stored())).toEqual(transcript(expected))\n expect(\n log.stored().some((chunk) => chunk.type === EventType.RUN_ERROR),\n ).toBe(false)\n // And nothing lands on the RECORD either: `isTerminalRunStatus` must\n // not answer for the loser's view of a run the winner completed.\n const record = await runs.get(runId)\n expect(record?.status).toBe('completed')\n expect(record?.error).toBeUndefined()\n expect(record?.driverEpoch).toBe(2)\n // `close()` is deliberately OUTSIDE both fences: it runs on the very\n // teardown caused by losing the claim, and a fenced close would wedge\n // the record at `'running'` with every live tailer parked forever. Two\n // drivers, two closes.\n expect(log.closes()).toBe(2)\n } finally {\n await cleanup(handle, runId)\n await dispose()\n }\n },\n )\n\n // ---------------------------------------------------------------------\n // 4. Journal cleanup on a terminal run, and the attach that follows it.\n // ---------------------------------------------------------------------\n it(\n \"deletes a terminal run's journal, and a later attach reports terminal-run instead of hanging\",\n { timeout: 180_000 },\n async () => {\n const { handle, dispose } = await config.createHandle()\n const runId = uniqueRunId('cleanup')\n const threadId = `${runId}-t`\n const deltas = ['1', '2']\n const paths = journalPaths(runId, CONFORMANCE_JOURNAL_DIR)\n try {\n const runs = await runningRun(runId, threadId)\n const log = conformanceLog()\n const fresh = durabilityFor(runs, log.log, false)\n await startJournaledAgent(\n handle,\n agentCommand(deltas, deltas.length),\n { journal: journalOptions(fresh, runId) },\n )\n const seen: Array<StreamChunk> = []\n // A backstop, so a reader that delivers nothing fails instead of\n // parking CI. Not the assertion — `backstopped` below proves it was not\n // what ended the loop.\n const backstop = AbortSignal.timeout(READ_BACKSTOP_MS)\n for await (const chunk of translate(\n runId,\n readJournalNdjson(handle, {\n signal: backstop,\n journal: journalOptions(fresh, runId),\n }),\n )) {\n seen.push(chunk)\n }\n // The causal witness, first: this loop has no `break`, so the ONLY\n // honest reasons for it to end are the sentinel or the backstop. A\n // clock-ended read must say so rather than report a short transcript.\n expect({ backstopped: backstop.aborted }).toEqual({\n backstopped: false,\n })\n // Reaching the sentinel is what makes the run terminal, and it is the\n // precondition for the deletion below.\n expect(transcript(seen)).toEqual(\n transcript(expectedTranscript(runId, deltas)),\n )\n\n // Real files, really gone — asserted through the shell, never\n // `handle.fs.exists`: on local-process the two resolve `/tmp`\n // differently, so an `fs` probe would answer about a path the journal\n // was never written to (`journal.ts` rule 3).\n // Named rather than two bare `.not.toBe(0)` assertions on an exit\n // code, so a regression reports WHICH file survived instead of\n // `expected +0 not to be +0`.\n const journalProbe = await handle.process.exec(\n journalExistsCommand(paths),\n )\n const stderrProbe = await handle.process.exec(\n journalExistsCommand({ ...paths, journal: paths.stderr }),\n )\n expect({\n journalDeleted: journalProbe.exitCode !== 0,\n stderrSidecarDeleted: stderrProbe.exitCode !== 0,\n }).toEqual({ journalDeleted: true, stderrSidecarDeleted: true })\n\n // The run is over, so the record says so — and the attach that follows\n // must answer from the record rather than tail the journal, which\n // `journalFollowCommand` would obligingly re-create as an empty file\n // that no sentinel can ever arrive in.\n await runs.update(runId, {\n status: 'completed',\n finishedAt: Date.now(),\n })\n const probes = countingExec(handle)\n const error = await awaitAttachableJournal(probes.handle, {\n paths,\n runId,\n runs,\n waitMs: 8_000,\n probeIntervalMs: POLL_INTERVAL_MS,\n }).then(\n () => null,\n (reason: unknown) => reason,\n )\n expect(error).toBeInstanceOf(JournalAttachUnavailableError)\n if (!(error instanceof JournalAttachUnavailableError)) return\n expect(error.reason).toBe('terminal-run')\n // The journal really is gone (asserted above), so the preflight takes\n // the record arm: one `test -f`, then the store, no wait. This is the\n // bound that failed as `expected 9652 to be less than 4000` under\n // parallel Docker load, where the 9.6s was one `docker exec`\n // round-trip and not the preflight — see `countingExec`.\n expect(probes.execs()).toBe(1)\n } finally {\n await cleanup(handle, runId)\n await dispose()\n }\n },\n )\n })\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"takeover-conformance.js","names":[],"sources":["../../../src/testkit/takeover-conformance.ts"],"sourcesContent":["/**\n * Provider conformance for TAKEOVER: a second driver picking up a run whose\n * first driver died, against a REAL sandbox.\n *\n * WHY THIS EXISTS SEPARATELY FROM THE UNIT TESTS. Every takeover unit test in\n * this package drives fakes — a scripted `spawn`, a `test -f` that answers from\n * a boolean, a log that is an array. Fakes model what we believe the shell and\n * the filesystem do, and on this feature that belief has been wrong three times:\n * `base64` delivers zero bytes on a live pipe, `tail -f` on a missing file exits\n * instead of waiting, and a provider's `kill` does not always reap a grandchild.\n * Each one passed every fake. So the four properties a takeover actually rests\n * on are asserted here through a provider's real `spawn`/`exec` against a real\n * journal file:\n *\n * 1. **The delivered sequence is the run's sequence, with no duplicated\n * prefix.** Asserted as a TRANSCRIPT, never as \"chunks arrived\": a takeover\n * that replays the whole journal and re-appends everything satisfies the weak\n * assertion while showing the user the entire run twice. That is the exact\n * failure `alignToStoredLog` exists to prevent, and the only assertion that\n * can see it is one that compares the stored log to the expected sequence\n * element for element.\n * 2. **The attach preflight decides, or fails, but never hangs.** It probes with\n * the provider's real `exec` (`test -f`), which is the layer where a fake's\n * assumptions break, and its three verdicts (`unknown-run`, `terminal-run`,\n * `journal-timeout`) plus the legitimate late-journal race are all timing\n * against a real filesystem.\n * 3. **The epoch fence and its latch hold under real concurrency.** Two drivers\n * reading one real journal at once: the second wins, the first appends\n * NOTHING — not even `pipeToRunLog`'s recovery `RUN_ERROR` — and cannot\n * terminalize the record out from under the live successor.\n * 4. **A terminal run's journal is deleted, and a later attach says so.** The\n * deletion is a real `rm` of real files, and the follow-up attach must report\n * `terminal-run` rather than tailing the file that `journalFollowCommand`\n * would helpfully re-create.\n *\n * WHAT IS REAL HERE. The provider (its `spawn`, `exec`, and shell), the journal\n * (a real NDJSON file the agent's stdout is redirected into), the agent (a real\n * process writing real lines with a real pause in the middle), the reader\n * (`readJournalNdjson`, including the follow/poll strategy split and the attach\n * preflight), the alignment (`alignedIfAttaching` over the real\n * `resolveSandboxDurability` output), the claim and BOTH fences\n * (`sandboxRunDriver`), and the run record (`InMemoryRunStore`). The event log is\n * in-process, exactly as the recommended `memoryStream` backend is.\n *\n * A provider that cannot satisfy the contract MUST declare `unsupported.reason`.\n * As in the journal suite there is deliberately no silent-skip path: a\n * conformance case that quietly returns prints as a pass, which is how an\n * unimplemented capability ships green.\n *\n * FOUND BY THIS SUITE, FIXED IN THE PROVIDER, STILL NOT ASSERTED HERE. On\n * local-process under Windows (git-bash `sh`), the follow read's `tail`\n * grandchild used to SURVIVE `proc.kill()`: `LocalProcessHandle.killTree` ran\n * `taskkill /PID <sh> /T /F` and returned as soon as `spawnSync` reported no\n * `error`. Two things were wrong. It never checked taskkill's exit status — and\n * that alone would not have caught it, because MSYS's fork emulation leaves the\n * `tail.exe` pointing at an intermediate shell that has already exited, so\n * `taskkill /T` (live parent links only) cannot reach it and still exits `0`.\n * Measured by counting `tail.exe` before and after a run: this suite leaked 4 per\n * run and the shipped journal suite 2, accumulating for the life of the machine.\n * It was a provider defect, not a takeover defect — every case here still\n * delivered the right transcript, because `untilAborted` (see\n * `journal-reader.ts`) stops honoring the pipe once the signal fires rather than\n * waiting for the kill, which is exactly why it never failed a test.\n * `killTree` now resolves the tree through MSYS's own process table and verifies\n * the survivors are gone (0 per run), covered in\n * `ai-sandbox-local-process/tests/kill-tree.test.ts`.\n * Deliberately still NOT asserted in this suite: a per-provider process census is\n * not portable (Docker's `tail` dies with its container), and a conformance case\n * that counted host processes would fail for reasons unrelated to takeover.\n *\n * EVERY WAIT IN THIS FILE IS BOUNDED. A hang stalls CI instead of failing it, so\n * each journal read carries a timeout signal, each poll loop carries a deadline\n * and a message naming what never happened, and each case carries an explicit\n * per-test timeout.\n *\n * Vitest is an OPTIONAL peer dependency: this module is imported only from test\n * files, which already run under Vitest.\n */\nimport { describe, expect, it } from 'vitest'\nimport { EventType, InMemoryRunStore } from '@tanstack/ai'\nimport { InMemoryLockStore } from '@tanstack/ai/locks'\nimport {\n journalCleanupCommand,\n journalExistsCommand,\n journalPaths,\n journaledCommand,\n} from '../journal'\nimport { readJournalNdjson, startJournaledAgent } from '../runner'\nimport {\n JournalAttachUnavailableError,\n awaitAttachableJournal,\n} from '../attach-preflight'\nimport {\n alignedIfAttaching,\n journalOptionsFor,\n resolveSandboxDurability,\n} from '../durability'\nimport { sandboxRunDriver } from '../driver'\nimport { fenceDurability, withRunClaim } from '../claim'\nimport { chunkFingerprint, createRunScopedIdGen } from '../chunk-identity'\nimport type { SandboxRunDurability } from '../durability'\nimport type { JournalOptions } from '../runner'\nimport type { SandboxHandle } from '../contracts'\nimport type { LockStore } from '@tanstack/ai/locks'\nimport type { RunStore, StreamChunk, StreamDurability } from '@tanstack/ai'\n\nexport interface TakeoverConformanceConfig {\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 takeover, 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\n/**\n * Journal directory for this suite, deliberately NOT\n * {@link DEFAULT_JOURNAL_DIR}: on local-process the sandbox shell shares the\n * host's real `/tmp`, so conformance runs must not write where an application's\n * runs live.\n */\nconst CONFORMANCE_JOURNAL_DIR = '/tmp/tanstack-takeover-conformance'\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 successor's first append. Short because the\n * predecessor in these cases has provably stopped (the suite sequenced it) —\n * the 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/**\n * Unique per case, and it must be: `journalPaths` derives the file name 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`). The counter covers two cases created inside the\n * same millisecond; the random suffix covers two suites sharing one `/tmp`.\n */\nlet caseCounter = 0\nfunction uniqueRunId(label: string): string {\n caseCounter += 1\n const suffix = Math.random().toString(36).slice(2, 8)\n return `tko-${label}-${Date.now()}-${caseCounter}-${suffix}`\n}\n\n/**\n * An in-process event log with real accumulated state, plus the two facts the\n * assertions need: what is stored (in append order) and how many times `close()`\n * ran.\n *\n * `snapshot()` returns fresh objects, per the `StreamDurability` contract, so a\n * caller cannot reach the stored log through the result.\n */\ninterface ConformanceLog {\n log: StreamDurability\n /** Stored chunks, in append order. The transcript under test. */\n stored: () => Array<StreamChunk>\n /** `close()` calls — proof that `close` is NOT fenced. */\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 = `conf:${entries.length}`\n entries.push({ offset, chunk })\n return offset\n }),\n ),\n // Nothing in this suite tails the log — every assertion reads the stored\n // transcript with `snapshot()`, which is also what `alignToStoredLog`\n // uses, 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 lock that grants every request immediately and never reports a loss.\n *\n * `InMemoryLockStore` SERIALIZES claims within one process, so a second attach\n * waits for the first to finish and the two drivers are never concurrent — which\n * means the epoch fence can never be observed there. `claim.ts` says exactly\n * that: in one process only layer 2, the `driverEpoch` fence, is provable. This\n * models a lease-less lock so the two drives overlap and layer 2 does the work.\n */\nconst permissiveLocks: LockStore = {\n withLock: (_key, fn) => fn(new AbortController().signal),\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 is REBUILT from them rather than asserted\n * into shape: a cast would let a provider that mangles the bytes (a folded\n * stderr diagnostic, a truncated line) reach `chunkFingerprint` as a\n * structurally invalid chunk and fail somewhere 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 `takeover 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 `takeover conformance: run ${runId} journal line has a non-string delta: ${JSON.stringify(value)}`,\n )\n }\n return contentChunk(messageId, delta)\n}\n\n/**\n * The translator. Deterministic by construction, which is what makes alignment\n * possible at all: the message id comes from {@link createRunScopedIdGen}, so\n * re-translating the same journal from byte 0 reproduces byte-identical chunks\n * (modulo `timestamp`, the one field `chunkFingerprint` excludes).\n */\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/**\n * A comparable transcript: each chunk reduced to its {@link chunkFingerprint}.\n *\n * The fingerprint, not the chunk object, and for the same reason alignment uses\n * it — `timestamp` is wall-clock and unreproducible, so a raw `toEqual` on\n * chunks would fail on the one field the feature deliberately ignores. Every\n * other field participates, so a duplicated prefix, a dropped chunk, or a\n * reordered one still fails.\n */\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 * A real agent: a shell command that prints one NDJSON line per delta, with an\n * optional real pause partway through, then exits.\n *\n * `printf '%s\\n' a b c` reuses the format for every operand on GNU coreutils and\n * on busybox alike, so this needs no loop. The JSON contains only double quotes,\n * so it is safe inside the POSIX single-quoted words this builds.\n */\nfunction agentCommand(deltas: Array<string>, pauseAfter: number): string {\n const line = (delta: string): string => `'{\"delta\":\"${delta}\"}'`\n const head = deltas.slice(0, pauseAfter)\n const tail = deltas.slice(pauseAfter)\n const parts = [`printf '%s\\\\n' ${head.map(line).join(' ')}`]\n if (tail.length > 0) {\n // A real sleep, so the takeover below happens while the agent is genuinely\n // still writing rather than against a finished file.\n parts.push('sleep 2', `printf '%s\\\\n' ${tail.map(line).join(' ')}`)\n }\n return parts.join('; ')\n}\n\n/** Resolve durability through the production resolver, fresh or attaching. */\nfunction durabilityFor(\n runs: RunStore,\n log: StreamDurability,\n attach: boolean,\n): SandboxRunDurability {\n const resolved = resolveSandboxDurability({\n runs,\n durability: {\n adapter: log,\n journal: CONFORMANCE_JOURNAL_DIR,\n attach,\n pollIntervalMs: POLL_INTERVAL_MS,\n },\n })\n if (resolved === undefined) {\n throw new Error(\n 'takeover conformance: resolveSandboxDurability returned undefined for a fully wired run',\n )\n }\n return resolved\n}\n\n/**\n * The reader's journal options for a resolved durability.\n *\n * `journalOptionsFor` answers `undefined` for a NON-durable run, which cannot\n * happen here — every run in this suite is fully wired. Narrowing it with a\n * thrown error rather than a non-null assertion keeps the impossible case loud\n * if the resolver's contract ever changes.\n */\nfunction journalOptions(\n durability: SandboxRunDurability,\n runId: string,\n): JournalOptions {\n const options = journalOptionsFor(durability, runId)\n if (options === undefined) {\n throw new Error(\n `takeover conformance: journalOptionsFor answered undefined for durable run ${runId}`,\n )\n }\n return options\n}\n\n/** A `'running'` record for `runId`, ready to be claimed. */\nasync function runningRun(\n runId: string,\n threadId: string,\n): Promise<InMemoryRunStore> {\n const runs = new InMemoryRunStore()\n await runs.createOrResume({ runId, threadId, startedAt: Date.now() })\n return runs\n}\n\n/**\n * Wrap a handle so the `process.exec` calls ONE operation makes can be counted.\n *\n * This is how the attach preflight's fail-fast cases are anchored, and the reason\n * they are not anchored on elapsed time. `awaitAttachableJournal` runs exactly one\n * `test -f` before it consults the run store, so a decision made from the record\n * costs one `exec` and a decision made by waiting costs one per\n * `probeIntervalMs`. The count separates those two behaviors exactly; elapsed time\n * does not, because a single `exec` is a provider round-trip whose latency the\n * suite does not control — a `docker exec` on a loaded daemon has been measured at\n * 9.6s, which fails a `< 4_000ms` bound while the preflight under test did\n * precisely the right thing. A timing bound that goes red on a busy machine\n * teaches people to ignore the suite.\n *\n * The spread copies the handle's own methods, so everything except `exec` is the\n * provider's; the wrapper delegates rather than reimplementing.\n */\nfunction countingExec(handle: SandboxHandle): {\n handle: SandboxHandle\n execs: () => number\n} {\n let execs = 0\n return {\n handle: {\n ...handle,\n process: {\n ...handle.process,\n exec: (command, options) => {\n execs += 1\n return handle.process.exec(command, options)\n },\n },\n },\n execs: () => execs,\n }\n}\n\n/** Poll `check` until it answers true, or fail with a message naming what never happened. */\nasync function waitUntil(\n check: () => Promise<boolean>,\n options: { timeoutMs: number; message: string },\n): Promise<void> {\n const deadline = Date.now() + options.timeoutMs\n for (;;) {\n if (await check()) return\n if (Date.now() > deadline) {\n throw new Error(\n `takeover conformance: ${options.message} within ${options.timeoutMs}ms`,\n )\n }\n await sleep(25)\n }\n}\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms))\n}\n\ninterface Gate {\n promise: Promise<void>\n open: () => void\n}\n\n/** A one-shot gate, for sequencing two concurrent drivers deterministically. */\nfunction gate(): Gate {\n let open = (): void => {}\n const promise = new Promise<void>((resolve) => {\n open = () => resolve()\n })\n return { promise, open }\n}\n\n/**\n * Build the driver a host would build for one run.\n *\n * `drive` is the real journal path: read the run's journal from byte 0 (through\n * the attach preflight when attaching), translate, and align against the stored\n * log — `alignedIfAttaching`, so alignment runs on an attach and only on an\n * attach.\n *\n * Returns the driver alongside `backstopped()`, the causal witness for\n * {@link READ_BACKSTOP_MS}: every case that drives this must assert it is\n * `false` before its transcript assertions, so a read the CLOCK ended fails\n * naming the backstop instead of as a truncated-transcript diff.\n */\nfunction driverFor(input: {\n handle: SandboxHandle\n runs: RunStore\n locks: LockStore\n log: StreamDurability\n runId: string\n attach: boolean\n /** Awaited before the FIRST translated chunk is yielded, never after. */\n beforeFirstChunk?: () => Promise<void>\n}): {\n driver: ReturnType<typeof sandboxRunDriver>\n /** True if any read this driver started was ended by the backstop clock. */\n backstopped: () => boolean\n} {\n const durability = durabilityFor(input.runs, input.log, input.attach)\n // One entry per `drive` invocation, so a re-drive cannot hide a backstopped\n // read behind a healthy one.\n const backstops: Array<AbortSignal> = []\n const driver = sandboxRunDriver({\n request: new Request(\n `http://takeover.local/attach?runId=${encodeURIComponent(input.runId)}&offset=-1`,\n ),\n runs: input.runs,\n locks: input.locks,\n durability: () => input.log,\n fenceQuietMs: FENCE_QUIET_MS,\n drive: ({ runId, signal }) => {\n // The read is bounded independently of `signal`: an `InMemoryLockStore`\n // hands out a signal it never aborts, so a journal that stops growing\n // would otherwise park this read forever and turn a broken takeover into a\n // hung CI job instead of a failing assertion.\n //\n // Not the assertion — see {@link READ_BACKSTOP_MS}. `backstopped()` below\n // is what proves the clock was not what ended the read.\n const backstop = AbortSignal.timeout(READ_BACKSTOP_MS)\n backstops.push(backstop)\n const bounded = AbortSignal.any([signal, backstop])\n const lines = readJournalNdjson(input.handle, {\n signal: bounded,\n journal: journalOptions(durability, runId),\n })\n const gated = input.beforeFirstChunk\n const source =\n gated === undefined\n ? lines\n : (async function* afterGate() {\n let first = true\n for await (const value of lines) {\n if (first) {\n first = false\n await gated()\n }\n yield value\n }\n })()\n return alignedIfAttaching(translate(runId, source), durability)\n },\n })\n return { driver, backstopped: () => backstops.some((s) => s.aborted) }\n}\n\n/** Exactly what core's `startRunDriver` does: claim, then pipe the drive. */\nfunction takeOver(\n driver: ReturnType<typeof sandboxRunDriver>,\n input: { runs: RunStore; runId: string; threadId: string },\n): Promise<unknown> {\n const { runs, runId, threadId } = input\n return driver.claim({ runs, locks: driver.locks, runId }, (claim) =>\n driver.pipe(driver.drive({ runId, threadId, signal: claim.signal }), {\n runId,\n threadId,\n signal: claim.signal,\n }),\n )\n}\n\n/** Best-effort removal of a case's journal files, through the shell (rule 3). */\nasync function cleanup(handle: SandboxHandle, runId: string): Promise<void> {\n try {\n await handle.process.exec(\n journalCleanupCommand(journalPaths(runId, CONFORMANCE_JOURNAL_DIR)),\n )\n } catch {\n // The sandbox may already be gone. Nothing under test depends on the files\n // being absent afterwards — the cases that DO assert deletion assert it\n // directly.\n }\n}\n\n/**\n * Assert `createHandle` satisfies the takeover conformance contract. Each `it`\n * gets a fresh sandbox via `createHandle`/`dispose`, and a unique `runId`, so no\n * case can observe another's journal.\n */\nexport function runTakeoverConformance(\n config: TakeoverConformanceConfig,\n): void {\n describe(`takeover 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. A real takeover, end to end.\n // ---------------------------------------------------------------------\n it(\n 'delivers the run sequence exactly once when a second driver takes over mid-stream',\n { timeout: 180_000 },\n async () => {\n const { handle, dispose } = await config.createHandle()\n const runId = uniqueRunId('e2e')\n const threadId = `${runId}-t`\n const deltas = ['1', '2', '3', '4', '5', '6']\n const prefixLength = 3\n const expected = expectedTranscript(runId, deltas)\n const runs = await runningRun(runId, threadId)\n const log = conformanceLog()\n try {\n const fresh = durabilityFor(runs, log.log, false)\n\n // THE HOST THAT DIES. A real claim, a real fence, a real journal read\n // of a real agent — and then it stops after `prefixLength` chunks\n // without closing the log and without terminalizing the record, which\n // is what a host vanishing looks like from the outside.\n const deliveredByFirst: Array<StreamChunk> = []\n // A backstop, so a reader that delivers nothing fails instead of\n // parking CI. Not the assertion — `backstopped` below proves it was not\n // what ended the loop.\n const firstBackstop = AbortSignal.timeout(READ_BACKSTOP_MS)\n await withRunClaim(\n { runs, locks: new InMemoryLockStore(), runId },\n async (claim) => {\n const fenced = fenceDurability(log.log, claim, { runs })\n await startJournaledAgent(\n handle,\n agentCommand(deltas, prefixLength),\n { journal: journalOptions(fresh, runId) },\n )\n const lines = readJournalNdjson(handle, {\n signal: firstBackstop,\n journal: journalOptions(fresh, runId),\n })\n for await (const chunk of translate(runId, lines)) {\n await fenced.append([chunk])\n deliveredByFirst.push(chunk)\n // Breaking ends the reader's `tail` before this host walks away;\n // the AGENT keeps running, which is the whole premise.\n if (deliveredByFirst.length === prefixLength) break\n }\n },\n )\n // The causal witness for the dying host's read: it must stop because\n // the consumer broke at `prefixLength`, not because the clock ran out.\n // A backstopped read here delivers a short prefix and the takeover the\n // case exists to exercise would start from the wrong offset.\n expect({ backstopped: firstBackstop.aborted }).toEqual({\n backstopped: false,\n })\n expect(transcript(deliveredByFirst)).toEqual(\n transcript(expected.slice(0, prefixLength)),\n )\n\n // THE SUCCESSOR. Same runId, same journal, a fresh claim.\n const successor = driverFor({\n handle,\n runs,\n locks: new InMemoryLockStore(),\n log: log.log,\n runId,\n attach: true,\n })\n const record = await takeOver(successor.driver, {\n runs,\n runId,\n threadId,\n })\n\n // THE CAUSAL WITNESS, first — see {@link READ_BACKSTOP_MS}. The\n // transcript assertions below can only speak about chunks that\n // arrived; this one says the successor's read ended because the\n // journal ended, not because the clock did. Without it a backstopped\n // read reports as a confusing short-transcript diff.\n expect({ backstopped: successor.backstopped() }).toEqual({\n backstopped: false,\n })\n\n // THE TRANSCRIPT, element for element. This is the assertion that can\n // see the failure the feature exists to prevent: a takeover that\n // replays the journal from byte 0 without aligning re-appends the\n // prefix, so `stored` would be 9 entries beginning `1,2,3,1,2,3,…` —\n // and the user would watch the first half of the run twice. \"Chunks\n // arrived\" passes against that; this does not.\n expect(transcript(log.stored())).toEqual(transcript(expected))\n // Stated separately so a failure reads as what it is rather than as a\n // 9-vs-6 array diff.\n expect(log.stored()).toHaveLength(deltas.length)\n expect(transcript(log.stored().slice(prefixLength))).toEqual(\n transcript(expected.slice(prefixLength)),\n )\n\n const finalRecord = await runs.get(runId)\n expect(finalRecord?.status).toBe('completed')\n // The successor's claim, not the predecessor's: a hardcoded epoch\n // would read 1 here and every takeover would be fenced out.\n expect(finalRecord?.driverEpoch).toBe(2)\n expect(record).not.toBeUndefined()\n } finally {\n await cleanup(handle, runId)\n await dispose()\n }\n },\n )\n\n // ---------------------------------------------------------------------\n // 2. The attach preflight, against a real filesystem.\n // ---------------------------------------------------------------------\n it(\n 'fails an attach to an unknown runId with unknown-run, without waiting it out',\n { timeout: 120_000 },\n async () => {\n const { handle, dispose } = await config.createHandle()\n const runId = uniqueRunId('unknown')\n try {\n expect.hasAssertions()\n const probes = countingExec(handle)\n const error = await awaitAttachableJournal(probes.handle, {\n paths: journalPaths(runId, CONFORMANCE_JOURNAL_DIR),\n runId,\n runs: new InMemoryRunStore(),\n // Generous on purpose: were the store verdict skipped, this would\n // poll for the full 8s and the probe count below would catch it.\n waitMs: 8_000,\n probeIntervalMs: POLL_INTERVAL_MS,\n }).then(\n () => null,\n (reason: unknown) => reason,\n )\n expect(error).toBeInstanceOf(JournalAttachUnavailableError)\n if (!(error instanceof JournalAttachUnavailableError)) return\n expect(error.reason).toBe('unknown-run')\n // Decided from the RECORD, not by waiting it out: one `test -f`, then\n // the store. A preflight that polled to the deadline would run ~80\n // probes here. See `countingExec` for why this is not a stopwatch.\n expect(probes.execs()).toBe(1)\n } finally {\n await dispose()\n }\n },\n )\n\n it(\n 'fails an attach to a terminal run whose journal is gone with terminal-run',\n { timeout: 120_000 },\n async () => {\n const { handle, dispose } = await config.createHandle()\n const runId = uniqueRunId('terminal')\n const threadId = `${runId}-t`\n try {\n expect.hasAssertions()\n const runs = await runningRun(runId, threadId)\n await runs.update(runId, { status: 'completed', finishedAt: 2 })\n const probes = countingExec(handle)\n const error = await awaitAttachableJournal(probes.handle, {\n paths: journalPaths(runId, CONFORMANCE_JOURNAL_DIR),\n runId,\n runs,\n waitMs: 8_000,\n probeIntervalMs: POLL_INTERVAL_MS,\n }).then(\n () => null,\n (reason: unknown) => reason,\n )\n expect(error).toBeInstanceOf(JournalAttachUnavailableError)\n if (!(error instanceof JournalAttachUnavailableError)) return\n expect(error.reason).toBe('terminal-run')\n // One `test -f`, then the record. Not a stopwatch — `countingExec`.\n expect(probes.execs()).toBe(1)\n } finally {\n await dispose()\n }\n },\n )\n\n it(\n 'waits for a live run whose journal appears late — the legitimate race',\n { timeout: 120_000 },\n async () => {\n const { handle, dispose } = await config.createHandle()\n const runId = uniqueRunId('race')\n const threadId = `${runId}-t`\n const paths = journalPaths(runId, CONFORMANCE_JOURNAL_DIR)\n try {\n const runs = await runningRun(runId, threadId)\n // A real driver writing its real first line ~400ms after the attach\n // starts probing. This is the NORMAL case — `journalFollowCommand`'s\n // `: >> file` exists for it — so failing fast here would reintroduce\n // the defect that fix cured.\n const writer = sleep(400).then(() =>\n handle.process.exec(\n journaledCommand(`printf '{\"delta\":\"1\"}\\\\n'`, paths),\n ),\n )\n try {\n await awaitAttachableJournal(handle, {\n paths,\n runId,\n runs,\n // Comfortably longer than the write above; the per-test timeout is\n // what turns a never-resolving wait into a failure.\n waitMs: 20_000,\n probeIntervalMs: POLL_INTERVAL_MS,\n })\n } finally {\n await writer\n }\n // Resolving at all is the assertion; this pins the premise that it\n // resolved because the file really is there now.\n expect(\n (await handle.process.exec(journalExistsCommand(paths))).exitCode,\n ).toBe(0)\n } finally {\n await cleanup(handle, runId)\n await dispose()\n }\n },\n )\n\n it(\n 'bounds the wait for a live run whose journal never appears, with journal-timeout',\n { timeout: 120_000 },\n async () => {\n const { handle, dispose } = await config.createHandle()\n const runId = uniqueRunId('timeout')\n const threadId = `${runId}-t`\n try {\n expect.hasAssertions()\n const runs = await runningRun(runId, threadId)\n const error = await awaitAttachableJournal(handle, {\n paths: journalPaths(runId, CONFORMANCE_JOURNAL_DIR),\n runId,\n runs,\n waitMs: 600,\n probeIntervalMs: POLL_INTERVAL_MS,\n }).then(\n () => null,\n (reason: unknown) => reason,\n )\n expect(error).toBeInstanceOf(JournalAttachUnavailableError)\n if (!(error instanceof JournalAttachUnavailableError)) return\n expect(error.reason).toBe('journal-timeout')\n // The three assertions above ARE the proof the bound was applied: an\n // unbounded wait never produces a `JournalAttachUnavailableError` at\n // all, and `'600ms'` in the message is the configured bound reported\n // back. No stopwatch assertion here on purpose — the case's own\n // `{ timeout: 120_000 }` already converts an unbounded wait into a\n // failure, and a wall-clock ceiling would red a CORRECT implementation\n // on a machine where one `docker exec` was measured at 95s.\n expect(error.message).toContain('600ms')\n } finally {\n await dispose()\n }\n },\n )\n\n // ---------------------------------------------------------------------\n // 3. The epoch fence and the shared latch, under real concurrency.\n // ---------------------------------------------------------------------\n it(\n 'lets the second of two concurrent drivers win, and the loser appends nothing at all',\n { timeout: 180_000 },\n async () => {\n const { handle, dispose } = await config.createHandle()\n const runId = uniqueRunId('fence')\n const threadId = `${runId}-t`\n const deltas = ['1', '2', '3']\n const expected = expectedTranscript(runId, deltas)\n try {\n const runs = await runningRun(runId, threadId)\n const log = conformanceLog()\n // One agent, one journal, two drivers reading it concurrently.\n await startJournaledAgent(\n handle,\n agentCommand(deltas, deltas.length),\n {\n journal: journalOptions(\n durabilityFor(runs, log.log, false),\n runId,\n ),\n },\n )\n\n // The LOSER: the original host, so it does not align (there is nothing\n // stored when it starts). Gated before its first chunk reaches the\n // log, which is where the fence has to catch it — after the successor\n // has claimed and finished. The gate sits INSIDE the source stream, so\n // the loser's alignment snapshot (were it attaching) and its first\n // append both happen after the release, exactly as a host paused by a\n // GC or a VM suspend would.\n const released = gate()\n const losingDriver = driverFor({\n handle,\n runs,\n locks: permissiveLocks,\n log: log.log,\n runId,\n attach: false,\n beforeFirstChunk: () => released.promise,\n })\n const loser = takeOver(losingDriver.driver, {\n runs,\n runId,\n threadId,\n })\n await waitUntil(\n async () => ((await runs.get(runId))?.driverEpoch ?? 0) >= 1,\n {\n timeoutMs: 30_000,\n message: `the first driver never claimed run ${runId}`,\n },\n )\n\n // The WINNER: claims at a higher epoch and drives the run to the end.\n const winner = driverFor({\n handle,\n runs,\n locks: permissiveLocks,\n log: log.log,\n runId,\n attach: true,\n })\n await takeOver(winner.driver, { runs, runId, threadId })\n // The causal witness, before the transcript — see\n // {@link READ_BACKSTOP_MS}. The winner drives the run to its sentinel,\n // so a clock-ended read here must say so rather than surface as a\n // missing chunk.\n expect({ backstopped: winner.backstopped() }).toEqual({\n backstopped: false,\n })\n expect(transcript(log.stored())).toEqual(transcript(expected))\n\n // Now let the superseded host try to write.\n released.open()\n await loser\n // The causal witness, and here it is load-bearing rather than merely\n // diagnostic: the loser's gate is awaited from INSIDE its read, so a\n // backstopped read would abandon the stream during the wait, the loser\n // would never attempt an append at all, and every \"nothing lands\"\n // assertion below would pass vacuously without the fence ever running.\n expect({ backstopped: losingDriver.backstopped() }).toEqual({\n backstopped: false,\n })\n\n // NOTHING lands — not the run's chunks a second time, and not\n // `pipeToRunLog`'s recovery `RUN_ERROR` either. That log belongs to the\n // WINNER: a terminal `RUN_ERROR` from a dead host would fail the stream\n // for every client attached to the live, healthy run.\n expect(transcript(log.stored())).toEqual(transcript(expected))\n expect(\n log.stored().some((chunk) => chunk.type === EventType.RUN_ERROR),\n ).toBe(false)\n // And nothing lands on the RECORD either: `isTerminalRunStatus` must\n // not answer for the loser's view of a run the winner completed.\n const record = await runs.get(runId)\n expect(record?.status).toBe('completed')\n expect(record?.error).toBeUndefined()\n expect(record?.driverEpoch).toBe(2)\n // `close()` is deliberately OUTSIDE both fences: it runs on the very\n // teardown caused by losing the claim, and a fenced close would wedge\n // the record at `'running'` with every live tailer parked forever. Two\n // drivers, two closes.\n expect(log.closes()).toBe(2)\n } finally {\n await cleanup(handle, runId)\n await dispose()\n }\n },\n )\n\n // ---------------------------------------------------------------------\n // 4. Journal cleanup on a terminal run, and the attach that follows it.\n // ---------------------------------------------------------------------\n it(\n \"deletes a terminal run's journal, and a later attach reports terminal-run instead of hanging\",\n { timeout: 180_000 },\n async () => {\n const { handle, dispose } = await config.createHandle()\n const runId = uniqueRunId('cleanup')\n const threadId = `${runId}-t`\n const deltas = ['1', '2']\n const paths = journalPaths(runId, CONFORMANCE_JOURNAL_DIR)\n try {\n const runs = await runningRun(runId, threadId)\n const log = conformanceLog()\n const fresh = durabilityFor(runs, log.log, false)\n await startJournaledAgent(\n handle,\n agentCommand(deltas, deltas.length),\n { journal: journalOptions(fresh, runId) },\n )\n const seen: Array<StreamChunk> = []\n // A backstop, so a reader that delivers nothing fails instead of\n // parking CI. Not the assertion — `backstopped` below proves it was not\n // what ended the loop.\n const backstop = AbortSignal.timeout(READ_BACKSTOP_MS)\n for await (const chunk of translate(\n runId,\n readJournalNdjson(handle, {\n signal: backstop,\n journal: journalOptions(fresh, runId),\n }),\n )) {\n seen.push(chunk)\n }\n // The causal witness, first: this loop has no `break`, so the ONLY\n // honest reasons for it to end are the sentinel or the backstop. A\n // clock-ended read must say so rather than report a short transcript.\n expect({ backstopped: backstop.aborted }).toEqual({\n backstopped: false,\n })\n // Reaching the sentinel is what makes the run terminal, and it is the\n // precondition for the deletion below.\n expect(transcript(seen)).toEqual(\n transcript(expectedTranscript(runId, deltas)),\n )\n\n // Real files, really gone — asserted through the shell, never\n // `handle.fs.exists`: on local-process the two resolve `/tmp`\n // differently, so an `fs` probe would answer about a path the journal\n // was never written to (`journal.ts` rule 3).\n // Named rather than two bare `.not.toBe(0)` assertions on an exit\n // code, so a regression reports WHICH file survived instead of\n // `expected +0 not to be +0`.\n const journalProbe = await handle.process.exec(\n journalExistsCommand(paths),\n )\n const stderrProbe = await handle.process.exec(\n journalExistsCommand({ ...paths, journal: paths.stderr }),\n )\n expect({\n journalDeleted: journalProbe.exitCode !== 0,\n stderrSidecarDeleted: stderrProbe.exitCode !== 0,\n }).toEqual({ journalDeleted: true, stderrSidecarDeleted: true })\n\n // The run is over, so the record says so — and the attach that follows\n // must answer from the record rather than tail the journal, which\n // `journalFollowCommand` would obligingly re-create as an empty file\n // that no sentinel can ever arrive in.\n await runs.update(runId, {\n status: 'completed',\n finishedAt: Date.now(),\n })\n const probes = countingExec(handle)\n const error = await awaitAttachableJournal(probes.handle, {\n paths,\n runId,\n runs,\n waitMs: 8_000,\n probeIntervalMs: POLL_INTERVAL_MS,\n }).then(\n () => null,\n (reason: unknown) => reason,\n )\n expect(error).toBeInstanceOf(JournalAttachUnavailableError)\n if (!(error instanceof JournalAttachUnavailableError)) return\n expect(error.reason).toBe('terminal-run')\n // The journal really is gone (asserted above), so the preflight takes\n // the record arm: one `test -f`, then the store, no wait. This is the\n // bound that failed as `expected 9652 to be less than 4000` under\n // parallel Docker load, where the 9.6s was one `docker exec`\n // round-trip and not the preflight — see `countingExec`.\n expect(probes.execs()).toBe(1)\n } finally {\n await cleanup(handle, runId)\n await dispose()\n }\n },\n )\n })\n}\n"],"mappings":"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"}
@@ -32,7 +32,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprot
32
32
  var BRIDGED_MCP_SERVER_NAME = "tanstack";
33
33
  /** Hostname the sandbox uses to reach the bridge endpoint, per provider. */
34
34
  function hostForSandbox(provider) {
35
- return provider === "docker" ? "host.docker.internal" : "127.0.0.1";
35
+ return provider === "docker" || provider === "sbx" ? "host.docker.internal" : "127.0.0.1";
36
36
  }
37
37
  /**
38
38
  * Coerce a tool's `inputSchema` into the object-schema shape MCP advertises,
@@ -1 +1 @@
1
- {"version":3,"file":"tool-bridge.js","names":[],"sources":["../../src/tool-bridge.ts"],"sourcesContent":["/**\n * MCP tool-proxy bridge, shared by all harness adapters.\n *\n * Exposes chat()-provided server tools to an in-sandbox agent as an MCP server.\n * The agent (inside the sandbox) calls `mcp__tanstack__<tool>`; the call is\n * proxied OUT to a bridge endpoint, where the tool's `execute()` runs in the\n * orchestrator process (with its closures / DB / secrets), and the result is\n * returned into the sandbox.\n *\n * The bridge is split into a transport-agnostic CORE and a TRANSPORT:\n * - {@link createToolBridgeCore} owns tool dispatch + the permission resolver\n * (no I/O). It is what makes the bridge portable.\n * - {@link startHostToolBridge} is the `node:http` transport for a long-running\n * host (laptop / CI / Docker orchestrator). It binds loopback unless the\n * sandbox must reach it via `host.docker.internal`, and authenticates with a\n * constant-time bearer check.\n * - A serverless/edge orchestrator (e.g. a Durable Object) instead serves the\n * SAME core from its own `fetch` handler — no raw TCP listener — see\n * {@link handleBridgeJsonRpc} and the Cloudflare example.\n */\nimport { createServer } from 'node:http'\nimport { randomBytes, timingSafeEqual } from 'node:crypto'\nimport { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'\nimport { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'\nimport {\n CallToolRequestSchema,\n ListToolsRequestSchema,\n} from '@modelcontextprotocol/sdk/types.js'\nimport type { AddressInfo } from 'node:net'\nimport type { AnyTool } from '@tanstack/ai'\n\n/**\n * Name of the bridged MCP server. The agent sees tools as\n * `mcp__tanstack__<tool>`; each adapter's stream translator strips this prefix\n * so tool-call events match the names the application registered.\n */\nexport const BRIDGED_MCP_SERVER_NAME = 'tanstack'\n\n/** Hostname the sandbox uses to reach the bridge endpoint, per provider. */\nexport function hostForSandbox(provider: string): string {\n return provider === 'docker' ? 'host.docker.internal' : '127.0.0.1'\n}\n\n/** Result of a permission decision returned to the harness's prompt tool. */\nexport interface PermissionToolResult {\n behavior: 'allow' | 'deny'\n message?: string\n updatedInput?: unknown\n}\n\nexport interface BridgePermission {\n toolName: string\n resolve: (input: {\n tool_name?: string\n input?: unknown\n }) => PermissionToolResult | Promise<PermissionToolResult>\n}\n\nexport interface ToolBridgeCoreOptions {\n /** Runtime context forwarded to each tool's `execute()`. */\n context?: unknown\n /** Abort signal forwarded to each tool's `execute()`. */\n signal?: AbortSignal\n /**\n * Forwarded to each tool's `execute()` so a bridged tool can stream progress /\n * custom events back to the client mid-execution (e.g. code mode's\n * `code_mode:console` logs). Without it those events are silently dropped — the\n * bridge runs out-of-band from the main tool executor, so the executor's own\n * `emitCustomEvent` never reaches a bridged tool. The harness adapter supplies\n * one that injects a CUSTOM chunk into its live output stream.\n */\n emitCustomEvent?: (eventName: string, value: Record<string, unknown>) => void\n /**\n * Optional permission-prompt tool (e.g. for Claude Code's\n * `--permission-prompt-tool`). When set, the bridge exposes an extra MCP tool\n * `<name>` whose handler returns the orchestrator's allow/deny decision.\n */\n permission?: BridgePermission\n}\n\n/** An MCP tool descriptor as advertised to the in-sandbox agent. */\nexport interface ToolDescriptor {\n name: string\n description?: string\n inputSchema: { type: 'object'; [key: string]: unknown }\n}\n\n/**\n * Coerce a tool's `inputSchema` into the object-schema shape MCP advertises,\n * substituting an empty object schema when it isn't already a JSON-schema object\n * (project rule: a guard, not an `as` cast).\n */\nfunction toObjectSchema(schema: unknown): {\n type: 'object'\n [key: string]: unknown\n} {\n if (\n schema !== null &&\n typeof schema === 'object' &&\n 'type' in schema &&\n schema.type === 'object'\n ) {\n return { ...schema, type: 'object' }\n }\n return { type: 'object', properties: {} }\n}\n\n/** MCP `tools/call` result shape. */\nexport interface ToolCallResult {\n content: Array<{ type: 'text'; text: string }>\n isError?: boolean\n}\n\n/**\n * Transport-agnostic bridge logic: list tools, and dispatch a tool/permission\n * call. No sockets, no auth — a transport ({@link startHostToolBridge} or a\n * `fetch` handler) wraps this and owns I/O + the bearer check.\n */\nexport interface ToolBridgeCore {\n listTools: () => Array<ToolDescriptor>\n callTool: (name: string, args: unknown) => Promise<ToolCallResult>\n}\n\n/** Build the transport-agnostic bridge core for the given tools. */\nexport function createToolBridgeCore(\n tools: Array<AnyTool>,\n options: ToolBridgeCoreOptions = {},\n): ToolBridgeCore {\n const toolsByName = new Map(tools.map((tool) => [tool.name, tool]))\n const permission = options.permission\n\n const permissionDescriptor: ToolDescriptor | undefined = permission\n ? {\n name: permission.toolName,\n description:\n 'Permission prompt: returns {behavior:\"allow\"|\"deny\"} for a requested action.',\n inputSchema: { type: 'object', properties: {} },\n }\n : undefined\n\n return {\n listTools() {\n return [\n ...tools.map((tool) => ({\n name: tool.name,\n description: tool.description,\n inputSchema: toObjectSchema(tool.inputSchema),\n })),\n ...(permissionDescriptor ? [permissionDescriptor] : []),\n ]\n },\n\n async callTool(name, args) {\n if (permission && name === permission.toolName) {\n const result = await permission.resolve(args ?? {})\n return { content: [{ type: 'text', text: JSON.stringify(result) }] }\n }\n const tool = toolsByName.get(name)\n if (!tool?.execute) throw new Error(`Unknown tool: ${name}`)\n try {\n const result: unknown = await tool.execute(args ?? {}, {\n context: options.context,\n abortSignal: options.signal,\n // No-op default so tools that always call it (e.g. code mode) don't\n // crash when the transport didn't wire a sink.\n emitCustomEvent: options.emitCustomEvent ?? (() => {}),\n })\n const text =\n typeof result === 'string' ? result : JSON.stringify(result)\n return { content: [{ type: 'text', text }] }\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n return {\n isError: true,\n content: [\n { type: 'text', text: `Tool execution failed: ${message}` },\n ],\n }\n }\n },\n }\n}\n\n/**\n * Minimal JSON-RPC dispatcher over a {@link ToolBridgeCore}, so a `fetch`-based\n * transport (Worker / Durable Object) can serve MCP `initialize` / `tools/list`\n * / `tools/call` without the node-specific HTTP transport. Returns the JSON-RPC\n * response object, or `null` for a notification (no `id`).\n */\nexport async function handleBridgeJsonRpc(\n core: ToolBridgeCore,\n message: unknown,\n): Promise<unknown> {\n if (message === null || typeof message !== 'object') {\n return {\n jsonrpc: '2.0',\n id: null,\n error: { code: -32600, message: 'Invalid Request' },\n }\n }\n const rpc = message as { id?: unknown; method?: unknown; params?: unknown }\n const id = rpc.id ?? null\n const respond = (result: unknown): unknown => ({ jsonrpc: '2.0', id, result })\n switch (rpc.method) {\n case 'initialize':\n return respond({\n protocolVersion: '2024-11-05',\n capabilities: { tools: {} },\n serverInfo: { name: BRIDGED_MCP_SERVER_NAME, version: '1.0.0' },\n })\n case 'notifications/initialized':\n return null\n case 'tools/list':\n return respond({ tools: core.listTools() })\n case 'tools/call': {\n const params = (rpc.params ?? {}) as {\n name?: unknown\n arguments?: unknown\n }\n if (typeof params.name !== 'string') {\n return {\n jsonrpc: '2.0',\n id,\n error: { code: -32602, message: 'Invalid params: name' },\n }\n }\n return respond(await core.callTool(params.name, params.arguments ?? {}))\n }\n default:\n return {\n jsonrpc: '2.0',\n id,\n error: { code: -32601, message: 'Method not found' },\n }\n }\n}\n\n/**\n * Constant-time check of an `Authorization: Bearer <token>` header against the\n * expected token. Length mismatch returns false early (token length is not\n * secret); equal-length comparison is timing-safe.\n */\nexport function timingSafeBearerEqual(\n header: string | undefined,\n token: string,\n): boolean {\n if (header === undefined) return false\n const a = Buffer.from(header)\n const b = Buffer.from(`Bearer ${token}`)\n if (a.length !== b.length) return false\n return timingSafeEqual(a, b)\n}\n\nexport interface HostToolBridge {\n /** MCP server name; tools appear to the agent as `mcp__<name>__<tool>`. */\n name: string\n /** URL the SANDBOX uses to reach this bridge. */\n url: string\n /** Per-run bearer token gating the endpoint. */\n token: string\n close: () => Promise<void>\n}\n\nexport interface StartBridgeOptions extends ToolBridgeCoreOptions {\n /** Hostname the sandbox uses to reach the host (e.g. `host.docker.internal`). */\n hostForSandbox: string\n /**\n * Address to bind the listener to. Defaults to `127.0.0.1` (loopback) and is\n * widened to `0.0.0.0` only when the sandbox reaches the host via\n * `host.docker.internal` (a container can't reach the host's loopback).\n */\n bindAddress?: string\n}\n\nfunction buildMcpServer(core: ToolBridgeCore): McpServer {\n const server = new McpServer(\n { name: BRIDGED_MCP_SERVER_NAME, version: '1.0.0' },\n { capabilities: { tools: {} } },\n )\n server.server.setRequestHandler(ListToolsRequestSchema, () => ({\n tools: core.listTools(),\n }))\n server.server.setRequestHandler(CallToolRequestSchema, async (request) => {\n const result = await core.callTool(\n request.params.name,\n request.params.arguments ?? {},\n )\n return {\n content: result.content,\n ...(result.isError ? { isError: true } : {}),\n }\n })\n return server\n}\n\n/**\n * Start the `node:http` MCP tool-proxy bridge for the given tools. For a\n * long-running host (laptop / CI / Docker orchestrator). Serverless/edge\n * orchestrators serve {@link createToolBridgeCore} from their own `fetch`\n * handler instead.\n */\nexport async function startHostToolBridge(\n tools: Array<AnyTool>,\n options: StartBridgeOptions,\n): Promise<HostToolBridge> {\n const token = randomBytes(24).toString('hex')\n const core = createToolBridgeCore(tools, options)\n // Loopback by default; widen to all interfaces only for the Docker bridge,\n // which a container reaches via host.docker.internal (host gateway).\n const bindAddress =\n options.bindAddress ??\n (options.hostForSandbox === 'host.docker.internal'\n ? '0.0.0.0'\n : '127.0.0.1')\n\n const httpServer = createServer((req, res) => {\n void (async () => {\n if (!timingSafeBearerEqual(req.headers['authorization'], token)) {\n res.writeHead(401).end('unauthorized')\n return\n }\n const server = buildMcpServer(core)\n const transport = new StreamableHTTPServerTransport({\n sessionIdGenerator: undefined,\n })\n res.on('close', () => {\n void transport.close()\n void server.close()\n })\n await server.connect(transport)\n\n let body = ''\n for await (const chunk of req) body += chunk\n let parsed: unknown\n try {\n parsed = body ? JSON.parse(body) : undefined\n } catch {\n // Malformed agent request → 400, distinct from an internal 500.\n if (!res.headersSent) res.writeHead(400).end('invalid JSON body')\n return\n }\n await transport.handleRequest(req, res, parsed)\n })().catch((error: unknown) => {\n // Log the underlying fault — on the host/Docker path there is no run-log\n // capturing it, so swallowing it leaves an operator with nothing.\n console.error('[tool-bridge] request handler failed:', error)\n if (!res.headersSent) res.writeHead(500).end('bridge error')\n })\n })\n\n await new Promise<void>((resolve) =>\n httpServer.listen(0, bindAddress, resolve),\n )\n const port = (httpServer.address() as AddressInfo).port\n const url = `http://${options.hostForSandbox}:${port}/mcp`\n\n return {\n name: BRIDGED_MCP_SERVER_NAME,\n url,\n token,\n close: () =>\n new Promise<void>((resolve) => httpServer.close(() => resolve())),\n }\n}\n\n/** A provisioned, reachable bridge endpoint (same shape as {@link HostToolBridge}). */\nexport type ProvisionedBridge = HostToolBridge\n\nexport interface ToolBridgeProvisionOptions extends ToolBridgeCoreOptions {\n /** Sandbox provider name, to derive how the sandbox reaches the bridge. */\n provider: string\n}\n\n/**\n * Stands up the tool-bridge endpoint for a run. The seam that makes the bridge\n * portable across runtimes: a harness adapter asks its capability context for a\n * provisioner and uses {@link nodeHttpBridgeProvisioner} as the default (host /\n * Docker). A serverless/edge orchestrator PROVIDES its own — e.g. a Durable\n * Object that mounts {@link createToolBridgeCore} / {@link handleBridgeJsonRpc}\n * on its `fetch` handler and returns a sandbox-reachable URL — so no raw TCP\n * listener is needed.\n */\nexport interface ToolBridgeProvisioner {\n provision: (\n tools: Array<AnyTool>,\n options: ToolBridgeProvisionOptions,\n ) => Promise<ProvisionedBridge>\n}\n\n/** Default provisioner: a `node:http` listener on the host. */\nexport const nodeHttpBridgeProvisioner: ToolBridgeProvisioner = {\n provision(tools, options) {\n const { provider, ...core } = options\n return startHostToolBridge(tools, {\n hostForSandbox: hostForSandbox(provider),\n ...core,\n })\n },\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"tool-bridge.js","names":[],"sources":["../../src/tool-bridge.ts"],"sourcesContent":["/**\n * MCP tool-proxy bridge, shared by all harness adapters.\n *\n * Exposes chat()-provided server tools to an in-sandbox agent as an MCP server.\n * The agent (inside the sandbox) calls `mcp__tanstack__<tool>`; the call is\n * proxied OUT to a bridge endpoint, where the tool's `execute()` runs in the\n * orchestrator process (with its closures / DB / secrets), and the result is\n * returned into the sandbox.\n *\n * The bridge is split into a transport-agnostic CORE and a TRANSPORT:\n * - {@link createToolBridgeCore} owns tool dispatch + the permission resolver\n * (no I/O). It is what makes the bridge portable.\n * - {@link startHostToolBridge} is the `node:http` transport for a long-running\n * host (laptop / CI / Docker orchestrator). It binds loopback unless the\n * sandbox must reach it via `host.docker.internal`, and authenticates with a\n * constant-time bearer check.\n * - A serverless/edge orchestrator (e.g. a Durable Object) instead serves the\n * SAME core from its own `fetch` handler — no raw TCP listener — see\n * {@link handleBridgeJsonRpc} and the Cloudflare example.\n */\nimport { createServer } from 'node:http'\nimport { randomBytes, timingSafeEqual } from 'node:crypto'\nimport { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'\nimport { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'\nimport {\n CallToolRequestSchema,\n ListToolsRequestSchema,\n} from '@modelcontextprotocol/sdk/types.js'\nimport type { AddressInfo } from 'node:net'\nimport type { AnyTool } from '@tanstack/ai'\n\n/**\n * Name of the bridged MCP server. The agent sees tools as\n * `mcp__tanstack__<tool>`; each adapter's stream translator strips this prefix\n * so tool-call events match the names the application registered.\n */\nexport const BRIDGED_MCP_SERVER_NAME = 'tanstack'\n\n/** Hostname the sandbox uses to reach the bridge endpoint, per provider. */\nexport function hostForSandbox(provider: string): string {\n return provider === 'docker' || provider === 'sbx'\n ? 'host.docker.internal'\n : '127.0.0.1'\n}\n\n/** Result of a permission decision returned to the harness's prompt tool. */\nexport interface PermissionToolResult {\n behavior: 'allow' | 'deny'\n message?: string\n updatedInput?: unknown\n}\n\nexport interface BridgePermission {\n toolName: string\n resolve: (input: {\n tool_name?: string\n input?: unknown\n }) => PermissionToolResult | Promise<PermissionToolResult>\n}\n\nexport interface ToolBridgeCoreOptions {\n /** Runtime context forwarded to each tool's `execute()`. */\n context?: unknown\n /** Abort signal forwarded to each tool's `execute()`. */\n signal?: AbortSignal\n /**\n * Forwarded to each tool's `execute()` so a bridged tool can stream progress /\n * custom events back to the client mid-execution (e.g. code mode's\n * `code_mode:console` logs). Without it those events are silently dropped — the\n * bridge runs out-of-band from the main tool executor, so the executor's own\n * `emitCustomEvent` never reaches a bridged tool. The harness adapter supplies\n * one that injects a CUSTOM chunk into its live output stream.\n */\n emitCustomEvent?: (eventName: string, value: Record<string, unknown>) => void\n /**\n * Optional permission-prompt tool (e.g. for Claude Code's\n * `--permission-prompt-tool`). When set, the bridge exposes an extra MCP tool\n * `<name>` whose handler returns the orchestrator's allow/deny decision.\n */\n permission?: BridgePermission\n}\n\n/** An MCP tool descriptor as advertised to the in-sandbox agent. */\nexport interface ToolDescriptor {\n name: string\n description?: string\n inputSchema: { type: 'object'; [key: string]: unknown }\n}\n\n/**\n * Coerce a tool's `inputSchema` into the object-schema shape MCP advertises,\n * substituting an empty object schema when it isn't already a JSON-schema object\n * (project rule: a guard, not an `as` cast).\n */\nfunction toObjectSchema(schema: unknown): {\n type: 'object'\n [key: string]: unknown\n} {\n if (\n schema !== null &&\n typeof schema === 'object' &&\n 'type' in schema &&\n schema.type === 'object'\n ) {\n return { ...schema, type: 'object' }\n }\n return { type: 'object', properties: {} }\n}\n\n/** MCP `tools/call` result shape. */\nexport interface ToolCallResult {\n content: Array<{ type: 'text'; text: string }>\n isError?: boolean\n}\n\n/**\n * Transport-agnostic bridge logic: list tools, and dispatch a tool/permission\n * call. No sockets, no auth — a transport ({@link startHostToolBridge} or a\n * `fetch` handler) wraps this and owns I/O + the bearer check.\n */\nexport interface ToolBridgeCore {\n listTools: () => Array<ToolDescriptor>\n callTool: (name: string, args: unknown) => Promise<ToolCallResult>\n}\n\n/** Build the transport-agnostic bridge core for the given tools. */\nexport function createToolBridgeCore(\n tools: Array<AnyTool>,\n options: ToolBridgeCoreOptions = {},\n): ToolBridgeCore {\n const toolsByName = new Map(tools.map((tool) => [tool.name, tool]))\n const permission = options.permission\n\n const permissionDescriptor: ToolDescriptor | undefined = permission\n ? {\n name: permission.toolName,\n description:\n 'Permission prompt: returns {behavior:\"allow\"|\"deny\"} for a requested action.',\n inputSchema: { type: 'object', properties: {} },\n }\n : undefined\n\n return {\n listTools() {\n return [\n ...tools.map((tool) => ({\n name: tool.name,\n description: tool.description,\n inputSchema: toObjectSchema(tool.inputSchema),\n })),\n ...(permissionDescriptor ? [permissionDescriptor] : []),\n ]\n },\n\n async callTool(name, args) {\n if (permission && name === permission.toolName) {\n const result = await permission.resolve(args ?? {})\n return { content: [{ type: 'text', text: JSON.stringify(result) }] }\n }\n const tool = toolsByName.get(name)\n if (!tool?.execute) throw new Error(`Unknown tool: ${name}`)\n try {\n const result: unknown = await tool.execute(args ?? {}, {\n context: options.context,\n abortSignal: options.signal,\n // No-op default so tools that always call it (e.g. code mode) don't\n // crash when the transport didn't wire a sink.\n emitCustomEvent: options.emitCustomEvent ?? (() => {}),\n })\n const text =\n typeof result === 'string' ? result : JSON.stringify(result)\n return { content: [{ type: 'text', text }] }\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n return {\n isError: true,\n content: [\n { type: 'text', text: `Tool execution failed: ${message}` },\n ],\n }\n }\n },\n }\n}\n\n/**\n * Minimal JSON-RPC dispatcher over a {@link ToolBridgeCore}, so a `fetch`-based\n * transport (Worker / Durable Object) can serve MCP `initialize` / `tools/list`\n * / `tools/call` without the node-specific HTTP transport. Returns the JSON-RPC\n * response object, or `null` for a notification (no `id`).\n */\nexport async function handleBridgeJsonRpc(\n core: ToolBridgeCore,\n message: unknown,\n): Promise<unknown> {\n if (message === null || typeof message !== 'object') {\n return {\n jsonrpc: '2.0',\n id: null,\n error: { code: -32600, message: 'Invalid Request' },\n }\n }\n const rpc = message as { id?: unknown; method?: unknown; params?: unknown }\n const id = rpc.id ?? null\n const respond = (result: unknown): unknown => ({ jsonrpc: '2.0', id, result })\n switch (rpc.method) {\n case 'initialize':\n return respond({\n protocolVersion: '2024-11-05',\n capabilities: { tools: {} },\n serverInfo: { name: BRIDGED_MCP_SERVER_NAME, version: '1.0.0' },\n })\n case 'notifications/initialized':\n return null\n case 'tools/list':\n return respond({ tools: core.listTools() })\n case 'tools/call': {\n const params = (rpc.params ?? {}) as {\n name?: unknown\n arguments?: unknown\n }\n if (typeof params.name !== 'string') {\n return {\n jsonrpc: '2.0',\n id,\n error: { code: -32602, message: 'Invalid params: name' },\n }\n }\n return respond(await core.callTool(params.name, params.arguments ?? {}))\n }\n default:\n return {\n jsonrpc: '2.0',\n id,\n error: { code: -32601, message: 'Method not found' },\n }\n }\n}\n\n/**\n * Constant-time check of an `Authorization: Bearer <token>` header against the\n * expected token. Length mismatch returns false early (token length is not\n * secret); equal-length comparison is timing-safe.\n */\nexport function timingSafeBearerEqual(\n header: string | undefined,\n token: string,\n): boolean {\n if (header === undefined) return false\n const a = Buffer.from(header)\n const b = Buffer.from(`Bearer ${token}`)\n if (a.length !== b.length) return false\n return timingSafeEqual(a, b)\n}\n\nexport interface HostToolBridge {\n /** MCP server name; tools appear to the agent as `mcp__<name>__<tool>`. */\n name: string\n /** URL the SANDBOX uses to reach this bridge. */\n url: string\n /** Per-run bearer token gating the endpoint. */\n token: string\n close: () => Promise<void>\n}\n\nexport interface StartBridgeOptions extends ToolBridgeCoreOptions {\n /** Hostname the sandbox uses to reach the host (e.g. `host.docker.internal`). */\n hostForSandbox: string\n /**\n * Address to bind the listener to. Defaults to `127.0.0.1` (loopback) and is\n * widened to `0.0.0.0` only when the sandbox reaches the host via\n * `host.docker.internal` (a container can't reach the host's loopback).\n */\n bindAddress?: string\n}\n\nfunction buildMcpServer(core: ToolBridgeCore): McpServer {\n const server = new McpServer(\n { name: BRIDGED_MCP_SERVER_NAME, version: '1.0.0' },\n { capabilities: { tools: {} } },\n )\n server.server.setRequestHandler(ListToolsRequestSchema, () => ({\n tools: core.listTools(),\n }))\n server.server.setRequestHandler(CallToolRequestSchema, async (request) => {\n const result = await core.callTool(\n request.params.name,\n request.params.arguments ?? {},\n )\n return {\n content: result.content,\n ...(result.isError ? { isError: true } : {}),\n }\n })\n return server\n}\n\n/**\n * Start the `node:http` MCP tool-proxy bridge for the given tools. For a\n * long-running host (laptop / CI / Docker orchestrator). Serverless/edge\n * orchestrators serve {@link createToolBridgeCore} from their own `fetch`\n * handler instead.\n */\nexport async function startHostToolBridge(\n tools: Array<AnyTool>,\n options: StartBridgeOptions,\n): Promise<HostToolBridge> {\n const token = randomBytes(24).toString('hex')\n const core = createToolBridgeCore(tools, options)\n // Loopback by default; widen to all interfaces only for the Docker bridge,\n // which a container reaches via host.docker.internal (host gateway).\n const bindAddress =\n options.bindAddress ??\n (options.hostForSandbox === 'host.docker.internal'\n ? '0.0.0.0'\n : '127.0.0.1')\n\n const httpServer = createServer((req, res) => {\n void (async () => {\n if (!timingSafeBearerEqual(req.headers['authorization'], token)) {\n res.writeHead(401).end('unauthorized')\n return\n }\n const server = buildMcpServer(core)\n const transport = new StreamableHTTPServerTransport({\n sessionIdGenerator: undefined,\n })\n res.on('close', () => {\n void transport.close()\n void server.close()\n })\n await server.connect(transport)\n\n let body = ''\n for await (const chunk of req) body += chunk\n let parsed: unknown\n try {\n parsed = body ? JSON.parse(body) : undefined\n } catch {\n // Malformed agent request → 400, distinct from an internal 500.\n if (!res.headersSent) res.writeHead(400).end('invalid JSON body')\n return\n }\n await transport.handleRequest(req, res, parsed)\n })().catch((error: unknown) => {\n // Log the underlying fault — on the host/Docker path there is no run-log\n // capturing it, so swallowing it leaves an operator with nothing.\n console.error('[tool-bridge] request handler failed:', error)\n if (!res.headersSent) res.writeHead(500).end('bridge error')\n })\n })\n\n await new Promise<void>((resolve) =>\n httpServer.listen(0, bindAddress, resolve),\n )\n const port = (httpServer.address() as AddressInfo).port\n const url = `http://${options.hostForSandbox}:${port}/mcp`\n\n return {\n name: BRIDGED_MCP_SERVER_NAME,\n url,\n token,\n close: () =>\n new Promise<void>((resolve) => httpServer.close(() => resolve())),\n }\n}\n\n/** A provisioned, reachable bridge endpoint (same shape as {@link HostToolBridge}). */\nexport type ProvisionedBridge = HostToolBridge\n\nexport interface ToolBridgeProvisionOptions extends ToolBridgeCoreOptions {\n /** Sandbox provider name, to derive how the sandbox reaches the bridge. */\n provider: string\n}\n\n/**\n * Stands up the tool-bridge endpoint for a run. The seam that makes the bridge\n * portable across runtimes: a harness adapter asks its capability context for a\n * provisioner and uses {@link nodeHttpBridgeProvisioner} as the default (host /\n * Docker). A serverless/edge orchestrator PROVIDES its own — e.g. a Durable\n * Object that mounts {@link createToolBridgeCore} / {@link handleBridgeJsonRpc}\n * on its `fetch` handler and returns a sandbox-reachable URL — so no raw TCP\n * listener is needed.\n */\nexport interface ToolBridgeProvisioner {\n provision: (\n tools: Array<AnyTool>,\n options: ToolBridgeProvisionOptions,\n ) => Promise<ProvisionedBridge>\n}\n\n/** Default provisioner: a `node:http` listener on the host. */\nexport const nodeHttpBridgeProvisioner: ToolBridgeProvisioner = {\n provision(tools, options) {\n const { provider, ...core } = options\n return startHostToolBridge(tools, {\n hostForSandbox: hostForSandbox(provider),\n ...core,\n })\n },\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/ai-sandbox",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "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.",
5
5
  "author": "",
6
6
  "license": "MIT",
@@ -44,13 +44,23 @@
44
44
  "src",
45
45
  "skills"
46
46
  ],
47
+ "nx": {
48
+ "targets": {
49
+ "test:types": {
50
+ "dependsOn": [
51
+ "build",
52
+ "^build"
53
+ ]
54
+ }
55
+ }
56
+ },
47
57
  "dependencies": {
48
58
  "@modelcontextprotocol/sdk": "^1.29.0"
49
59
  },
50
60
  "peerDependencies": {
51
61
  "@ngrok/ngrok": "^1.0.0",
52
62
  "vitest": "^4.1.10",
53
- "@tanstack/ai": "^0.44.0"
63
+ "@tanstack/ai": "^0.45.0"
54
64
  },
55
65
  "peerDependenciesMeta": {
56
66
  "@ngrok/ngrok": {
@@ -62,9 +72,9 @@
62
72
  },
63
73
  "devDependencies": {
64
74
  "@ngrok/ngrok": "^1.7.0",
65
- "@vitest/coverage-v8": "4.0.14",
75
+ "@vitest/coverage-v8": "4.1.10",
66
76
  "vitest": "^4.1.10",
67
- "@tanstack/ai": "0.44.0"
77
+ "@tanstack/ai": "0.45.0"
68
78
  },
69
79
  "scripts": {
70
80
  "build": "vite build",
@@ -189,8 +189,13 @@ Providers without snapshot support skip the step silently.
189
189
 
190
190
  - `localProcessSandbox()` — runs on the host (no isolation; dev loop only).
191
191
  - `dockerSandbox({ image })` — isolated container; snapshots, fork, resume-by-id.
192
+ - `daytonaSandbox({ apiKey, snapshot, autoStopInterval, ephemeral })` —
193
+ Daytona cloud sandbox; snapshots after setup; resume starts stopped or
194
+ archived sandboxes. `/workspace` maps to `/home/daytona/workspace`. Setup
195
+ that installs packages must use `sudo -n` (do not deny `sudo *`). See
196
+ `docs/sandbox/providers.md` for network and secret injection details.
192
197
 
193
- Both implement the same `SandboxHandle`: `fs` (read/write/list/mkdir/remove/
198
+ All implement the same `SandboxHandle`: `fs` (read/write/list/mkdir/remove/
194
199
  rename/exists), `git` (clone/status/add/commit/push/pull/branch), `process`
195
200
  (`exec` + duplex `spawn`), `ports.connect(port)`, `env.set`, optional
196
201
  `snapshot()`/`fork()`, `destroy()`. Providers advertise support via
@@ -202,18 +207,18 @@ rename/exists), `git` (clone/status/add/commit/push/pull/branch), `process`
202
207
  ```typescript
203
208
  import { defineSandboxPolicy } from '@tanstack/ai-sandbox'
204
209
 
210
+ // Headless Grok Build / Codex: stay on auto-approve. Isolation is the
211
+ // outer sandbox (Docker, Daytona, …), not commands.deny on this policy.
205
212
  const policy = defineSandboxPolicy({
206
- commands: {
207
- allow: ['pnpm test'],
208
- ask: ['curl *'],
209
- deny: ['sudo *', 'rm -rf *'],
210
- },
211
- capabilities: { fileWrite: 'allow', network: 'ask' },
212
- default: 'ask', // deny > ask > allow
213
+ default: 'allow',
213
214
  })
214
215
  // pass to defineSandbox({ policy }); harness adapters map it to native permissions
215
216
  ```
216
217
 
218
+ Claude Code can use `default: 'ask'` plus allow/ask/deny lists. Use Claude Code
219
+ when you need command-level deny. Provider privilege rules (non-root users,
220
+ network block at create) live in `docs/sandbox/providers.md`.
221
+
217
222
  ## Lifecycle &amp; resume
218
223
 
219
224
  `reuse: 'thread'` resumes one sandbox per `threadId`; the compound key folds in
@@ -43,6 +43,71 @@ export function resolveGitSkillDir(
43
43
  return `${root}/.tanstack-skills/${basename}`
44
44
  }
45
45
 
46
+ /** A folder that contains `SKILL.md`, ready to project under a harness skills dir. */
47
+ export interface DiscoveredSkillDir {
48
+ name: string
49
+ dir: string
50
+ }
51
+
52
+ const SKILL_FILE = 'SKILL.md'
53
+ const SKIP_DIR_NAMES = new Set(['.git', 'node_modules'])
54
+ const MAX_SKILL_WALK_DEPTH = 6
55
+
56
+ function basenameOf(path: string): string {
57
+ const segments = path.split('/').filter((segment) => segment !== '')
58
+ return segments[segments.length - 1] ?? path
59
+ }
60
+
61
+ /**
62
+ * Find every skill folder under a cloned `gitSkill` repo.
63
+ *
64
+ * A skill folder is a directory that contains `SKILL.md`. Nested packs
65
+ * (`skills/foo/SKILL.md`) are returned as `{ name: 'foo', dir: '…/skills/foo' }`.
66
+ * A flat clone with `SKILL.md` at the root is returned as one entry named
67
+ * after the clone. If no `SKILL.md` is found, the clone itself is returned
68
+ * so existing basename projection still works.
69
+ */
70
+ export async function discoverSkillDirs(
71
+ handle: SandboxHandle,
72
+ cloneDir: string,
73
+ ): Promise<Array<DiscoveredSkillDir>> {
74
+ const found: Array<DiscoveredSkillDir> = []
75
+ await walkSkillDirs(handle, cloneDir, found, 0)
76
+ if (found.length === 0) {
77
+ return [{ name: basenameOf(cloneDir), dir: cloneDir }]
78
+ }
79
+ return found
80
+ }
81
+
82
+ async function walkSkillDirs(
83
+ handle: SandboxHandle,
84
+ dir: string,
85
+ found: Array<DiscoveredSkillDir>,
86
+ depth: number,
87
+ ): Promise<void> {
88
+ if (depth > MAX_SKILL_WALK_DEPTH) return
89
+ let entries: Awaited<ReturnType<SandboxHandle['fs']['list']>>
90
+ try {
91
+ entries = await handle.fs.list(dir)
92
+ } catch {
93
+ return
94
+ }
95
+ const hasSkill = entries.some(
96
+ (entry) =>
97
+ entry.type === 'file' &&
98
+ entry.name.toLowerCase() === SKILL_FILE.toLowerCase(),
99
+ )
100
+ if (hasSkill) {
101
+ found.push({ name: basenameOf(dir), dir })
102
+ return
103
+ }
104
+ for (const entry of entries) {
105
+ if (entry.type !== 'dir') continue
106
+ if (entry.name.startsWith('.') || SKIP_DIR_NAMES.has(entry.name)) continue
107
+ await walkSkillDirs(handle, entry.path, found, depth + 1)
108
+ }
109
+ }
110
+
46
111
  /** Format workspace scripts as a `## Workspace scripts` markdown section. */
47
112
  export function formatWorkspaceScriptsSection(
48
113
  scripts: Record<string, string>,
package/src/bootstrap.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  * here — that's each adapter's `projectWorkspace()` hook, since the format
9
9
  * differs per harness.
10
10
  */
11
+ import { resolveHarnessCwd } from './harness-cwd'
11
12
  import { buildSetupPlan } from './setup-plan'
12
13
  import { createBootstrapShell } from './shell'
13
14
  import {
@@ -98,7 +99,10 @@ export async function bootstrapWorkspace(
98
99
  const url = skill.repo.startsWith('http')
99
100
  ? skill.repo
100
101
  : `https://github.com/${skill.repo}.git`
101
- const dir = skill.into ?? resolveGitSkillDir(root, skill)
102
+ const dir = resolveHarnessCwd(
103
+ handle,
104
+ skill.into ?? resolveGitSkillDir(root, skill),
105
+ )
102
106
  const auth =
103
107
  skill.secret !== undefined && workspace.secrets !== undefined
104
108
  ? { token: resolveSecret(workspace.secrets, skill.secret) }
package/src/contracts.ts CHANGED
@@ -30,19 +30,21 @@ export interface SandboxCapabilities {
30
30
  backgroundProcesses: boolean
31
31
  /**
32
32
  * A spawned process exposes a writable host→process stdin
33
- * ({@link SpawnHandle.stdin}). `true` for host/Docker; some edge providers
34
- * (e.g. Cloudflare) run background processes WITHOUT a writable stdin, so
35
- * harness adapters that feed a prompt over stdin must instead deliver it via a
36
- * file + shell redirection.
33
+ * ({@link SpawnHandle.stdin}). `true` for host (`localProcessSandbox`).
34
+ * `false` for Docker container, Docker Sandboxes (`sbx`), Daytona, Vercel,
35
+ * and Cloudflare. When `false`, harness adapters that feed a prompt over
36
+ * stdin must instead deliver it via a file + shell redirection.
37
37
  */
38
38
  writableStdin: boolean
39
39
  /**
40
40
  * A spawned process can be forcibly terminated via {@link SpawnHandle.kill}
41
41
  * and aborted mid-flight via the {@link ProcessOptions.signal} passed to
42
- * {@link SandboxProcess.spawn}. `true` for host/Docker; some edge providers
43
- * (e.g. Cloudflare) implement `kill()` as a no-op and drop the abort signal
44
- * entirely, so a long-running follower process (e.g. `tail -f`) started
45
- * there can never be stopped by the caller — only polled and abandoned.
42
+ * {@link SandboxProcess.spawn}. `true` for host and Docker container.
43
+ * `false` for Docker Sandboxes (`sbx`) until measured, and for Daytona,
44
+ * Vercel, and Cloudflare. Those providers implement `kill()` as a no-op or
45
+ * have not been measured yet, so a long-running follower process
46
+ * (e.g. `tail -f`) started there can never be stopped by the caller, only
47
+ * polled and abandoned.
46
48
  * Callers MUST branch on this before relying on `kill`/abort to reclaim a
47
49
  * background process: a bring-your-own provider that omits it would
48
50
  * otherwise be silently treated as killable, leaking an unstoppable process
@@ -220,6 +222,8 @@ export interface SandboxCreateInput {
220
222
  policy?: SandboxPolicy
221
223
  env?: Record<string, string>
222
224
  signal?: AbortSignal
225
+ /** Harness adapter name. Optional. Providers that do not use it ignore it. */
226
+ adapterName?: string
223
227
  }
224
228
 
225
229
  /** Input passed to {@link SandboxProvider.resume}. */
package/src/git-exec.ts CHANGED
@@ -72,6 +72,13 @@ export function createExecBackedGit(
72
72
  ? ''
73
73
  : `--depth ${resolvedDepth} --single-branch `
74
74
 
75
+ // `git clone` does not create missing parents. gitSkill clones into
76
+ // `<root>/.tanstack-skills/<name>`, so create that parent first.
77
+ const parentSlash = target.lastIndexOf('/')
78
+ if (parentSlash > 0) {
79
+ await process.exec(`mkdir -p ${q(target.slice(0, parentSlash))}`)
80
+ }
81
+
75
82
  if (auth?.token) {
76
83
  await process.exec(
77
84
  `git -c credential.helper=${q(CREDENTIAL_HELPER)} clone ${refArg}${depthArg}-- ${q(url)} ${q(target)}`,
package/src/index.ts CHANGED
@@ -130,9 +130,11 @@ export type { BootstrapResult } from './bootstrap'
130
130
  export {
131
131
  writeAgentsFile,
132
132
  resolveGitSkillDir,
133
+ discoverSkillDirs,
133
134
  formatWorkspaceScriptsSection,
134
135
  mergeAgentsContent,
135
136
  } from './agents-file'
137
+ export type { DiscoveredSkillDir } from './agents-file'
136
138
 
137
139
  // Exec-backed git helper (for providers without native git)
138
140
  export { createExecBackedGit } from './git-exec'