UNPKG

@tanstack/ai-sandbox

Version:

Provider-agnostic sandbox layer for TanStack AI — run harness adapters inside isolated sandboxes (defineSandbox, defineWorkspace, withSandbox) with a uniform SandboxHandle, workspace bootstrap, policy, and resumable lifecycle.

679 lines (678 loc) 35.8 kB
import { createHash } from "node:crypto"; //#region src/journal.ts /** * The agent output journal: an append-only NDJSON file INSIDE the sandbox that * the agent's stdout is redirected to, and that the host tails. * * This module is pure string composition — no I/O — so every shell fragment the * feature depends on is unit-testable without a sandbox, and a successor host * derives byte-identical commands from the `runId` alone. * * Three rules are encoded here and must not be relaxed: * * 1. **No pipe from the agent.** The agent's stdout is *redirected*, never * piped. `agent | tee file` gives the agent a reader whose disappearance * SIGPIPEs it — precisely the host-death failure this feature exists to * prevent. Redirection leaves nothing to break. * 2. **Every read silences stderr; only the BOUNDED read base64-frames its * output.** `2>/dev/null` is on both: Daytona's `exec` folds stderr into * stdout (`stderr: ''`, by contract) and Sprites' fast path does too, so a * `tail` diagnostic would otherwise splice itself into the event bytes. * Silencing it inside the sandbox means there is nothing left to fold. * * base64, however, is only on {@link journalReadCommand}. It cannot be on * {@link journalFollowCommand}: `base64` fully buffers its stdout when that * is a pipe rather than a tty, so `tail -f file | base64` emits NOTHING * until the ~4KB libc stdio buffer fills or `base64`'s stdin closes — and * `tail -f`'s stdin never closes until the reader kills it, by which point * the consumer has stopped reading. Measured on GNU coreutils 8.32 `base64` * (0 bytes delivered over 12s) and on busybox 1.36.1 `base64` in Alpine * (identical), so it is a property of stdio, not of a provider or an OS. * `stdbuf -o0` does not fix it portably (absent from busybox entirely) and * re-`exec`ing `base64` per line costs a fork per journal event. * * Dropping it from the follow path is safe because the bounded read keeps * every property base64 was chosen for where that path needs them, and the * follow path needs none of them: `2>/dev/null` already prevents the * stderr splice, the journal is line-delimited JSON (a raw newline can only * ever be a record separator — inside a JSON string it is `\n`), and * `journal-bytes.ts` reassembles bytes across chunk boundaries and yields * only newline-terminated lines. The follow path therefore consumes * `SpawnHandle.stdout` exactly as `runner.ts` already consumes the agent's * own stdout, i.e. it relies on the same provider decoding contract the * package already depends on rather than a stricter one. * 3. **The journal is touched ONLY through the shell.** On local-process, * `fs.write` resolves `/tmp` under the sandbox root while a shell redirect * hits the real host `/tmp`. Both halves agree with each other only as long * as nothing uses `fs.*` here — hence {@link journalExistsCommand} rather * than `handle.fs.exists`. * * The composed commands below are handed to two different execution * mechanisms depending on provider, not always `sh -c`: daytona hands the raw * string to `executeCommand` with an `export`-prefixed env, and cloudflare * hands it to a Durable Object RPC. Redirection, `mkdir -p`, `tail`, and * `base64` all still work because both paths are shell-interpreted * downstream — the doc comment intentionally does not claim every provider * wraps the command in `sh -c` itself. */ /** Default journal directory. `/tmp` is the convention the harness adapters already use. */ var DEFAULT_JOURNAL_DIR = "/tmp/tanstack-runs"; /** * Key of the sentinel object the journaled command appends after the agent * exits. It tells a *new* host the agent finished, with no pid probe and no * provider-specific liveness API — which matters because `pid` is `-1` on five * of six providers. */ var EXIT_SENTINEL_KEY = "__exit"; /** * Key carrying the per-run sentinel nonce that makes the sentinel * DISTINGUISHABLE from agent output. * * **Why the nonce exists.** `journaledCommand` redirects the agent's stdout and * the sentinel `printf` into the SAME file with no framing, so on the wire an * agent's own line is indistinguishable from the shell's. Without a nonce, any * agent that ever prints a JSON object carrying `__exit` — echoing a fixture, * `cat`-ing a file, dumping diagnostics — makes {@link parseJournalExit} report a * MID-FLIGHT run as finished, and `reapOne` then drives that run to terminal and * reclaims its sandbox out from under a live agent. A confident wrong answer is * strictly worse than the `'unknown'` every other failure on that path returns. * * **What the nonce is.** A domain-separated SHA-256 of the runId (see * {@link journalPaths}), NOT process-random. It has to be recomputable by a * SUCCESSOR host from the run record alone — that is this module's stated * contract ("a successor host derives byte-identical commands from the `runId` * alone"), and the reaper's probe runs in a different process from the one that * composed the command, with nothing but the runId to go on. A process-random * nonce would make every journal written by a dead host unreadable. * * **The residual, stated honestly.** Because it is derived rather than secret, * an agent that knows its own runId AND reimplements this derivation could still * emit a matching line. What the nonce removes is the entire accidental class — * which is the class that actually occurs — and it removes it completely. Closing * the deliberate case needs a secret the successor host can also read, i.e. a * nonce persisted on the run record; that is a `RunStore` schema change, not a * change to this pure-composition module. Two further mitigations narrow the * deliberate case: {@link parseJournalExit} takes the LAST matching sentinel in * the window rather than the first (the shell always writes the real one after * the agent's own output), and a matching sentinel whose code is not an integer * is refused rather than coerced to 0. */ var EXIT_SENTINEL_NONCE_KEY = "__nonce"; /** * Domain-separation prefix for the sentinel nonce, so the digest can never * collide with some other SHA-256-of-runId this codebase computes (e.g. * {@link encodeRunId}'s truncation hash). */ var EXIT_SENTINEL_NONCE_DOMAIN = "tanstack-ai-sandbox/journal-exit-sentinel/v1"; /** Hex digits of the sentinel nonce. 128 bits of digest is far beyond luck. */ var EXIT_SENTINEL_NONCE_LENGTH = 32; /** Derive a run's sentinel nonce. Pure, and a function of the runId alone. */ function deriveExitSentinelNonce(runId) { return createHash("sha256").update(`${EXIT_SENTINEL_NONCE_DOMAIN}:${runId}`, "utf8").digest("hex").slice(0, EXIT_SENTINEL_NONCE_LENGTH); } /** * The exact sentinel LINE (no trailing newline) `journaledCommand` appends for * `exitCode`. * * Exported because a test or a fake host that seeds a journal by hand has to * write the same bytes the shell would; hand-writing `{"__exit":0}` produces a * line the reader now correctly refuses. Key order matches the `printf` format * below, and both are asserted against each other in `journal.test.ts`. */ function exitSentinelLine(paths, exitCode) { return JSON.stringify({ [EXIT_SENTINEL_KEY]: exitCode, [EXIT_SENTINEL_NONCE_KEY]: paths.nonce }); } /** Single-quote a shell word, escaping embedded single quotes POSIX-style. */ function shellQuote(value) { return `'${value.replaceAll("'", `'\\''`)}'`; } /** * Windows reserves these names (case-insensitively) even when followed by an * extension — `CON.ndjson` still opens the `CON` device on Windows, it does * not create a file. {@link encodeRunId} only ever needs to check for an * EXACT match because, as its doc explains, that is the only way one of these * names can appear as the encoded output at all. */ var WINDOWS_RESERVED_NAME = /^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i; /** * Hard cap on the encoded token's length, well under the ~255-byte filename * limit shared by NTFS and most POSIX filesystems, leaving headroom for the * longest extension this module appends (`.ndjson`) plus the directory * component of the path. Long runIds are hashed rather than rejected — see * {@link encodeRunId}. */ var MAX_ENCODED_NAME_LENGTH = 200; /** Hex digest length appended when a runId is long enough to be hashed. */ var TRUNCATION_HASH_LENGTH = 16; /** * Hex-escape every byte of `input`, ignoring the "safe character" allowance * entirely. Used only where the caller has already proven that no OTHER * runId can produce the same output through the normal per-character path * (see the call sites), because unlike that path this one escapes letters * and digits too. */ function hexEscapeAllBytes(input) { let out = ""; for (const byte of new TextEncoder().encode(input)) out += `_${byte.toString(16).padStart(2, "0")}`; return out; } /** * Map a runId to a filename-safe token that is INJECTIVE: distinct runIds * must never produce the same token, because the journal is looked up by * this token alone and a collision means two runs would share one journal — * one run's takeover replaying another run's transcript. * * Encoding rather than rejecting keeps the mapping total: a client may choose * any `runId`, and a run that cannot be journaled would be a run that cannot be * made durable. The encoding is a pure function of the input, which is what lets * a successor host recompute the same path from the run record alone. * * The scheme is a straightforward escaping over `_`: any character matching * `[A-Za-z0-9.-]` passes through literally; everything else — INCLUDING a * literal `_` — is replaced by `_` followed by two lowercase hex digits per * UTF-8 byte. Because `_` itself is never a safe (pass-through) character, * every `_` in the output unambiguously starts a two-hex-digit escape; a * left-to-right scan can always tell literal from escape. That is what makes * the mapping injective: two different inputs can never parse to the same * output, because the (unimplemented, but well-defined) decoder is * deterministic — if it were not injective, running that decoder on a shared * output would have to yield both original strings, which is impossible for a * deterministic function. * * This is a DELIBERATE change from a prior scheme that also treated `_` as * safe. That made the encoding non-injective: `_` doubled as both a literal * and the escape prefix, so an escaped byte could read back as a literal * escape sequence typed by someone else. Concretely, under the old scheme * `encodeRunId('@')` and `encodeRunId('_40')` both produced `'_40'` — `@` is * `0x40` and gets escaped to `_40`, while the literal characters `_`, `4`, `0` * were all "safe" and passed through unchanged. This change breaks that * collision by escaping `_` like any other unsafe character. * * BREAKING CHANGE for existing journals: a journal file written under the * old scheme (where a literal `_` in the runId was left unescaped) will not * be found by this scheme, because a runId containing `_` now encodes * differently. Durability has not shipped publicly yet (this repo has no * released version with `encodeRunId` in it), so there is no compatibility * obligation and no changeset is warranted — there is nothing in the wild to * migrate. * * EXPORTED for adapters that derive their OWN in-sandbox paths from a `runId` * (`ai-codex`'s prompt file and MCP bridge config, `ai-claude-code`'s prompt * file). Durability makes `runId` caller-chosen, so an unencoded interpolation * lets a `/` produce a directory-bearing path, `..` escape the workdir, and an * over-long id fail the spawn with `ENAMETOOLONG` — the same hazards * {@link journalPaths} already routes through here. Reuse this rather than * writing a second encoder: a divergent copy would reintroduce the * non-injectivity documented above. */ function encodeRunId(runId) { if (runId.length === 0) throw new Error("journal: runId must not be empty"); let out = ""; for (const char of runId) { if (/^[A-Za-z0-9.-]$/.test(char)) { out += char; continue; } for (const byte of new TextEncoder().encode(char)) out += `_${byte.toString(16).padStart(2, "0")}`; } if (WINDOWS_RESERVED_NAME.test(out)) out = hexEscapeAllBytes(runId); if (out.length > MAX_ENCODED_NAME_LENGTH) { const hash = createHash("sha256").update(runId, "utf8").digest("hex").slice(0, TRUNCATION_HASH_LENGTH); const prefixLength = MAX_ENCODED_NAME_LENGTH - hash.length - 1; out = `${out.slice(0, prefixLength)}-${hash}`; } return out; } /** Extensions {@link journalPaths} appends, longest-first so stripping is unambiguous. */ var JOURNAL_EXTENSIONS = [".ndjson", ".err"]; /** * Recover the `runId` behind a journal filename — FAIL CLOSED. * * The consumer of this function DELETES files, so every arm that is not a * proven-correct decode must be one the caller keeps. There is no "probably * fine" arm. * * `name` is the filename as {@link journalListCommand} reports it, extension * included. The extension is required, not optional: `.` is a pass-through-safe * character, so a runId of `'x.ndjson'` encodes to the token `x.ndjson` and the * file `x.ndjson.ndjson`. A function that stripped an extension only "if * present" could not tell those two strings apart. Requiring it keeps that * sharp edge here instead of in every caller that would otherwise reach for * `name.split('.')[0]`. * * **Why `truncated` is a distinct refusal and not a decode.** `encodeRunId` * caps its output at {@link MAX_ENCODED_NAME_LENGTH} by replacing the tail with * `-` plus a SHA-256 prefix. That branch discards bytes, so the encoding is not * invertible there — and because `-` is itself a pass-through-safe character, * the truncated form is syntactically indistinguishable from a legitimately * encoded id. Decoding it anyway would yield a plausible but WRONG runId; the * store would not recognise it, a sweep would read that as "no such run", and * it would delete the journal of a run that may still be mid-flight. So any * name that *could* be the truncated form is refused, at the cost of never * sweeping journals of runIds long enough to hash — a bounded leak, versus * data loss on a live run. * * The truncation check runs BEFORE the character scan on purpose: truncating at * a fixed byte offset can cut an `_hh` escape in half, so a truncated name may * also be malformed, and the more specific diagnosis is the useful one. * * The rest is the inverse of the escaping scheme: `[A-Za-z0-9.-]` is a literal * ASCII byte, `_` must be followed by EXACTLY two hex digits (either case), * and anything else — a bare `_`, a one-digit escape, `/`, `\`, a space — is * malformed. The resulting bytes go through a `fatal: true` `TextDecoder`, so * an escape sequence that is not valid UTF-8 is a refusal rather than a string * silently peppered with U+FFFD (which would be a *different* runId than any * encoder input, i.e. exactly the wrong-runId deletion this guards against). */ function decodeJournalRunId(name) { const extension = JOURNAL_EXTENSIONS.find((candidate) => name.endsWith(candidate)); if (extension === void 0) return { kind: "malformed" }; const token = name.slice(0, name.length - extension.length); if (token.length === 0) return { kind: "malformed" }; if (token.length > MAX_ENCODED_NAME_LENGTH || token.length === MAX_ENCODED_NAME_LENGTH && new RegExp(`-[0-9a-f]{${TRUNCATION_HASH_LENGTH}}$`).test(token)) return { kind: "truncated" }; const bytes = []; let index = 0; while (index < token.length) { const char = token.charAt(index); if (char === "_") { const hex = token.slice(index + 1, index + 3); if (!/^[0-9a-fA-F]{2}$/.test(hex)) return { kind: "malformed" }; bytes.push(Number.parseInt(hex, 16)); index += 3; continue; } if (!/^[A-Za-z0-9.-]$/.test(char)) return { kind: "malformed" }; bytes.push(char.charCodeAt(0)); index += 1; } try { return { kind: "runId", runId: new TextDecoder("utf-8", { fatal: true }).decode(new Uint8Array(bytes)) }; } catch { return { kind: "malformed" }; } } /** * Derive both journal paths for a run. Pure; no I/O. * * **`runId` MUST be unique per run.** The journal is append-only by design (a * takeover depends on a prefix a previous host delivered still being there), and * {@link DEFAULT_JOURNAL_DIR} is a fixed absolute path that outlives any single * sandbox, test, or process. So a reused `runId` does not start a fresh journal * — it appends to the old one, behind the old run's `{"__exit":N}` sentinel. A * streaming reader stops at the first sentinel it reaches — and a reused runId * derives the SAME nonce, so the old run's sentinel matches — meaning the new run * appears to emit nothing at all, or to fail with the previous run's exit code. * (The nonce is per-run, not per-attempt: it defends against the AGENT forging a * sentinel, not against a caller reusing an id.) This is not * enforced here on purpose: refusing to append would break the takeover the * append-only rule exists for. Callers derive `runId` from something unique * (the adapters use a timestamp plus a random suffix); a test that hardcodes a * literal `runId` will observe a stale run's journal on its second execution. */ function journalPaths(runId, dir = DEFAULT_JOURNAL_DIR) { const normalizedDir = normalizeJournalDir(dir); const name = encodeRunId(runId); return { dir: normalizedDir, journal: `${normalizedDir}/${name}.ndjson`, stderr: `${normalizedDir}/${name}.err`, nonce: deriveExitSentinelNonce(runId) }; } /** * Wrap an agent command so its stdout lands in the journal, its stderr lands in * the sidecar file, and an `{"__exit":N,"__nonce":"…"}` sentinel is appended once * it exits. * * The nonce is what keeps the sentinel apart from the agent's own stdout, which * lands in the very same file with no framing — see * {@link EXIT_SENTINEL_NONCE_KEY}. It is interpolated as a bare hex token inside * a single-quoted `printf` FORMAT string, which is safe by construction: * {@link deriveExitSentinelNonce} emits `[0-9a-f]` only, so there is no quote to * escape and no `%` for `printf` to interpret. * * `command` is interpolated raw: callers build real shell text (the Claude Code * and Codex adapters append `< promptFile`, for instance), so quoting it would * break them. Every path this module contributes IS quoted. * * `>>` rather than `>` on purpose: truncating would let a stray re-spawn destroy * a prefix a previous host already translated and delivered. */ function journaledCommand(command, paths) { return `mkdir -p ${shellQuote(paths.dir)} && { ( ${command} ); printf '{"${EXIT_SENTINEL_KEY}":%d,"${EXIT_SENTINEL_NONCE_KEY}":"${paths.nonce}"}\\n' "$?"; } >> ${shellQuote(paths.journal)} 2>> ${shellQuote(paths.stderr)}`; } /** * `tail -c +N` is 1-based over bytes, while `fromByte` is a 0-based count of * bytes already consumed. `+fromByte + 1` is therefore "the first byte we have * not seen". */ function tailFrom(fromByte) { if (!Number.isSafeInteger(fromByte) || fromByte < 0) throw new Error(`journal: fromByte must be a non-negative safe integer, got ${fromByte}`); return fromByte + 1; } /** * Following read, for `process.spawn` only. Never pass this to `exec`: * `ProcessOptions` has no timeout, so a following `exec` blocks until the * sandbox or the RPC times out. * * Deliberately pipes into NOTHING. `tail -f` flushes each append as it sees it, * so it is the one stage in this pipeline that streams; adding any filter puts * that filter's stdio buffer between the agent and the host and the follow * strategy stops following (see rule 2 in the module doc for the measurements). * The host turns these raw bytes into positioned lines with * `journal-bytes.ts`. * * It also creates the journal before tailing it, because `tail -f` on a path * that does not exist yet prints a diagnostic and EXITS rather than waiting — * so the reader would deliver zero lines for a run whose journal simply had not * been created yet. The reader and the agent are two independent spawns and * nothing orders them, so that race is the normal case, not the unlucky one. * `: >> file` is a builtin no-op plus an O_CREAT|O_APPEND open: it creates the * file when absent and, critically, does NOT truncate one that already has a * prefix a previous host already delivered. `;` rather than `&&` throughout, so * a prep step that fails still lets the `tail` run and fail the way it used to * rather than turning a read into a silent no-op. (`tail -F` would also retry, * but `-F` is a GNU/busybox extension, not POSIX, and this file only emits * POSIX shell.) */ function journalFollowCommand(paths, fromByte) { return `mkdir -p ${shellQuote(paths.dir)} 2>/dev/null; : >> ${shellQuote(paths.journal)} 2>/dev/null; tail -c +${tailFrom(fromByte)} -f ${shellQuote(paths.journal)} 2>/dev/null`; } /** * Bounded read: `-f` dropped so it always terminates, and base64-framed because * it can be — `exec` closes `base64`'s stdin, which flushes it, and the whole * result arrives as one already-complete `ExecResult.stdout` string. This is the * Cloudflare path, whose `spawn` cannot be killed and whose `exec` drops the * AbortSignal, making a following read unstoppable there. */ function journalReadCommand(paths, fromByte) { return `tail -c +${tailFrom(fromByte)} ${shellQuote(paths.journal)} 2>/dev/null | base64`; } /** * Existence probe. A shell `test -f`, not `handle.fs.exists`: see rule 3 in the * module doc — on local-process the two resolve `/tmp` differently. */ function journalExistsCommand(paths) { return `test -f ${shellQuote(paths.journal)}`; } /** Bytes of the stderr sidecar {@link journalStderrReadCommand} reads by default. */ var DEFAULT_STDERR_TAIL_BYTES = 4096; /** * Bounded read of the stderr SIDECAR (not the journal), so a non-zero exit can * carry the agent's own diagnostics instead of a bare exit code. * * `exec`-only, like {@link journalReadCommand}, and base64-framed for the same * reason: `exec` closes the encoder's stdin so it flushes, and the frame keeps a * provider that folds stderr into stdout from splicing its own text into the * bytes. Unlike the journal, the sidecar is NOT line-delimited JSON — an agent * writes whatever it likes there, including partial lines and raw control bytes * — so framing is what makes it safe to hand to a single `ExecResult.stdout`. * * `tail -c -N` (the LAST N bytes) rather than the first: the read has to be * bounded, because a runaway agent's sidecar can be arbitrarily large and this * runs on the host, and a crash's cause is at the end of stderr, not the start. * The cost is that the first character can be a truncated UTF-8 sequence; the * caller decodes lossily rather than failing, since this text is diagnostic. */ function journalStderrReadCommand(paths, maxBytes = DEFAULT_STDERR_TAIL_BYTES) { if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) throw new Error(`journal: maxBytes must be a positive safe integer, got ${maxBytes}`); return `tail -c -${maxBytes} ${shellQuote(paths.stderr)} 2>/dev/null | base64`; } /** * Delete both of a run's journal files. * * **Ordering is the whole contract here, not the `rm`.** This may only run once * the run is TERMINAL — i.e. after the `{"__exit":N}` sentinel has been observed * — and must never run on an abort. The three claims that make the deletion safe: * * 1. **Terminal means the event log holds the whole run.** The journal exists so * a successor host can replay a run from byte 0 and re-derive the chunks a * dead host never got to append. Once the sentinel has been read and the * replay has been forwarded, the log — not the journal — is the record. A late * takeover therefore aligns against the log: `align.ts`'s `alignToStoredLog` * takes a `StreamDurability` and an `AsyncIterable<StreamChunk>`, has no * `SandboxHandle` and no {@link JournalPaths} in its signature, and reads the * prefix with `durability.snapshot()`. It *cannot* read the journal, so * deleting one that is terminal cannot break it. * 2. **A non-zero exit is terminal too.** `{"__exit":7}` is as final as * `{"__exit":0}`; the run failed, it is not resumable, and the failure is * already on its way to the client as a `RUN_ERROR`. Keeping a failed run's * journal would leak exactly the runs most likely to be numerous. * 3. **An abort is NOT terminal.** A consumer that stops early (lease lost, * client gone, host shutting down) may be handing the run off to a successor * host that still needs every byte, so an aborted read must leave both files * alone. * * Shell `rm`, never `handle.fs.remove`: rule 3 in the module doc. On * local-process `/tmp` resolves under the sandbox root through `fs.*` but to the * host's real `/tmp` through the shell, so an `fs.remove` would delete a * different path than the one `journaledCommand` wrote — i.e. nothing, silently. * * `-f` so a journal that is already gone (a provider that reaped `/tmp`, a * successor that cleaned up first) is a success, not an error. Callers treat the * whole thing as best effort regardless: a failed cleanup must never fail a run * that has already completed. * * **What this does NOT bound:** a run that reaches its sentinel while DETACHED * has no host reading its journal, so nothing ever observes the sentinel and * nothing calls this. Bounding it is `pruneJournals`' job (`journal-sweep.ts`): * a sweep over {@link DEFAULT_JOURNAL_DIR} that deletes only the journals whose * runs the store says are terminal. It runs from a cron the application * schedules, not from a run, so such a journal survives until that sweep — on a * `keepAlive` sandbox, indefinitely without one. */ function journalCleanupCommand(paths) { return `rm -f ${shellQuote(paths.journal)} ${shellQuote(paths.stderr)}`; } /** * List the journal directory, one entry per line. * * **`2>/dev/null` is load-bearing, not tidiness.** Daytona's `exec` folds * stderr into stdout by contract and the Sprites fast path does the same, so on * a directory that does not exist yet — the normal state before the first run — * an `ls: cannot access '/tmp/tanstack-runs': No such file or directory` * diagnostic would arrive as if it were a LINE OF OUTPUT. The sweep would then * hand that sentence to {@link decodeJournalRunId} and, if it decoded, delete * whatever it named. Silencing it inside the sandbox means a missing directory * produces zero lines, which is the truth. * * `-1` so one entry occupies one line: `ls` only defaults to columns on a tty, * but `exec`'s stdout is not always a pipe on every provider and the flag costs * nothing. * * **Dot-files are not listed**, by `ls` default. A runId beginning with `.` * encodes to a hidden filename (`.` passes through the encoder), so its journal * is invisible to a sweep and leaks rather than being deleted. That is the safe * direction of the two and the reason this is documented rather than fixed with * `-a`, which would also introduce `.` and `..` as entries. */ function journalListCommand(dir = DEFAULT_JOURNAL_DIR) { return `ls -1 ${shellQuote(normalizeJournalDir(dir))} 2>/dev/null`; } /** Strip a trailing slash so a dir compares equal to `stat`'s echoed operand. */ function normalizeJournalDir(dir) { return dir.endsWith("/") ? dir.slice(0, -1) : dir; } /** * List the journal directory WITH modification times, so a sweep can leave * recently-touched journals alone. * * **Neither `find -newermt` nor `find -printf` may be used here.** Both are GNU * extensions, absent from BusyBox 1.37 — the `alpine:3` shell every docker- * provider journal test runs in — and absent from MINGW64's `find`. Measured * working on BusyBox 1.37, GNU coreutils, and MINGW64: `stat -c "%Y %n"`, which * is what this emits. (`touch -d <ts> ref` plus `find ! -newer ref` also works * on all three, but it needs a writable reference file OUTSIDE the journal * directory — inside, `ls -1` would report the reference as an entry — and a * write is a side effect this pure-composition module has no business having.) * * **The directory is passed as its own first operand on purpose.** It is a * self-witness. `stat` reports every operand it can and only *then* exits * non-zero, so: * * - populated directory → witness line + one line per file, exit 0 * - EMPTY directory → witness line only, exit 1 (the unexpanded glob is an * operand `stat` cannot stat) * - `stat` without `-c` support → NO output at all, exit 1 * * That is what makes "no files" distinguishable from "the mechanism is * unavailable", and it has to be distinguishable because BusyBox exits 1 with * EMPTY stdout on an unrecognised flag. A caller that ignored the exit code and * took an empty parse as an empty directory would conclude every journal is * absent; one that then inferred "therefore nothing is recent" would delete the * whole directory. Hence {@link parseJournalMtimeListing} returns * `{ kind: 'unavailable' }` rather than `[]`, and the exit code is not consulted * at all — the witness line, not the status, is the evidence. * * Note the glob shares `ls`'s dot-file blindness (same fail-safe consequence), * and that `stat` cannot distinguish a file from a subdirectory here; a stray * subdirectory is caught downstream, because its name will not decode. */ function journalMtimeListCommand(dir = DEFAULT_JOURNAL_DIR) { const normalized = normalizeJournalDir(dir); return `stat -c '%Y %n' ${shellQuote(normalized)} ${shellQuote(normalized)}/* 2>/dev/null`; } /** * Parse {@link journalMtimeListCommand}'s stdout. * * Line-based, space-split parsing is unambiguous here: an encoded filename can * only contain `[A-Za-z0-9.-]` and `_hh` escapes (see {@link encodeRunId}), so * it can never contain a space or a newline, and `%Y` is digits. A line that * does not fit the shape — including a directory prefix that is not `dir` — is * dropped rather than guessed at. */ function parseJournalMtimeListing(text, dir = DEFAULT_JOURNAL_DIR) { const normalized = normalizeJournalDir(dir); const entries = []; let sawWitness = false; for (const rawLine of text.split("\n")) { const line = rawLine.trim(); if (line === "") continue; const separator = line.indexOf(" "); if (separator === -1) continue; const seconds = line.slice(0, separator); if (!/^\d+$/.test(seconds)) continue; const path = line.slice(separator + 1); if (path === normalized) { sawWitness = true; continue; } const prefix = `${normalized}/`; if (!path.startsWith(prefix)) continue; const name = path.slice(prefix.length); if (name === "" || name.includes("/")) continue; entries.push({ name, mtimeMs: Number.parseInt(seconds, 10) * 1e3 }); } if (!sawWitness) return { kind: "unavailable" }; return { kind: "listed", entries }; } /** Bytes of the journal tail {@link journalExitProbeCommand} reads by default. */ var DEFAULT_EXIT_PROBE_TAIL_BYTES = 4096; /** * Bounded read of the END of a run's journal, purely to learn whether the agent * reached its `{"__exit":N}` sentinel. * * **This exists so a reaper does not have to drive the run to find out.** * Entering `pipeToRunLog` to check writes a terminal status and calls * `durability.close()` on every path, including for a healthy mid-flight run — * recording it as `'completed'`, which drops it out of `listReclaimable` * forever. This probe is read-only and provider-neutral, and it is what makes a * reclaim candidate safe to drive. * * The command is the byte-identical idiom to {@link journalStderrReadCommand}, * pointed at the journal instead of the sidecar: `tail -c -N` (the LAST N * bytes, because the sentinel is at the end), `2>/dev/null` so a missing * journal cannot splice a diagnostic into the bytes on a provider that folds * stderr into stdout, and base64 framing. Verified on BusyBox 1.37. * * base64 is correct HERE and forbidden on {@link journalFollowCommand} for the * reason rule 2 in the module doc measures: the encoder fully buffers a piped * stdout, which is harmless when `exec` closes its stdin and fatal when the * producer is `tail -f`. This read is bounded and terminates, so it never * streams. */ function journalExitProbeCommand(paths, maxBytes = DEFAULT_EXIT_PROBE_TAIL_BYTES) { if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) throw new Error(`journal: maxBytes must be a positive safe integer, got ${maxBytes}`); return `tail -c -${maxBytes} ${shellQuote(paths.journal)} 2>/dev/null | base64`; } /** * Is ONE journal line this run's genuine exit sentinel? The exit code if so, * `null` for anything else — including a line that carries * {@link EXIT_SENTINEL_KEY} but not this run's nonce, which is agent output and * nothing more. * * FAIL CLOSED at every step, because the consumers of a non-`null` answer stop * the run and reclaim its sandbox: * * - not JSON, or not an object → `null`. This is also what absorbs the partial * first line a byte-bounded `tail -c -N` can start in the middle of. * - no `__nonce`, or a `__nonce` that is not exactly `paths.nonce` → `null`. An * agent line cannot be told from the shell's without this (see * {@link EXIT_SENTINEL_NONCE_KEY}). * - a matching nonce but a non-integer `__exit` → `null`, NOT `0`. The old code * coerced a non-number to `0`, which turned a garbled sentinel into a reported * SUCCESS. Nothing that reaches here legitimately can be non-integer: the only * writer is `printf '…%d…' "$?"`. * * Exported so the streaming reader (`runner.ts`) applies exactly the same test, * line by line, that the reaper's bounded tail probe applies — one definition of * "the run ended", not two that can drift. */ function parseExitSentinel(line, paths) { const trimmed = line.trim(); if (trimmed === "") return null; let parsed; try { parsed = JSON.parse(trimmed); } catch { return null; } if (typeof parsed !== "object" || parsed === null) return null; if (!("__exit" in parsed)) return null; const nonce = Reflect.get(parsed, EXIT_SENTINEL_NONCE_KEY); if (typeof nonce !== "string" || nonce !== paths.nonce) return null; const code = Reflect.get(parsed, EXIT_SENTINEL_KEY); if (typeof code !== "number" || !Number.isInteger(code)) return null; return code; } /** * Find the exit sentinel in a decoded journal tail; `null` when it is absent, * which is the mid-flight (or never-started) case. * * **Scanned from the END, and the nonce is REQUIRED.** Both matter, and both are * corrections: * * - The shell appends the real sentinel AFTER the command's own output, so the * genuine one is always the last matching line in the window. Taking the first * match let an agent line that happened to look like a sentinel win over the * truth that followed it. * - `paths.nonce` must match, or the line is not a sentinel at all. Without that, * a mid-flight run whose agent printed any JSON object containing `__exit` read * as `finished`, and the reaper destroyed a live sandbox on the strength of it. * * `paths` rather than a bare nonce string so callers pass the object they already * hold and cannot pair a tail with another run's nonce. */ function parseJournalExit(text, paths) { const lines = text.split("\n"); for (let index = lines.length - 1; index >= 0; index -= 1) { const code = parseExitSentinel(lines[index] ?? "", paths); if (code !== null) return code; } return null; } //#endregion export { DEFAULT_JOURNAL_DIR, EXIT_SENTINEL_KEY, EXIT_SENTINEL_NONCE_KEY, decodeJournalRunId, encodeRunId, exitSentinelLine, journalCleanupCommand, journalExistsCommand, journalExitProbeCommand, journalFollowCommand, journalListCommand, journalMtimeListCommand, journalPaths, journalReadCommand, journalStderrReadCommand, journaledCommand, parseExitSentinel, parseJournalExit, parseJournalMtimeListing }; //# sourceMappingURL=journal.js.map