@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.
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
@@ -0,0 +1,676 @@
1
+ /**
2
+ * Provider conformance for the agent output journal.
3
+ *
4
+ * The journal design rests on two provider-level claims: a command string is
5
+ * framed through a POSIX shell (so `>>` redirection works), and `tail -c +N -f`
6
+ * is available. Both are asserted here against a real sandbox rather than
7
+ * assumed from the audit.
8
+ *
9
+ * A provider that cannot satisfy them MUST declare `unsupported.reason`. There
10
+ * is deliberately no silent-skip path: a conformance case that quietly returns
11
+ * prints as a pass, which is how an unimplemented capability ships green. The
12
+ * three FOLLOW cases obey the same rule through a second declaration,
13
+ * {@link JournalConformanceConfig.followUnsupported} — see {@link itFollows} for
14
+ * why the strategy has to be declared rather than detected at registration time,
15
+ * and {@link expectDeclaredStrategy} for what keeps the declaration honest.
16
+ *
17
+ * THE THIRD FOLLOW CASE TESTS THE OTHER SIDE OF THE BOUNDARY, and it is here
18
+ * because the first two do not. `killableProcesses` is what selects `'follow'`
19
+ * over `'poll'`, and a wrong `true` means `tail -f` is spawned on the assumption
20
+ * it can be reclaimed — leaking one follower per run when it cannot. The two
21
+ * follow cases only ever asserted that the READER stops, which
22
+ * `journal-reader.ts`'s `untilAborted` guarantees on its own by abandoning the
23
+ * pipe the moment the signal fires. So both of them pass a provider whose
24
+ * `kill()` is `() => Promise.resolve()`, and three of the four `true`
25
+ * declarations in this repo were in fact false: Docker's `stream.destroy()` only
26
+ * detached the client, local-process's `sh -c` forks so signalling the shell left
27
+ * the command alive, and Vercel's `kill()` never called the SDK's real
28
+ * `Command.kill` at all. Every one of them shipped green through this suite.
29
+ * "kills the sandbox-side process, not just the host's view of it" is the case
30
+ * that fails them — see its own comment for how it probes.
31
+ *
32
+ * Vitest is an OPTIONAL peer dependency: this module is imported only from test
33
+ * files, which already run under Vitest.
34
+ */
35
+ import { randomUUID } from 'node:crypto'
36
+ import { describe, expect, it } from 'vitest'
37
+ import {
38
+ exitSentinelLine,
39
+ journalExistsCommand,
40
+ journalPaths,
41
+ journalReadCommand,
42
+ journaledCommand,
43
+ } from '../journal'
44
+ import { journalReadStrategy, readJournal } from '../journal-reader'
45
+ import type { JournalPaths } from '../journal'
46
+ import type { SandboxHandle } from '../contracts'
47
+
48
+ export interface JournalConformanceConfig {
49
+ /** Provider name, used in the describe title. */
50
+ name: string
51
+ /** Create a live sandbox plus its teardown. */
52
+ createHandle: () => Promise<{
53
+ handle: SandboxHandle
54
+ dispose: () => Promise<void>
55
+ }>
56
+ /**
57
+ * Declare that this provider cannot journal, with the reason. Registers a
58
+ * skipped case whose title carries the reason. Omit it and the suite runs.
59
+ */
60
+ unsupported?: { reason: string }
61
+ /**
62
+ * Declare that this provider's reads take the POLL strategy rather than the
63
+ * FOLLOW one — i.e. `journalReadStrategy` answers `'poll'` for its handles,
64
+ * because it lacks `backgroundProcesses` or `killableProcesses`. The two follow
65
+ * cases then register as NAMED skips carrying the reason.
66
+ *
67
+ * Declare this ONLY when the provider really cannot follow. It is checked
68
+ * against a live handle in a case that always runs
69
+ * ({@link expectDeclaredStrategy}), so a wrong declaration fails the suite in
70
+ * either direction rather than quietly removing coverage.
71
+ */
72
+ followUnsupported?: { reason: string }
73
+ }
74
+
75
+ /**
76
+ * Per-case timeout. Every case here spawns a real sandbox and a real agent.
77
+ *
78
+ * 180s, not the 60s this used to be, and it matches the ceiling
79
+ * `takeover-conformance.ts` already gives its heaviest cases. It is the one
80
+ * wall-clock number left in the file and it is deliberately far outside the range
81
+ * any healthy run needs: a case here makes half a dozen provider round-trips, and
82
+ * ONE `docker exec` on a loaded daemon has been measured at 9.6s (see
83
+ * `takeover-conformance.ts`'s `countingExec`) and at 20–45s on a saturated one, so
84
+ * a 60s budget put the timeout itself in the same load-sensitive class as the
85
+ * assertions that were removed from these cases — measured going red on cases that
86
+ * pass in 7–13s each on a quiet machine.
87
+ *
88
+ * This bound exists only so a genuine hang FAILS instead of parking CI; it is not
89
+ * an assertion about speed, and nothing here should be tuned to sit near it.
90
+ */
91
+ const CASE_TIMEOUT_MS = 180_000
92
+
93
+ /**
94
+ * Register a case that only means anything on a provider whose reads FOLLOW.
95
+ *
96
+ * `journalReadStrategy` needs a live handle and a live handle needs the async
97
+ * `createHandle`, so the strategy is not knowable when the cases are registered.
98
+ * It is therefore DECLARED, and the declaration selects `it` or `it.skip` here.
99
+ *
100
+ * This exists because the alternative — checking the strategy inside the case and
101
+ * returning early — is the silent-skip the module doc forbids. Such a case prints
102
+ * `✓` with a duration and a title claiming a property was verified while every
103
+ * real assertion in it (including the incremental-delivery handshake, which is
104
+ * the entire reason the follow path exists) was skipped. A named `it.skip` prints
105
+ * `↓` with the reason instead.
106
+ */
107
+ function itFollows(
108
+ config: JournalConformanceConfig,
109
+ title: string,
110
+ fn: () => Promise<void>,
111
+ ): void {
112
+ const unsupported = config.followUnsupported
113
+ if (unsupported === undefined) {
114
+ it(title, fn, CASE_TIMEOUT_MS)
115
+ return
116
+ }
117
+ it.skip(
118
+ `${title} — follow strategy unsupported: ${unsupported.reason}`,
119
+ fn,
120
+ CASE_TIMEOUT_MS,
121
+ )
122
+ }
123
+
124
+ /**
125
+ * Assert the live handle's read strategy is the one the config DECLARED.
126
+ *
127
+ * BOTH directions are defects, and neither is a skip. A provider that declared
128
+ * `followUnsupported` but whose handles do follow silently loses the two cases it
129
+ * could pass. One that declared nothing but polls would reach the follow
130
+ * assertions and fail them for a reason unrelated to journaling — which is what
131
+ * the previous `expect(handle.capabilities.killableProcesses).toBe(false)` branch
132
+ * did to a provider with `backgroundProcesses: false, killableProcesses: true`.
133
+ * Either way the config does not describe the provider, and that is worth
134
+ * failing.
135
+ */
136
+ function expectDeclaredStrategy(
137
+ handle: SandboxHandle,
138
+ config: JournalConformanceConfig,
139
+ ): void {
140
+ expect(journalReadStrategy(handle)).toBe(
141
+ config.followUnsupported === undefined ? 'follow' : 'poll',
142
+ )
143
+ }
144
+
145
+ /** Decode the base64 frame a journal read command produces into raw text. */
146
+ function decodeJournalRead(stdout: string): string {
147
+ return Buffer.from(stdout.replace(/\s+/g, ''), 'base64').toString('utf8')
148
+ }
149
+
150
+ /**
151
+ * Block until the run's journal file exists in the sandbox.
152
+ *
153
+ * Through the shell (`journalExistsCommand`), never `handle.fs.exists` — see
154
+ * rule 3 in `../journal.ts`: on local-process the two resolve `/tmp`
155
+ * differently, so an `fs` probe would report the wrong file.
156
+ *
157
+ * Exported for `./reaper-conformance.ts`, which needs the same bounded,
158
+ * shell-only wait before probing a still-producing run. Internal to the testkit;
159
+ * not part of the `./testkit` public surface.
160
+ */
161
+ export async function waitForJournal(
162
+ handle: SandboxHandle,
163
+ paths: JournalPaths,
164
+ ): Promise<void> {
165
+ const deadline = Date.now() + 15_000
166
+ for (;;) {
167
+ const probe = await handle.process.exec(journalExistsCommand(paths))
168
+ if (probe.exitCode === 0) return
169
+ if (Date.now() > deadline) {
170
+ throw new Error(`journal conformance: ${paths.journal} never appeared`)
171
+ }
172
+ await sleep(100)
173
+ }
174
+ }
175
+
176
+ function sleep(ms: number): Promise<void> {
177
+ return new Promise((resolve) => setTimeout(resolve, ms))
178
+ }
179
+
180
+ /**
181
+ * An absolute path inside the sandbox that no other case, suite, or machine will
182
+ * touch.
183
+ *
184
+ * Every character is in `[A-Za-z0-9./-]`, so these interpolate into the shell
185
+ * commands below as a single word without quoting. `/tmp` and not the workspace:
186
+ * on local-process a shell redirect reaches the host's real `/tmp` while
187
+ * `handle.fs` resolves under the sandbox root (see rule 3 in `../journal.ts`),
188
+ * and everything here is written AND read through the shell so the two never have
189
+ * to agree.
190
+ */
191
+ function noncePath(label: string): string {
192
+ return `/tmp/tanstack-journal-conformance-${label}-${randomUUID()}`
193
+ }
194
+
195
+ /** Iteration cap on the kill probe's loop, so nothing can outlive the suite. */
196
+ const PROBE_MAX_TICKS = 600
197
+
198
+ /**
199
+ * Bound on a journal read, so a reader that delivers nothing FAILS instead of
200
+ * parking CI.
201
+ *
202
+ * Never an assertion, and deliberately far above anything a healthy read needs
203
+ * (measured: 10–18s for the follow cases on both providers). Each case that uses
204
+ * it proves its property some other way — a causal handshake, or
205
+ * `backstop.aborted` — so this number can be raised freely and must never be the
206
+ * thing a case is tuned against.
207
+ */
208
+ const READ_BACKSTOP_MS = 90_000
209
+
210
+ /**
211
+ * How long to let an asynchronous kill land before the quiet window opens.
212
+ *
213
+ * A kill is asynchronous on every provider here — Docker signals through a
214
+ * second `exec`, local-process signals a process group and lets the OS reap — so
215
+ * one more heartbeat tick immediately after `kill()` resolves is not a survivor.
216
+ */
217
+ const KILL_SETTLE_MS = 5_000
218
+
219
+ /**
220
+ * The quiet window: how long the heartbeat must stay frozen.
221
+ *
222
+ * This is NOT a load-sensitive bound, and the asymmetry is the point. A dead
223
+ * process can never write again, so a slow or busy machine can only make this
224
+ * window MORE reliable, never less — unlike a "must happen within Nms" ceiling,
225
+ * which fails on load. Only a live survivor can end this window, and a live
226
+ * survivor writes once a second.
227
+ */
228
+ const HEARTBEAT_QUIET_MS = 6_000
229
+
230
+ /**
231
+ * Byte count of `path`, according to the SANDBOX'S OWN shell, or `null` when it
232
+ * cannot be read.
233
+ *
234
+ * `wc -c` through the shell, never `handle.fs`: on local-process the two resolve
235
+ * `/tmp` differently (rule 3), so an `fs` probe would answer about a file the
236
+ * sandbox never wrote and the growth below would look frozen from the first
237
+ * sample — a vacuous pass. Parsed strictly rather than coerced, so a shell
238
+ * diagnostic cannot become `NaN` and compare unequal to itself.
239
+ */
240
+ async function fileSize(
241
+ handle: SandboxHandle,
242
+ path: string,
243
+ ): Promise<number | null> {
244
+ const probe = await handle.process.exec(`wc -c < ${path} 2>/dev/null`)
245
+ const text = probe.stdout.trim()
246
+ return /^\d+$/.test(text) ? Number(text) : null
247
+ }
248
+
249
+ /**
250
+ * Wait until `path` has grown to at least `bytes`, i.e. the probe process is
251
+ * provably DOING WORK inside the sandbox, and answer whether it got there.
252
+ *
253
+ * Returning the observation rather than throwing keeps the verdict inside the
254
+ * case's own `expect`: this is the "before" half of the assertion, and it is what
255
+ * makes the "after" half a live detector instead of a formality.
256
+ */
257
+ async function waitForTicks(
258
+ handle: SandboxHandle,
259
+ path: string,
260
+ bytes: number,
261
+ ): Promise<boolean> {
262
+ const deadline = Date.now() + 30_000
263
+ for (;;) {
264
+ const size = await fileSize(handle, path)
265
+ if (size !== null && size >= bytes) return true
266
+ if (Date.now() > deadline) return false
267
+ // Matched to the heartbeat's own 1s period on purpose. Every poll is a
268
+ // provider round-trip, so a 250ms interval spent three of them per tick it
269
+ // could not possibly observe — pure pressure on the very `exec` path the rest
270
+ // of the case depends on.
271
+ await sleep(1_000)
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Assert `createHandle` satisfies the journal conformance contract. Each `it`
277
+ * gets a fresh sandbox via `createHandle`/`dispose`, so implementations may
278
+ * share process state across calls without cross-test bleed only if
279
+ * `createHandle` returns an isolated sandbox.
280
+ */
281
+ export function runJournalConformance(config: JournalConformanceConfig): void {
282
+ describe(`journal conformance — ${config.name}`, () => {
283
+ if (config.unsupported) {
284
+ it.skip(`unsupported: ${config.unsupported.reason}`, () => {
285
+ expect(true).toBe(true)
286
+ })
287
+ return
288
+ }
289
+
290
+ it(
291
+ "redirects a command's stdout into the journal and appends the exit sentinel",
292
+ async () => {
293
+ const { handle, dispose } = await config.createHandle()
294
+ try {
295
+ // Checked HERE, in a case that always runs, because
296
+ // `followUnsupported` gates the two follow cases below: a declaration
297
+ // that does not match the live handle must fail the suite rather than
298
+ // remove coverage from it. This is the only place a `poll` declaration
299
+ // can be caught, since the cases it skips never execute.
300
+ expectDeclaredStrategy(handle, config)
301
+ const paths = journalPaths(`conf-${Date.now()}`)
302
+ const command = journaledCommand(
303
+ `printf '{"a":1}\\n{"b":2}\\n'`,
304
+ paths,
305
+ )
306
+ const proc = await handle.process.spawn(command)
307
+ expect(await proc.wait()).toBe(0)
308
+
309
+ const read = await handle.process.exec(journalReadCommand(paths, 0))
310
+ const text = decodeJournalRead(read.stdout)
311
+ expect(text).toBe(`{"a":1}\n{"b":2}\n${exitSentinelLine(paths, 0)}\n`)
312
+ } finally {
313
+ await dispose()
314
+ }
315
+ },
316
+ CASE_TIMEOUT_MS,
317
+ )
318
+
319
+ it(
320
+ "records the agent's non-zero exit in the sentinel",
321
+ async () => {
322
+ const { handle, dispose } = await config.createHandle()
323
+ try {
324
+ const paths = journalPaths(`conf-exit-${Date.now()}`)
325
+ const proc = await handle.process.spawn(
326
+ journaledCommand('exit 7', paths),
327
+ )
328
+ await proc.wait()
329
+ const read = await handle.process.exec(journalReadCommand(paths, 0))
330
+ const text = decodeJournalRead(read.stdout)
331
+ expect(text).toBe(`${exitSentinelLine(paths, 7)}\n`)
332
+ } finally {
333
+ await dispose()
334
+ }
335
+ },
336
+ CASE_TIMEOUT_MS,
337
+ )
338
+
339
+ it(
340
+ "keeps the agent's stderr out of the journal",
341
+ async () => {
342
+ const { handle, dispose } = await config.createHandle()
343
+ try {
344
+ const paths = journalPaths(`conf-err-${Date.now()}`)
345
+ const proc = await handle.process.spawn(
346
+ journaledCommand(
347
+ `printf '{"a":1}\\n'; printf 'a warning\\n' 1>&2`,
348
+ paths,
349
+ ),
350
+ )
351
+ await proc.wait()
352
+ const read = await handle.process.exec(journalReadCommand(paths, 0))
353
+ const text = decodeJournalRead(read.stdout)
354
+ expect(text).toBe(`{"a":1}\n${exitSentinelLine(paths, 0)}\n`)
355
+ expect(text).not.toContain('a warning')
356
+ } finally {
357
+ await dispose()
358
+ }
359
+ },
360
+ CASE_TIMEOUT_MS,
361
+ )
362
+
363
+ it(
364
+ 'reads incrementally from a byte offset with absolute positions',
365
+ async () => {
366
+ const { handle, dispose } = await config.createHandle()
367
+ try {
368
+ const paths = journalPaths(`conf-seek-${Date.now()}`)
369
+ const proc = await handle.process.spawn(
370
+ journaledCommand(`printf '{"a":1}\\n{"b":2}\\n'`, paths),
371
+ )
372
+ await proc.wait()
373
+
374
+ const all = []
375
+ for await (const line of readJournal(handle, {
376
+ paths,
377
+ fromByte: 0,
378
+ strategy: 'poll',
379
+ pollIntervalMs: 0,
380
+ // Not an assertion — see {@link READ_BACKSTOP_MS}. This was
381
+ // `AbortSignal.timeout(5_000)`, and it is a POLL read, so it costs one
382
+ // provider round-trip per line: measured going red on a saturated
383
+ // Docker daemon where a single `exec` took ~20s, while the lines it
384
+ // asserts were perfectly correct.
385
+ signal: AbortSignal.timeout(READ_BACKSTOP_MS),
386
+ })) {
387
+ all.push(line)
388
+ if (all.length === 3) break
389
+ }
390
+ expect(all.map((l) => l.line)).toEqual([
391
+ '{"a":1}',
392
+ '{"b":2}',
393
+ exitSentinelLine(paths, 0),
394
+ ])
395
+
396
+ const resumed = []
397
+ for await (const line of readJournal(handle, {
398
+ paths,
399
+ fromByte: all[0]?.endPosition ?? 0,
400
+ strategy: 'poll',
401
+ pollIntervalMs: 0,
402
+ signal: AbortSignal.timeout(READ_BACKSTOP_MS),
403
+ })) {
404
+ resumed.push(line)
405
+ if (resumed.length === 2) break
406
+ }
407
+ expect(resumed.map((l) => l.line)).toEqual([
408
+ '{"b":2}',
409
+ exitSentinelLine(paths, 0),
410
+ ])
411
+ expect(resumed[0]?.endPosition).toBe(all[1]?.endPosition)
412
+ } finally {
413
+ await dispose()
414
+ }
415
+ },
416
+ CASE_TIMEOUT_MS,
417
+ )
418
+
419
+ // This case is the reason `journalFollowCommand` pipes into nothing.
420
+ // `tail -f journal | base64` delivers ZERO bytes while the agent is still
421
+ // running — measured on GNU coreutils 8.32 `base64` and on busybox 1.36.1
422
+ // `base64` in Alpine — because the encoder buffers its stdout until its
423
+ // stdin closes, which only happens when the reader kills `tail`.
424
+ //
425
+ // INCREMENTAL, NOT MERELY EVENTUAL, AND PROVED CAUSALLY RATHER THAN BY A
426
+ // STOPWATCH. The agent writes its first line and then BLOCKS on a gate file
427
+ // that only this reader can create, and it creates it only on receiving that
428
+ // first line. So the agent cannot reach its second line until the first was
429
+ // delivered — receiving `{"b":2}` at all IS the proof of incremental
430
+ // delivery, and the `toEqual` below is the whole assertion. A buffering
431
+ // filter reintroduced onto the follow path deadlocks instead: nothing is
432
+ // delivered, the gate is never created, the agent never writes its second
433
+ // line, the read ends on its signal and `seen` is empty.
434
+ //
435
+ // This replaced `expect(firstLineMs).toBeLessThan(3_000)`, which measured
436
+ // MACHINE LOAD as much as behavior: it was observed failing in whole-suite
437
+ // fleet runs while passing 5/5 in isolation, because one `exec`/`spawn` is a
438
+ // provider round-trip whose latency this suite does not control (a
439
+ // `docker exec` on a loaded daemon has been measured at 9.6s). Same instinct
440
+ // as `takeover-conformance.ts`'s `countingExec`: anchor on the property, not
441
+ // on the clock. A bound that goes red on a busy machine teaches people to
442
+ // ignore the suite. Do NOT "fix" a failure here by widening a window —
443
+ // there is no window left to widen.
444
+ itFollows(
445
+ config,
446
+ 'follows a journal that is still being written, delivering each line before the next is produced',
447
+ async () => {
448
+ // Every assertion below sits after an `await`, so a case that threw its way
449
+ // out of the loop early would report an unrelated failure; this one reports
450
+ // "nothing was asserted", which is the failure this case used to HIDE.
451
+ expect.hasAssertions()
452
+ const { handle, dispose } = await config.createHandle()
453
+ const gate = noncePath('follow-gate')
454
+ try {
455
+ expectDeclaredStrategy(handle, config)
456
+ const paths = journalPaths(`conf-follow-${Date.now()}`)
457
+ // The wait is bounded in the SANDBOX too, and on timeout it emits a
458
+ // line that names what went wrong instead of the expected one — so a
459
+ // gate that never arrives fails the `toEqual` with `{"gate":"never"}`
460
+ // rather than eventually satisfying it. 30 ticks so that diagnostic
461
+ // lands INSIDE `CASE_TIMEOUT_MS`; a longer cap would just time the case
462
+ // out and lose the message.
463
+ const agentCommand =
464
+ `printf '{"a":1}\\n'; ` +
465
+ `i=0; while [ ! -f ${gate} ]; do ` +
466
+ `i=$((i+1)); ` +
467
+ `if [ $i -gt 30 ]; then printf '{"gate":"never"}\\n'; break; fi; ` +
468
+ `sleep 1; done; ` +
469
+ `printf '{"b":2}\\n'`
470
+ // Not awaited anywhere: reading the `__exit` sentinel below IS the
471
+ // proof it finished. (`SpawnHandle.wait()` is not safe to call after the
472
+ // fact on every provider — local-process registers a `close` listener at
473
+ // call time, so a `wait()` issued after the process already exited never
474
+ // resolves.)
475
+ void handle.process.spawn(journaledCommand(agentCommand, paths))
476
+ // `tail` on a file that does not exist yet exits immediately, and the
477
+ // agent's spawn and the reader's spawn race. Waiting removes that race
478
+ // WITHOUT touching the property under test: with a buffering filter on
479
+ // the follow path the journal still exists, `tail` still runs, and the
480
+ // reader still receives nothing.
481
+ await waitForJournal(handle, paths)
482
+ // The premise, pinned: the gate is genuinely absent, so the agent
483
+ // really is blocked and its second line really is downstream of this
484
+ // reader. Without this a pre-existing gate path would make the case
485
+ // pass without following anything.
486
+ expect(
487
+ (await handle.process.exec(`test -e ${gate}`)).exitCode,
488
+ ).not.toBe(0)
489
+ const seen: Array<string> = []
490
+ for await (const line of readJournal(handle, {
491
+ paths,
492
+ fromByte: 0,
493
+ // Not the assertion — see {@link READ_BACKSTOP_MS}. The gate's own
494
+ // 30-tick cap fires well inside it, so the `{"gate":"never"}`
495
+ // diagnostic still reaches this reader.
496
+ signal: AbortSignal.timeout(READ_BACKSTOP_MS),
497
+ })) {
498
+ seen.push(line.line)
499
+ // Releases the agent, and only from inside the stream. `touch` is
500
+ // not portable to every BusyBox build with these flags, so this
501
+ // creates the file with a redirect, through the shell — `handle.fs`
502
+ // would write a path the agent's `test -f` cannot see on
503
+ // local-process (rule 3).
504
+ if (seen.length === 1) {
505
+ await handle.process.exec(`: >> ${gate}`)
506
+ }
507
+ if (seen.length === 3) break
508
+ }
509
+ expect(seen).toEqual([
510
+ '{"a":1}',
511
+ '{"b":2}',
512
+ exitSentinelLine(paths, 0),
513
+ ])
514
+ } finally {
515
+ // Unblocks the agent even when the case failed, so no `while` loop
516
+ // outlives it on a provider whose sandbox teardown does not reap.
517
+ await handle.process.exec(`: >> ${gate}`).catch(() => undefined)
518
+ await dispose()
519
+ }
520
+ },
521
+ )
522
+
523
+ // The follow read must obey its own AbortSignal rather than waiting for the
524
+ // provider's `kill` to close the stream — a provider whose kill misses a
525
+ // grandchild would otherwise hang the reader past its deadline.
526
+ itFollows(
527
+ config,
528
+ 'stops a follow read when its signal aborts, without a consumer break',
529
+ async () => {
530
+ expect.hasAssertions()
531
+ const { handle, dispose } = await config.createHandle()
532
+ try {
533
+ expectDeclaredStrategy(handle, config)
534
+ const paths = journalPaths(`conf-abort-${Date.now()}`)
535
+ // Outlives the read on purpose: the journal must still be open, and the
536
+ // agent still running, when the signal fires.
537
+ const agent = await handle.process.spawn(
538
+ journaledCommand(`printf '{"a":1}\\n'; sleep 30`, paths),
539
+ )
540
+ try {
541
+ await waitForJournal(handle, paths)
542
+ const seen: Array<string> = []
543
+ // The signal fires ON the first line rather than on a stopwatch. The
544
+ // old form was `AbortSignal.timeout(3_000)`, which asked the provider
545
+ // to spawn a `tail` AND deliver a line inside 3s — measured failing on
546
+ // a loaded Windows machine (git-bash `sh` + `tail`, `seen` came back
547
+ // empty) while passing in isolation, the same load-sensitivity as the
548
+ // `firstLineMs` bound the case above replaced. Aborting from inside the
549
+ // stream keeps the property exactly: the agent is still running, the
550
+ // journal is still open, and the reader must stop because its SIGNAL
551
+ // said so.
552
+ const stop = new AbortController()
553
+ // A backstop, so a reader that delivers nothing fails instead of
554
+ // parking CI. It is not the assertion — `backstopped` below proves it
555
+ // was not what ended the loop.
556
+ const backstop = AbortSignal.timeout(READ_BACKSTOP_MS)
557
+ for await (const line of readJournal(handle, {
558
+ paths,
559
+ fromByte: 0,
560
+ signal: AbortSignal.any([stop.signal, backstop]),
561
+ })) {
562
+ seen.push(line.line)
563
+ // No `break`, ever: the pre-fix reader honored a consumer break but
564
+ // rode straight past its signal, so a `break` here would pass it.
565
+ stop.abort()
566
+ }
567
+ expect({ seen, backstopped: backstop.aborted }).toEqual({
568
+ seen: ['{"a":1}'],
569
+ // The loop ended, and NOT because the backstop timed out — which is
570
+ // the causal witness that "the signal ends it at all", with no clock
571
+ // in the assertion.
572
+ backstopped: false,
573
+ })
574
+ } finally {
575
+ await agent.kill()
576
+ }
577
+ } finally {
578
+ await dispose()
579
+ }
580
+ },
581
+ )
582
+
583
+ // THE CAPABILITY, not the reader's reaction to it.
584
+ //
585
+ // `killableProcesses` is the flag `journalReadStrategy` reads to choose
586
+ // `'follow'`, and the promise it makes is about the SANDBOX-SIDE process:
587
+ // `tail -f` may be spawned because the caller can reclaim it. Every other
588
+ // case in this file asserts only that the READER stopped, which
589
+ // `untilAborted` delivers unilaterally by abandoning the pipe — so all of
590
+ // them pass a provider whose `kill()` is `() => Promise.resolve()`. This one
591
+ // asks the sandbox itself.
592
+ //
593
+ // WHAT IT SPAWNS, AND WHY THAT SHAPE. The heartbeat loop is BACKGROUNDED
594
+ // (`( … ) & wait`), so the long-lived work is a grandchild of the wrapper
595
+ // shell rather than the wrapper itself. That is deliberate: it is the shape
596
+ // of the defects this bites on. `sh -c '<cmd>'` does not reliably exec its
597
+ // command, so a provider that signals only the wrapper leaves the real work
598
+ // running — measured on local-process POSIX, where `sh -c 'sleep 987654321'`
599
+ // survived `child.kill('SIGKILL')` — and Docker's `kill -SIG -"$pid"` group
600
+ // form exists precisely so a backgrounded grandchild is not orphaned. A probe
601
+ // that `exec`ed itself into the wrapper would be reclaimed by the correct and
602
+ // the broken implementation alike, and prove nothing.
603
+ //
604
+ // WHY IT MEASURES WORK AND NOT EXISTENCE. `kill -0 <pid>` was the obvious
605
+ // probe and it is WRONG here, measured: alpine's PID 1 under this provider is
606
+ // `tail -f /dev/null`, which never `wait()`s, so a correctly killed child
607
+ // lingers as an unreaped `[sleep]` forever and `kill -0` answers 0 for it.
608
+ // That fails a healthy provider. `ps` is no better and is why the identity
609
+ // here is a file rather than a nonce in the command line: on local-process
610
+ // under Windows the shell is git-bash, whose MSYS `ps` prints only the process
611
+ // IMAGE PATH (`/usr/bin/sleep`) and never argv — verified, `ps`, `ps -ef` and
612
+ // `ps -W` all omit it — so `ps | grep <nonce>` would match nothing there, and
613
+ // "no match" is indistinguishable from "it is gone": a vacuous pass on
614
+ // exactly the provider whose kill was broken. (`pgrep` is worse: BusyBox has
615
+ // it, git-bash does not.) A host-side census is not portable at all — Docker's
616
+ // container-side process has no host process, and a remote provider has none
617
+ // either.
618
+ //
619
+ // So the probe is a heartbeat: the process appends one byte per second to a
620
+ // nonce-named file, through the shell. Only a RUNNING process can do that. A
621
+ // zombie cannot, a killed process cannot, and no other test on the machine
622
+ // writes to that path.
623
+ //
624
+ // BOTH OBSERVATIONS ARE ASSERTED, in one object, and the "before" one is not
625
+ // decoration: a frozen-file check passes trivially against a file that never
626
+ // grew at all, which is the exact failure shape this whole review keeps
627
+ // turning up. `tickedBeforeKill` is what makes the detector live.
628
+ itFollows(
629
+ config,
630
+ "kills the sandbox-side process, not just the host's view of it",
631
+ async () => {
632
+ expect.hasAssertions()
633
+ const { handle, dispose } = await config.createHandle()
634
+ const heartbeat = noncePath('killprobe-hb')
635
+ const stop = noncePath('killprobe-stop')
636
+ try {
637
+ expectDeclaredStrategy(handle, config)
638
+ // The `stop` file is how a SURVIVOR is reclaimed in teardown, since a
639
+ // provider that fails this case cannot be trusted to kill it and the
640
+ // suite must not leak a spinner either way. The tick cap is the second
641
+ // net, for a teardown that never ran at all.
642
+ const probe = await handle.process.spawn(
643
+ `( i=0; while [ ! -f ${stop} ] && [ $i -lt ${PROBE_MAX_TICKS} ]; do ` +
644
+ `printf '.' >> ${heartbeat}; i=$((i+1)); sleep 1; ` +
645
+ `done ) & wait`,
646
+ )
647
+ // Two bytes, not one: one byte is "it started", two is "it is looping".
648
+ const tickedBeforeKill = await waitForTicks(handle, heartbeat, 2)
649
+
650
+ await probe.kill()
651
+ await sleep(KILL_SETTLE_MS)
652
+ const atSettle = await fileSize(handle, heartbeat)
653
+ await sleep(HEARTBEAT_QUIET_MS)
654
+ const afterQuietWindow = await fileSize(handle, heartbeat)
655
+
656
+ expect({
657
+ tickedBeforeKill,
658
+ // An UNREADABLE sample counts as a tick, i.e. fails: the file was
659
+ // provably readable a moment ago (`tickedBeforeKill`), so `null` here
660
+ // means the probe itself broke and the quiet window proves nothing.
661
+ // Two `null`s compare equal, which would otherwise read as "frozen".
662
+ tickedAfterKill:
663
+ atSettle === null ||
664
+ afterQuietWindow === null ||
665
+ atSettle !== afterQuietWindow,
666
+ }).toEqual({ tickedBeforeKill: true, tickedAfterKill: false })
667
+ } finally {
668
+ await handle.process
669
+ .exec(`: >> ${stop}; rm -f ${heartbeat}`)
670
+ .catch(() => undefined)
671
+ await dispose()
672
+ }
673
+ },
674
+ )
675
+ })
676
+ }