@namzu/sandbox 13.0.0 → 15.0.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 (104) hide show
  1. package/CHANGELOG.md +1147 -0
  2. package/README.md +447 -0
  3. package/dist/backends/aci-standby-pool/index.d.ts.map +1 -1
  4. package/dist/backends/aci-standby-pool/index.js +13 -1
  5. package/dist/backends/aci-standby-pool/index.js.map +1 -1
  6. package/dist/backends/docker/index.d.ts.map +1 -1
  7. package/dist/backends/docker/index.js +19 -1
  8. package/dist/backends/docker/index.js.map +1 -1
  9. package/dist/backends/firecracker/index.d.ts.map +1 -1
  10. package/dist/backends/firecracker/index.js +12 -2
  11. package/dist/backends/firecracker/index.js.map +1 -1
  12. package/dist/backends/firecracker/protocol.d.ts +481 -8
  13. package/dist/backends/firecracker/protocol.d.ts.map +1 -1
  14. package/dist/backends/firecracker/protocol.js +136 -0
  15. package/dist/backends/firecracker/protocol.js.map +1 -1
  16. package/dist/backends/firecracker/transport.d.ts +642 -14
  17. package/dist/backends/firecracker/transport.d.ts.map +1 -1
  18. package/dist/backends/firecracker/transport.js +1307 -34
  19. package/dist/backends/firecracker/transport.js.map +1 -1
  20. package/dist/backends/kubernetes/egress-policy.d.ts +1296 -0
  21. package/dist/backends/kubernetes/egress-policy.d.ts.map +1 -0
  22. package/dist/backends/kubernetes/egress-policy.js +2458 -0
  23. package/dist/backends/kubernetes/egress-policy.js.map +1 -0
  24. package/dist/backends/kubernetes/identity.d.ts +193 -0
  25. package/dist/backends/kubernetes/identity.d.ts.map +1 -0
  26. package/dist/backends/kubernetes/identity.js +147 -0
  27. package/dist/backends/kubernetes/identity.js.map +1 -0
  28. package/dist/backends/kubernetes/index.d.ts +1019 -0
  29. package/dist/backends/kubernetes/index.d.ts.map +1 -0
  30. package/dist/backends/kubernetes/index.js +1756 -0
  31. package/dist/backends/kubernetes/index.js.map +1 -0
  32. package/dist/backends/kubernetes/ingress-policy.d.ts +375 -0
  33. package/dist/backends/kubernetes/ingress-policy.d.ts.map +1 -0
  34. package/dist/backends/kubernetes/ingress-policy.js +1050 -0
  35. package/dist/backends/kubernetes/ingress-policy.js.map +1 -0
  36. package/dist/backends/kubernetes/k8s-client.d.ts +334 -0
  37. package/dist/backends/kubernetes/k8s-client.d.ts.map +1 -0
  38. package/dist/backends/kubernetes/k8s-client.js +553 -0
  39. package/dist/backends/kubernetes/k8s-client.js.map +1 -0
  40. package/dist/backends/kubernetes/lease.d.ts +145 -0
  41. package/dist/backends/kubernetes/lease.d.ts.map +1 -0
  42. package/dist/backends/kubernetes/lease.js +201 -0
  43. package/dist/backends/kubernetes/lease.js.map +1 -0
  44. package/dist/backends/kubernetes/objects.d.ts +702 -0
  45. package/dist/backends/kubernetes/objects.d.ts.map +1 -0
  46. package/dist/backends/kubernetes/objects.js +518 -0
  47. package/dist/backends/kubernetes/objects.js.map +1 -0
  48. package/dist/backends/kubernetes/per-sandbox-policy.d.ts +219 -0
  49. package/dist/backends/kubernetes/per-sandbox-policy.d.ts.map +1 -0
  50. package/dist/backends/kubernetes/per-sandbox-policy.js +407 -0
  51. package/dist/backends/kubernetes/per-sandbox-policy.js.map +1 -0
  52. package/dist/backends/kubernetes/privilege-probe.d.ts +136 -0
  53. package/dist/backends/kubernetes/privilege-probe.d.ts.map +1 -0
  54. package/dist/backends/kubernetes/privilege-probe.js +185 -0
  55. package/dist/backends/kubernetes/privilege-probe.js.map +1 -0
  56. package/dist/backends/kubernetes/rbac.d.ts +153 -0
  57. package/dist/backends/kubernetes/rbac.d.ts.map +1 -0
  58. package/dist/backends/kubernetes/rbac.js +177 -0
  59. package/dist/backends/kubernetes/rbac.js.map +1 -0
  60. package/dist/backends/kubernetes/sandbox.d.ts +190 -0
  61. package/dist/backends/kubernetes/sandbox.d.ts.map +1 -0
  62. package/dist/backends/kubernetes/sandbox.js +433 -0
  63. package/dist/backends/kubernetes/sandbox.js.map +1 -0
  64. package/dist/backends/kubernetes/transport.d.ts +1048 -0
  65. package/dist/backends/kubernetes/transport.d.ts.map +1 -0
  66. package/dist/backends/kubernetes/transport.js +2093 -0
  67. package/dist/backends/kubernetes/transport.js.map +1 -0
  68. package/dist/backends/kubernetes/workspace.d.ts +1512 -0
  69. package/dist/backends/kubernetes/workspace.d.ts.map +1 -0
  70. package/dist/backends/kubernetes/workspace.js +3703 -0
  71. package/dist/backends/kubernetes/workspace.js.map +1 -0
  72. package/dist/backends/remote-execution-controller.d.ts +14 -0
  73. package/dist/backends/remote-execution-controller.d.ts.map +1 -1
  74. package/dist/backends/remote-execution-controller.js.map +1 -1
  75. package/dist/index.d.ts +350 -2
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +344 -34
  78. package/dist/index.js.map +1 -1
  79. package/dist/testing/sandbox-conformance.d.ts +227 -0
  80. package/dist/testing/sandbox-conformance.d.ts.map +1 -0
  81. package/dist/testing/sandbox-conformance.js +896 -0
  82. package/dist/testing/sandbox-conformance.js.map +1 -0
  83. package/package.json +5 -4
  84. package/src/backends/aci-standby-pool/index.ts +16 -1
  85. package/src/backends/docker/index.ts +22 -1
  86. package/src/backends/firecracker/index.ts +14 -2
  87. package/src/backends/firecracker/protocol.ts +541 -6
  88. package/src/backends/firecracker/transport.ts +1687 -64
  89. package/src/backends/kubernetes/egress-policy.ts +3448 -0
  90. package/src/backends/kubernetes/identity.ts +261 -0
  91. package/src/backends/kubernetes/index.ts +2670 -0
  92. package/src/backends/kubernetes/ingress-policy.ts +1344 -0
  93. package/src/backends/kubernetes/k8s-client.ts +742 -0
  94. package/src/backends/kubernetes/lease.ts +254 -0
  95. package/src/backends/kubernetes/objects.ts +983 -0
  96. package/src/backends/kubernetes/per-sandbox-policy.ts +542 -0
  97. package/src/backends/kubernetes/privilege-probe.ts +261 -0
  98. package/src/backends/kubernetes/rbac.ts +192 -0
  99. package/src/backends/kubernetes/sandbox.ts +593 -0
  100. package/src/backends/kubernetes/transport.ts +2895 -0
  101. package/src/backends/kubernetes/workspace.ts +5640 -0
  102. package/src/backends/remote-execution-controller.ts +14 -0
  103. package/src/index.ts +838 -35
  104. package/src/testing/sandbox-conformance.ts +1202 -0
