@tanstack/ai-sandbox 0.2.3 → 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.
Files changed (158) hide show
  1. package/dist/esm/agents-file.js +53 -34
  2. package/dist/esm/agents-file.js.map +1 -1
  3. package/dist/esm/align.d.ts +121 -0
  4. package/dist/esm/align.js +197 -0
  5. package/dist/esm/align.js.map +1 -0
  6. package/dist/esm/approvals.js +63 -29
  7. package/dist/esm/approvals.js.map +1 -1
  8. package/dist/esm/attach-preflight.d.ts +85 -0
  9. package/dist/esm/attach-preflight.js +189 -0
  10. package/dist/esm/attach-preflight.js.map +1 -0
  11. package/dist/esm/bootstrap.js +103 -117
  12. package/dist/esm/bootstrap.js.map +1 -1
  13. package/dist/esm/bridge-events.js +96 -71
  14. package/dist/esm/bridge-events.js.map +1 -1
  15. package/dist/esm/capabilities.d.ts +0 -5
  16. package/dist/esm/capabilities.js +32 -28
  17. package/dist/esm/capabilities.js.map +1 -1
  18. package/dist/esm/chunk-identity.d.ts +52 -0
  19. package/dist/esm/chunk-identity.js +102 -0
  20. package/dist/esm/chunk-identity.js.map +1 -0
  21. package/dist/esm/claim.d.ts +187 -0
  22. package/dist/esm/claim.js +349 -0
  23. package/dist/esm/claim.js.map +1 -0
  24. package/dist/esm/contracts.d.ts +13 -0
  25. package/dist/esm/driver.d.ts +83 -0
  26. package/dist/esm/driver.js +138 -0
  27. package/dist/esm/driver.js.map +1 -0
  28. package/dist/esm/durability.d.ts +263 -0
  29. package/dist/esm/durability.js +230 -0
  30. package/dist/esm/durability.js.map +1 -0
  31. package/dist/esm/errors.js +28 -24
  32. package/dist/esm/errors.js.map +1 -1
  33. package/dist/esm/file-diff.js +151 -135
  34. package/dist/esm/file-diff.js.map +1 -1
  35. package/dist/esm/git-exec.js +51 -62
  36. package/dist/esm/git-exec.js.map +1 -1
  37. package/dist/esm/harness-cwd.js +24 -19
  38. package/dist/esm/harness-cwd.js.map +1 -1
  39. package/dist/esm/index.d.ts +30 -8
  40. package/dist/esm/index.js +23 -91
  41. package/dist/esm/instance-store.d.ts +88 -0
  42. package/dist/esm/instance-store.js +67 -0
  43. package/dist/esm/instance-store.js.map +1 -0
  44. package/dist/esm/journal-bytes.d.ts +67 -0
  45. package/dist/esm/journal-bytes.js +110 -0
  46. package/dist/esm/journal-bytes.js.map +1 -0
  47. package/dist/esm/journal-reader.d.ts +66 -0
  48. package/dist/esm/journal-reader.js +228 -0
  49. package/dist/esm/journal-reader.js.map +1 -0
  50. package/dist/esm/journal-sweep.d.ts +113 -0
  51. package/dist/esm/journal-sweep.js +309 -0
  52. package/dist/esm/journal-sweep.js.map +1 -0
  53. package/dist/esm/journal.d.ts +542 -0
  54. package/dist/esm/journal.js +679 -0
  55. package/dist/esm/journal.js.map +1 -0
  56. package/dist/esm/key.js +36 -33
  57. package/dist/esm/key.js.map +1 -1
  58. package/dist/esm/middleware.d.ts +50 -2
  59. package/dist/esm/middleware.js +335 -208
  60. package/dist/esm/middleware.js.map +1 -1
  61. package/dist/esm/ngrok.js +75 -49
  62. package/dist/esm/ngrok.js.map +1 -1
  63. package/dist/esm/policy.js +43 -34
  64. package/dist/esm/policy.js.map +1 -1
  65. package/dist/esm/projection.js +16 -8
  66. package/dist/esm/projection.js.map +1 -1
  67. package/dist/esm/reap.d.ts +238 -0
  68. package/dist/esm/reap.js +355 -0
  69. package/dist/esm/reap.js.map +1 -0
  70. package/dist/esm/reclaim.d.ts +84 -0
  71. package/dist/esm/reclaim.js +106 -0
  72. package/dist/esm/reclaim.js.map +1 -0
  73. package/dist/esm/remote-tools.js +73 -62
  74. package/dist/esm/remote-tools.js.map +1 -1
  75. package/dist/esm/run.d.ts +93 -25
  76. package/dist/esm/run.js +274 -79
  77. package/dist/esm/run.js.map +1 -1
  78. package/dist/esm/runner.d.ts +119 -2
  79. package/dist/esm/runner.js +270 -51
  80. package/dist/esm/runner.js.map +1 -1
  81. package/dist/esm/sandbox.d.ts +3 -2
  82. package/dist/esm/sandbox.js +139 -123
  83. package/dist/esm/sandbox.js.map +1 -1
  84. package/dist/esm/secrets.js +39 -47
  85. package/dist/esm/secrets.js.map +1 -1
  86. package/dist/esm/setup-plan.js +22 -14
  87. package/dist/esm/setup-plan.js.map +1 -1
  88. package/dist/esm/shell.d.ts +8 -0
  89. package/dist/esm/shell.js +197 -158
  90. package/dist/esm/shell.js.map +1 -1
  91. package/dist/esm/testkit/conformance.d.ts +16 -0
  92. package/dist/esm/testkit/conformance.js +97 -0
  93. package/dist/esm/testkit/conformance.js.map +1 -0
  94. package/dist/esm/testkit/durable-run-fields-conformance.d.ts +4 -0
  95. package/dist/esm/testkit/durable-run-fields-conformance.js +95 -0
  96. package/dist/esm/testkit/durable-run-fields-conformance.js.map +1 -0
  97. package/dist/esm/testkit/journal-conformance.d.ts +51 -0
  98. package/dist/esm/testkit/journal-conformance.js +378 -0
  99. package/dist/esm/testkit/journal-conformance.js.map +1 -0
  100. package/dist/esm/testkit/reaper-conformance.d.ts +37 -0
  101. package/dist/esm/testkit/reaper-conformance.js +847 -0
  102. package/dist/esm/testkit/reaper-conformance.js.map +1 -0
  103. package/dist/esm/testkit/shell-spawn.d.ts +2 -0
  104. package/dist/esm/testkit/shell-spawn.js +60 -0
  105. package/dist/esm/testkit/shell-spawn.js.map +1 -0
  106. package/dist/esm/testkit/takeover-conformance.d.ts +24 -0
  107. package/dist/esm/testkit/takeover-conformance.js +685 -0
  108. package/dist/esm/testkit/takeover-conformance.js.map +1 -0
  109. package/dist/esm/tool-bridge.js +227 -180
  110. package/dist/esm/tool-bridge.js.map +1 -1
  111. package/dist/esm/tool-history.d.ts +62 -0
  112. package/dist/esm/tool-history.js +171 -0
  113. package/dist/esm/tool-history.js.map +1 -0
  114. package/dist/esm/watch.js +310 -236
  115. package/dist/esm/watch.js.map +1 -1
  116. package/dist/esm/workspace.d.ts +1 -1
  117. package/dist/esm/workspace.js +49 -28
  118. package/dist/esm/workspace.js.map +1 -1
  119. package/package.json +16 -6
  120. package/skills/ai-sandbox/SKILL.md +658 -20
  121. package/src/align.ts +297 -0
  122. package/src/attach-preflight.ts +292 -0
  123. package/src/capabilities.ts +4 -13
  124. package/src/chunk-identity.ts +154 -0
  125. package/src/claim.ts +479 -0
  126. package/src/contracts.ts +13 -0
  127. package/src/driver.ts +205 -0
  128. package/src/durability.ts +380 -0
  129. package/src/index.ts +212 -27
  130. package/src/instance-store.ts +122 -0
  131. package/src/journal-bytes.ts +136 -0
  132. package/src/journal-reader.ts +359 -0
  133. package/src/journal-sweep.ts +406 -0
  134. package/src/journal.ts +875 -0
  135. package/src/middleware.ts +470 -30
  136. package/src/reap.ts +723 -0
  137. package/src/reclaim.ts +191 -0
  138. package/src/run.ts +365 -75
  139. package/src/runner.ts +347 -3
  140. package/src/sandbox.ts +38 -8
  141. package/src/shell.ts +106 -38
  142. package/src/testkit/conformance.ts +117 -0
  143. package/src/testkit/durable-run-fields-conformance.ts +147 -0
  144. package/src/testkit/journal-conformance.ts +676 -0
  145. package/src/testkit/reaper-conformance.ts +1201 -0
  146. package/src/testkit/shell-spawn.ts +67 -0
  147. package/src/testkit/takeover-conformance.ts +1040 -0
  148. package/src/tool-history.ts +245 -0
  149. package/src/workspace.ts +1 -1
  150. package/dist/esm/index.js.map +0 -1
  151. package/dist/esm/run-log.d.ts +0 -81
  152. package/dist/esm/run-log.js +0 -107
  153. package/dist/esm/run-log.js.map +0 -1
  154. package/dist/esm/store.d.ts +0 -53
  155. package/dist/esm/store.js +0 -34
  156. package/dist/esm/store.js.map +0 -1
  157. package/src/run-log.ts +0 -224
  158. package/src/store.ts +0 -83
