@tanstack/ai-sandbox 0.2.4 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/agents-file.js +53 -34
- package/dist/esm/agents-file.js.map +1 -1
- package/dist/esm/align.d.ts +121 -0
- package/dist/esm/align.js +197 -0
- package/dist/esm/align.js.map +1 -0
- package/dist/esm/approvals.js +63 -29
- package/dist/esm/approvals.js.map +1 -1
- package/dist/esm/attach-preflight.d.ts +85 -0
- package/dist/esm/attach-preflight.js +189 -0
- package/dist/esm/attach-preflight.js.map +1 -0
- package/dist/esm/bootstrap.js +103 -117
- package/dist/esm/bootstrap.js.map +1 -1
- package/dist/esm/bridge-events.js +96 -71
- package/dist/esm/bridge-events.js.map +1 -1
- package/dist/esm/capabilities.d.ts +0 -5
- package/dist/esm/capabilities.js +32 -28
- package/dist/esm/capabilities.js.map +1 -1
- package/dist/esm/chunk-identity.d.ts +52 -0
- package/dist/esm/chunk-identity.js +102 -0
- package/dist/esm/chunk-identity.js.map +1 -0
- package/dist/esm/claim.d.ts +187 -0
- package/dist/esm/claim.js +349 -0
- package/dist/esm/claim.js.map +1 -0
- package/dist/esm/contracts.d.ts +13 -0
- package/dist/esm/driver.d.ts +83 -0
- package/dist/esm/driver.js +138 -0
- package/dist/esm/driver.js.map +1 -0
- package/dist/esm/durability.d.ts +263 -0
- package/dist/esm/durability.js +230 -0
- package/dist/esm/durability.js.map +1 -0
- package/dist/esm/errors.js +28 -24
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/file-diff.js +151 -135
- package/dist/esm/file-diff.js.map +1 -1
- package/dist/esm/git-exec.js +51 -62
- package/dist/esm/git-exec.js.map +1 -1
- package/dist/esm/harness-cwd.js +24 -19
- package/dist/esm/harness-cwd.js.map +1 -1
- package/dist/esm/index.d.ts +30 -8
- package/dist/esm/index.js +23 -91
- package/dist/esm/instance-store.d.ts +88 -0
- package/dist/esm/instance-store.js +67 -0
- package/dist/esm/instance-store.js.map +1 -0
- package/dist/esm/journal-bytes.d.ts +67 -0
- package/dist/esm/journal-bytes.js +110 -0
- package/dist/esm/journal-bytes.js.map +1 -0
- package/dist/esm/journal-reader.d.ts +66 -0
- package/dist/esm/journal-reader.js +228 -0
- package/dist/esm/journal-reader.js.map +1 -0
- package/dist/esm/journal-sweep.d.ts +113 -0
- package/dist/esm/journal-sweep.js +309 -0
- package/dist/esm/journal-sweep.js.map +1 -0
- package/dist/esm/journal.d.ts +542 -0
- package/dist/esm/journal.js +679 -0
- package/dist/esm/journal.js.map +1 -0
- package/dist/esm/key.js +36 -33
- package/dist/esm/key.js.map +1 -1
- package/dist/esm/middleware.d.ts +50 -2
- package/dist/esm/middleware.js +335 -208
- package/dist/esm/middleware.js.map +1 -1
- package/dist/esm/ngrok.js +75 -49
- package/dist/esm/ngrok.js.map +1 -1
- package/dist/esm/policy.js +43 -34
- package/dist/esm/policy.js.map +1 -1
- package/dist/esm/projection.js +16 -8
- package/dist/esm/projection.js.map +1 -1
- package/dist/esm/reap.d.ts +238 -0
- package/dist/esm/reap.js +355 -0
- package/dist/esm/reap.js.map +1 -0
- package/dist/esm/reclaim.d.ts +84 -0
- package/dist/esm/reclaim.js +106 -0
- package/dist/esm/reclaim.js.map +1 -0
- package/dist/esm/remote-tools.js +73 -62
- package/dist/esm/remote-tools.js.map +1 -1
- package/dist/esm/run.d.ts +93 -25
- package/dist/esm/run.js +274 -79
- package/dist/esm/run.js.map +1 -1
- package/dist/esm/runner.d.ts +119 -2
- package/dist/esm/runner.js +270 -51
- package/dist/esm/runner.js.map +1 -1
- package/dist/esm/sandbox.d.ts +3 -2
- package/dist/esm/sandbox.js +139 -123
- package/dist/esm/sandbox.js.map +1 -1
- package/dist/esm/secrets.js +39 -47
- package/dist/esm/secrets.js.map +1 -1
- package/dist/esm/setup-plan.js +22 -14
- package/dist/esm/setup-plan.js.map +1 -1
- package/dist/esm/shell.d.ts +8 -0
- package/dist/esm/shell.js +197 -158
- package/dist/esm/shell.js.map +1 -1
- package/dist/esm/testkit/conformance.d.ts +16 -0
- package/dist/esm/testkit/conformance.js +97 -0
- package/dist/esm/testkit/conformance.js.map +1 -0
- package/dist/esm/testkit/durable-run-fields-conformance.d.ts +4 -0
- package/dist/esm/testkit/durable-run-fields-conformance.js +95 -0
- package/dist/esm/testkit/durable-run-fields-conformance.js.map +1 -0
- package/dist/esm/testkit/journal-conformance.d.ts +51 -0
- package/dist/esm/testkit/journal-conformance.js +378 -0
- package/dist/esm/testkit/journal-conformance.js.map +1 -0
- package/dist/esm/testkit/reaper-conformance.d.ts +37 -0
- package/dist/esm/testkit/reaper-conformance.js +847 -0
- package/dist/esm/testkit/reaper-conformance.js.map +1 -0
- package/dist/esm/testkit/shell-spawn.d.ts +2 -0
- package/dist/esm/testkit/shell-spawn.js +60 -0
- package/dist/esm/testkit/shell-spawn.js.map +1 -0
- package/dist/esm/testkit/takeover-conformance.d.ts +24 -0
- package/dist/esm/testkit/takeover-conformance.js +685 -0
- package/dist/esm/testkit/takeover-conformance.js.map +1 -0
- package/dist/esm/tool-bridge.js +227 -180
- package/dist/esm/tool-bridge.js.map +1 -1
- package/dist/esm/tool-history.d.ts +62 -0
- package/dist/esm/tool-history.js +171 -0
- package/dist/esm/tool-history.js.map +1 -0
- package/dist/esm/watch.js +310 -236
- package/dist/esm/watch.js.map +1 -1
- package/dist/esm/workspace.d.ts +1 -1
- package/dist/esm/workspace.js +49 -28
- package/dist/esm/workspace.js.map +1 -1
- package/package.json +16 -6
- package/skills/ai-sandbox/SKILL.md +658 -20
- package/src/align.ts +297 -0
- package/src/attach-preflight.ts +292 -0
- package/src/capabilities.ts +4 -13
- package/src/chunk-identity.ts +154 -0
- package/src/claim.ts +479 -0
- package/src/contracts.ts +13 -0
- package/src/driver.ts +205 -0
- package/src/durability.ts +380 -0
- package/src/index.ts +212 -27
- package/src/instance-store.ts +122 -0
- package/src/journal-bytes.ts +136 -0
- package/src/journal-reader.ts +359 -0
- package/src/journal-sweep.ts +406 -0
- package/src/journal.ts +875 -0
- package/src/middleware.ts +470 -30
- package/src/reap.ts +723 -0
- package/src/reclaim.ts +191 -0
- package/src/run.ts +365 -75
- package/src/runner.ts +347 -3
- package/src/sandbox.ts +38 -8
- package/src/shell.ts +106 -38
- package/src/testkit/conformance.ts +117 -0
- package/src/testkit/durable-run-fields-conformance.ts +147 -0
- package/src/testkit/journal-conformance.ts +676 -0
- package/src/testkit/reaper-conformance.ts +1201 -0
- package/src/testkit/shell-spawn.ts +67 -0
- package/src/testkit/takeover-conformance.ts +1040 -0
- package/src/tool-history.ts +245 -0
- package/src/workspace.ts +1 -1
- package/dist/esm/index.js.map +0 -1
- package/dist/esm/run-log.d.ts +0 -81
- package/dist/esm/run-log.js +0 -107
- package/dist/esm/run-log.js.map +0 -1
- package/dist/esm/store.d.ts +0 -53
- package/dist/esm/store.js +0 -34
- package/dist/esm/store.js.map +0 -1
- package/src/run-log.ts +0 -224
- package/src/store.ts +0 -83
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"journal.js","names":[],"sources":["../../src/journal.ts"],"sourcesContent":["/**\n * The agent output journal: an append-only NDJSON file INSIDE the sandbox that\n * the agent's stdout is redirected to, and that the host tails.\n *\n * This module is pure string composition — no I/O — so every shell fragment the\n * feature depends on is unit-testable without a sandbox, and a successor host\n * derives byte-identical commands from the `runId` alone.\n *\n * Three rules are encoded here and must not be relaxed:\n *\n * 1. **No pipe from the agent.** The agent's stdout is *redirected*, never\n * piped. `agent | tee file` gives the agent a reader whose disappearance\n * SIGPIPEs it — precisely the host-death failure this feature exists to\n * prevent. Redirection leaves nothing to break.\n * 2. **Every read silences stderr; only the BOUNDED read base64-frames its\n * output.** `2>/dev/null` is on both: Daytona's `exec` folds stderr into\n * stdout (`stderr: ''`, by contract) and Sprites' fast path does too, so a\n * `tail` diagnostic would otherwise splice itself into the event bytes.\n * Silencing it inside the sandbox means there is nothing left to fold.\n *\n * base64, however, is only on {@link journalReadCommand}. It cannot be on\n * {@link journalFollowCommand}: `base64` fully buffers its stdout when that\n * is a pipe rather than a tty, so `tail -f file | base64` emits NOTHING\n * until the ~4KB libc stdio buffer fills or `base64`'s stdin closes — and\n * `tail -f`'s stdin never closes until the reader kills it, by which point\n * the consumer has stopped reading. Measured on GNU coreutils 8.32 `base64`\n * (0 bytes delivered over 12s) and on busybox 1.36.1 `base64` in Alpine\n * (identical), so it is a property of stdio, not of a provider or an OS.\n * `stdbuf -o0` does not fix it portably (absent from busybox entirely) and\n * re-`exec`ing `base64` per line costs a fork per journal event.\n *\n * Dropping it from the follow path is safe because the bounded read keeps\n * every property base64 was chosen for where that path needs them, and the\n * follow path needs none of them: `2>/dev/null` already prevents the\n * stderr splice, the journal is line-delimited JSON (a raw newline can only\n * ever be a record separator — inside a JSON string it is `\\n`), and\n * `journal-bytes.ts` reassembles bytes across chunk boundaries and yields\n * only newline-terminated lines. The follow path therefore consumes\n * `SpawnHandle.stdout` exactly as `runner.ts` already consumes the agent's\n * own stdout, i.e. it relies on the same provider decoding contract the\n * package already depends on rather than a stricter one.\n * 3. **The journal is touched ONLY through the shell.** On local-process,\n * `fs.write` resolves `/tmp` under the sandbox root while a shell redirect\n * hits the real host `/tmp`. Both halves agree with each other only as long\n * as nothing uses `fs.*` here — hence {@link journalExistsCommand} rather\n * than `handle.fs.exists`.\n *\n * The composed commands below are handed to two different execution\n * mechanisms depending on provider, not always `sh -c`: daytona hands the raw\n * string to `executeCommand` with an `export`-prefixed env, and cloudflare\n * hands it to a Durable Object RPC. Redirection, `mkdir -p`, `tail`, and\n * `base64` all still work because both paths are shell-interpreted\n * downstream — the doc comment intentionally does not claim every provider\n * wraps the command in `sh -c` itself.\n */\n\nimport { createHash } from 'node:crypto'\n\n/** Default journal directory. `/tmp` is the convention the harness adapters already use. */\nexport const DEFAULT_JOURNAL_DIR = '/tmp/tanstack-runs'\n\n/**\n * Key of the sentinel object the journaled command appends after the agent\n * exits. It tells a *new* host the agent finished, with no pid probe and no\n * provider-specific liveness API — which matters because `pid` is `-1` on five\n * of six providers.\n */\nexport const EXIT_SENTINEL_KEY = '__exit'\n\n/**\n * Key carrying the per-run sentinel nonce that makes the sentinel\n * DISTINGUISHABLE from agent output.\n *\n * **Why the nonce exists.** `journaledCommand` redirects the agent's stdout and\n * the sentinel `printf` into the SAME file with no framing, so on the wire an\n * agent's own line is indistinguishable from the shell's. Without a nonce, any\n * agent that ever prints a JSON object carrying `__exit` — echoing a fixture,\n * `cat`-ing a file, dumping diagnostics — makes {@link parseJournalExit} report a\n * MID-FLIGHT run as finished, and `reapOne` then drives that run to terminal and\n * reclaims its sandbox out from under a live agent. A confident wrong answer is\n * strictly worse than the `'unknown'` every other failure on that path returns.\n *\n * **What the nonce is.** A domain-separated SHA-256 of the runId (see\n * {@link journalPaths}), NOT process-random. It has to be recomputable by a\n * SUCCESSOR host from the run record alone — that is this module's stated\n * contract (\"a successor host derives byte-identical commands from the `runId`\n * alone\"), and the reaper's probe runs in a different process from the one that\n * composed the command, with nothing but the runId to go on. A process-random\n * nonce would make every journal written by a dead host unreadable.\n *\n * **The residual, stated honestly.** Because it is derived rather than secret,\n * an agent that knows its own runId AND reimplements this derivation could still\n * emit a matching line. What the nonce removes is the entire accidental class —\n * which is the class that actually occurs — and it removes it completely. Closing\n * the deliberate case needs a secret the successor host can also read, i.e. a\n * nonce persisted on the run record; that is a `RunStore` schema change, not a\n * change to this pure-composition module. Two further mitigations narrow the\n * deliberate case: {@link parseJournalExit} takes the LAST matching sentinel in\n * the window rather than the first (the shell always writes the real one after\n * the agent's own output), and a matching sentinel whose code is not an integer\n * is refused rather than coerced to 0.\n */\nexport const EXIT_SENTINEL_NONCE_KEY = '__nonce'\n\n/**\n * Domain-separation prefix for the sentinel nonce, so the digest can never\n * collide with some other SHA-256-of-runId this codebase computes (e.g.\n * {@link encodeRunId}'s truncation hash).\n */\nconst EXIT_SENTINEL_NONCE_DOMAIN =\n 'tanstack-ai-sandbox/journal-exit-sentinel/v1'\n\n/** Hex digits of the sentinel nonce. 128 bits of digest is far beyond luck. */\nconst EXIT_SENTINEL_NONCE_LENGTH = 32\n\n/** Derive a run's sentinel nonce. Pure, and a function of the runId alone. */\nfunction deriveExitSentinelNonce(runId: string): string {\n return createHash('sha256')\n .update(`${EXIT_SENTINEL_NONCE_DOMAIN}:${runId}`, 'utf8')\n .digest('hex')\n .slice(0, EXIT_SENTINEL_NONCE_LENGTH)\n}\n\n/** Absolute in-sandbox paths for one run's journal. */\nexport interface JournalPaths {\n /** Directory both files live in; created by {@link journaledCommand}. */\n dir: string\n /** Append-only NDJSON file the agent's stdout is redirected to. */\n journal: string\n /** Separate file the agent's stderr goes to; NEVER mixed into the journal. */\n stderr: string\n /**\n * Per-run nonce the exit sentinel carries, so agent stdout cannot forge it.\n * See {@link EXIT_SENTINEL_NONCE_KEY}. Carried alongside the paths because\n * every producer and every reader of the sentinel already threads a\n * `JournalPaths` through, and the two must agree or the run reads as\n * unterminated.\n */\n nonce: string\n}\n\n/**\n * The exact sentinel LINE (no trailing newline) `journaledCommand` appends for\n * `exitCode`.\n *\n * Exported because a test or a fake host that seeds a journal by hand has to\n * write the same bytes the shell would; hand-writing `{\"__exit\":0}` produces a\n * line the reader now correctly refuses. Key order matches the `printf` format\n * below, and both are asserted against each other in `journal.test.ts`.\n */\nexport function exitSentinelLine(\n paths: JournalPaths,\n exitCode: number,\n): string {\n return JSON.stringify({\n [EXIT_SENTINEL_KEY]: exitCode,\n [EXIT_SENTINEL_NONCE_KEY]: paths.nonce,\n })\n}\n\n/** Single-quote a shell word, escaping embedded single quotes POSIX-style. */\nfunction shellQuote(value: string): string {\n return `'${value.replaceAll(\"'\", `'\\\\''`)}'`\n}\n\n/**\n * Windows reserves these names (case-insensitively) even when followed by an\n * extension — `CON.ndjson` still opens the `CON` device on Windows, it does\n * not create a file. {@link encodeRunId} only ever needs to check for an\n * EXACT match because, as its doc explains, that is the only way one of these\n * names can appear as the encoded output at all.\n */\nconst WINDOWS_RESERVED_NAME = /^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i\n\n/**\n * Hard cap on the encoded token's length, well under the ~255-byte filename\n * limit shared by NTFS and most POSIX filesystems, leaving headroom for the\n * longest extension this module appends (`.ndjson`) plus the directory\n * component of the path. Long runIds are hashed rather than rejected — see\n * {@link encodeRunId}.\n */\nconst MAX_ENCODED_NAME_LENGTH = 200\n\n/** Hex digest length appended when a runId is long enough to be hashed. */\nconst TRUNCATION_HASH_LENGTH = 16\n\n/**\n * Hex-escape every byte of `input`, ignoring the \"safe character\" allowance\n * entirely. Used only where the caller has already proven that no OTHER\n * runId can produce the same output through the normal per-character path\n * (see the call sites), because unlike that path this one escapes letters\n * and digits too.\n */\nfunction hexEscapeAllBytes(input: string): string {\n let out = ''\n for (const byte of new TextEncoder().encode(input)) {\n out += `_${byte.toString(16).padStart(2, '0')}`\n }\n return out\n}\n\n/**\n * Map a runId to a filename-safe token that is INJECTIVE: distinct runIds\n * must never produce the same token, because the journal is looked up by\n * this token alone and a collision means two runs would share one journal —\n * one run's takeover replaying another run's transcript.\n *\n * Encoding rather than rejecting keeps the mapping total: a client may choose\n * any `runId`, and a run that cannot be journaled would be a run that cannot be\n * made durable. The encoding is a pure function of the input, which is what lets\n * a successor host recompute the same path from the run record alone.\n *\n * The scheme is a straightforward escaping over `_`: any character matching\n * `[A-Za-z0-9.-]` passes through literally; everything else — INCLUDING a\n * literal `_` — is replaced by `_` followed by two lowercase hex digits per\n * UTF-8 byte. Because `_` itself is never a safe (pass-through) character,\n * every `_` in the output unambiguously starts a two-hex-digit escape; a\n * left-to-right scan can always tell literal from escape. That is what makes\n * the mapping injective: two different inputs can never parse to the same\n * output, because the (unimplemented, but well-defined) decoder is\n * deterministic — if it were not injective, running that decoder on a shared\n * output would have to yield both original strings, which is impossible for a\n * deterministic function.\n *\n * This is a DELIBERATE change from a prior scheme that also treated `_` as\n * safe. That made the encoding non-injective: `_` doubled as both a literal\n * and the escape prefix, so an escaped byte could read back as a literal\n * escape sequence typed by someone else. Concretely, under the old scheme\n * `encodeRunId('@')` and `encodeRunId('_40')` both produced `'_40'` — `@` is\n * `0x40` and gets escaped to `_40`, while the literal characters `_`, `4`, `0`\n * were all \"safe\" and passed through unchanged. This change breaks that\n * collision by escaping `_` like any other unsafe character.\n *\n * BREAKING CHANGE for existing journals: a journal file written under the\n * old scheme (where a literal `_` in the runId was left unescaped) will not\n * be found by this scheme, because a runId containing `_` now encodes\n * differently. Durability has not shipped publicly yet (this repo has no\n * released version with `encodeRunId` in it), so there is no compatibility\n * obligation and no changeset is warranted — there is nothing in the wild to\n * migrate.\n *\n * EXPORTED for adapters that derive their OWN in-sandbox paths from a `runId`\n * (`ai-codex`'s prompt file and MCP bridge config, `ai-claude-code`'s prompt\n * file). Durability makes `runId` caller-chosen, so an unencoded interpolation\n * lets a `/` produce a directory-bearing path, `..` escape the workdir, and an\n * over-long id fail the spawn with `ENAMETOOLONG` — the same hazards\n * {@link journalPaths} already routes through here. Reuse this rather than\n * writing a second encoder: a divergent copy would reintroduce the\n * non-injectivity documented above.\n */\nexport function encodeRunId(runId: string): string {\n if (runId.length === 0) {\n throw new Error('journal: runId must not be empty')\n }\n let out = ''\n for (const char of runId) {\n if (/^[A-Za-z0-9.-]$/.test(char)) {\n out += char\n continue\n }\n for (const byte of new TextEncoder().encode(char)) {\n out += `_${byte.toString(16).padStart(2, '0')}`\n }\n }\n\n // Reserved Windows device names. `out` can equal one of these ONLY when\n // every character of `runId` was itself safe (no `_` was introduced), which\n // means `runId` IS that literal word (e.g. `runId === 'CON'`) — the safe\n // characters this function passes through are letters, digits, `.`, and\n // `-`, none of which this branch ever escapes on the normal path, so no\n // OTHER runId can land here. Re-encoding with `hexEscapeAllBytes` is\n // therefore collision-free: the result starts with `_` followed by hex for\n // a letter/digit byte, a pattern the normal per-character path can never\n // produce for ANY input, because letters and digits are always safe and\n // never escaped.\n if (WINDOWS_RESERVED_NAME.test(out)) {\n out = hexEscapeAllBytes(runId)\n }\n\n // Bound the length so a very long runId cannot blow the filesystem's\n // filename limit. Truncating the encoded token alone would destroy\n // injectivity (two long runIds sharing a prefix would collapse to the same\n // truncated string), so the truncated prefix is paired with a hash of the\n // FULL original runId. Distinct runIds can then only collide here if they\n // share both the truncated prefix AND the hash — a SHA-256-collision, not\n // a scheme defect.\n if (out.length > MAX_ENCODED_NAME_LENGTH) {\n const hash = createHash('sha256')\n .update(runId, 'utf8')\n .digest('hex')\n .slice(0, TRUNCATION_HASH_LENGTH)\n const prefixLength = MAX_ENCODED_NAME_LENGTH - hash.length - 1\n out = `${out.slice(0, prefixLength)}-${hash}`\n }\n\n return out\n}\n\n/**\n * Reverse of {@link encodeRunId}, for a filename as `ls -1` reports it.\n *\n * `runId` on the success arm is a STORE KEY, never a path component. The\n * encoding is total over client-chosen strings, so a perfectly valid decode can\n * be `'..'`, `'.hidden'`, or `'a/b'` — a caller that interpolates it into a\n * path would escape the journal directory. Look it up in the run store; do not\n * join it onto anything.\n */\nexport type DecodedJournalRunId =\n /** The name decoded to exactly one runId. */\n | { kind: 'runId'; runId: string }\n /**\n * The name is length-capped output of {@link encodeRunId}, whose truncating\n * branch is LOSSY. The original runId is unrecoverable — KEEP the file.\n */\n | { kind: 'truncated' }\n /** Not output this module could have produced. KEEP the file. */\n | { kind: 'malformed' }\n\n/** Extensions {@link journalPaths} appends, longest-first so stripping is unambiguous. */\nconst JOURNAL_EXTENSIONS = ['.ndjson', '.err'] as const\n\n/**\n * Recover the `runId` behind a journal filename — FAIL CLOSED.\n *\n * The consumer of this function DELETES files, so every arm that is not a\n * proven-correct decode must be one the caller keeps. There is no \"probably\n * fine\" arm.\n *\n * `name` is the filename as {@link journalListCommand} reports it, extension\n * included. The extension is required, not optional: `.` is a pass-through-safe\n * character, so a runId of `'x.ndjson'` encodes to the token `x.ndjson` and the\n * file `x.ndjson.ndjson`. A function that stripped an extension only \"if\n * present\" could not tell those two strings apart. Requiring it keeps that\n * sharp edge here instead of in every caller that would otherwise reach for\n * `name.split('.')[0]`.\n *\n * **Why `truncated` is a distinct refusal and not a decode.** `encodeRunId`\n * caps its output at {@link MAX_ENCODED_NAME_LENGTH} by replacing the tail with\n * `-` plus a SHA-256 prefix. That branch discards bytes, so the encoding is not\n * invertible there — and because `-` is itself a pass-through-safe character,\n * the truncated form is syntactically indistinguishable from a legitimately\n * encoded id. Decoding it anyway would yield a plausible but WRONG runId; the\n * store would not recognise it, a sweep would read that as \"no such run\", and\n * it would delete the journal of a run that may still be mid-flight. So any\n * name that *could* be the truncated form is refused, at the cost of never\n * sweeping journals of runIds long enough to hash — a bounded leak, versus\n * data loss on a live run.\n *\n * The truncation check runs BEFORE the character scan on purpose: truncating at\n * a fixed byte offset can cut an `_hh` escape in half, so a truncated name may\n * also be malformed, and the more specific diagnosis is the useful one.\n *\n * The rest is the inverse of the escaping scheme: `[A-Za-z0-9.-]` is a literal\n * ASCII byte, `_` must be followed by EXACTLY two hex digits (either case),\n * and anything else — a bare `_`, a one-digit escape, `/`, `\\`, a space — is\n * malformed. The resulting bytes go through a `fatal: true` `TextDecoder`, so\n * an escape sequence that is not valid UTF-8 is a refusal rather than a string\n * silently peppered with U+FFFD (which would be a *different* runId than any\n * encoder input, i.e. exactly the wrong-runId deletion this guards against).\n */\nexport function decodeJournalRunId(name: string): DecodedJournalRunId {\n const extension = JOURNAL_EXTENSIONS.find((candidate) =>\n name.endsWith(candidate),\n )\n if (extension === undefined) return { kind: 'malformed' }\n const token = name.slice(0, name.length - extension.length)\n if (token.length === 0) return { kind: 'malformed' }\n\n // Only the truncating branch emits a token of exactly the cap ending in `-`\n // plus a hash of that width, and it always emits one. A longer token is not\n // producible by this module at all.\n if (\n token.length > MAX_ENCODED_NAME_LENGTH ||\n (token.length === MAX_ENCODED_NAME_LENGTH &&\n new RegExp(`-[0-9a-f]{${TRUNCATION_HASH_LENGTH}}$`).test(token))\n ) {\n return { kind: 'truncated' }\n }\n\n const bytes: Array<number> = []\n let index = 0\n while (index < token.length) {\n const char = token.charAt(index)\n if (char === '_') {\n const hex = token.slice(index + 1, index + 3)\n if (!/^[0-9a-fA-F]{2}$/.test(hex)) return { kind: 'malformed' }\n bytes.push(Number.parseInt(hex, 16))\n index += 3\n continue\n }\n // Every pass-through-safe character is single-byte ASCII, so its code unit\n // IS its UTF-8 byte.\n if (!/^[A-Za-z0-9.-]$/.test(char)) return { kind: 'malformed' }\n bytes.push(char.charCodeAt(0))\n index += 1\n }\n\n try {\n const runId = new TextDecoder('utf-8', { fatal: true }).decode(\n new Uint8Array(bytes),\n )\n return { kind: 'runId', runId }\n } catch {\n return { kind: 'malformed' }\n }\n}\n\n/**\n * Derive both journal paths for a run. Pure; no I/O.\n *\n * **`runId` MUST be unique per run.** The journal is append-only by design (a\n * takeover depends on a prefix a previous host delivered still being there), and\n * {@link DEFAULT_JOURNAL_DIR} is a fixed absolute path that outlives any single\n * sandbox, test, or process. So a reused `runId` does not start a fresh journal\n * — it appends to the old one, behind the old run's `{\"__exit\":N}` sentinel. A\n * streaming reader stops at the first sentinel it reaches — and a reused runId\n * derives the SAME nonce, so the old run's sentinel matches — meaning the new run\n * appears to emit nothing at all, or to fail with the previous run's exit code.\n * (The nonce is per-run, not per-attempt: it defends against the AGENT forging a\n * sentinel, not against a caller reusing an id.) This is not\n * enforced here on purpose: refusing to append would break the takeover the\n * append-only rule exists for. Callers derive `runId` from something unique\n * (the adapters use a timestamp plus a random suffix); a test that hardcodes a\n * literal `runId` will observe a stale run's journal on its second execution.\n */\nexport function journalPaths(\n runId: string,\n dir: string = DEFAULT_JOURNAL_DIR,\n): JournalPaths {\n const normalizedDir = normalizeJournalDir(dir)\n const name = encodeRunId(runId)\n return {\n dir: normalizedDir,\n journal: `${normalizedDir}/${name}.ndjson`,\n stderr: `${normalizedDir}/${name}.err`,\n nonce: deriveExitSentinelNonce(runId),\n }\n}\n\n/**\n * Wrap an agent command so its stdout lands in the journal, its stderr lands in\n * the sidecar file, and an `{\"__exit\":N,\"__nonce\":\"…\"}` sentinel is appended once\n * it exits.\n *\n * The nonce is what keeps the sentinel apart from the agent's own stdout, which\n * lands in the very same file with no framing — see\n * {@link EXIT_SENTINEL_NONCE_KEY}. It is interpolated as a bare hex token inside\n * a single-quoted `printf` FORMAT string, which is safe by construction:\n * {@link deriveExitSentinelNonce} emits `[0-9a-f]` only, so there is no quote to\n * escape and no `%` for `printf` to interpret.\n *\n * `command` is interpolated raw: callers build real shell text (the Claude Code\n * and Codex adapters append `< promptFile`, for instance), so quoting it would\n * break them. Every path this module contributes IS quoted.\n *\n * `>>` rather than `>` on purpose: truncating would let a stray re-spawn destroy\n * a prefix a previous host already translated and delivered.\n */\nexport function journaledCommand(command: string, paths: JournalPaths): string {\n return (\n `mkdir -p ${shellQuote(paths.dir)} && ` +\n // `command` runs inside its OWN subshell `( … )`, not merely a `{ … }`\n // group: a group runs in the CURRENT shell, so a bare `exit` inside\n // `command` (an agent legitimately calling `exit N`) would terminate the\n // whole compound statement before the sentinel `printf` ever ran — the\n // journal would end with no `__exit` line at all. A subshell gives\n // `exit` its own process to terminate, leaving `$?` (the subshell's exit\n // status) and the following `printf` intact in the outer shell.\n `{ ( ${command} ); ` +\n `printf '{\"${EXIT_SENTINEL_KEY}\":%d,\"${EXIT_SENTINEL_NONCE_KEY}\":\"${paths.nonce}\"}\\\\n' \"$?\"; } ` +\n `>> ${shellQuote(paths.journal)} 2>> ${shellQuote(paths.stderr)}`\n )\n}\n\n/**\n * `tail -c +N` is 1-based over bytes, while `fromByte` is a 0-based count of\n * bytes already consumed. `+fromByte + 1` is therefore \"the first byte we have\n * not seen\".\n */\nfunction tailFrom(fromByte: number): number {\n if (!Number.isSafeInteger(fromByte) || fromByte < 0) {\n throw new Error(\n `journal: fromByte must be a non-negative safe integer, got ${fromByte}`,\n )\n }\n return fromByte + 1\n}\n\n/**\n * Following read, for `process.spawn` only. Never pass this to `exec`:\n * `ProcessOptions` has no timeout, so a following `exec` blocks until the\n * sandbox or the RPC times out.\n *\n * Deliberately pipes into NOTHING. `tail -f` flushes each append as it sees it,\n * so it is the one stage in this pipeline that streams; adding any filter puts\n * that filter's stdio buffer between the agent and the host and the follow\n * strategy stops following (see rule 2 in the module doc for the measurements).\n * The host turns these raw bytes into positioned lines with\n * `journal-bytes.ts`.\n *\n * It also creates the journal before tailing it, because `tail -f` on a path\n * that does not exist yet prints a diagnostic and EXITS rather than waiting —\n * so the reader would deliver zero lines for a run whose journal simply had not\n * been created yet. The reader and the agent are two independent spawns and\n * nothing orders them, so that race is the normal case, not the unlucky one.\n * `: >> file` is a builtin no-op plus an O_CREAT|O_APPEND open: it creates the\n * file when absent and, critically, does NOT truncate one that already has a\n * prefix a previous host already delivered. `;` rather than `&&` throughout, so\n * a prep step that fails still lets the `tail` run and fail the way it used to\n * rather than turning a read into a silent no-op. (`tail -F` would also retry,\n * but `-F` is a GNU/busybox extension, not POSIX, and this file only emits\n * POSIX shell.)\n */\nexport function journalFollowCommand(\n paths: JournalPaths,\n fromByte: number,\n): string {\n return (\n `mkdir -p ${shellQuote(paths.dir)} 2>/dev/null; ` +\n `: >> ${shellQuote(paths.journal)} 2>/dev/null; ` +\n `tail -c +${tailFrom(fromByte)} -f ${shellQuote(paths.journal)} 2>/dev/null`\n )\n}\n\n/**\n * Bounded read: `-f` dropped so it always terminates, and base64-framed because\n * it can be — `exec` closes `base64`'s stdin, which flushes it, and the whole\n * result arrives as one already-complete `ExecResult.stdout` string. This is the\n * Cloudflare path, whose `spawn` cannot be killed and whose `exec` drops the\n * AbortSignal, making a following read unstoppable there.\n */\nexport function journalReadCommand(\n paths: JournalPaths,\n fromByte: number,\n): string {\n return `tail -c +${tailFrom(fromByte)} ${shellQuote(paths.journal)} 2>/dev/null | base64`\n}\n\n/**\n * Existence probe. A shell `test -f`, not `handle.fs.exists`: see rule 3 in the\n * module doc — on local-process the two resolve `/tmp` differently.\n */\nexport function journalExistsCommand(\n paths: Pick<JournalPaths, 'journal'>,\n): string {\n return `test -f ${shellQuote(paths.journal)}`\n}\n\n/** Bytes of the stderr sidecar {@link journalStderrReadCommand} reads by default. */\nconst DEFAULT_STDERR_TAIL_BYTES = 4096\n\n/**\n * Bounded read of the stderr SIDECAR (not the journal), so a non-zero exit can\n * carry the agent's own diagnostics instead of a bare exit code.\n *\n * `exec`-only, like {@link journalReadCommand}, and base64-framed for the same\n * reason: `exec` closes the encoder's stdin so it flushes, and the frame keeps a\n * provider that folds stderr into stdout from splicing its own text into the\n * bytes. Unlike the journal, the sidecar is NOT line-delimited JSON — an agent\n * writes whatever it likes there, including partial lines and raw control bytes\n * — so framing is what makes it safe to hand to a single `ExecResult.stdout`.\n *\n * `tail -c -N` (the LAST N bytes) rather than the first: the read has to be\n * bounded, because a runaway agent's sidecar can be arbitrarily large and this\n * runs on the host, and a crash's cause is at the end of stderr, not the start.\n * The cost is that the first character can be a truncated UTF-8 sequence; the\n * caller decodes lossily rather than failing, since this text is diagnostic.\n */\nexport function journalStderrReadCommand(\n paths: JournalPaths,\n maxBytes: number = DEFAULT_STDERR_TAIL_BYTES,\n): string {\n if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {\n throw new Error(\n `journal: maxBytes must be a positive safe integer, got ${maxBytes}`,\n )\n }\n return `tail -c -${maxBytes} ${shellQuote(paths.stderr)} 2>/dev/null | base64`\n}\n\n/**\n * Delete both of a run's journal files.\n *\n * **Ordering is the whole contract here, not the `rm`.** This may only run once\n * the run is TERMINAL — i.e. after the `{\"__exit\":N}` sentinel has been observed\n * — and must never run on an abort. The three claims that make the deletion safe:\n *\n * 1. **Terminal means the event log holds the whole run.** The journal exists so\n * a successor host can replay a run from byte 0 and re-derive the chunks a\n * dead host never got to append. Once the sentinel has been read and the\n * replay has been forwarded, the log — not the journal — is the record. A late\n * takeover therefore aligns against the log: `align.ts`'s `alignToStoredLog`\n * takes a `StreamDurability` and an `AsyncIterable<StreamChunk>`, has no\n * `SandboxHandle` and no {@link JournalPaths} in its signature, and reads the\n * prefix with `durability.snapshot()`. It *cannot* read the journal, so\n * deleting one that is terminal cannot break it.\n * 2. **A non-zero exit is terminal too.** `{\"__exit\":7}` is as final as\n * `{\"__exit\":0}`; the run failed, it is not resumable, and the failure is\n * already on its way to the client as a `RUN_ERROR`. Keeping a failed run's\n * journal would leak exactly the runs most likely to be numerous.\n * 3. **An abort is NOT terminal.** A consumer that stops early (lease lost,\n * client gone, host shutting down) may be handing the run off to a successor\n * host that still needs every byte, so an aborted read must leave both files\n * alone.\n *\n * Shell `rm`, never `handle.fs.remove`: rule 3 in the module doc. On\n * local-process `/tmp` resolves under the sandbox root through `fs.*` but to the\n * host's real `/tmp` through the shell, so an `fs.remove` would delete a\n * different path than the one `journaledCommand` wrote — i.e. nothing, silently.\n *\n * `-f` so a journal that is already gone (a provider that reaped `/tmp`, a\n * successor that cleaned up first) is a success, not an error. Callers treat the\n * whole thing as best effort regardless: a failed cleanup must never fail a run\n * that has already completed.\n *\n * **What this does NOT bound:** a run that reaches its sentinel while DETACHED\n * has no host reading its journal, so nothing ever observes the sentinel and\n * nothing calls this. Bounding it is `pruneJournals`' job (`journal-sweep.ts`):\n * a sweep over {@link DEFAULT_JOURNAL_DIR} that deletes only the journals whose\n * runs the store says are terminal. It runs from a cron the application\n * schedules, not from a run, so such a journal survives until that sweep — on a\n * `keepAlive` sandbox, indefinitely without one.\n */\nexport function journalCleanupCommand(paths: JournalPaths): string {\n return `rm -f ${shellQuote(paths.journal)} ${shellQuote(paths.stderr)}`\n}\n\n/**\n * List the journal directory, one entry per line.\n *\n * **`2>/dev/null` is load-bearing, not tidiness.** Daytona's `exec` folds\n * stderr into stdout by contract and the Sprites fast path does the same, so on\n * a directory that does not exist yet — the normal state before the first run —\n * an `ls: cannot access '/tmp/tanstack-runs': No such file or directory`\n * diagnostic would arrive as if it were a LINE OF OUTPUT. The sweep would then\n * hand that sentence to {@link decodeJournalRunId} and, if it decoded, delete\n * whatever it named. Silencing it inside the sandbox means a missing directory\n * produces zero lines, which is the truth.\n *\n * `-1` so one entry occupies one line: `ls` only defaults to columns on a tty,\n * but `exec`'s stdout is not always a pipe on every provider and the flag costs\n * nothing.\n *\n * **Dot-files are not listed**, by `ls` default. A runId beginning with `.`\n * encodes to a hidden filename (`.` passes through the encoder), so its journal\n * is invisible to a sweep and leaks rather than being deleted. That is the safe\n * direction of the two and the reason this is documented rather than fixed with\n * `-a`, which would also introduce `.` and `..` as entries.\n */\nexport function journalListCommand(dir: string = DEFAULT_JOURNAL_DIR): string {\n return `ls -1 ${shellQuote(normalizeJournalDir(dir))} 2>/dev/null`\n}\n\n/** Strip a trailing slash so a dir compares equal to `stat`'s echoed operand. */\nfunction normalizeJournalDir(dir: string): string {\n return dir.endsWith('/') ? dir.slice(0, -1) : dir\n}\n\n/** One listed journal file with its modification time. */\nexport interface JournalDirEntry {\n /** Filename as listed, extension included; feed to {@link decodeJournalRunId}. */\n name: string\n /** Modification time in milliseconds since the epoch. */\n mtimeMs: number\n}\n\n/**\n * Outcome of {@link parseJournalMtimeListing}. Deliberately NOT an array: see\n * that function's doc for why an empty list must not be the failure value.\n */\nexport type JournalMtimeListing =\n /** The mechanism ran. `entries` is complete — possibly, and meaningfully, empty. */\n | { kind: 'listed'; entries: Array<JournalDirEntry> }\n /**\n * The listing did not run (no `stat -c`, or the directory is absent). Nothing\n * is known about the directory's contents — in particular NOT that it is\n * empty, and NOT that anything in it is old.\n */\n | { kind: 'unavailable' }\n\n/**\n * List the journal directory WITH modification times, so a sweep can leave\n * recently-touched journals alone.\n *\n * **Neither `find -newermt` nor `find -printf` may be used here.** Both are GNU\n * extensions, absent from BusyBox 1.37 — the `alpine:3` shell every docker-\n * provider journal test runs in — and absent from MINGW64's `find`. Measured\n * working on BusyBox 1.37, GNU coreutils, and MINGW64: `stat -c \"%Y %n\"`, which\n * is what this emits. (`touch -d <ts> ref` plus `find ! -newer ref` also works\n * on all three, but it needs a writable reference file OUTSIDE the journal\n * directory — inside, `ls -1` would report the reference as an entry — and a\n * write is a side effect this pure-composition module has no business having.)\n *\n * **The directory is passed as its own first operand on purpose.** It is a\n * self-witness. `stat` reports every operand it can and only *then* exits\n * non-zero, so:\n *\n * - populated directory → witness line + one line per file, exit 0\n * - EMPTY directory → witness line only, exit 1 (the unexpanded glob is an\n * operand `stat` cannot stat)\n * - `stat` without `-c` support → NO output at all, exit 1\n *\n * That is what makes \"no files\" distinguishable from \"the mechanism is\n * unavailable\", and it has to be distinguishable because BusyBox exits 1 with\n * EMPTY stdout on an unrecognised flag. A caller that ignored the exit code and\n * took an empty parse as an empty directory would conclude every journal is\n * absent; one that then inferred \"therefore nothing is recent\" would delete the\n * whole directory. Hence {@link parseJournalMtimeListing} returns\n * `{ kind: 'unavailable' }` rather than `[]`, and the exit code is not consulted\n * at all — the witness line, not the status, is the evidence.\n *\n * Note the glob shares `ls`'s dot-file blindness (same fail-safe consequence),\n * and that `stat` cannot distinguish a file from a subdirectory here; a stray\n * subdirectory is caught downstream, because its name will not decode.\n */\nexport function journalMtimeListCommand(\n dir: string = DEFAULT_JOURNAL_DIR,\n): string {\n const normalized = normalizeJournalDir(dir)\n return `stat -c '%Y %n' ${shellQuote(normalized)} ${shellQuote(normalized)}/* 2>/dev/null`\n}\n\n/**\n * Parse {@link journalMtimeListCommand}'s stdout.\n *\n * Line-based, space-split parsing is unambiguous here: an encoded filename can\n * only contain `[A-Za-z0-9.-]` and `_hh` escapes (see {@link encodeRunId}), so\n * it can never contain a space or a newline, and `%Y` is digits. A line that\n * does not fit the shape — including a directory prefix that is not `dir` — is\n * dropped rather than guessed at.\n */\nexport function parseJournalMtimeListing(\n text: string,\n dir: string = DEFAULT_JOURNAL_DIR,\n): JournalMtimeListing {\n const normalized = normalizeJournalDir(dir)\n const entries: Array<JournalDirEntry> = []\n let sawWitness = false\n for (const rawLine of text.split('\\n')) {\n const line = rawLine.trim()\n if (line === '') continue\n const separator = line.indexOf(' ')\n if (separator === -1) continue\n const seconds = line.slice(0, separator)\n if (!/^\\d+$/.test(seconds)) continue\n const path = line.slice(separator + 1)\n if (path === normalized) {\n sawWitness = true\n continue\n }\n const prefix = `${normalized}/`\n if (!path.startsWith(prefix)) continue\n const name = path.slice(prefix.length)\n // A nested path is not something the single-level glob produces; refuse to\n // invent an entry for it.\n if (name === '' || name.includes('/')) continue\n entries.push({ name, mtimeMs: Number.parseInt(seconds, 10) * 1000 })\n }\n // No witness means `stat -c` never reported the directory itself, so the\n // command did not run as designed and `entries` is not a listing of anything.\n if (!sawWitness) return { kind: 'unavailable' }\n return { kind: 'listed', entries }\n}\n\n/** Bytes of the journal tail {@link journalExitProbeCommand} reads by default. */\nconst DEFAULT_EXIT_PROBE_TAIL_BYTES = 4096\n\n/**\n * Bounded read of the END of a run's journal, purely to learn whether the agent\n * reached its `{\"__exit\":N}` sentinel.\n *\n * **This exists so a reaper does not have to drive the run to find out.**\n * Entering `pipeToRunLog` to check writes a terminal status and calls\n * `durability.close()` on every path, including for a healthy mid-flight run —\n * recording it as `'completed'`, which drops it out of `listReclaimable`\n * forever. This probe is read-only and provider-neutral, and it is what makes a\n * reclaim candidate safe to drive.\n *\n * The command is the byte-identical idiom to {@link journalStderrReadCommand},\n * pointed at the journal instead of the sidecar: `tail -c -N` (the LAST N\n * bytes, because the sentinel is at the end), `2>/dev/null` so a missing\n * journal cannot splice a diagnostic into the bytes on a provider that folds\n * stderr into stdout, and base64 framing. Verified on BusyBox 1.37.\n *\n * base64 is correct HERE and forbidden on {@link journalFollowCommand} for the\n * reason rule 2 in the module doc measures: the encoder fully buffers a piped\n * stdout, which is harmless when `exec` closes its stdin and fatal when the\n * producer is `tail -f`. This read is bounded and terminates, so it never\n * streams.\n */\nexport function journalExitProbeCommand(\n paths: JournalPaths,\n maxBytes: number = DEFAULT_EXIT_PROBE_TAIL_BYTES,\n): string {\n if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {\n throw new Error(\n `journal: maxBytes must be a positive safe integer, got ${maxBytes}`,\n )\n }\n return `tail -c -${maxBytes} ${shellQuote(paths.journal)} 2>/dev/null | base64`\n}\n\n/**\n * Is ONE journal line this run's genuine exit sentinel? The exit code if so,\n * `null` for anything else — including a line that carries\n * {@link EXIT_SENTINEL_KEY} but not this run's nonce, which is agent output and\n * nothing more.\n *\n * FAIL CLOSED at every step, because the consumers of a non-`null` answer stop\n * the run and reclaim its sandbox:\n *\n * - not JSON, or not an object → `null`. This is also what absorbs the partial\n * first line a byte-bounded `tail -c -N` can start in the middle of.\n * - no `__nonce`, or a `__nonce` that is not exactly `paths.nonce` → `null`. An\n * agent line cannot be told from the shell's without this (see\n * {@link EXIT_SENTINEL_NONCE_KEY}).\n * - a matching nonce but a non-integer `__exit` → `null`, NOT `0`. The old code\n * coerced a non-number to `0`, which turned a garbled sentinel into a reported\n * SUCCESS. Nothing that reaches here legitimately can be non-integer: the only\n * writer is `printf '…%d…' \"$?\"`.\n *\n * Exported so the streaming reader (`runner.ts`) applies exactly the same test,\n * line by line, that the reaper's bounded tail probe applies — one definition of\n * \"the run ended\", not two that can drift.\n */\nexport function parseExitSentinel(\n line: string,\n paths: JournalPaths,\n): number | null {\n const trimmed = line.trim()\n if (trimmed === '') return null\n let parsed: unknown\n try {\n parsed = JSON.parse(trimmed)\n } catch {\n return null\n }\n if (typeof parsed !== 'object' || parsed === null) return null\n if (!(EXIT_SENTINEL_KEY in parsed)) return null\n const nonce: unknown = Reflect.get(parsed, EXIT_SENTINEL_NONCE_KEY)\n if (typeof nonce !== 'string' || nonce !== paths.nonce) return null\n const code: unknown = Reflect.get(parsed, EXIT_SENTINEL_KEY)\n if (typeof code !== 'number' || !Number.isInteger(code)) return null\n return code\n}\n\n/**\n * Find the exit sentinel in a decoded journal tail; `null` when it is absent,\n * which is the mid-flight (or never-started) case.\n *\n * **Scanned from the END, and the nonce is REQUIRED.** Both matter, and both are\n * corrections:\n *\n * - The shell appends the real sentinel AFTER the command's own output, so the\n * genuine one is always the last matching line in the window. Taking the first\n * match let an agent line that happened to look like a sentinel win over the\n * truth that followed it.\n * - `paths.nonce` must match, or the line is not a sentinel at all. Without that,\n * a mid-flight run whose agent printed any JSON object containing `__exit` read\n * as `finished`, and the reaper destroyed a live sandbox on the strength of it.\n *\n * `paths` rather than a bare nonce string so callers pass the object they already\n * hold and cannot pair a tail with another run's nonce.\n */\nexport function parseJournalExit(\n text: string,\n paths: JournalPaths,\n): number | null {\n const lines = text.split('\\n')\n for (let index = lines.length - 1; index >= 0; index -= 1) {\n const code = parseExitSentinel(lines[index] ?? '', paths)\n if (code !== null) return code\n }\n return null\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA2DA,IAAa,sBAAsB;;;;;;;AAQnC,IAAa,oBAAoB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmCjC,IAAa,0BAA0B;;;;;;AAOvC,IAAM,6BACJ;;AAGF,IAAM,6BAA6B;;AAGnC,SAAS,wBAAwB,OAAuB;CACtD,OAAO,WAAW,QAAQ,CAAC,CACxB,OAAO,GAAG,2BAA2B,GAAG,SAAS,MAAM,CAAC,CACxD,OAAO,KAAK,CAAC,CACb,MAAM,GAAG,0BAA0B;AACxC;;;;;;;;;;AA6BA,SAAgB,iBACd,OACA,UACQ;CACR,OAAO,KAAK,UAAU;GACnB,oBAAoB;GACpB,0BAA0B,MAAM;CACnC,CAAC;AACH;;AAGA,SAAS,WAAW,OAAuB;CACzC,OAAO,IAAI,MAAM,WAAW,KAAK,OAAO,EAAE;AAC5C;;;;;;;;AASA,IAAM,wBAAwB;;;;;;;;AAS9B,IAAM,0BAA0B;;AAGhC,IAAM,yBAAyB;;;;;;;;AAS/B,SAAS,kBAAkB,OAAuB;CAChD,IAAI,MAAM;CACV,KAAK,MAAM,QAAQ,IAAI,YAAY,CAAC,CAAC,OAAO,KAAK,GAC/C,OAAO,IAAI,KAAK,SAAS,EAAE,CAAC,CAAC,SAAS,GAAG,GAAG;CAE9C,OAAO;AACT;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmDA,SAAgB,YAAY,OAAuB;CACjD,IAAI,MAAM,WAAW,GACnB,MAAM,IAAI,MAAM,kCAAkC;CAEpD,IAAI,MAAM;CACV,KAAK,MAAM,QAAQ,OAAO;EACxB,IAAI,kBAAkB,KAAK,IAAI,GAAG;GAChC,OAAO;GACP;EACF;EACA,KAAK,MAAM,QAAQ,IAAI,YAAY,CAAC,CAAC,OAAO,IAAI,GAC9C,OAAO,IAAI,KAAK,SAAS,EAAE,CAAC,CAAC,SAAS,GAAG,GAAG;CAEhD;CAYA,IAAI,sBAAsB,KAAK,GAAG,GAChC,MAAM,kBAAkB,KAAK;CAU/B,IAAI,IAAI,SAAS,yBAAyB;EACxC,MAAM,OAAO,WAAW,QAAQ,CAAC,CAC9B,OAAO,OAAO,MAAM,CAAC,CACrB,OAAO,KAAK,CAAC,CACb,MAAM,GAAG,sBAAsB;EAClC,MAAM,eAAe,0BAA0B,KAAK,SAAS;EAC7D,MAAM,GAAG,IAAI,MAAM,GAAG,YAAY,EAAE,GAAG;CACzC;CAEA,OAAO;AACT;;AAuBA,IAAM,qBAAqB,CAAC,WAAW,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAyC7C,SAAgB,mBAAmB,MAAmC;CACpE,MAAM,YAAY,mBAAmB,MAAM,cACzC,KAAK,SAAS,SAAS,CACzB;CACA,IAAI,cAAc,KAAA,GAAW,OAAO,EAAE,MAAM,YAAY;CACxD,MAAM,QAAQ,KAAK,MAAM,GAAG,KAAK,SAAS,UAAU,MAAM;CAC1D,IAAI,MAAM,WAAW,GAAG,OAAO,EAAE,MAAM,YAAY;CAKnD,IACE,MAAM,SAAS,2BACd,MAAM,WAAW,2BAChB,IAAI,OAAO,aAAa,uBAAuB,GAAG,CAAC,CAAC,KAAK,KAAK,GAEhE,OAAO,EAAE,MAAM,YAAY;CAG7B,MAAM,QAAuB,CAAC;CAC9B,IAAI,QAAQ;CACZ,OAAO,QAAQ,MAAM,QAAQ;EAC3B,MAAM,OAAO,MAAM,OAAO,KAAK;EAC/B,IAAI,SAAS,KAAK;GAChB,MAAM,MAAM,MAAM,MAAM,QAAQ,GAAG,QAAQ,CAAC;GAC5C,IAAI,CAAC,mBAAmB,KAAK,GAAG,GAAG,OAAO,EAAE,MAAM,YAAY;GAC9D,MAAM,KAAK,OAAO,SAAS,KAAK,EAAE,CAAC;GACnC,SAAS;GACT;EACF;EAGA,IAAI,CAAC,kBAAkB,KAAK,IAAI,GAAG,OAAO,EAAE,MAAM,YAAY;EAC9D,MAAM,KAAK,KAAK,WAAW,CAAC,CAAC;EAC7B,SAAS;CACX;CAEA,IAAI;EAIF,OAAO;GAAE,MAAM;GAAS,OAHV,IAAI,YAAY,SAAS,EAAE,OAAO,KAAK,CAAC,CAAC,CAAC,OACtD,IAAI,WAAW,KAAK,CAEE;EAAM;CAChC,QAAQ;EACN,OAAO,EAAE,MAAM,YAAY;CAC7B;AACF;;;;;;;;;;;;;;;;;;;AAoBA,SAAgB,aACd,OACA,MAAc,qBACA;CACd,MAAM,gBAAgB,oBAAoB,GAAG;CAC7C,MAAM,OAAO,YAAY,KAAK;CAC9B,OAAO;EACL,KAAK;EACL,SAAS,GAAG,cAAc,GAAG,KAAK;EAClC,QAAQ,GAAG,cAAc,GAAG,KAAK;EACjC,OAAO,wBAAwB,KAAK;CACtC;AACF;;;;;;;;;;;;;;;;;;;;AAqBA,SAAgB,iBAAiB,SAAiB,OAA6B;CAC7E,OACE,YAAY,WAAW,MAAM,GAAG,EAAE,UAQ3B,QAAQ,gBACF,kBAAkB,QAAQ,wBAAwB,KAAK,MAAM,MAAM,oBAC1E,WAAW,MAAM,OAAO,EAAE,OAAO,WAAW,MAAM,MAAM;AAElE;;;;;;AAOA,SAAS,SAAS,UAA0B;CAC1C,IAAI,CAAC,OAAO,cAAc,QAAQ,KAAK,WAAW,GAChD,MAAM,IAAI,MACR,8DAA8D,UAChE;CAEF,OAAO,WAAW;AACpB;;;;;;;;;;;;;;;;;;;;;;;;;;AA2BA,SAAgB,qBACd,OACA,UACQ;CACR,OACE,YAAY,WAAW,MAAM,GAAG,EAAE,qBAC1B,WAAW,MAAM,OAAO,EAAE,yBACtB,SAAS,QAAQ,EAAE,MAAM,WAAW,MAAM,OAAO,EAAE;AAEnE;;;;;;;;AASA,SAAgB,mBACd,OACA,UACQ;CACR,OAAO,YAAY,SAAS,QAAQ,EAAE,GAAG,WAAW,MAAM,OAAO,EAAE;AACrE;;;;;AAMA,SAAgB,qBACd,OACQ;CACR,OAAO,WAAW,WAAW,MAAM,OAAO;AAC5C;;AAGA,IAAM,4BAA4B;;;;;;;;;;;;;;;;;;AAmBlC,SAAgB,yBACd,OACA,WAAmB,2BACX;CACR,IAAI,CAAC,OAAO,cAAc,QAAQ,KAAK,YAAY,GACjD,MAAM,IAAI,MACR,0DAA0D,UAC5D;CAEF,OAAO,YAAY,SAAS,GAAG,WAAW,MAAM,MAAM,EAAE;AAC1D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6CA,SAAgB,sBAAsB,OAA6B;CACjE,OAAO,SAAS,WAAW,MAAM,OAAO,EAAE,GAAG,WAAW,MAAM,MAAM;AACtE;;;;;;;;;;;;;;;;;;;;;;;AAwBA,SAAgB,mBAAmB,MAAc,qBAA6B;CAC5E,OAAO,SAAS,WAAW,oBAAoB,GAAG,CAAC,EAAE;AACvD;;AAGA,SAAS,oBAAoB,KAAqB;CAChD,OAAO,IAAI,SAAS,GAAG,IAAI,IAAI,MAAM,GAAG,EAAE,IAAI;AAChD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA2DA,SAAgB,wBACd,MAAc,qBACN;CACR,MAAM,aAAa,oBAAoB,GAAG;CAC1C,OAAO,mBAAmB,WAAW,UAAU,EAAE,GAAG,WAAW,UAAU,EAAE;AAC7E;;;;;;;;;;AAWA,SAAgB,yBACd,MACA,MAAc,qBACO;CACrB,MAAM,aAAa,oBAAoB,GAAG;CAC1C,MAAM,UAAkC,CAAC;CACzC,IAAI,aAAa;CACjB,KAAK,MAAM,WAAW,KAAK,MAAM,IAAI,GAAG;EACtC,MAAM,OAAO,QAAQ,KAAK;EAC1B,IAAI,SAAS,IAAI;EACjB,MAAM,YAAY,KAAK,QAAQ,GAAG;EAClC,IAAI,cAAc,IAAI;EACtB,MAAM,UAAU,KAAK,MAAM,GAAG,SAAS;EACvC,IAAI,CAAC,QAAQ,KAAK,OAAO,GAAG;EAC5B,MAAM,OAAO,KAAK,MAAM,YAAY,CAAC;EACrC,IAAI,SAAS,YAAY;GACvB,aAAa;GACb;EACF;EACA,MAAM,SAAS,GAAG,WAAW;EAC7B,IAAI,CAAC,KAAK,WAAW,MAAM,GAAG;EAC9B,MAAM,OAAO,KAAK,MAAM,OAAO,MAAM;EAGrC,IAAI,SAAS,MAAM,KAAK,SAAS,GAAG,GAAG;EACvC,QAAQ,KAAK;GAAE;GAAM,SAAS,OAAO,SAAS,SAAS,EAAE,IAAI;EAAK,CAAC;CACrE;CAGA,IAAI,CAAC,YAAY,OAAO,EAAE,MAAM,cAAc;CAC9C,OAAO;EAAE,MAAM;EAAU;CAAQ;AACnC;;AAGA,IAAM,gCAAgC;;;;;;;;;;;;;;;;;;;;;;;;AAyBtC,SAAgB,wBACd,OACA,WAAmB,+BACX;CACR,IAAI,CAAC,OAAO,cAAc,QAAQ,KAAK,YAAY,GACjD,MAAM,IAAI,MACR,0DAA0D,UAC5D;CAEF,OAAO,YAAY,SAAS,GAAG,WAAW,MAAM,OAAO,EAAE;AAC3D;;;;;;;;;;;;;;;;;;;;;;;;AAyBA,SAAgB,kBACd,MACA,OACe;CACf,MAAM,UAAU,KAAK,KAAK;CAC1B,IAAI,YAAY,IAAI,OAAO;CAC3B,IAAI;CACJ,IAAI;EACF,SAAS,KAAK,MAAM,OAAO;CAC7B,QAAQ;EACN,OAAO;CACT;CACA,IAAI,OAAO,WAAW,YAAY,WAAW,MAAM,OAAO;CAC1D,IAAI,EAAA,YAAuB,SAAS,OAAO;CAC3C,MAAM,QAAiB,QAAQ,IAAI,QAAQ,uBAAuB;CAClE,IAAI,OAAO,UAAU,YAAY,UAAU,MAAM,OAAO,OAAO;CAC/D,MAAM,OAAgB,QAAQ,IAAI,QAAQ,iBAAiB;CAC3D,IAAI,OAAO,SAAS,YAAY,CAAC,OAAO,UAAU,IAAI,GAAG,OAAO;CAChE,OAAO;AACT;;;;;;;;;;;;;;;;;;;AAoBA,SAAgB,iBACd,MACA,OACe;CACf,MAAM,QAAQ,KAAK,MAAM,IAAI;CAC7B,KAAK,IAAI,QAAQ,MAAM,SAAS,GAAG,SAAS,GAAG,SAAS,GAAG;EACzD,MAAM,OAAO,kBAAkB,MAAM,UAAU,IAAI,KAAK;EACxD,IAAI,SAAS,MAAM,OAAO;CAC5B;CACA,OAAO;AACT"}
|
package/dist/esm/key.js
CHANGED
|
@@ -1,41 +1,44 @@
|
|
|
1
|
+
//#region src/key.ts
|
|
2
|
+
/** Deterministic, dependency-free 64-bit FNV-1a hash → hex string. */
|
|
1
3
|
function fnv1a(input) {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
4
|
+
let h1 = 2166136261;
|
|
5
|
+
let h2 = 2166136261;
|
|
6
|
+
for (let i = 0; i < input.length; i++) {
|
|
7
|
+
const c = input.charCodeAt(i);
|
|
8
|
+
h1 ^= c & 255;
|
|
9
|
+
h1 = Math.imul(h1, 16777619);
|
|
10
|
+
h2 ^= c >> 8 & 255;
|
|
11
|
+
h2 = Math.imul(h2, 16777619);
|
|
12
|
+
}
|
|
13
|
+
const hex = (n) => (n >>> 0).toString(16).padStart(8, "0");
|
|
14
|
+
return hex(h1) + hex(h2);
|
|
13
15
|
}
|
|
16
|
+
/** Canonical, key-sorted JSON so logically-equal inputs hash identically. */
|
|
14
17
|
function canonical(value) {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
return `{${keys.map(
|
|
19
|
-
(k) => `${JSON.stringify(k)}:${canonical(value[k])}`
|
|
20
|
-
).join(",")}}`;
|
|
18
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
19
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
20
|
+
return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(",")}}`;
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Hash of the parts of a workspace that change what the agent sees. Secrets are
|
|
24
|
+
* intentionally excluded (rotating a token must not orphan the sandbox).
|
|
25
|
+
*/
|
|
22
26
|
function computeWorkspaceHash(workspace) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
if (!workspace) return fnv1a("no-workspace");
|
|
28
|
+
const { secrets: _secrets, ...rest } = workspace;
|
|
29
|
+
return fnv1a(canonical(rest));
|
|
26
30
|
}
|
|
31
|
+
/** Compute the compound sandbox instance key. */
|
|
27
32
|
function computeSandboxKey(input) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
return fnv1a(material);
|
|
33
|
+
return fnv1a(canonical({
|
|
34
|
+
threadId: input.threadId,
|
|
35
|
+
sandboxId: input.sandboxId,
|
|
36
|
+
providerName: input.providerName,
|
|
37
|
+
workspaceHash: computeWorkspaceHash(input.workspace),
|
|
38
|
+
tenant: input.tenant ?? null
|
|
39
|
+
}));
|
|
36
40
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
//# sourceMappingURL=key.js.map
|
|
41
|
+
//#endregion
|
|
42
|
+
export { computeSandboxKey, computeWorkspaceHash };
|
|
43
|
+
|
|
44
|
+
//# sourceMappingURL=key.js.map
|
package/dist/esm/key.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"key.js","sources":["../../src/key.ts"],"sourcesContent":["/**\n * Compound sandbox identity. We never key a resumable sandbox on `threadId`\n * alone — that would resume the WRONG environment when the provider,\n * workspace, image, or tenant changes. The key folds all of those in, so any\n * change busts the sandbox and forces a fresh create+bootstrap (safe default).\n */\nimport type { WorkspaceDefinition } from './workspace'\n\n/** Inputs that, together, identify one resumable sandbox instance. */\nexport interface SandboxKeyInput {\n threadId: string\n sandboxId: string\n providerName: string\n workspace?: WorkspaceDefinition\n /** Optional tenant scoping pulled from runtimeContext. */\n tenant?: { userId?: string; orgId?: string }\n}\n\n/** Deterministic, dependency-free 64-bit FNV-1a hash → hex string. */\nfunction fnv1a(input: string): string {\n // Two 32-bit lanes to approximate 64-bit without BigInt overhead concerns.\n let h1 = 0x811c9dc5\n let h2 = 0x811c9dc5\n for (let i = 0; i < input.length; i++) {\n const c = input.charCodeAt(i)\n h1 ^= c & 0xff\n h1 = Math.imul(h1, 0x01000193)\n h2 ^= (c >> 8) & 0xff\n h2 = Math.imul(h2, 0x01000193)\n }\n const hex = (n: number): string => (n >>> 0).toString(16).padStart(8, '0')\n return hex(h1) + hex(h2)\n}\n\n/** Canonical, key-sorted JSON so logically-equal inputs hash identically. */\nfunction canonical(value: unknown): string {\n if (value === null || typeof value !== 'object') return JSON.stringify(value)\n if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`\n const keys = Object.keys(value).sort()\n return `{${keys\n .map(\n (k) =>\n `${JSON.stringify(k)}:${canonical((value as Record<string, unknown>)[k])}`,\n )\n .join(',')}}`\n}\n\n/**\n * Hash of the parts of a workspace that change what the agent sees. Secrets are\n * intentionally excluded (rotating a token must not orphan the sandbox).\n */\nexport function computeWorkspaceHash(\n workspace: WorkspaceDefinition | undefined,\n): string {\n if (!workspace) return fnv1a('no-workspace')\n const { secrets: _secrets, ...rest } = workspace\n return fnv1a(canonical(rest))\n}\n\n/** Compute the compound sandbox instance key. */\nexport function computeSandboxKey(input: SandboxKeyInput): string {\n const material = canonical({\n threadId: input.threadId,\n sandboxId: input.sandboxId,\n providerName: input.providerName,\n workspaceHash: computeWorkspaceHash(input.workspace),\n tenant: input.tenant ?? null,\n })\n return fnv1a(material)\n}\n"],"
|
|
1
|
+
{"version":3,"file":"key.js","names":[],"sources":["../../src/key.ts"],"sourcesContent":["/**\n * Compound sandbox identity. We never key a resumable sandbox on `threadId`\n * alone — that would resume the WRONG environment when the provider,\n * workspace, image, or tenant changes. The key folds all of those in, so any\n * change busts the sandbox and forces a fresh create+bootstrap (safe default).\n */\nimport type { WorkspaceDefinition } from './workspace'\n\n/** Inputs that, together, identify one resumable sandbox instance. */\nexport interface SandboxKeyInput {\n threadId: string\n sandboxId: string\n providerName: string\n workspace?: WorkspaceDefinition\n /** Optional tenant scoping pulled from runtimeContext. */\n tenant?: { userId?: string; orgId?: string }\n}\n\n/** Deterministic, dependency-free 64-bit FNV-1a hash → hex string. */\nfunction fnv1a(input: string): string {\n // Two 32-bit lanes to approximate 64-bit without BigInt overhead concerns.\n let h1 = 0x811c9dc5\n let h2 = 0x811c9dc5\n for (let i = 0; i < input.length; i++) {\n const c = input.charCodeAt(i)\n h1 ^= c & 0xff\n h1 = Math.imul(h1, 0x01000193)\n h2 ^= (c >> 8) & 0xff\n h2 = Math.imul(h2, 0x01000193)\n }\n const hex = (n: number): string => (n >>> 0).toString(16).padStart(8, '0')\n return hex(h1) + hex(h2)\n}\n\n/** Canonical, key-sorted JSON so logically-equal inputs hash identically. */\nfunction canonical(value: unknown): string {\n if (value === null || typeof value !== 'object') return JSON.stringify(value)\n if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`\n const keys = Object.keys(value).sort()\n return `{${keys\n .map(\n (k) =>\n `${JSON.stringify(k)}:${canonical((value as Record<string, unknown>)[k])}`,\n )\n .join(',')}}`\n}\n\n/**\n * Hash of the parts of a workspace that change what the agent sees. Secrets are\n * intentionally excluded (rotating a token must not orphan the sandbox).\n */\nexport function computeWorkspaceHash(\n workspace: WorkspaceDefinition | undefined,\n): string {\n if (!workspace) return fnv1a('no-workspace')\n const { secrets: _secrets, ...rest } = workspace\n return fnv1a(canonical(rest))\n}\n\n/** Compute the compound sandbox instance key. */\nexport function computeSandboxKey(input: SandboxKeyInput): string {\n const material = canonical({\n threadId: input.threadId,\n sandboxId: input.sandboxId,\n providerName: input.providerName,\n workspaceHash: computeWorkspaceHash(input.workspace),\n tenant: input.tenant ?? null,\n })\n return fnv1a(material)\n}\n"],"mappings":";;AAmBA,SAAS,MAAM,OAAuB;CAEpC,IAAI,KAAK;CACT,IAAI,KAAK;CACT,KAAK,IAAI,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK;EACrC,MAAM,IAAI,MAAM,WAAW,CAAC;EAC5B,MAAM,IAAI;EACV,KAAK,KAAK,KAAK,IAAI,QAAU;EAC7B,MAAO,KAAK,IAAK;EACjB,KAAK,KAAK,KAAK,IAAI,QAAU;CAC/B;CACA,MAAM,OAAO,OAAuB,MAAM,EAAA,CAAG,SAAS,EAAE,CAAC,CAAC,SAAS,GAAG,GAAG;CACzE,OAAO,IAAI,EAAE,IAAI,IAAI,EAAE;AACzB;;AAGA,SAAS,UAAU,OAAwB;CACzC,IAAI,UAAU,QAAQ,OAAO,UAAU,UAAU,OAAO,KAAK,UAAU,KAAK;CAC5E,IAAI,MAAM,QAAQ,KAAK,GAAG,OAAO,IAAI,MAAM,IAAI,SAAS,CAAC,CAAC,KAAK,GAAG,EAAE;CAEpE,OAAO,IADM,OAAO,KAAK,KAAK,CAAC,CAAC,KACrB,CAAA,CACR,KACE,MACC,GAAG,KAAK,UAAU,CAAC,EAAE,GAAG,UAAW,MAAkC,EAAE,GAC3E,CAAC,CACA,KAAK,GAAG,EAAE;AACf;;;;;AAMA,SAAgB,qBACd,WACQ;CACR,IAAI,CAAC,WAAW,OAAO,MAAM,cAAc;CAC3C,MAAM,EAAE,SAAS,UAAU,GAAG,SAAS;CACvC,OAAO,MAAM,UAAU,IAAI,CAAC;AAC9B;;AAGA,SAAgB,kBAAkB,OAAgC;CAQhE,OAAO,MAPU,UAAU;EACzB,UAAU,MAAM;EAChB,WAAW,MAAM;EACjB,cAAc,MAAM;EACpB,eAAe,qBAAqB,MAAM,SAAS;EACnD,QAAQ,MAAM,UAAU;CAC1B,CACa,CAAQ;AACvB"}
|
package/dist/esm/middleware.d.ts
CHANGED
|
@@ -1,5 +1,53 @@
|
|
|
1
1
|
import { SandboxCapability } from './capabilities.js';
|
|
2
2
|
import { ProjectionCapability } from './projection.js';
|
|
3
|
-
import {
|
|
3
|
+
import { LockStore } from '@tanstack/ai/locks';
|
|
4
|
+
import { DefinedChatMiddleware, RunStore } from '@tanstack/ai';
|
|
5
|
+
import { SandboxDurabilityOptions } from './durability.js';
|
|
6
|
+
import { SandboxInstanceStore } from './instance-store.js';
|
|
4
7
|
import { SandboxDefinition } from './sandbox.js';
|
|
5
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Durability seams for a sandboxed run. Both are optional; each independently
|
|
10
|
+
* falls back to a process-lifetime in-memory default, which is correct for a
|
|
11
|
+
* single process but NOT across replicas.
|
|
12
|
+
*/
|
|
13
|
+
export interface SandboxMiddlewareOptions<TOffset extends string = string> {
|
|
14
|
+
/**
|
|
15
|
+
* Durable instance map (which provider sandbox to resume for a key). Pass
|
|
16
|
+
* your own store to make resume survive across processes/replicas.
|
|
17
|
+
*
|
|
18
|
+
* Takes precedence over a store provided on the capability bus (see
|
|
19
|
+
* `provideSandboxInstanceStore`), so the call site wins over ambient wiring.
|
|
20
|
+
*/
|
|
21
|
+
instances?: SandboxInstanceStore;
|
|
22
|
+
/**
|
|
23
|
+
* Distributed lock serializing resume-or-create for one key. Needed for
|
|
24
|
+
* multi-replica correctness so two concurrent runs don't both create.
|
|
25
|
+
*
|
|
26
|
+
* Prefer `withLocks` from `@tanstack/ai/locks` when other middleware also
|
|
27
|
+
* needs the lock; use this option to scope one to this sandbox. Takes
|
|
28
|
+
* precedence over a bus-provided lock.
|
|
29
|
+
*/
|
|
30
|
+
locks?: LockStore;
|
|
31
|
+
/**
|
|
32
|
+
* Run lifecycle records. Pair with `durability.adapter` to make a run
|
|
33
|
+
* DETACHABLE: a client disconnect then leaves the agent running and records
|
|
34
|
+
* `detachedSince` instead of destroying the sandbox.
|
|
35
|
+
*
|
|
36
|
+
* Pass the SAME store chat persistence uses (`persistence.stores.runs`) so
|
|
37
|
+
* one record describes the run instead of two that can disagree.
|
|
38
|
+
*
|
|
39
|
+
* Defaults to `undefined`: an app that passes neither this nor `durability`
|
|
40
|
+
* keeps today's destroy-on-disconnect behavior exactly.
|
|
41
|
+
*/
|
|
42
|
+
runs?: RunStore;
|
|
43
|
+
/**
|
|
44
|
+
* Delivery durability for the run's event log, plus the journal and detach
|
|
45
|
+
* knobs. Requires `runs`; either alone is not durable.
|
|
46
|
+
*
|
|
47
|
+
* `TOffset` is inferred from the adapter passed here, so a branded-cursor
|
|
48
|
+
* backend (`durableStream`) wires without a cast and without the call site
|
|
49
|
+
* ever naming the parameter.
|
|
50
|
+
*/
|
|
51
|
+
durability?: SandboxDurabilityOptions<TOffset>;
|
|
52
|
+
}
|
|
53
|
+
export declare function withSandbox<TOffset extends string = string>(definition: SandboxDefinition, options?: SandboxMiddlewareOptions<TOffset>): DefinedChatMiddleware<unknown, readonly [], readonly [typeof SandboxCapability, typeof ProjectionCapability]>;
|