@@ -0,0 +1,1202 @@
1
+ /**
2
+ * The {@link Sandbox} contract, as a suite a backend author runs against
3
+ * their own implementation.
4
+ *
5
+ * ## Why this exists
6
+ *
7
+ * `@namzu/sdk`'s `Sandbox` interface is the one thing every backend in this
8
+ * package promises to implement the same way — `exec`'s exit codes, the
9
+ * `AbortSignal` contract, a `writeFile`/`readFile` round trip, terminal
10
+ * ownership on `destroy()` — and until now nothing PROVED that two backends
11
+ * agreed on any of it. Each backend carried its own bespoke test file
12
+ * (`sandbox-surface.test.ts`, `backend.test.ts`, …), written by whoever
13
+ * built that backend, checking whatever that author thought to check. A
14
+ * shared contract can be silently narrower than either file: this suite is
15
+ * the thing that would have caught it.
16
+ *
17
+ * ## Why it takes its runner as an argument
18
+ *
19
+ * The same shape as `@namzu/sdk/testing`'s checkpoint-store and provider
20
+ * driver suites, and for the same two reasons: `@namzu/sandbox` gains no
21
+ * test dependency from publishing it, and a caller can pass a RECORDING
22
+ * `describe`/`it` and run the whole contract as ordinary code — which is
23
+ * how `testing/__tests__/conformance-fails-a-broken-sandbox.test.ts`
24
+ * proves a deliberately wrong `Sandbox` fails it.
25
+ *
26
+ * ## What is asserted, and what deliberately is not
27
+ *
28
+ * Contract behaviour only — never a backend-specific object, field or
29
+ * error string. A case never inspects `sandbox.constructor.name`, never
30
+ * matches an error message, and never assumes a particular
31
+ * {@link SandboxEnvironment}. `openTerminal`, `openTcpConnection` and
32
+ * `walkFiles` are OPTIONAL on {@link Sandbox} by the SDK's own contract — a
33
+ * backend that cannot honour one must omit it rather than accept and ignore
34
+ * it — so a factory whose sandbox omits any of them skips that section rather
35
+ * than failing it. Every other section runs against every sandbox.
36
+ *
37
+ * ## Where it runs today
38
+ *
39
+ * Both `backends/kubernetes/__tests__/conformance.test.ts` and
40
+ * `backends/firecracker/__tests__/conformance.test.ts` call this against a
41
+ * real `agent/agent.cjs` on a loopback socket — proving the suite is
42
+ * backend-agnostic rather than one backend's tests wearing a new name.
43
+ * `packages/sandbox/k8s/scripts/contract-suite.mjs` runs it a third time,
44
+ * against a live cluster.
45
+ *
46
+ * ## Not published from `@namzu/sandbox`'s entry point
47
+ *
48
+ * The package has no `testing` subpath today (unlike `@namzu/sdk`), and
49
+ * this batch does not add one — promoting this to a public import path is
50
+ * a deliberate, separate decision. Within the monorepo a caller imports it
51
+ * by relative path, exactly as the two files above do:
52
+ *
53
+ * ```ts sketch
54
+ * import { defineSandboxConformance } from '../../../testing/sandbox-conformance.js'
55
+ *
56
+ * defineSandboxConformance({
57
+ * describe, it, expect,
58
+ * label: 'my-backend',
59
+ * makeSandbox: async () => ({ sandbox: await myBackend.create(), dispose: async () => {} }),
60
+ * })
61
+ * ```
62
+ */
63
+
64
+ import { createHash } from 'node:crypto'
65
+ import type {
66
+ OpenTerminalOptions,
67
+ Sandbox,
68
+ SandboxExecResult,
69
+ SandboxTcpConnectOptions,
70
+ SandboxWalkFilesOptions,
71
+ TerminalSession,
72
+ } from '@namzu/sdk'
73
+
74
+ import type {
75
+ ConformanceAssertion,
76
+ ConformanceDescribe,
77
+ ConformanceExpect,
78
+ ConformanceIt,
79
+ } from '@namzu/sdk/testing'
80
+
81
+ /**
82
+ * The contract revision these assertions express. Carried on the describe
83
+ * label so a failure is legible on sight as "the sandbox contract", the
84
+ * same convention `PROVIDER_DRIVER_CONTRACT_VERSION` uses — raised only
85
+ * when a case is ADDED or TIGHTENED, never on a rewording.
86
+ *
87
+ * 3 is the `walkFiles`, concurrent-`exec` and `exec`-timeout sections, none
88
+ * of which needs a guest feature that did not already exist.
89
+ *
90
+ * **The ranged and streamed read cases deliberately did NOT raise it
91
+ * further.** Raising it for them would assert that every backend this suite
92
+ * runs against implements them, and one does not: the Firecracker tier's
93
+ * guest lives in a golden rootfs image that is NOT built from this
94
+ * repository — nothing here builds one, `packages/sandbox/package.json#files`
95
+ * does not even ship `agent/`, and `README.md` documents the image as
96
+ * something the operator builds and canaries on their own schedule. A
97
+ * deployment therefore runs whatever agent its last image build baked in,
98
+ * and a contract version that claimed otherwise would be a claim about
99
+ * images this repository cannot see. Those two cases are gated on
100
+ * {@link SandboxConformanceOptions.supportsRangedAndStreamedReads}
101
+ * instead, and skip by name when a backend does not declare them.
102
+ */
103
+ export const SANDBOX_CONTRACT_VERSION = 3
104
+
105
+ /** A sandbox to test, plus whatever teardown building it required. */
106
+ export interface SandboxConformanceHandle {
107
+ readonly sandbox: Sandbox
108
+ /**
109
+ * Called after each case, pass or fail — closes fixture servers, restores
110
+ * environment variables, removes temp directories. Distinct from
111
+ * `sandbox.destroy()`, which the suite calls itself (idempotently) as
112
+ * part of every case's teardown; `dispose` is for what `makeSandbox`
113
+ * itself stood up, not for the sandbox's own lifecycle.
114
+ */
115
+ dispose?(): void | Promise<void>
116
+ }
117
+
118
+ /**
119
+ * Build one fresh {@link Sandbox}. Called once per case, so no case can be
120
+ * affected by another's writes, aborts or destroys — the suite never
121
+ * assumes a shared instance and never reuses one across cases.
122
+ */
123
+ export type MakeSandbox = () => SandboxConformanceHandle | Promise<SandboxConformanceHandle>
124
+
125
+ export interface SandboxConformanceOptions {
126
+ readonly describe: ConformanceDescribe
127
+ readonly it: ConformanceIt
128
+ readonly expect: ConformanceExpect
129
+ readonly makeSandbox: MakeSandbox
130
+ /** Names the backend in test output. Defaults to `sandbox`. */
131
+ readonly label?: string
132
+ /**
133
+ * Whether this backend's guest can run the `openTcpConnection` positive
134
+ * case's listener at all — by default {@link nodeGuestListener}, `node
135
+ * -e`. Defaults to `true`: every backend this suite ships against runs
136
+ * `agent/agent.cjs` in the guest, and that agent IS node, so node on the
137
+ * guest's own `PATH` is a precondition of the agent existing rather than
138
+ * an extra capability this suite demands.
139
+ *
140
+ * Set `false` for a guest that cannot run a listener this way at all
141
+ * (no `openTerminal`, or an image with neither node nor a substitute) —
142
+ * the case then SKIPS, its own title stating why, rather than failing a
143
+ * backend for a capability its contract never promised. A backend that
144
+ * can run *some* listener, just not node, keeps this `true` (or omits
145
+ * it) and supplies {@link SandboxConformanceOptions.guestListenerCommand}
146
+ * instead.
147
+ */
148
+ readonly guestCanRunNode?: boolean
149
+ /**
150
+ * Overrides the program the `openTcpConnection` positive case starts
151
+ * inside the guest. Defaults to {@link nodeGuestListener}. A guest
152
+ * without node but with, say, busybox `nc` can supply its own command as
153
+ * long as it reports the bound port the way
154
+ * {@link GuestListenerCommand.parsePort} expects.
155
+ */
156
+ readonly guestListenerCommand?: () => GuestListenerCommand
157
+ /**
158
+ * Whether this backend's guest honours `readFile`'s `offset`/`length`
159
+ * and implements {@link Sandbox.readFileStream}.
160
+ *
161
+ * **Defaults to `false`, and the default is the honest one.** These
162
+ * cases are not in {@link SANDBOX_CONTRACT_VERSION} — see the comment
163
+ * on that constant for why — so the suite cannot assume a backend has
164
+ * them. A backend whose guest is built from this repository sets it
165
+ * `true`; every other backend gets the cases as named skips, counted in
166
+ * the runner's own totals and titled with the reason, rather than as
167
+ * failures for a contract it never agreed to.
168
+ *
169
+ * A backend that sets this `true` while its guest ignores
170
+ * `offset`/`length` FAILS, and that is the point: returning the whole
171
+ * file where a slice was asked for is a wrong answer, not a missing
172
+ * capability.
173
+ */
174
+ readonly supportsRangedAndStreamedReads?: boolean
175
+ }
176
+
177
+ /** Assert `call()` rejects. The contract cares that admission was refused, never the message. */
178
+ async function expectRejects(
179
+ expect: ConformanceExpect,
180
+ call: () => Promise<unknown>,
181
+ ): Promise<void> {
182
+ let rejected = false
183
+ try {
184
+ await call()
185
+ } catch {
186
+ rejected = true
187
+ }
188
+ expect(rejected).toBe(true)
189
+ }
190
+
191
+ /** Assert `call()` resolves — the inverse check, for the rare case a rejection is the defect. */
192
+ async function expectResolves(
193
+ expect: ConformanceExpect,
194
+ call: () => Promise<unknown>,
195
+ ): Promise<void> {
196
+ let threw: unknown
197
+ try {
198
+ await call()
199
+ } catch (error) {
200
+ threw = error
201
+ }
202
+ expect(threw === undefined).toBe(true)
203
+ }
204
+
205
+ function sleep(ms: number): Promise<void> {
206
+ return new Promise((resolve) => setTimeout(resolve, ms))
207
+ }
208
+
209
+ /**
210
+ * `size` bytes of deterministic pseudo-random content (xorshift32 from a
211
+ * fixed seed).
212
+ *
213
+ * Pseudo-random rather than a repeated byte because the large-body case
214
+ * below is about whether every byte survived in the right ORDER: a body of
215
+ * one repeated value passes a length check and a content check even if the
216
+ * transport shipped its pieces out of order, duplicated one, or dropped
217
+ * one and padded. Deterministic rather than `randomBytes` so a failure is
218
+ * reproducible from the size alone.
219
+ */
220
+ function deterministicBytes(size: number): Buffer {
221
+ const out = Buffer.allocUnsafe(size)
222
+ let x = 0x9e3779b9
223
+ for (let i = 0; i < size; i += 1) {
224
+ x ^= x << 13
225
+ x >>>= 0
226
+ x ^= x >> 17
227
+ x ^= x << 5
228
+ x >>>= 0
229
+ out[i] = x & 0xff
230
+ }
231
+ return out
232
+ }
233
+
234
+ /** `sha256` of a buffer, hex — a byte-exact comparison that prints short. */
235
+ function digest(buffer: Buffer): string {
236
+ return createHash('sha256').update(buffer).digest('hex')
237
+ }
238
+
239
+ /**
240
+ * A program the `openTcpConnection` positive case can start INSIDE a guest
241
+ * through {@link Sandbox.openTerminal}, and dial back into over
242
+ * `openTcpConnection` itself.
243
+ *
244
+ * Starting the listener in the guest — rather than in the orchestrator/test
245
+ * process, which is what this case used to do — is the whole point: a
246
+ * listener on the HOST'S loopback only ever proves anything for a backend
247
+ * whose "guest" happens to share that loopback (a Firecracker fixture over a
248
+ * local socket, a fake-agent-in-this-process kubernetes test). It never
249
+ * proves anything for a real remote guest, which cannot dial the
250
+ * orchestrator's loopback at all — that gap is exactly what let the case
251
+ * pass in every colocated fixture and fail the one time it ran against a
252
+ * live cluster.
253
+ */
254
+ export interface GuestListenerCommand {
255
+ /** The program `openTerminal` runs as the session's top-level process. */
256
+ readonly command: string
257
+ readonly args: readonly string[]
258
+ /**
259
+ * Reads the port the listener bound out of everything it has printed to
260
+ * its terminal so far. Returns `undefined` until the listener has
261
+ * reported one — the suite polls this as output arrives rather than
262
+ * parsing a single chunk, because a pty may deliver the report split
263
+ * across reads.
264
+ */
265
+ parsePort(output: string): number | undefined
266
+ }
267
+
268
+ /** What {@link nodeGuestListener} has its script print once it is bound. */
269
+ const NODE_LISTENER_MARKER = 'namzu-conformance-listening:'
270
+
271
+ /**
272
+ * The default {@link GuestListenerCommand}: `node -e` binding an ephemeral
273
+ * port on the GUEST's own loopback, echoing `conformance-reply:<payload>`
274
+ * back for the first chunk of the one connection it accepts, then reporting
275
+ * the bound port on its own stdout — the only way the host, which cannot
276
+ * inspect a real remote guest's open ports any other way, learns which port
277
+ * to dial.
278
+ *
279
+ * Every backend this suite ships against runs `agent/agent.cjs` in the
280
+ * guest, which is itself node — so node on the guest's `PATH` is not an
281
+ * extra requirement this suite invents, it is a precondition of the agent
282
+ * existing at all. A backend whose guest genuinely cannot run node (or
283
+ * cannot run `openTerminal`) declares that through
284
+ * {@link SandboxConformanceOptions.guestCanRunNode} or supplies its own
285
+ * command via {@link SandboxConformanceOptions.guestListenerCommand}.
286
+ */
287
+ export function nodeGuestListener(): GuestListenerCommand {
288
+ const script = [
289
+ "const net = require('node:net');",
290
+ 'const server = net.createServer((socket) => {',
291
+ " socket.once('data', (chunk) => {",
292
+ " socket.end(Buffer.concat([Buffer.from('conformance-reply:'), chunk]));",
293
+ ' });',
294
+ '});',
295
+ "server.listen(0, '127.0.0.1', () => {",
296
+ ` process.stdout.write(${JSON.stringify(NODE_LISTENER_MARKER)} + server.address().port + '\\n');`,
297
+ '});',
298
+ ].join('\n')
299
+ return {
300
+ command: 'node',
301
+ args: ['-e', script],
302
+ parsePort(output) {
303
+ const marker = output.indexOf(NODE_LISTENER_MARKER)
304
+ if (marker === -1) return undefined
305
+ const match = /\d+/.exec(output.slice(marker + NODE_LISTENER_MARKER.length))
306
+ return match ? Number(match[0]) : undefined
307
+ },
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Start `listener` through `openTerminal`, and resolve once it has reported
313
+ * the port it bound.
314
+ *
315
+ * The returned `stop()` kills the terminal's owned process tree — the exact
316
+ * ownership guarantee the `openTerminal` section above already proves
317
+ * `destroy()` gets for free, used here to tear the listener down without
318
+ * waiting for the whole sandbox to go away.
319
+ *
320
+ * Takes `openTerminal` as a plain function rather than a `Sandbox`, so a
321
+ * unit test can exercise the port-parsing and exit-races above without a
322
+ * `Sandbox` fixture — see `__tests__/guest-listener.test.ts`.
323
+ */
324
+ export async function startGuestListener(
325
+ openTerminal: (options: OpenTerminalOptions) => Promise<TerminalSession>,
326
+ listener: GuestListenerCommand,
327
+ ): Promise<{ readonly port: number; stop(): Promise<void> }> {
328
+ const terminal = await openTerminal({
329
+ command: listener.command,
330
+ args: listener.args,
331
+ size: { cols: 80, rows: 24 },
332
+ })
333
+
334
+ let output = ''
335
+ const port = await new Promise<number>((resolve, reject) => {
336
+ const unsubscribe = terminal.onData((chunk) => {
337
+ output += chunk
338
+ const found = listener.parsePort(output)
339
+ if (found !== undefined) {
340
+ unsubscribe()
341
+ resolve(found)
342
+ }
343
+ })
344
+ void terminal.exited.then((result) => {
345
+ // A settled promise ignores a later resolve/reject, so this is a
346
+ // no-op on the path where the port was already found and `stop()`
347
+ // is what causes this exit — it only fires the rejection when the
348
+ // listener died before ever reporting a port.
349
+ if (listener.parsePort(output) === undefined) {
350
+ unsubscribe()
351
+ reject(
352
+ new Error(
353
+ `guest listener exited before reporting a port (exit code ${result.exitCode}): ${
354
+ output || '<no output>'
355
+ }`,
356
+ ),
357
+ )
358
+ }
359
+ })
360
+ })
361
+
362
+ return {
363
+ port,
364
+ async stop() {
365
+ terminal.kill()
366
+ await terminal.exited.catch(() => {})
367
+ },
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Register the {@link Sandbox} contract against one backend.
373
+ *
374
+ * Call it once per backend. It registers cases through the supplied
375
+ * `describe`/`it`; it does not run them.
376
+ */
377
+ export function defineSandboxConformance(options: SandboxConformanceOptions): void {
378
+ const { describe, it, expect, makeSandbox } = options
379
+ const label = options.label ?? 'sandbox'
380
+ const guestCanRunNode = options.guestCanRunNode ?? true
381
+ const guestListenerCommand = options.guestListenerCommand ?? nodeGuestListener
382
+ const supportsRangedAndStreamedReads = options.supportsRangedAndStreamedReads ?? false
383
+
384
+ /**
385
+ * Run `body` against a sandbox built for this case alone.
386
+ *
387
+ * Destroys the sandbox itself (idempotent, so a body that already
388
+ * destroyed it costs nothing extra) before calling `dispose`, so a
389
+ * case that forgets to release a pod/microVM does not leak one — the
390
+ * same reasoning `withStore`'s `finally` states for a leaked temp
391
+ * directory: a suite that is expensive to run red is a suite people
392
+ * stop running.
393
+ */
394
+ const withSandbox = (body: (sandbox: Sandbox) => Promise<void>) => async () => {
395
+ const handle = await makeSandbox()
396
+ try {
397
+ await body(handle.sandbox)
398
+ } finally {
399
+ await handle.sandbox.destroy().catch(() => {})
400
+ await handle.dispose?.()
401
+ }
402
+ }
403
+
404
+ /**
405
+ * A case that needs {@link SandboxConformanceOptions.supportsRangedAndStreamedReads}.
406
+ *
407
+ * The skip is deliberately visible: the reason is in the case's TITLE and
408
+ * the case still runs and is counted in the runner's totals, which is what
409
+ * {@link SANDBOX_CONTRACT_VERSION}'s note asks for. What it does not do is
410
+ * build a sandbox to skip inside — `withSandbox` is applied only on the
411
+ * branch that uses one, so a backend without the capability pays nothing
412
+ * per skipped case.
413
+ */
414
+ const rangedReadCase = (
415
+ title: string,
416
+ body: (sandbox: Sandbox) => Promise<void>,
417
+ ): [string, () => Promise<void>] =>
418
+ supportsRangedAndStreamedReads
419
+ ? [title, withSandbox(body)]
420
+ : [`${title} (skipped: supportsRangedAndStreamedReads is false)`, async () => {}]
421
+
422
+ describe(`${label} — sandbox contract v${SANDBOX_CONTRACT_VERSION}`, () => {
423
+ describe('exec', () => {
424
+ it(
425
+ 'reports the exit code and streams stdout/stderr as the command runs',
426
+ withSandbox(async (sandbox) => {
427
+ const chunks: { stream: string; data: string }[] = []
428
+ const result = await sandbox.exec(
429
+ '/bin/sh',
430
+ ['-c', 'echo conformance-out; echo conformance-err 1>&2; exit 7'],
431
+ { onOutput: (chunk) => chunks.push({ ...chunk }) },
432
+ )
433
+
434
+ expect(result.exitCode).toBe(7)
435
+ expect(result.timedOut).toBe(false)
436
+ expect(result.stdout).toMatch(/conformance-out/)
437
+ expect(result.stderr).toMatch(/conformance-err/)
438
+ // Streamed, not just present in the final string: a backend
439
+ // that buffers everything until exit and calls `onOutput`
440
+ // once at the end would satisfy the two checks above and
441
+ // fail this one.
442
+ expect(
443
+ chunks.some((c) => c.stream === 'stdout' && c.data.includes('conformance-out')),
444
+ ).toBe(true)
445
+ expect(
446
+ chunks.some((c) => c.stream === 'stderr' && c.data.includes('conformance-err')),
447
+ ).toBe(true)
448
+ }),
449
+ )
450
+
451
+ it(
452
+ 'reports busy while a command is in flight and ready once it settles',
453
+ withSandbox(async (sandbox) => {
454
+ let observedBusy = false
455
+ await sandbox.exec('/bin/sh', ['-c', 'echo started; sleep 0.2'], {
456
+ onOutput: (chunk) => {
457
+ if (chunk.data.includes('started')) observedBusy = sandbox.status === 'busy'
458
+ },
459
+ })
460
+ expect(observedBusy).toBe(true)
461
+ expect(sandbox.status).toBe('ready')
462
+ }),
463
+ )
464
+
465
+ it(
466
+ 'honours an AbortSignal: the process is really terminated, never a partial success',
467
+ withSandbox(async (sandbox) => {
468
+ // The contract (`SandboxExecOptions.signal`'s own doc comment):
469
+ // a backend that accepts the signal must terminate the process
470
+ // it owns, or prove admission never happened; it must never
471
+ // silently ignore the signal and let the command run to
472
+ // completion while reporting as though it had been cancelled.
473
+ // The command below writes a marker file a moment after
474
+ // printing "ready" — if the process is genuinely killed on
475
+ // abort, that write never happens. That is the decisive
476
+ // check; whatever the settled promise looks like is a second,
477
+ // weaker one.
478
+ const marker = 'conformance-abort-marker.txt'
479
+ const caller = new AbortController()
480
+ let signalReady: (() => void) | undefined
481
+ const ready = new Promise<void>((resolve) => {
482
+ signalReady = resolve
483
+ })
484
+
485
+ const running = sandbox.exec(
486
+ '/bin/sh',
487
+ [
488
+ '-c',
489
+ `trap '' TERM; (trap '' TERM; sleep 0.4; printf late > ${marker}) & echo ready; wait`,
490
+ ],
491
+ {
492
+ signal: caller.signal,
493
+ onOutput: (chunk) => {
494
+ if (chunk.stream === 'stdout' && chunk.data.includes('ready')) signalReady?.()
495
+ },
496
+ },
497
+ )
498
+ await ready
499
+ caller.abort(new Error('conformance suite cancelled this command'))
500
+
501
+ // Resolve OR reject are both compliant — a backend that cannot
502
+ // confirm the kill may refuse instead of reporting a result it
503
+ // is not sure of. What is never compliant is reporting a clean,
504
+ // unaborted-looking success.
505
+ let settled: { exitCode: number; signal?: string } | undefined
506
+ try {
507
+ settled = await running
508
+ } catch {
509
+ settled = undefined
510
+ }
511
+ if (settled !== undefined) {
512
+ expect(settled.exitCode === 0 && settled.signal === undefined).toBe(false)
513
+ }
514
+
515
+ // Long enough that an un-killed process would have finished its
516
+ // sleep and written the file.
517
+ await sleep(900)
518
+ await expectRejects(expect, () => sandbox.readFile(marker))
519
+ }),
520
+ )
521
+ })
522
+
523
+ describe('file IO', () => {
524
+ it(
525
+ 'round-trips a UTF-8 string through writeFile/readFile',
526
+ withSandbox(async (sandbox) => {
527
+ await sandbox.writeFile('conformance-notes.txt', 'héllo wörld')
528
+ const read = await sandbox.readFile('conformance-notes.txt')
529
+ expect(read.toString('utf8')).toBe('héllo wörld')
530
+ }),
531
+ )
532
+
533
+ it(
534
+ 'round-trips arbitrary binary content byte for byte',
535
+ withSandbox(async (sandbox) => {
536
+ const payload = Buffer.from([0x00, 0xff, 0x10, 0x00, 0x42, 0xfe, 0x7f, 0x80, 0x01])
537
+ await sandbox.writeFile('nested/conformance/blob.bin', payload)
538
+ const read = await sandbox.readFile('nested/conformance/blob.bin')
539
+ // Compared as base64 rather than through a deep-equality
540
+ // matcher: the four matchers this suite is allowed to assume
541
+ // (`toBe`/`toEqual`/`toBeGreaterThan`/`toMatch`) do not
542
+ // guarantee byte-exact `Buffer` comparison across every
543
+ // runner a caller might wire in, and a corrupted byte belongs
544
+ // in the string this failure prints.
545
+ expect(read.toString('base64')).toBe(payload.toString('base64'))
546
+ }),
547
+ )
548
+
549
+ /**
550
+ * A body too large to cross the wire in ONE message.
551
+ *
552
+ * 7 MiB is chosen against a real number rather than a round one:
553
+ * a `write-file` body travels base64-encoded inside the request
554
+ * envelope, so 7 MiB of content is ~9.3 MiB of frame — past the
555
+ * 8 MiB ceiling the guest agent enforces on an unauthenticated
556
+ * connection's first frame, which on a transport that dials
557
+ * fresh per call is EVERY frame. That ceiling used to make this
558
+ * case a documented refusal on the kubernetes backend while the
559
+ * host-local backends served it without noticing, which is
560
+ * exactly the shape of divergence a contract suite exists to
561
+ * catch: `Sandbox.writeFile` promises to write a file, and a
562
+ * caller seeding a repository archive into a workspace cannot
563
+ * be told that the promise holds below a number nothing in the
564
+ * interface names.
565
+ *
566
+ * Compared by digest, not by content: a mismatch here belongs in
567
+ * the failure message as a short string, and 7 MiB of base64
568
+ * does not.
569
+ */
570
+ it(
571
+ 'round-trips a body larger than one wire frame',
572
+ withSandbox(async (sandbox) => {
573
+ const payload = deterministicBytes(7 * 1024 * 1024)
574
+ await sandbox.writeFile('conformance-large/archive.bin', payload)
575
+ const read = await sandbox.readFile('conformance-large/archive.bin')
576
+ expect(read.length).toBe(payload.length)
577
+ expect(digest(read)).toBe(digest(payload))
578
+ }),
579
+ )
580
+
581
+ /**
582
+ * A read ABOVE the ceiling the case above sits below.
583
+ *
584
+ * 7 MiB is chosen for what the WRITE costs — 9.3 MiB of base64
585
+ * envelope, past the guest's 8 MiB pre-auth frame limit — and a
586
+ * reply frame is not measured against that limit at all, so that
587
+ * case proves nothing about the read side. 9 MiB is above the
588
+ * number on both sides of the wire, which is the only way to tell
589
+ * a backend that reads a file in bounded pieces from one that
590
+ * hands back a single reply and hopes.
591
+ *
592
+ * Skipped by NAME, and counted in the runner's totals as a case,
593
+ * for a backend that has not declared the capability — see
594
+ * {@link SandboxConformanceOptions.supportsRangedAndStreamedReads}
595
+ * and the note on {@link SANDBOX_CONTRACT_VERSION}.
596
+ */
597
+ it(
598
+ ...rangedReadCase(
599
+ 'reads a file larger than one wire frame back in bounded pieces',
600
+ async (sandbox) => {
601
+ const payload = deterministicBytes(9 * 1024 * 1024)
602
+ await sandbox.writeFile('conformance-large/wide.bin', payload)
603
+
604
+ const whole = await sandbox.readFile('conformance-large/wide.bin')
605
+ expect(whole.length).toBe(payload.length)
606
+ expect(digest(whole)).toBe(digest(payload))
607
+
608
+ // A backend declaring the capability must expose the stream
609
+ // too: the whole point is that a caller can read a file it
610
+ // could not hold, and `readFile` hands back one buffer.
611
+ const readFileStream = sandbox.readFileStream
612
+ if (!readFileStream) {
613
+ throw new Error(
614
+ 'supportsRangedAndStreamedReads is true but this sandbox has no readFileStream. ' +
615
+ 'A backend that honours offset/length but cannot stream must pass ' +
616
+ 'supportsRangedAndStreamedReads: false and state why.',
617
+ )
618
+ }
619
+ const chunks: Buffer[] = []
620
+ for await (const chunk of readFileStream.call(sandbox, 'conformance-large/wide.bin')) {
621
+ chunks.push(Buffer.from(chunk))
622
+ }
623
+ // More than one chunk is what "bounded pieces" means; a
624
+ // backend yielding the whole file once satisfies the
625
+ // signature and none of the promise.
626
+ expect(chunks.length > 1).toBe(true)
627
+ expect(digest(Buffer.concat(chunks))).toBe(digest(payload))
628
+ },
629
+ ),
630
+ )
631
+
632
+ /**
633
+ * A slice, and the three things a slice has to get right: the
634
+ * bytes, a range that runs off the end, and the fact that asking
635
+ * for one must not hand back the whole file.
636
+ */
637
+ it(
638
+ ...rangedReadCase(
639
+ 'reads an explicit byte range, and clips it to the end of the file',
640
+ async (sandbox) => {
641
+ const payload = deterministicBytes(64 * 1024)
642
+ await sandbox.writeFile('conformance-range/slice.bin', payload)
643
+
644
+ const middle = await sandbox.readFile('conformance-range/slice.bin', {
645
+ offset: 1_000,
646
+ length: 256,
647
+ })
648
+ expect(middle.length).toBe(256)
649
+ expect(middle.toString('base64')).toBe(
650
+ payload.subarray(1_000, 1_256).toString('base64'),
651
+ )
652
+
653
+ // Past the end returns what exists rather than failing: a
654
+ // caller resuming from a remembered offset cannot be made to
655
+ // know the answer before it asks.
656
+ const straddling = await sandbox.readFile('conformance-range/slice.bin', {
657
+ offset: payload.length - 10,
658
+ length: 500,
659
+ })
660
+ expect(straddling.length).toBe(10)
661
+ expect(straddling.toString('base64')).toBe(payload.subarray(-10).toString('base64'))
662
+ },
663
+ ),
664
+ )
665
+ })
666
+
667
+ describe('listFiles', () => {
668
+ it(
669
+ 'lists written files as absolute paths with their sizes',
670
+ withSandbox(async (sandbox) => {
671
+ const contentA = '123456789'
672
+ const contentB = '42 bytes worth of fixed content!!'
673
+ await sandbox.writeFile('conformance-list/a.txt', contentA)
674
+ await sandbox.writeFile('conformance-list/b.txt', contentB)
675
+ const dir = `${sandbox.rootDir}/conformance-list`
676
+ const files = await sandbox.listFiles(dir)
677
+ const byPath = new Map(files.map((f) => [f.path, f.size]))
678
+ expect(byPath.get(`${dir}/a.txt`)).toBe(Buffer.byteLength(contentA))
679
+ expect(byPath.get(`${dir}/b.txt`)).toBe(Buffer.byteLength(contentB))
680
+ }),
681
+ )
682
+
683
+ it(
684
+ 'reports a root that does not exist as empty rather than failing',
685
+ withSandbox(async (sandbox) => {
686
+ const files = await sandbox.listFiles(`${sandbox.rootDir}/conformance-never-created`)
687
+ expect(files.length).toBe(0)
688
+ }),
689
+ )
690
+ })
691
+
692
+ /**
693
+ * Optional on {@link Sandbox} by the SDK's own contract: a backend that
694
+ * cannot provide a real pseudo-terminal must OMIT the method rather
695
+ * than hand back a pipe masquerading as one. So a factory whose
696
+ * sandbox has no `openTerminal` is not in violation of anything — the
697
+ * case below passes vacuously for it, which is the documented
698
+ * skip-if-unavailable this suite promises rather than a silent hole:
699
+ * both shipped backends (kubernetes, firecracker) DO implement it, so
700
+ * in CI this case only ever runs vacuously against a fixture that
701
+ * deliberately declines the capability.
702
+ */
703
+ describe('openTerminal', () => {
704
+ it(
705
+ 'is owned by the sandbox: destroy() kills and awaits every terminal it returned',
706
+ withSandbox(async (sandbox) => {
707
+ if (!sandbox.openTerminal) return
708
+
709
+ const terminal = await sandbox.openTerminal({
710
+ command: '/bin/sh',
711
+ args: ['-c', 'sleep 30'],
712
+ size: { cols: 80, rows: 24 },
713
+ })
714
+
715
+ let exited = false
716
+ void terminal.exited.then(() => {
717
+ exited = true
718
+ })
719
+
720
+ await sandbox.destroy()
721
+ // Nothing awaited in between: awaiting `terminal.exited` here
722
+ // would rescue a `destroy()` that only fired the kill and
723
+ // returned without waiting for it, which is exactly the
724
+ // defect this case exists to catch.
725
+ expect(exited).toBe(true)
726
+ await terminal.exited
727
+ }),
728
+ )
729
+ })
730
+
731
+ /**
732
+ * Same optionality and the same documented skip as `openTerminal`,
733
+ * above: a factory whose sandbox has no `openTcpConnection` passes
734
+ * vacuously. The positive case below adds a second, independent skip
735
+ * axis on top of that — see `guestCanRunNode` and
736
+ * `guestListenerCommand` on {@link SandboxConformanceOptions} — because
737
+ * proving the forward really crosses into a REMOTE guest needs a
738
+ * listener running there, and not every guest can start one the same
739
+ * way.
740
+ */
741
+ describe('openTcpConnection', () => {
742
+ // The reason for a title, rather than a console message, printing
743
+ // the skip: `ConformanceIt` promises only `(name, body) => unknown`
744
+ // (`contract-suite.mjs`'s own flat recorder has no skip concept
745
+ // either), so the one channel a skip can travel through every
746
+ // runner this suite is ever handed is the case's own name — decided
747
+ // once, here, from options given synchronously to
748
+ // `defineSandboxConformance`, not from anything discovered at run
749
+ // time.
750
+ const positiveCaseTitle = guestCanRunNode
751
+ ? 'forwards a bidirectional stream to a service started inside the guest'
752
+ : 'forwards a bidirectional stream to a service started inside the guest (skipped: guestCanRunNode is false)'
753
+
754
+ it(
755
+ positiveCaseTitle,
756
+ withSandbox(async (sandbox) => {
757
+ if (!sandbox.openTcpConnection) return
758
+ if (!guestCanRunNode) return
759
+ const openTerminal = sandbox.openTerminal
760
+ if (!openTerminal) {
761
+ // A backend offering `openTcpConnection` without
762
+ // `openTerminal` has no portable way for this suite to
763
+ // start a guest-side listener — declare the skip
764
+ // explicitly (`guestCanRunNode: false`) rather than
765
+ // leaving the default to discover it here as a failure.
766
+ throw new Error(
767
+ 'openTcpConnection conformance: starting a guest-side listener needs openTerminal, ' +
768
+ 'which this sandbox does not implement. Pass guestCanRunNode: false to ' +
769
+ 'defineSandboxConformance to skip this case with a stated reason, or supply ' +
770
+ 'guestListenerCommand for a guest that can run a listener some other way.',
771
+ )
772
+ }
773
+
774
+ // Called through `.call(sandbox, …)` rather than passed as
775
+ // a bare reference: `openTerminal` may be an ordinary
776
+ // method relying on `this` (a class-based fixture, for
777
+ // instance), and detaching it from `sandbox` would drop
778
+ // that binding.
779
+ const listener = await startGuestListener(
780
+ (terminalOptions) => openTerminal.call(sandbox, terminalOptions),
781
+ guestListenerCommand(),
782
+ )
783
+ try {
784
+ const connection = await sandbox.openTcpConnection({ port: listener.port })
785
+ let received = ''
786
+ const unsubscribe = connection.onData((chunk) => {
787
+ received += Buffer.from(chunk).toString('utf8')
788
+ })
789
+ connection.write('conformance-hello')
790
+ await connection.closed
791
+ expect(received).toBe('conformance-reply:conformance-hello')
792
+ unsubscribe()
793
+ } finally {
794
+ await listener.stop()
795
+ }
796
+ }),
797
+ )
798
+
799
+ it(
800
+ 'refuses a non-loopback host',
801
+ withSandbox(async (sandbox) => {
802
+ if (!sandbox.openTcpConnection) return
803
+
804
+ // `SandboxTcpConnectOptions.host` types as loopback-only; the
805
+ // cast is deliberate — this proves the refusal is enforced at
806
+ // RUNTIME, not merely by the type checker a compliant caller
807
+ // could route around with the same cast.
808
+ const nonLoopback = {
809
+ port: 9,
810
+ host: '203.0.113.10',
811
+ } as unknown as SandboxTcpConnectOptions
812
+ await expectRejects(
813
+ expect,
814
+ () =>
815
+ sandbox.openTcpConnection?.(nonLoopback) ?? Promise.reject(new Error('unreachable')),
816
+ )
817
+ }),
818
+ )
819
+ })
820
+
821
+ describe('destroy', () => {
822
+ it(
823
+ 'is idempotent, however many times or however concurrently it is called',
824
+ withSandbox(async (sandbox) => {
825
+ await Promise.all([sandbox.destroy(), sandbox.destroy()])
826
+ expect(sandbox.status).toBe('destroyed')
827
+ await expectResolves(expect, () => sandbox.destroy())
828
+ expect(sandbox.status).toBe('destroyed')
829
+ }),
830
+ )
831
+
832
+ it(
833
+ 'refuses every call once destroyed, rather than admitting one',
834
+ withSandbox(async (sandbox) => {
835
+ await sandbox.destroy()
836
+
837
+ await expectRejects(expect, () => sandbox.exec('/bin/sh', ['-c', 'true']))
838
+ await expectRejects(expect, () => sandbox.writeFile('x.txt', 'x'))
839
+ await expectRejects(expect, () => sandbox.readFile('x.txt'))
840
+ await expectRejects(expect, () => sandbox.listFiles(sandbox.rootDir))
841
+ // `?.()` rather than an `if` guard around the assertion: it is
842
+ // type-correct whether or not the capability exists, and when
843
+ // it does not exist there is nothing to refuse — the
844
+ // documented skip-if-unavailable this suite promises for
845
+ // every optional capability.
846
+ if (sandbox.openTerminal) {
847
+ await expectRejects(
848
+ expect,
849
+ () =>
850
+ sandbox.openTerminal?.({ size: { cols: 80, rows: 24 } }) ??
851
+ Promise.reject(new Error('unreachable')),
852
+ )
853
+ }
854
+ if (sandbox.openTcpConnection) {
855
+ await expectRejects(
856
+ expect,
857
+ () =>
858
+ sandbox.openTcpConnection?.({ port: 9 }) ??
859
+ Promise.reject(new Error('unreachable')),
860
+ )
861
+ }
862
+ }),
863
+ )
864
+ })
865
+
866
+ /**
867
+ * Optional on {@link Sandbox} by the SDK's own contract, and the same
868
+ * documented skip as `openTerminal`: a factory whose sandbox omits
869
+ * `walkFiles` passes every case below vacuously. It is not a small
870
+ * omission to make, though — the SDK's `glob` and `grep` builtins
871
+ * REFUSE a sandbox that has no `walkFiles`, so a host that registers
872
+ * the default builtin set and moves to such a backend loses both
873
+ * tools with no change on its own side.
874
+ *
875
+ * What is asserted here is the contract's own wording: absolute paths,
876
+ * regular files only, symlinks not followed, the bounds honoured by
877
+ * whoever does the walking, and — the one that is easy to get wrong —
878
+ * an exhausted examined-entry budget raising an error carrying
879
+ * `ERR_FILE_WALK_LIMIT` rather than handing back a short list a caller
880
+ * would read as complete.
881
+ */
882
+ describe('walkFiles', () => {
883
+ /**
884
+ * Every path this walk yielded, sorted.
885
+ *
886
+ * `walkFiles` is called through `.call(sandbox, …)` for the same
887
+ * reason `openTerminal` is above: it may be an ordinary method
888
+ * relying on `this`, and detaching it would drop that binding.
889
+ */
890
+ const walkPaths = async (
891
+ sandbox: Sandbox,
892
+ root: string,
893
+ options: SandboxWalkFilesOptions,
894
+ ): Promise<string[]> => {
895
+ const walk = sandbox.walkFiles
896
+ if (!walk) throw new Error('unreachable: every case guards on walkFiles first')
897
+ const paths: string[] = []
898
+ for await (const entry of walk.call(sandbox, root, options)) paths.push(entry.path)
899
+ return paths.sort()
900
+ }
901
+
902
+ it(
903
+ 'yields written files as absolute paths with their sizes, bounded by maxEntries',
904
+ withSandbox(async (sandbox) => {
905
+ if (!sandbox.walkFiles) return
906
+ await sandbox.writeFile('conformance-walk/a.txt', '1')
907
+ await sandbox.writeFile('conformance-walk/b.txt', '22')
908
+ await sandbox.writeFile('conformance-walk/c.txt', '333')
909
+ const dir = `${sandbox.rootDir}/conformance-walk`
910
+
911
+ const sizes = new Map<string, number>()
912
+ const walk = sandbox.walkFiles
913
+ for await (const entry of walk.call(sandbox, dir, { maxEntries: 10 })) {
914
+ sizes.set(entry.path, entry.size)
915
+ }
916
+ expect([...sizes.keys()].sort()).toEqual([`${dir}/a.txt`, `${dir}/b.txt`, `${dir}/c.txt`])
917
+ expect(sizes.get(`${dir}/c.txt`)).toBe(3)
918
+
919
+ // The bound is on what is EMITTED, so a walk asked for two
920
+ // entries yields two and stops — it does not yield three and
921
+ // leave the caller to discard one.
922
+ expect((await walkPaths(sandbox, dir, { maxEntries: 2 })).length).toBe(2)
923
+ }),
924
+ )
925
+
926
+ it(
927
+ 'bounds the descent with maxDepth, counting direct children as depth 1',
928
+ withSandbox(async (sandbox) => {
929
+ if (!sandbox.walkFiles) return
930
+ await sandbox.writeFile('conformance-depth/top.txt', 'top')
931
+ await sandbox.writeFile('conformance-depth/one/mid.txt', 'mid')
932
+ await sandbox.writeFile('conformance-depth/one/two/deep.txt', 'deep')
933
+ const dir = `${sandbox.rootDir}/conformance-depth`
934
+
935
+ expect(await walkPaths(sandbox, dir, { maxEntries: 50, maxDepth: 1 })).toEqual([
936
+ `${dir}/top.txt`,
937
+ ])
938
+ expect(await walkPaths(sandbox, dir, { maxEntries: 50, maxDepth: 2 })).toEqual([
939
+ `${dir}/one/mid.txt`,
940
+ `${dir}/top.txt`,
941
+ ])
942
+ }),
943
+ )
944
+
945
+ it(
946
+ 'keeps hidden names out of a wildcard unless includeHidden is set',
947
+ withSandbox(async (sandbox) => {
948
+ if (!sandbox.walkFiles) return
949
+ await sandbox.writeFile('conformance-hidden/visible.txt', 'v')
950
+ await sandbox.writeFile('conformance-hidden/.secret.txt', 'h')
951
+ const dir = `${sandbox.rootDir}/conformance-hidden`
952
+
953
+ expect(await walkPaths(sandbox, dir, { maxEntries: 50 })).toEqual([`${dir}/visible.txt`])
954
+ expect(await walkPaths(sandbox, dir, { maxEntries: 50, includeHidden: true })).toEqual([
955
+ `${dir}/.secret.txt`,
956
+ `${dir}/visible.txt`,
957
+ ])
958
+ }),
959
+ )
960
+
961
+ it(
962
+ 'reports a root that does not exist as an empty walk rather than failing',
963
+ withSandbox(async (sandbox) => {
964
+ if (!sandbox.walkFiles) return
965
+ const paths = await walkPaths(sandbox, `${sandbox.rootDir}/conformance-never-walked`, {
966
+ maxEntries: 10,
967
+ })
968
+ expect(paths.length).toBe(0)
969
+ }),
970
+ )
971
+
972
+ it(
973
+ 'does not follow symbolic links, to a file or to a directory',
974
+ withSandbox(async (sandbox) => {
975
+ if (!sandbox.walkFiles) return
976
+ await sandbox.writeFile('conformance-links/real/target.txt', 'target')
977
+ const dir = `${sandbox.rootDir}/conformance-links`
978
+ const linked = await sandbox.exec('/bin/sh', [
979
+ '-c',
980
+ `cd ${dir} && ln -s real/target.txt link-to-file.txt && ln -s real link-to-dir`,
981
+ ])
982
+ // Never a silent skip. A runtime discovery cannot travel
983
+ // through this suite's one skip channel — the case's own
984
+ // title, decided synchronously from the options — so a guest
985
+ // that cannot build the fixture says so out loud instead of
986
+ // passing a case that asserted nothing.
987
+ if (linked.exitCode !== 0) {
988
+ throw new Error(
989
+ `walkFiles conformance: this case builds its fixture with POSIX \`ln -s\`, and the guest's shell answered ${linked.exitCode}: ${linked.stderr.trim()}. A guest image that cannot make a symbolic link is not held to "symlinks are not followed" by asserting nothing — ship \`ln\`, or raise the gap so the suite grows a declared skip rather than a quiet one.`,
990
+ )
991
+ }
992
+
993
+ // The real file, once, under its real path. Following the
994
+ // directory link would have reported it again through
995
+ // `link-to-dir/target.txt`, and the file link again as itself.
996
+ expect(await walkPaths(sandbox, dir, { maxEntries: 50 })).toEqual([
997
+ `${dir}/real/target.txt`,
998
+ ])
999
+ }),
1000
+ )
1001
+
1002
+ it(
1003
+ 'raises ERR_FILE_WALK_LIMIT when the examined-entry budget runs out',
1004
+ withSandbox(async (sandbox) => {
1005
+ if (!sandbox.walkFiles) return
1006
+ for (let index = 0; index < 12; index += 1) {
1007
+ await sandbox.writeFile(`conformance-budget/f${index}.txt`, 'x')
1008
+ }
1009
+ // An incomplete search is an ERROR carrying a code, never a
1010
+ // short list: a caller handed six of twelve files with no
1011
+ // signal reads it as "that is all there is".
1012
+ let code: unknown
1013
+ try {
1014
+ await walkPaths(sandbox, `${sandbox.rootDir}/conformance-budget`, {
1015
+ maxEntries: 50,
1016
+ maxVisitedEntries: 4,
1017
+ })
1018
+ } catch (error) {
1019
+ code = (error as { code?: unknown }).code
1020
+ }
1021
+ expect(code).toBe('ERR_FILE_WALK_LIMIT')
1022
+ }),
1023
+ )
1024
+
1025
+ it(
1026
+ 'refuses a walk once the sandbox has been destroyed',
1027
+ withSandbox(async (sandbox) => {
1028
+ if (!sandbox.walkFiles) return
1029
+ const root = sandbox.rootDir
1030
+ await sandbox.destroy()
1031
+ // Consumed, not merely constructed: an async generator runs
1032
+ // nothing until its first `next()`, so a case that built the
1033
+ // iterator and stopped would pass against a sandbox that
1034
+ // admits the walk happily.
1035
+ await expectRejects(expect, () => walkPaths(sandbox, root, { maxEntries: 10 }))
1036
+ }),
1037
+ )
1038
+ })
1039
+
1040
+ /**
1041
+ * One sandbox, several commands at once.
1042
+ *
1043
+ * A supervisor and its sub-agents share a sandbox, so this is the
1044
+ * ordinary case rather than an exotic one — and nothing in this suite
1045
+ * used to exercise it. A backend that serialises executions behind one
1046
+ * connection, or that lets two commands' output frames land in each
1047
+ * other's result, passes every other case here.
1048
+ *
1049
+ * Overlap is proved by the commands themselves rather than by a clock:
1050
+ * each appends its own mark to a shared file, waits for every other
1051
+ * mark to appear, and then prints the whole file back. If the backend
1052
+ * really ran them together every command sees every mark; if it ran
1053
+ * them one at a time the first one waits out its ceiling and can only
1054
+ * ever see its own.
1055
+ */
1056
+ describe('concurrent exec', () => {
1057
+ it(
1058
+ 'runs several commands at once on one sandbox, with no cross-talk between their results',
1059
+ withSandbox(async (sandbox) => {
1060
+ const count = 4
1061
+ await sandbox.writeFile('conformance-concurrent/marks', '')
1062
+ const marks = `${sandbox.rootDir}/conformance-concurrent/marks`
1063
+
1064
+ // A rendezvous rather than a sleep: each command appends its
1065
+ // own mark and then WAITS for every other mark to appear
1066
+ // before it prints. A backend that really runs them together
1067
+ // settles as soon as the slowest one has started — no clock
1068
+ // to tune, and nothing that gets tighter on a machine or a
1069
+ // cluster where four dials cost more than a fixed pause. A
1070
+ // backend that serialises them has the first command wait
1071
+ // out the ceiling below and still print only its own mark,
1072
+ // which is what the assertions catch.
1073
+ const everyMark = Array.from({ length: count }, (_unused, index) => index).join(' ')
1074
+ const rendezvous = (index: number): string =>
1075
+ [
1076
+ `printf '[%s]' '${index}' >> "${marks}"`,
1077
+ 'waited=0',
1078
+ // 60 × 0.1 s. Only a backend that has already failed
1079
+ // the case ever reaches it.
1080
+ 'while [ "$waited" -lt 60 ]; do',
1081
+ ` all=$(cat "${marks}")`,
1082
+ ' seen=1',
1083
+ ` for mark in ${everyMark}; do`,
1084
+ ' case "$all" in *"[$mark]"*) ;; *) seen=0 ;; esac',
1085
+ ' done',
1086
+ ' [ "$seen" -eq 1 ] && break',
1087
+ ' waited=$((waited + 1))',
1088
+ ' sleep 0.1',
1089
+ 'done',
1090
+ `printf 'own:%s ' '${index}'`,
1091
+ `cat "${marks}"`,
1092
+ ].join('\n')
1093
+
1094
+ const running = Array.from({ length: count }, (_unused, index) =>
1095
+ sandbox.exec('/bin/sh', ['-c', rendezvous(index)]),
1096
+ )
1097
+ // Attached BEFORE the first assertion, and that ordering is
1098
+ // load-bearing: an assertion that threw with four commands
1099
+ // still in flight would leave four promises nobody is
1100
+ // handling, and a backend that then rejects one of them
1101
+ // takes the runner down with an unhandled rejection instead
1102
+ // of failing this case.
1103
+ const settled = Promise.allSettled(running)
1104
+ // Every one of them is in flight right now.
1105
+ expect(sandbox.status).toBe('busy')
1106
+ const results: SandboxExecResult[] = []
1107
+ for (const outcome of await settled) {
1108
+ if (outcome.status === 'rejected') {
1109
+ throw outcome.reason instanceof Error
1110
+ ? outcome.reason
1111
+ : new Error(String(outcome.reason))
1112
+ }
1113
+ results.push(outcome.value)
1114
+ }
1115
+
1116
+ for (let index = 0; index < count; index += 1) {
1117
+ const result = results[index]
1118
+ if (!result) throw new Error('unreachable: one result per started command')
1119
+ expect(result.exitCode).toBe(0)
1120
+ // Its OWN identity, on its own result: a backend that
1121
+ // mixed two commands' output frames fails here.
1122
+ expect(result.stdout).toMatch(new RegExp(`own:${index} `))
1123
+ // And every other command's mark, which only holds if
1124
+ // they were all admitted before any of them finished.
1125
+ for (let other = 0; other < count; other += 1) {
1126
+ expect(result.stdout).toMatch(new RegExp(`\\[${other}\\]`))
1127
+ }
1128
+ }
1129
+ expect(sandbox.status).toBe('ready')
1130
+ }),
1131
+ )
1132
+ })
1133
+
1134
+ /**
1135
+ * `SandboxExecOptions.timeout` and the `timedOut` flag it sets.
1136
+ *
1137
+ * `SandboxExecResult.timedOut` is REQUIRED on the contract, so every
1138
+ * backend answers it on every result — and until now nothing checked
1139
+ * that a backend ever sets it to `true`. A backend that accepts
1140
+ * `timeout` and ignores it reports a clean, unaborted-looking success
1141
+ * after however long the command felt like taking, which is the same
1142
+ * defect class the `AbortSignal` case above exists for and reads the
1143
+ * same way to a caller: a result that says the work is done.
1144
+ */
1145
+ describe('exec timeout', () => {
1146
+ it(
1147
+ 'reports a command that outran its timeout as timedOut, and really terminates it',
1148
+ withSandbox(async (sandbox) => {
1149
+ // The same shape as the abort case: the marker file is
1150
+ // written a moment after "ready", so it only ever exists if
1151
+ // the command was left running past its timeout. `trap ''
1152
+ // TERM` on both the shell and the background job makes a
1153
+ // polite TERM insufficient, exactly as a real runaway
1154
+ // command would.
1155
+ const marker = 'conformance-timeout-marker.txt'
1156
+ const started = Date.now()
1157
+ const result = await sandbox.exec(
1158
+ '/bin/sh',
1159
+ [
1160
+ '-c',
1161
+ `trap '' TERM; (trap '' TERM; sleep 1.5; printf late > ${marker}) & echo ready; wait`,
1162
+ ],
1163
+ { timeout: 400 },
1164
+ )
1165
+ const elapsed = Date.now() - started
1166
+
1167
+ expect(result.timedOut).toBe(true)
1168
+ // Settled on the timeout rather than on the command
1169
+ // finishing: the script itself waits 1.5 s, and a generous
1170
+ // ceiling still separates the two outcomes.
1171
+ expect(elapsed < 10_000).toBe(true)
1172
+ // Long enough that a command left running would have written.
1173
+ await sleep(1_800)
1174
+ await expectRejects(expect, () => sandbox.readFile(marker))
1175
+ // And the sandbox is still usable: a timeout is one
1176
+ // command's outcome, not the handle's.
1177
+ expect(sandbox.status).toBe('ready')
1178
+ const after = await sandbox.exec('/bin/sh', ['-c', 'echo conformance-after-timeout'])
1179
+ expect(after.exitCode).toBe(0)
1180
+ expect(after.stdout).toMatch(/conformance-after-timeout/)
1181
+ }),
1182
+ )
1183
+
1184
+ it(
1185
+ 'leaves timedOut false for a command that finishes inside its timeout',
1186
+ withSandbox(async (sandbox) => {
1187
+ const result = await sandbox.exec('/bin/sh', ['-c', 'echo conformance-quick'], {
1188
+ timeout: 30_000,
1189
+ })
1190
+ expect(result.timedOut).toBe(false)
1191
+ expect(result.exitCode).toBe(0)
1192
+ expect(result.stdout).toMatch(/conformance-quick/)
1193
+ }),
1194
+ )
1195
+ })
1196
+ })
1197
+ }
1198
+
1199
+ // Re-exported so a caller building a recording harness (as this suite's own
1200
+ // negative test does) can type it without reaching into `@namzu/sdk/testing`
1201
+ // a second time.
1202
+ export type { ConformanceAssertion, ConformanceDescribe, ConformanceExpect, ConformanceIt }