package/src/journal.ts ADDED
@@ -0,0 +1,875 @@
1
+ /**
2
+ * The agent output journal: an append-only NDJSON file INSIDE the sandbox that
3
+ * the agent's stdout is redirected to, and that the host tails.
4
+ *
5
+ * This module is pure string composition — no I/O — so every shell fragment the
6
+ * feature depends on is unit-testable without a sandbox, and a successor host
7
+ * derives byte-identical commands from the `runId` alone.
8
+ *
9
+ * Three rules are encoded here and must not be relaxed:
10
+ *
11
+ * 1. **No pipe from the agent.** The agent's stdout is *redirected*, never
12
+ * piped. `agent | tee file` gives the agent a reader whose disappearance
13
+ * SIGPIPEs it — precisely the host-death failure this feature exists to
14
+ * prevent. Redirection leaves nothing to break.
15
+ * 2. **Every read silences stderr; only the BOUNDED read base64-frames its
16
+ * output.** `2>/dev/null` is on both: Daytona's `exec` folds stderr into
17
+ * stdout (`stderr: ''`, by contract) and Sprites' fast path does too, so a
18
+ * `tail` diagnostic would otherwise splice itself into the event bytes.
19
+ * Silencing it inside the sandbox means there is nothing left to fold.
20
+ *
21
+ * base64, however, is only on {@link journalReadCommand}. It cannot be on
22
+ * {@link journalFollowCommand}: `base64` fully buffers its stdout when that
23
+ * is a pipe rather than a tty, so `tail -f file | base64` emits NOTHING
24
+ * until the ~4KB libc stdio buffer fills or `base64`'s stdin closes — and
25
+ * `tail -f`'s stdin never closes until the reader kills it, by which point
26
+ * the consumer has stopped reading. Measured on GNU coreutils 8.32 `base64`
27
+ * (0 bytes delivered over 12s) and on busybox 1.36.1 `base64` in Alpine
28
+ * (identical), so it is a property of stdio, not of a provider or an OS.
29
+ * `stdbuf -o0` does not fix it portably (absent from busybox entirely) and
30
+ * re-`exec`ing `base64` per line costs a fork per journal event.
31
+ *
32
+ * Dropping it from the follow path is safe because the bounded read keeps
33
+ * every property base64 was chosen for where that path needs them, and the
34
+ * follow path needs none of them: `2>/dev/null` already prevents the
35
+ * stderr splice, the journal is line-delimited JSON (a raw newline can only
36
+ * ever be a record separator — inside a JSON string it is `\n`), and
37
+ * `journal-bytes.ts` reassembles bytes across chunk boundaries and yields
38
+ * only newline-terminated lines. The follow path therefore consumes
39
+ * `SpawnHandle.stdout` exactly as `runner.ts` already consumes the agent's
40
+ * own stdout, i.e. it relies on the same provider decoding contract the
41
+ * package already depends on rather than a stricter one.
42
+ * 3. **The journal is touched ONLY through the shell.** On local-process,
43
+ * `fs.write` resolves `/tmp` under the sandbox root while a shell redirect
44
+ * hits the real host `/tmp`. Both halves agree with each other only as long
45
+ * as nothing uses `fs.*` here — hence {@link journalExistsCommand} rather
46
+ * than `handle.fs.exists`.
47
+ *
48
+ * The composed commands below are handed to two different execution
49
+ * mechanisms depending on provider, not always `sh -c`: daytona hands the raw
50
+ * string to `executeCommand` with an `export`-prefixed env, and cloudflare
51
+ * hands it to a Durable Object RPC. Redirection, `mkdir -p`, `tail`, and
52
+ * `base64` all still work because both paths are shell-interpreted
53
+ * downstream — the doc comment intentionally does not claim every provider
54
+ * wraps the command in `sh -c` itself.
55
+ */
56
+
57
+ import { createHash } from 'node:crypto'
58
+
59
+ /** Default journal directory. `/tmp` is the convention the harness adapters already use. */
60
+ export const DEFAULT_JOURNAL_DIR = '/tmp/tanstack-runs'
61
+
62
+ /**
63
+ * Key of the sentinel object the journaled command appends after the agent
64
+ * exits. It tells a *new* host the agent finished, with no pid probe and no
65
+ * provider-specific liveness API — which matters because `pid` is `-1` on five
66
+ * of six providers.
67
+ */
68
+ export const EXIT_SENTINEL_KEY = '__exit'
69
+
70
+ /**
71
+ * Key carrying the per-run sentinel nonce that makes the sentinel
72
+ * DISTINGUISHABLE from agent output.
73
+ *
74
+ * **Why the nonce exists.** `journaledCommand` redirects the agent's stdout and
75
+ * the sentinel `printf` into the SAME file with no framing, so on the wire an
76
+ * agent's own line is indistinguishable from the shell's. Without a nonce, any
77
+ * agent that ever prints a JSON object carrying `__exit` — echoing a fixture,
78
+ * `cat`-ing a file, dumping diagnostics — makes {@link parseJournalExit} report a
79
+ * MID-FLIGHT run as finished, and `reapOne` then drives that run to terminal and
80
+ * reclaims its sandbox out from under a live agent. A confident wrong answer is
81
+ * strictly worse than the `'unknown'` every other failure on that path returns.
82
+ *
83
+ * **What the nonce is.** A domain-separated SHA-256 of the runId (see
84
+ * {@link journalPaths}), NOT process-random. It has to be recomputable by a
85
+ * SUCCESSOR host from the run record alone — that is this module's stated
86
+ * contract ("a successor host derives byte-identical commands from the `runId`
87
+ * alone"), and the reaper's probe runs in a different process from the one that
88
+ * composed the command, with nothing but the runId to go on. A process-random
89
+ * nonce would make every journal written by a dead host unreadable.
90
+ *
91
+ * **The residual, stated honestly.** Because it is derived rather than secret,
92
+ * an agent that knows its own runId AND reimplements this derivation could still
93
+ * emit a matching line. What the nonce removes is the entire accidental class —
94
+ * which is the class that actually occurs — and it removes it completely. Closing
95
+ * the deliberate case needs a secret the successor host can also read, i.e. a
96
+ * nonce persisted on the run record; that is a `RunStore` schema change, not a
97
+ * change to this pure-composition module. Two further mitigations narrow the
98
+ * deliberate case: {@link parseJournalExit} takes the LAST matching sentinel in
99
+ * the window rather than the first (the shell always writes the real one after
100
+ * the agent's own output), and a matching sentinel whose code is not an integer
101
+ * is refused rather than coerced to 0.
102
+ */
103
+ export const EXIT_SENTINEL_NONCE_KEY = '__nonce'
104
+
105
+ /**
106
+ * Domain-separation prefix for the sentinel nonce, so the digest can never
107
+ * collide with some other SHA-256-of-runId this codebase computes (e.g.
108
+ * {@link encodeRunId}'s truncation hash).
109
+ */
110
+ const EXIT_SENTINEL_NONCE_DOMAIN =
111
+ 'tanstack-ai-sandbox/journal-exit-sentinel/v1'
112
+
113
+ /** Hex digits of the sentinel nonce. 128 bits of digest is far beyond luck. */
114
+ const EXIT_SENTINEL_NONCE_LENGTH = 32
115
+
116
+ /** Derive a run's sentinel nonce. Pure, and a function of the runId alone. */
117
+ function deriveExitSentinelNonce(runId: string): string {
118
+ return createHash('sha256')
119
+ .update(`${EXIT_SENTINEL_NONCE_DOMAIN}:${runId}`, 'utf8')
120
+ .digest('hex')
121
+ .slice(0, EXIT_SENTINEL_NONCE_LENGTH)
122
+ }
123
+
124
+ /** Absolute in-sandbox paths for one run's journal. */
125
+ export interface JournalPaths {
126
+ /** Directory both files live in; created by {@link journaledCommand}. */
127
+ dir: string
128
+ /** Append-only NDJSON file the agent's stdout is redirected to. */
129
+ journal: string
130
+ /** Separate file the agent's stderr goes to; NEVER mixed into the journal. */
131
+ stderr: string
132
+ /**
133
+ * Per-run nonce the exit sentinel carries, so agent stdout cannot forge it.
134
+ * See {@link EXIT_SENTINEL_NONCE_KEY}. Carried alongside the paths because
135
+ * every producer and every reader of the sentinel already threads a
136
+ * `JournalPaths` through, and the two must agree or the run reads as
137
+ * unterminated.
138
+ */
139
+ nonce: string
140
+ }
141
+
142
+ /**
143
+ * The exact sentinel LINE (no trailing newline) `journaledCommand` appends for
144
+ * `exitCode`.
145
+ *
146
+ * Exported because a test or a fake host that seeds a journal by hand has to
147
+ * write the same bytes the shell would; hand-writing `{"__exit":0}` produces a
148
+ * line the reader now correctly refuses. Key order matches the `printf` format
149
+ * below, and both are asserted against each other in `journal.test.ts`.
150
+ */
151
+ export function exitSentinelLine(
152
+ paths: JournalPaths,
153
+ exitCode: number,
154
+ ): string {
155
+ return JSON.stringify({
156
+ [EXIT_SENTINEL_KEY]: exitCode,
157
+ [EXIT_SENTINEL_NONCE_KEY]: paths.nonce,
158
+ })
159
+ }
160
+
161
+ /** Single-quote a shell word, escaping embedded single quotes POSIX-style. */
162
+ function shellQuote(value: string): string {
163
+ return `'${value.replaceAll("'", `'\\''`)}'`
164
+ }
165
+
166
+ /**
167
+ * Windows reserves these names (case-insensitively) even when followed by an
168
+ * extension — `CON.ndjson` still opens the `CON` device on Windows, it does
169
+ * not create a file. {@link encodeRunId} only ever needs to check for an
170
+ * EXACT match because, as its doc explains, that is the only way one of these
171
+ * names can appear as the encoded output at all.
172
+ */
173
+ const WINDOWS_RESERVED_NAME = /^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i
174
+
175
+ /**
176
+ * Hard cap on the encoded token's length, well under the ~255-byte filename
177
+ * limit shared by NTFS and most POSIX filesystems, leaving headroom for the
178
+ * longest extension this module appends (`.ndjson`) plus the directory
179
+ * component of the path. Long runIds are hashed rather than rejected — see
180
+ * {@link encodeRunId}.
181
+ */
182
+ const MAX_ENCODED_NAME_LENGTH = 200
183
+
184
+ /** Hex digest length appended when a runId is long enough to be hashed. */
185
+ const TRUNCATION_HASH_LENGTH = 16
186
+
187
+ /**
188
+ * Hex-escape every byte of `input`, ignoring the "safe character" allowance
189
+ * entirely. Used only where the caller has already proven that no OTHER
190
+ * runId can produce the same output through the normal per-character path
191
+ * (see the call sites), because unlike that path this one escapes letters
192
+ * and digits too.
193
+ */
194
+ function hexEscapeAllBytes(input: string): string {
195
+ let out = ''
196
+ for (const byte of new TextEncoder().encode(input)) {
197
+ out += `_${byte.toString(16).padStart(2, '0')}`
198
+ }
199
+ return out
200
+ }
201
+
202
+ /**
203
+ * Map a runId to a filename-safe token that is INJECTIVE: distinct runIds
204
+ * must never produce the same token, because the journal is looked up by
205
+ * this token alone and a collision means two runs would share one journal —
206
+ * one run's takeover replaying another run's transcript.
207
+ *
208
+ * Encoding rather than rejecting keeps the mapping total: a client may choose
209
+ * any `runId`, and a run that cannot be journaled would be a run that cannot be
210
+ * made durable. The encoding is a pure function of the input, which is what lets
211
+ * a successor host recompute the same path from the run record alone.
212
+ *
213
+ * The scheme is a straightforward escaping over `_`: any character matching
214
+ * `[A-Za-z0-9.-]` passes through literally; everything else — INCLUDING a
215
+ * literal `_` — is replaced by `_` followed by two lowercase hex digits per
216
+ * UTF-8 byte. Because `_` itself is never a safe (pass-through) character,
217
+ * every `_` in the output unambiguously starts a two-hex-digit escape; a
218
+ * left-to-right scan can always tell literal from escape. That is what makes
219
+ * the mapping injective: two different inputs can never parse to the same
220
+ * output, because the (unimplemented, but well-defined) decoder is
221
+ * deterministic — if it were not injective, running that decoder on a shared
222
+ * output would have to yield both original strings, which is impossible for a
223
+ * deterministic function.
224
+ *
225
+ * This is a DELIBERATE change from a prior scheme that also treated `_` as
226
+ * safe. That made the encoding non-injective: `_` doubled as both a literal
227
+ * and the escape prefix, so an escaped byte could read back as a literal
228
+ * escape sequence typed by someone else. Concretely, under the old scheme
229
+ * `encodeRunId('@')` and `encodeRunId('_40')` both produced `'_40'` — `@` is
230
+ * `0x40` and gets escaped to `_40`, while the literal characters `_`, `4`, `0`
231
+ * were all "safe" and passed through unchanged. This change breaks that
232
+ * collision by escaping `_` like any other unsafe character.
233
+ *
234
+ * BREAKING CHANGE for existing journals: a journal file written under the
235
+ * old scheme (where a literal `_` in the runId was left unescaped) will not
236
+ * be found by this scheme, because a runId containing `_` now encodes
237
+ * differently. Durability has not shipped publicly yet (this repo has no
238
+ * released version with `encodeRunId` in it), so there is no compatibility
239
+ * obligation and no changeset is warranted — there is nothing in the wild to
240
+ * migrate.
241
+ *
242
+ * EXPORTED for adapters that derive their OWN in-sandbox paths from a `runId`
243
+ * (`ai-codex`'s prompt file and MCP bridge config, `ai-claude-code`'s prompt
244
+ * file). Durability makes `runId` caller-chosen, so an unencoded interpolation
245
+ * lets a `/` produce a directory-bearing path, `..` escape the workdir, and an
246
+ * over-long id fail the spawn with `ENAMETOOLONG` — the same hazards
247
+ * {@link journalPaths} already routes through here. Reuse this rather than
248
+ * writing a second encoder: a divergent copy would reintroduce the
249
+ * non-injectivity documented above.
250
+ */
251
+ export function encodeRunId(runId: string): string {
252
+ if (runId.length === 0) {
253
+ throw new Error('journal: runId must not be empty')
254
+ }
255
+ let out = ''
256
+ for (const char of runId) {
257
+ if (/^[A-Za-z0-9.-]$/.test(char)) {
258
+ out += char
259
+ continue
260
+ }
261
+ for (const byte of new TextEncoder().encode(char)) {
262
+ out += `_${byte.toString(16).padStart(2, '0')}`
263
+ }
264
+ }
265
+
266
+ // Reserved Windows device names. `out` can equal one of these ONLY when
267
+ // every character of `runId` was itself safe (no `_` was introduced), which
268
+ // means `runId` IS that literal word (e.g. `runId === 'CON'`) — the safe
269
+ // characters this function passes through are letters, digits, `.`, and
270
+ // `-`, none of which this branch ever escapes on the normal path, so no
271
+ // OTHER runId can land here. Re-encoding with `hexEscapeAllBytes` is
272
+ // therefore collision-free: the result starts with `_` followed by hex for
273
+ // a letter/digit byte, a pattern the normal per-character path can never
274
+ // produce for ANY input, because letters and digits are always safe and
275
+ // never escaped.
276
+ if (WINDOWS_RESERVED_NAME.test(out)) {
277
+ out = hexEscapeAllBytes(runId)
278
+ }
279
+
280
+ // Bound the length so a very long runId cannot blow the filesystem's
281
+ // filename limit. Truncating the encoded token alone would destroy
282
+ // injectivity (two long runIds sharing a prefix would collapse to the same
283
+ // truncated string), so the truncated prefix is paired with a hash of the
284
+ // FULL original runId. Distinct runIds can then only collide here if they
285
+ // share both the truncated prefix AND the hash — a SHA-256-collision, not
286
+ // a scheme defect.
287
+ if (out.length > MAX_ENCODED_NAME_LENGTH) {
288
+ const hash = createHash('sha256')
289
+ .update(runId, 'utf8')
290
+ .digest('hex')
291
+ .slice(0, TRUNCATION_HASH_LENGTH)
292
+ const prefixLength = MAX_ENCODED_NAME_LENGTH - hash.length - 1
293
+ out = `${out.slice(0, prefixLength)}-${hash}`
294
+ }
295
+
296
+ return out
297
+ }
298
+
299
+ /**
300
+ * Reverse of {@link encodeRunId}, for a filename as `ls -1` reports it.
301
+ *
302
+ * `runId` on the success arm is a STORE KEY, never a path component. The
303
+ * encoding is total over client-chosen strings, so a perfectly valid decode can
304
+ * be `'..'`, `'.hidden'`, or `'a/b'` — a caller that interpolates it into a
305
+ * path would escape the journal directory. Look it up in the run store; do not
306
+ * join it onto anything.
307
+ */
308
+ export type DecodedJournalRunId =
309
+ /** The name decoded to exactly one runId. */
310
+ | { kind: 'runId'; runId: string }
311
+ /**
312
+ * The name is length-capped output of {@link encodeRunId}, whose truncating
313
+ * branch is LOSSY. The original runId is unrecoverable — KEEP the file.
314
+ */
315
+ | { kind: 'truncated' }
316
+ /** Not output this module could have produced. KEEP the file. */
317
+ | { kind: 'malformed' }
318
+
319
+ /** Extensions {@link journalPaths} appends, longest-first so stripping is unambiguous. */
320
+ const JOURNAL_EXTENSIONS = ['.ndjson', '.err'] as const
321
+
322
+ /**
323
+ * Recover the `runId` behind a journal filename — FAIL CLOSED.
324
+ *
325
+ * The consumer of this function DELETES files, so every arm that is not a
326
+ * proven-correct decode must be one the caller keeps. There is no "probably
327
+ * fine" arm.
328
+ *
329
+ * `name` is the filename as {@link journalListCommand} reports it, extension
330
+ * included. The extension is required, not optional: `.` is a pass-through-safe
331
+ * character, so a runId of `'x.ndjson'` encodes to the token `x.ndjson` and the
332
+ * file `x.ndjson.ndjson`. A function that stripped an extension only "if
333
+ * present" could not tell those two strings apart. Requiring it keeps that
334
+ * sharp edge here instead of in every caller that would otherwise reach for
335
+ * `name.split('.')[0]`.
336
+ *
337
+ * **Why `truncated` is a distinct refusal and not a decode.** `encodeRunId`
338
+ * caps its output at {@link MAX_ENCODED_NAME_LENGTH} by replacing the tail with
339
+ * `-` plus a SHA-256 prefix. That branch discards bytes, so the encoding is not
340
+ * invertible there — and because `-` is itself a pass-through-safe character,
341
+ * the truncated form is syntactically indistinguishable from a legitimately
342
+ * encoded id. Decoding it anyway would yield a plausible but WRONG runId; the
343
+ * store would not recognise it, a sweep would read that as "no such run", and
344
+ * it would delete the journal of a run that may still be mid-flight. So any
345
+ * name that *could* be the truncated form is refused, at the cost of never
346
+ * sweeping journals of runIds long enough to hash — a bounded leak, versus
347
+ * data loss on a live run.
348
+ *
349
+ * The truncation check runs BEFORE the character scan on purpose: truncating at
350
+ * a fixed byte offset can cut an `_hh` escape in half, so a truncated name may
351
+ * also be malformed, and the more specific diagnosis is the useful one.
352
+ *
353
+ * The rest is the inverse of the escaping scheme: `[A-Za-z0-9.-]` is a literal
354
+ * ASCII byte, `_` must be followed by EXACTLY two hex digits (either case),
355
+ * and anything else — a bare `_`, a one-digit escape, `/`, `\`, a space — is
356
+ * malformed. The resulting bytes go through a `fatal: true` `TextDecoder`, so
357
+ * an escape sequence that is not valid UTF-8 is a refusal rather than a string
358
+ * silently peppered with U+FFFD (which would be a *different* runId than any
359
+ * encoder input, i.e. exactly the wrong-runId deletion this guards against).
360
+ */
361
+ export function decodeJournalRunId(name: string): DecodedJournalRunId {
362
+ const extension = JOURNAL_EXTENSIONS.find((candidate) =>
363
+ name.endsWith(candidate),
364
+ )
365
+ if (extension === undefined) return { kind: 'malformed' }
366
+ const token = name.slice(0, name.length - extension.length)
367
+ if (token.length === 0) return { kind: 'malformed' }
368
+
369
+ // Only the truncating branch emits a token of exactly the cap ending in `-`
370
+ // plus a hash of that width, and it always emits one. A longer token is not
371
+ // producible by this module at all.
372
+ if (
373
+ token.length > MAX_ENCODED_NAME_LENGTH ||
374
+ (token.length === MAX_ENCODED_NAME_LENGTH &&
375
+ new RegExp(`-[0-9a-f]{${TRUNCATION_HASH_LENGTH}}$`).test(token))
376
+ ) {
377
+ return { kind: 'truncated' }
378
+ }
379
+
380
+ const bytes: Array<number> = []
381
+ let index = 0
382
+ while (index < token.length) {
383
+ const char = token.charAt(index)
384
+ if (char === '_') {
385
+ const hex = token.slice(index + 1, index + 3)
386
+ if (!/^[0-9a-fA-F]{2}$/.test(hex)) return { kind: 'malformed' }
387
+ bytes.push(Number.parseInt(hex, 16))
388
+ index += 3
389
+ continue
390
+ }
391
+ // Every pass-through-safe character is single-byte ASCII, so its code unit
392
+ // IS its UTF-8 byte.
393
+ if (!/^[A-Za-z0-9.-]$/.test(char)) return { kind: 'malformed' }
394
+ bytes.push(char.charCodeAt(0))
395
+ index += 1
396
+ }
397
+
398
+ try {
399
+ const runId = new TextDecoder('utf-8', { fatal: true }).decode(
400
+ new Uint8Array(bytes),
401
+ )
402
+ return { kind: 'runId', runId }
403
+ } catch {
404
+ return { kind: 'malformed' }
405
+ }
406
+ }
407
+
408
+ /**
409
+ * Derive both journal paths for a run. Pure; no I/O.
410
+ *
411
+ * **`runId` MUST be unique per run.** The journal is append-only by design (a
412
+ * takeover depends on a prefix a previous host delivered still being there), and
413
+ * {@link DEFAULT_JOURNAL_DIR} is a fixed absolute path that outlives any single
414
+ * sandbox, test, or process. So a reused `runId` does not start a fresh journal
415
+ * — it appends to the old one, behind the old run's `{"__exit":N}` sentinel. A
416
+ * streaming reader stops at the first sentinel it reaches — and a reused runId
417
+ * derives the SAME nonce, so the old run's sentinel matches — meaning the new run
418
+ * appears to emit nothing at all, or to fail with the previous run's exit code.
419
+ * (The nonce is per-run, not per-attempt: it defends against the AGENT forging a
420
+ * sentinel, not against a caller reusing an id.) This is not
421
+ * enforced here on purpose: refusing to append would break the takeover the
422
+ * append-only rule exists for. Callers derive `runId` from something unique
423
+ * (the adapters use a timestamp plus a random suffix); a test that hardcodes a
424
+ * literal `runId` will observe a stale run's journal on its second execution.
425
+ */
426
+ export function journalPaths(
427
+ runId: string,
428
+ dir: string = DEFAULT_JOURNAL_DIR,
429
+ ): JournalPaths {
430
+ const normalizedDir = normalizeJournalDir(dir)
431
+ const name = encodeRunId(runId)
432
+ return {
433
+ dir: normalizedDir,
434
+ journal: `${normalizedDir}/${name}.ndjson`,
435
+ stderr: `${normalizedDir}/${name}.err`,
436
+ nonce: deriveExitSentinelNonce(runId),
437
+ }
438
+ }
439
+
440
+ /**
441
+ * Wrap an agent command so its stdout lands in the journal, its stderr lands in
442
+ * the sidecar file, and an `{"__exit":N,"__nonce":"…"}` sentinel is appended once
443
+ * it exits.
444
+ *
445
+ * The nonce is what keeps the sentinel apart from the agent's own stdout, which
446
+ * lands in the very same file with no framing — see
447
+ * {@link EXIT_SENTINEL_NONCE_KEY}. It is interpolated as a bare hex token inside
448
+ * a single-quoted `printf` FORMAT string, which is safe by construction:
449
+ * {@link deriveExitSentinelNonce} emits `[0-9a-f]` only, so there is no quote to
450
+ * escape and no `%` for `printf` to interpret.
451
+ *
452
+ * `command` is interpolated raw: callers build real shell text (the Claude Code
453
+ * and Codex adapters append `< promptFile`, for instance), so quoting it would
454
+ * break them. Every path this module contributes IS quoted.
455
+ *
456
+ * `>>` rather than `>` on purpose: truncating would let a stray re-spawn destroy
457
+ * a prefix a previous host already translated and delivered.
458
+ */
459
+ export function journaledCommand(command: string, paths: JournalPaths): string {
460
+ return (
461
+ `mkdir -p ${shellQuote(paths.dir)} && ` +
462
+ // `command` runs inside its OWN subshell `( … )`, not merely a `{ … }`
463
+ // group: a group runs in the CURRENT shell, so a bare `exit` inside
464
+ // `command` (an agent legitimately calling `exit N`) would terminate the
465
+ // whole compound statement before the sentinel `printf` ever ran — the
466
+ // journal would end with no `__exit` line at all. A subshell gives
467
+ // `exit` its own process to terminate, leaving `$?` (the subshell's exit
468
+ // status) and the following `printf` intact in the outer shell.
469
+ `{ ( ${command} ); ` +
470
+ `printf '{"${EXIT_SENTINEL_KEY}":%d,"${EXIT_SENTINEL_NONCE_KEY}":"${paths.nonce}"}\\n' "$?"; } ` +
471
+ `>> ${shellQuote(paths.journal)} 2>> ${shellQuote(paths.stderr)}`
472
+ )
473
+ }
474
+
475
+ /**
476
+ * `tail -c +N` is 1-based over bytes, while `fromByte` is a 0-based count of
477
+ * bytes already consumed. `+fromByte + 1` is therefore "the first byte we have
478
+ * not seen".
479
+ */
480
+ function tailFrom(fromByte: number): number {
481
+ if (!Number.isSafeInteger(fromByte) || fromByte < 0) {
482
+ throw new Error(
483
+ `journal: fromByte must be a non-negative safe integer, got ${fromByte}`,
484
+ )
485
+ }
486
+ return fromByte + 1
487
+ }
488
+
489
+ /**
490
+ * Following read, for `process.spawn` only. Never pass this to `exec`:
491
+ * `ProcessOptions` has no timeout, so a following `exec` blocks until the
492
+ * sandbox or the RPC times out.
493
+ *
494
+ * Deliberately pipes into NOTHING. `tail -f` flushes each append as it sees it,
495
+ * so it is the one stage in this pipeline that streams; adding any filter puts
496
+ * that filter's stdio buffer between the agent and the host and the follow
497
+ * strategy stops following (see rule 2 in the module doc for the measurements).
498
+ * The host turns these raw bytes into positioned lines with
499
+ * `journal-bytes.ts`.
500
+ *
501
+ * It also creates the journal before tailing it, because `tail -f` on a path
502
+ * that does not exist yet prints a diagnostic and EXITS rather than waiting —
503
+ * so the reader would deliver zero lines for a run whose journal simply had not
504
+ * been created yet. The reader and the agent are two independent spawns and
505
+ * nothing orders them, so that race is the normal case, not the unlucky one.
506
+ * `: >> file` is a builtin no-op plus an O_CREAT|O_APPEND open: it creates the
507
+ * file when absent and, critically, does NOT truncate one that already has a
508
+ * prefix a previous host already delivered. `;` rather than `&&` throughout, so
509
+ * a prep step that fails still lets the `tail` run and fail the way it used to
510
+ * rather than turning a read into a silent no-op. (`tail -F` would also retry,
511
+ * but `-F` is a GNU/busybox extension, not POSIX, and this file only emits
512
+ * POSIX shell.)
513
+ */
514
+ export function journalFollowCommand(
515
+ paths: JournalPaths,
516
+ fromByte: number,
517
+ ): string {
518
+ return (
519
+ `mkdir -p ${shellQuote(paths.dir)} 2>/dev/null; ` +
520
+ `: >> ${shellQuote(paths.journal)} 2>/dev/null; ` +
521
+ `tail -c +${tailFrom(fromByte)} -f ${shellQuote(paths.journal)} 2>/dev/null`
522
+ )
523
+ }
524
+
525
+ /**
526
+ * Bounded read: `-f` dropped so it always terminates, and base64-framed because
527
+ * it can be — `exec` closes `base64`'s stdin, which flushes it, and the whole
528
+ * result arrives as one already-complete `ExecResult.stdout` string. This is the
529
+ * Cloudflare path, whose `spawn` cannot be killed and whose `exec` drops the
530
+ * AbortSignal, making a following read unstoppable there.
531
+ */
532
+ export function journalReadCommand(
533
+ paths: JournalPaths,
534
+ fromByte: number,
535
+ ): string {
536
+ return `tail -c +${tailFrom(fromByte)} ${shellQuote(paths.journal)} 2>/dev/null | base64`
537
+ }
538
+
539
+ /**
540
+ * Existence probe. A shell `test -f`, not `handle.fs.exists`: see rule 3 in the
541
+ * module doc — on local-process the two resolve `/tmp` differently.
542
+ */
543
+ export function journalExistsCommand(
544
+ paths: Pick<JournalPaths, 'journal'>,
545
+ ): string {
546
+ return `test -f ${shellQuote(paths.journal)}`
547
+ }
548
+
549
+ /** Bytes of the stderr sidecar {@link journalStderrReadCommand} reads by default. */
550
+ const DEFAULT_STDERR_TAIL_BYTES = 4096
551
+
552
+ /**
553
+ * Bounded read of the stderr SIDECAR (not the journal), so a non-zero exit can
554
+ * carry the agent's own diagnostics instead of a bare exit code.
555
+ *
556
+ * `exec`-only, like {@link journalReadCommand}, and base64-framed for the same
557
+ * reason: `exec` closes the encoder's stdin so it flushes, and the frame keeps a
558
+ * provider that folds stderr into stdout from splicing its own text into the
559
+ * bytes. Unlike the journal, the sidecar is NOT line-delimited JSON — an agent
560
+ * writes whatever it likes there, including partial lines and raw control bytes
561
+ * — so framing is what makes it safe to hand to a single `ExecResult.stdout`.
562
+ *
563
+ * `tail -c -N` (the LAST N bytes) rather than the first: the read has to be
564
+ * bounded, because a runaway agent's sidecar can be arbitrarily large and this
565
+ * runs on the host, and a crash's cause is at the end of stderr, not the start.
566
+ * The cost is that the first character can be a truncated UTF-8 sequence; the
567
+ * caller decodes lossily rather than failing, since this text is diagnostic.
568
+ */
569
+ export function journalStderrReadCommand(
570
+ paths: JournalPaths,
571
+ maxBytes: number = DEFAULT_STDERR_TAIL_BYTES,
572
+ ): string {
573
+ if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
574
+ throw new Error(
575
+ `journal: maxBytes must be a positive safe integer, got ${maxBytes}`,
576
+ )
577
+ }
578
+ return `tail -c -${maxBytes} ${shellQuote(paths.stderr)} 2>/dev/null | base64`
579
+ }
580
+
581
+ /**
582
+ * Delete both of a run's journal files.
583
+ *
584
+ * **Ordering is the whole contract here, not the `rm`.** This may only run once
585
+ * the run is TERMINAL — i.e. after the `{"__exit":N}` sentinel has been observed
586
+ * — and must never run on an abort. The three claims that make the deletion safe:
587
+ *
588
+ * 1. **Terminal means the event log holds the whole run.** The journal exists so
589
+ * a successor host can replay a run from byte 0 and re-derive the chunks a
590
+ * dead host never got to append. Once the sentinel has been read and the
591
+ * replay has been forwarded, the log — not the journal — is the record. A late
592
+ * takeover therefore aligns against the log: `align.ts`'s `alignToStoredLog`
593
+ * takes a `StreamDurability` and an `AsyncIterable<StreamChunk>`, has no
594
+ * `SandboxHandle` and no {@link JournalPaths} in its signature, and reads the
595
+ * prefix with `durability.snapshot()`. It *cannot* read the journal, so
596
+ * deleting one that is terminal cannot break it.
597
+ * 2. **A non-zero exit is terminal too.** `{"__exit":7}` is as final as
598
+ * `{"__exit":0}`; the run failed, it is not resumable, and the failure is
599
+ * already on its way to the client as a `RUN_ERROR`. Keeping a failed run's
600
+ * journal would leak exactly the runs most likely to be numerous.
601
+ * 3. **An abort is NOT terminal.** A consumer that stops early (lease lost,
602
+ * client gone, host shutting down) may be handing the run off to a successor
603
+ * host that still needs every byte, so an aborted read must leave both files
604
+ * alone.
605
+ *
606
+ * Shell `rm`, never `handle.fs.remove`: rule 3 in the module doc. On
607
+ * local-process `/tmp` resolves under the sandbox root through `fs.*` but to the
608
+ * host's real `/tmp` through the shell, so an `fs.remove` would delete a
609
+ * different path than the one `journaledCommand` wrote — i.e. nothing, silently.
610
+ *
611
+ * `-f` so a journal that is already gone (a provider that reaped `/tmp`, a
612
+ * successor that cleaned up first) is a success, not an error. Callers treat the
613
+ * whole thing as best effort regardless: a failed cleanup must never fail a run
614
+ * that has already completed.
615
+ *
616
+ * **What this does NOT bound:** a run that reaches its sentinel while DETACHED
617
+ * has no host reading its journal, so nothing ever observes the sentinel and
618
+ * nothing calls this. Bounding it is `pruneJournals`' job (`journal-sweep.ts`):
619
+ * a sweep over {@link DEFAULT_JOURNAL_DIR} that deletes only the journals whose
620
+ * runs the store says are terminal. It runs from a cron the application
621
+ * schedules, not from a run, so such a journal survives until that sweep — on a
622
+ * `keepAlive` sandbox, indefinitely without one.
623
+ */
624
+ export function journalCleanupCommand(paths: JournalPaths): string {
625
+ return `rm -f ${shellQuote(paths.journal)} ${shellQuote(paths.stderr)}`
626
+ }
627
+
628
+ /**
629
+ * List the journal directory, one entry per line.
630
+ *
631
+ * **`2>/dev/null` is load-bearing, not tidiness.** Daytona's `exec` folds
632
+ * stderr into stdout by contract and the Sprites fast path does the same, so on
633
+ * a directory that does not exist yet — the normal state before the first run —
634
+ * an `ls: cannot access '/tmp/tanstack-runs': No such file or directory`
635
+ * diagnostic would arrive as if it were a LINE OF OUTPUT. The sweep would then
636
+ * hand that sentence to {@link decodeJournalRunId} and, if it decoded, delete
637
+ * whatever it named. Silencing it inside the sandbox means a missing directory
638
+ * produces zero lines, which is the truth.
639
+ *
640
+ * `-1` so one entry occupies one line: `ls` only defaults to columns on a tty,
641
+ * but `exec`'s stdout is not always a pipe on every provider and the flag costs
642
+ * nothing.
643
+ *
644
+ * **Dot-files are not listed**, by `ls` default. A runId beginning with `.`
645
+ * encodes to a hidden filename (`.` passes through the encoder), so its journal
646
+ * is invisible to a sweep and leaks rather than being deleted. That is the safe
647
+ * direction of the two and the reason this is documented rather than fixed with
648
+ * `-a`, which would also introduce `.` and `..` as entries.
649
+ */
650
+ export function journalListCommand(dir: string = DEFAULT_JOURNAL_DIR): string {
651
+ return `ls -1 ${shellQuote(normalizeJournalDir(dir))} 2>/dev/null`
652
+ }
653
+
654
+ /** Strip a trailing slash so a dir compares equal to `stat`'s echoed operand. */
655
+ function normalizeJournalDir(dir: string): string {
656
+ return dir.endsWith('/') ? dir.slice(0, -1) : dir
657
+ }
658
+
659
+ /** One listed journal file with its modification time. */
660
+ export interface JournalDirEntry {
661
+ /** Filename as listed, extension included; feed to {@link decodeJournalRunId}. */
662
+ name: string
663
+ /** Modification time in milliseconds since the epoch. */
664
+ mtimeMs: number
665
+ }
666
+
667
+ /**
668
+ * Outcome of {@link parseJournalMtimeListing}. Deliberately NOT an array: see
669
+ * that function's doc for why an empty list must not be the failure value.
670
+ */
671
+ export type JournalMtimeListing =
672
+ /** The mechanism ran. `entries` is complete — possibly, and meaningfully, empty. */
673
+ | { kind: 'listed'; entries: Array<JournalDirEntry> }
674
+ /**
675
+ * The listing did not run (no `stat -c`, or the directory is absent). Nothing
676
+ * is known about the directory's contents — in particular NOT that it is
677
+ * empty, and NOT that anything in it is old.
678
+ */
679
+ | { kind: 'unavailable' }
680
+
681
+ /**
682
+ * List the journal directory WITH modification times, so a sweep can leave
683
+ * recently-touched journals alone.
684
+ *
685
+ * **Neither `find -newermt` nor `find -printf` may be used here.** Both are GNU
686
+ * extensions, absent from BusyBox 1.37 — the `alpine:3` shell every docker-
687
+ * provider journal test runs in — and absent from MINGW64's `find`. Measured
688
+ * working on BusyBox 1.37, GNU coreutils, and MINGW64: `stat -c "%Y %n"`, which
689
+ * is what this emits. (`touch -d <ts> ref` plus `find ! -newer ref` also works
690
+ * on all three, but it needs a writable reference file OUTSIDE the journal
691
+ * directory — inside, `ls -1` would report the reference as an entry — and a
692
+ * write is a side effect this pure-composition module has no business having.)
693
+ *
694
+ * **The directory is passed as its own first operand on purpose.** It is a
695
+ * self-witness. `stat` reports every operand it can and only *then* exits
696
+ * non-zero, so:
697
+ *
698
+ * - populated directory → witness line + one line per file, exit 0
699
+ * - EMPTY directory → witness line only, exit 1 (the unexpanded glob is an
700
+ * operand `stat` cannot stat)
701
+ * - `stat` without `-c` support → NO output at all, exit 1
702
+ *
703
+ * That is what makes "no files" distinguishable from "the mechanism is
704
+ * unavailable", and it has to be distinguishable because BusyBox exits 1 with
705
+ * EMPTY stdout on an unrecognised flag. A caller that ignored the exit code and
706
+ * took an empty parse as an empty directory would conclude every journal is
707
+ * absent; one that then inferred "therefore nothing is recent" would delete the
708
+ * whole directory. Hence {@link parseJournalMtimeListing} returns
709
+ * `{ kind: 'unavailable' }` rather than `[]`, and the exit code is not consulted
710
+ * at all — the witness line, not the status, is the evidence.
711
+ *
712
+ * Note the glob shares `ls`'s dot-file blindness (same fail-safe consequence),
713
+ * and that `stat` cannot distinguish a file from a subdirectory here; a stray
714
+ * subdirectory is caught downstream, because its name will not decode.
715
+ */
716
+ export function journalMtimeListCommand(
717
+ dir: string = DEFAULT_JOURNAL_DIR,
718
+ ): string {
719
+ const normalized = normalizeJournalDir(dir)
720
+ return `stat -c '%Y %n' ${shellQuote(normalized)} ${shellQuote(normalized)}/* 2>/dev/null`
721
+ }
722
+
723
+ /**
724
+ * Parse {@link journalMtimeListCommand}'s stdout.
725
+ *
726
+ * Line-based, space-split parsing is unambiguous here: an encoded filename can
727
+ * only contain `[A-Za-z0-9.-]` and `_hh` escapes (see {@link encodeRunId}), so
728
+ * it can never contain a space or a newline, and `%Y` is digits. A line that
729
+ * does not fit the shape — including a directory prefix that is not `dir` — is
730
+ * dropped rather than guessed at.
731
+ */
732
+ export function parseJournalMtimeListing(
733
+ text: string,
734
+ dir: string = DEFAULT_JOURNAL_DIR,
735
+ ): JournalMtimeListing {
736
+ const normalized = normalizeJournalDir(dir)
737
+ const entries: Array<JournalDirEntry> = []
738
+ let sawWitness = false
739
+ for (const rawLine of text.split('\n')) {
740
+ const line = rawLine.trim()
741
+ if (line === '') continue
742
+ const separator = line.indexOf(' ')
743
+ if (separator === -1) continue
744
+ const seconds = line.slice(0, separator)
745
+ if (!/^\d+$/.test(seconds)) continue
746
+ const path = line.slice(separator + 1)
747
+ if (path === normalized) {
748
+ sawWitness = true
749
+ continue
750
+ }
751
+ const prefix = `${normalized}/`
752
+ if (!path.startsWith(prefix)) continue
753
+ const name = path.slice(prefix.length)
754
+ // A nested path is not something the single-level glob produces; refuse to
755
+ // invent an entry for it.
756
+ if (name === '' || name.includes('/')) continue
757
+ entries.push({ name, mtimeMs: Number.parseInt(seconds, 10) * 1000 })
758
+ }
759
+ // No witness means `stat -c` never reported the directory itself, so the
760
+ // command did not run as designed and `entries` is not a listing of anything.
761
+ if (!sawWitness) return { kind: 'unavailable' }
762
+ return { kind: 'listed', entries }
763
+ }
764
+
765
+ /** Bytes of the journal tail {@link journalExitProbeCommand} reads by default. */
766
+ const DEFAULT_EXIT_PROBE_TAIL_BYTES = 4096
767
+
768
+ /**
769
+ * Bounded read of the END of a run's journal, purely to learn whether the agent
770
+ * reached its `{"__exit":N}` sentinel.
771
+ *
772
+ * **This exists so a reaper does not have to drive the run to find out.**
773
+ * Entering `pipeToRunLog` to check writes a terminal status and calls
774
+ * `durability.close()` on every path, including for a healthy mid-flight run —
775
+ * recording it as `'completed'`, which drops it out of `listReclaimable`
776
+ * forever. This probe is read-only and provider-neutral, and it is what makes a
777
+ * reclaim candidate safe to drive.
778
+ *
779
+ * The command is the byte-identical idiom to {@link journalStderrReadCommand},
780
+ * pointed at the journal instead of the sidecar: `tail -c -N` (the LAST N
781
+ * bytes, because the sentinel is at the end), `2>/dev/null` so a missing
782
+ * journal cannot splice a diagnostic into the bytes on a provider that folds
783
+ * stderr into stdout, and base64 framing. Verified on BusyBox 1.37.
784
+ *
785
+ * base64 is correct HERE and forbidden on {@link journalFollowCommand} for the
786
+ * reason rule 2 in the module doc measures: the encoder fully buffers a piped
787
+ * stdout, which is harmless when `exec` closes its stdin and fatal when the
788
+ * producer is `tail -f`. This read is bounded and terminates, so it never
789
+ * streams.
790
+ */
791
+ export function journalExitProbeCommand(
792
+ paths: JournalPaths,
793
+ maxBytes: number = DEFAULT_EXIT_PROBE_TAIL_BYTES,
794
+ ): string {
795
+ if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
796
+ throw new Error(
797
+ `journal: maxBytes must be a positive safe integer, got ${maxBytes}`,
798
+ )
799
+ }
800
+ return `tail -c -${maxBytes} ${shellQuote(paths.journal)} 2>/dev/null | base64`
801
+ }
802
+
803
+ /**
804
+ * Is ONE journal line this run's genuine exit sentinel? The exit code if so,
805
+ * `null` for anything else — including a line that carries
806
+ * {@link EXIT_SENTINEL_KEY} but not this run's nonce, which is agent output and
807
+ * nothing more.
808
+ *
809
+ * FAIL CLOSED at every step, because the consumers of a non-`null` answer stop
810
+ * the run and reclaim its sandbox:
811
+ *
812
+ * - not JSON, or not an object → `null`. This is also what absorbs the partial
813
+ * first line a byte-bounded `tail -c -N` can start in the middle of.
814
+ * - no `__nonce`, or a `__nonce` that is not exactly `paths.nonce` → `null`. An
815
+ * agent line cannot be told from the shell's without this (see
816
+ * {@link EXIT_SENTINEL_NONCE_KEY}).
817
+ * - a matching nonce but a non-integer `__exit` → `null`, NOT `0`. The old code
818
+ * coerced a non-number to `0`, which turned a garbled sentinel into a reported
819
+ * SUCCESS. Nothing that reaches here legitimately can be non-integer: the only
820
+ * writer is `printf '…%d…' "$?"`.
821
+ *
822
+ * Exported so the streaming reader (`runner.ts`) applies exactly the same test,
823
+ * line by line, that the reaper's bounded tail probe applies — one definition of
824
+ * "the run ended", not two that can drift.
825
+ */
826
+ export function parseExitSentinel(
827
+ line: string,
828
+ paths: JournalPaths,
829
+ ): number | null {
830
+ const trimmed = line.trim()
831
+ if (trimmed === '') return null
832
+ let parsed: unknown
833
+ try {
834
+ parsed = JSON.parse(trimmed)
835
+ } catch {
836
+ return null
837
+ }
838
+ if (typeof parsed !== 'object' || parsed === null) return null
839
+ if (!(EXIT_SENTINEL_KEY in parsed)) return null
840
+ const nonce: unknown = Reflect.get(parsed, EXIT_SENTINEL_NONCE_KEY)
841
+ if (typeof nonce !== 'string' || nonce !== paths.nonce) return null
842
+ const code: unknown = Reflect.get(parsed, EXIT_SENTINEL_KEY)
843
+ if (typeof code !== 'number' || !Number.isInteger(code)) return null
844
+ return code
845
+ }
846
+
847
+ /**
848
+ * Find the exit sentinel in a decoded journal tail; `null` when it is absent,
849
+ * which is the mid-flight (or never-started) case.
850
+ *
851
+ * **Scanned from the END, and the nonce is REQUIRED.** Both matter, and both are
852
+ * corrections:
853
+ *
854
+ * - The shell appends the real sentinel AFTER the command's own output, so the
855
+ * genuine one is always the last matching line in the window. Taking the first
856
+ * match let an agent line that happened to look like a sentinel win over the
857
+ * truth that followed it.
858
+ * - `paths.nonce` must match, or the line is not a sentinel at all. Without that,
859
+ * a mid-flight run whose agent printed any JSON object containing `__exit` read
860
+ * as `finished`, and the reaper destroyed a live sandbox on the strength of it.
861
+ *
862
+ * `paths` rather than a bare nonce string so callers pass the object they already
863
+ * hold and cannot pair a tail with another run's nonce.
864
+ */
865
+ export function parseJournalExit(
866
+ text: string,
867
+ paths: JournalPaths,
868
+ ): number | null {
869
+ const lines = text.split('\n')
870
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
871
+ const code = parseExitSentinel(lines[index] ?? '', paths)
872
+ if (code !== null) return code
873
+ }
874
+ return null
875
+ }