@namzu/sandbox 13.0.0 → 14.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 (67) hide show
  1. package/CHANGELOG.md +309 -0
  2. package/README.md +151 -0
  3. package/dist/backends/firecracker/protocol.d.ts +22 -0
  4. package/dist/backends/firecracker/protocol.d.ts.map +1 -1
  5. package/dist/backends/firecracker/protocol.js.map +1 -1
  6. package/dist/backends/firecracker/transport.d.ts +104 -9
  7. package/dist/backends/firecracker/transport.d.ts.map +1 -1
  8. package/dist/backends/firecracker/transport.js +139 -13
  9. package/dist/backends/firecracker/transport.js.map +1 -1
  10. package/dist/backends/kubernetes/egress-policy.d.ts +219 -0
  11. package/dist/backends/kubernetes/egress-policy.d.ts.map +1 -0
  12. package/dist/backends/kubernetes/egress-policy.js +314 -0
  13. package/dist/backends/kubernetes/egress-policy.js.map +1 -0
  14. package/dist/backends/kubernetes/index.d.ts +374 -0
  15. package/dist/backends/kubernetes/index.d.ts.map +1 -0
  16. package/dist/backends/kubernetes/index.js +671 -0
  17. package/dist/backends/kubernetes/index.js.map +1 -0
  18. package/dist/backends/kubernetes/k8s-client.d.ts +125 -0
  19. package/dist/backends/kubernetes/k8s-client.d.ts.map +1 -0
  20. package/dist/backends/kubernetes/k8s-client.js +246 -0
  21. package/dist/backends/kubernetes/k8s-client.js.map +1 -0
  22. package/dist/backends/kubernetes/lease.d.ts +119 -0
  23. package/dist/backends/kubernetes/lease.d.ts.map +1 -0
  24. package/dist/backends/kubernetes/lease.js +151 -0
  25. package/dist/backends/kubernetes/lease.js.map +1 -0
  26. package/dist/backends/kubernetes/objects.d.ts +282 -0
  27. package/dist/backends/kubernetes/objects.d.ts.map +1 -0
  28. package/dist/backends/kubernetes/objects.js +156 -0
  29. package/dist/backends/kubernetes/objects.js.map +1 -0
  30. package/dist/backends/kubernetes/privilege-probe.d.ts +136 -0
  31. package/dist/backends/kubernetes/privilege-probe.d.ts.map +1 -0
  32. package/dist/backends/kubernetes/privilege-probe.js +185 -0
  33. package/dist/backends/kubernetes/privilege-probe.js.map +1 -0
  34. package/dist/backends/kubernetes/sandbox.d.ts +123 -0
  35. package/dist/backends/kubernetes/sandbox.d.ts.map +1 -0
  36. package/dist/backends/kubernetes/sandbox.js +299 -0
  37. package/dist/backends/kubernetes/sandbox.js.map +1 -0
  38. package/dist/backends/kubernetes/transport.d.ts +122 -0
  39. package/dist/backends/kubernetes/transport.d.ts.map +1 -0
  40. package/dist/backends/kubernetes/transport.js +197 -0
  41. package/dist/backends/kubernetes/transport.js.map +1 -0
  42. package/dist/backends/kubernetes/workspace.d.ts +381 -0
  43. package/dist/backends/kubernetes/workspace.d.ts.map +1 -0
  44. package/dist/backends/kubernetes/workspace.js +1064 -0
  45. package/dist/backends/kubernetes/workspace.js.map +1 -0
  46. package/dist/index.d.ts +132 -2
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +102 -34
  49. package/dist/index.js.map +1 -1
  50. package/dist/testing/sandbox-conformance.d.ts +193 -0
  51. package/dist/testing/sandbox-conformance.d.ts.map +1 -0
  52. package/dist/testing/sandbox-conformance.js +465 -0
  53. package/dist/testing/sandbox-conformance.js.map +1 -0
  54. package/package.json +5 -4
  55. package/src/backends/firecracker/protocol.ts +27 -0
  56. package/src/backends/firecracker/transport.ts +199 -28
  57. package/src/backends/kubernetes/egress-policy.ts +437 -0
  58. package/src/backends/kubernetes/index.ts +1012 -0
  59. package/src/backends/kubernetes/k8s-client.ts +352 -0
  60. package/src/backends/kubernetes/lease.ts +198 -0
  61. package/src/backends/kubernetes/objects.ts +363 -0
  62. package/src/backends/kubernetes/privilege-probe.ts +261 -0
  63. package/src/backends/kubernetes/sandbox.ts +395 -0
  64. package/src/backends/kubernetes/transport.ts +286 -0
  65. package/src/backends/kubernetes/workspace.ts +1386 -0
  66. package/src/index.ts +257 -35
  67. package/src/testing/sandbox-conformance.ts +667 -0
@@ -0,0 +1,667 @@
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` and `openTcpConnection` are
32
+ * OPTIONAL on {@link Sandbox} by the SDK's own contract — a backend that
33
+ * cannot honour one must omit it rather than accept and ignore it — so a
34
+ * factory whose sandbox omits either capability 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 type {
65
+ OpenTerminalOptions,
66
+ Sandbox,
67
+ SandboxTcpConnectOptions,
68
+ TerminalSession,
69
+ } from '@namzu/sdk'
70
+ import type {
71
+ ConformanceAssertion,
72
+ ConformanceDescribe,
73
+ ConformanceExpect,
74
+ ConformanceIt,
75
+ } from '@namzu/sdk/testing'
76
+
77
+ /**
78
+ * The contract revision these assertions express. Carried on the describe
79
+ * label so a failure is legible on sight as "the sandbox contract", the
80
+ * same convention `PROVIDER_DRIVER_CONTRACT_VERSION` uses — raised only
81
+ * when a case is ADDED or TIGHTENED, never on a rewording.
82
+ */
83
+ export const SANDBOX_CONTRACT_VERSION = 1
84
+
85
+ /** A sandbox to test, plus whatever teardown building it required. */
86
+ export interface SandboxConformanceHandle {
87
+ readonly sandbox: Sandbox
88
+ /**
89
+ * Called after each case, pass or fail — closes fixture servers, restores
90
+ * environment variables, removes temp directories. Distinct from
91
+ * `sandbox.destroy()`, which the suite calls itself (idempotently) as
92
+ * part of every case's teardown; `dispose` is for what `makeSandbox`
93
+ * itself stood up, not for the sandbox's own lifecycle.
94
+ */
95
+ dispose?(): void | Promise<void>
96
+ }
97
+
98
+ /**
99
+ * Build one fresh {@link Sandbox}. Called once per case, so no case can be
100
+ * affected by another's writes, aborts or destroys — the suite never
101
+ * assumes a shared instance and never reuses one across cases.
102
+ */
103
+ export type MakeSandbox = () => SandboxConformanceHandle | Promise<SandboxConformanceHandle>
104
+
105
+ export interface SandboxConformanceOptions {
106
+ readonly describe: ConformanceDescribe
107
+ readonly it: ConformanceIt
108
+ readonly expect: ConformanceExpect
109
+ readonly makeSandbox: MakeSandbox
110
+ /** Names the backend in test output. Defaults to `sandbox`. */
111
+ readonly label?: string
112
+ /**
113
+ * Whether this backend's guest can run the `openTcpConnection` positive
114
+ * case's listener at all — by default {@link nodeGuestListener}, `node
115
+ * -e`. Defaults to `true`: every backend this suite ships against runs
116
+ * `agent/agent.cjs` in the guest, and that agent IS node, so node on the
117
+ * guest's own `PATH` is a precondition of the agent existing rather than
118
+ * an extra capability this suite demands.
119
+ *
120
+ * Set `false` for a guest that cannot run a listener this way at all
121
+ * (no `openTerminal`, or an image with neither node nor a substitute) —
122
+ * the case then SKIPS, its own title stating why, rather than failing a
123
+ * backend for a capability its contract never promised. A backend that
124
+ * can run *some* listener, just not node, keeps this `true` (or omits
125
+ * it) and supplies {@link SandboxConformanceOptions.guestListenerCommand}
126
+ * instead.
127
+ */
128
+ readonly guestCanRunNode?: boolean
129
+ /**
130
+ * Overrides the program the `openTcpConnection` positive case starts
131
+ * inside the guest. Defaults to {@link nodeGuestListener}. A guest
132
+ * without node but with, say, busybox `nc` can supply its own command as
133
+ * long as it reports the bound port the way
134
+ * {@link GuestListenerCommand.parsePort} expects.
135
+ */
136
+ readonly guestListenerCommand?: () => GuestListenerCommand
137
+ }
138
+
139
+ /** Assert `call()` rejects. The contract cares that admission was refused, never the message. */
140
+ async function expectRejects(
141
+ expect: ConformanceExpect,
142
+ call: () => Promise<unknown>,
143
+ ): Promise<void> {
144
+ let rejected = false
145
+ try {
146
+ await call()
147
+ } catch {
148
+ rejected = true
149
+ }
150
+ expect(rejected).toBe(true)
151
+ }
152
+
153
+ /** Assert `call()` resolves — the inverse check, for the rare case a rejection is the defect. */
154
+ async function expectResolves(
155
+ expect: ConformanceExpect,
156
+ call: () => Promise<unknown>,
157
+ ): Promise<void> {
158
+ let threw: unknown
159
+ try {
160
+ await call()
161
+ } catch (error) {
162
+ threw = error
163
+ }
164
+ expect(threw === undefined).toBe(true)
165
+ }
166
+
167
+ function sleep(ms: number): Promise<void> {
168
+ return new Promise((resolve) => setTimeout(resolve, ms))
169
+ }
170
+
171
+ /**
172
+ * A program the `openTcpConnection` positive case can start INSIDE a guest
173
+ * through {@link Sandbox.openTerminal}, and dial back into over
174
+ * `openTcpConnection` itself.
175
+ *
176
+ * Starting the listener in the guest — rather than in the orchestrator/test
177
+ * process, which is what this case used to do — is the whole point: a
178
+ * listener on the HOST'S loopback only ever proves anything for a backend
179
+ * whose "guest" happens to share that loopback (a Firecracker fixture over a
180
+ * local socket, a fake-agent-in-this-process kubernetes test). It never
181
+ * proves anything for a real remote guest, which cannot dial the
182
+ * orchestrator's loopback at all — that gap is exactly what let the case
183
+ * pass in every colocated fixture and fail the one time it ran against a
184
+ * live cluster.
185
+ */
186
+ export interface GuestListenerCommand {
187
+ /** The program `openTerminal` runs as the session's top-level process. */
188
+ readonly command: string
189
+ readonly args: readonly string[]
190
+ /**
191
+ * Reads the port the listener bound out of everything it has printed to
192
+ * its terminal so far. Returns `undefined` until the listener has
193
+ * reported one — the suite polls this as output arrives rather than
194
+ * parsing a single chunk, because a pty may deliver the report split
195
+ * across reads.
196
+ */
197
+ parsePort(output: string): number | undefined
198
+ }
199
+
200
+ /** What {@link nodeGuestListener} has its script print once it is bound. */
201
+ const NODE_LISTENER_MARKER = 'namzu-conformance-listening:'
202
+
203
+ /**
204
+ * The default {@link GuestListenerCommand}: `node -e` binding an ephemeral
205
+ * port on the GUEST's own loopback, echoing `conformance-reply:<payload>`
206
+ * back for the first chunk of the one connection it accepts, then reporting
207
+ * the bound port on its own stdout — the only way the host, which cannot
208
+ * inspect a real remote guest's open ports any other way, learns which port
209
+ * to dial.
210
+ *
211
+ * Every backend this suite ships against runs `agent/agent.cjs` in the
212
+ * guest, which is itself node — so node on the guest's `PATH` is not an
213
+ * extra requirement this suite invents, it is a precondition of the agent
214
+ * existing at all. A backend whose guest genuinely cannot run node (or
215
+ * cannot run `openTerminal`) declares that through
216
+ * {@link SandboxConformanceOptions.guestCanRunNode} or supplies its own
217
+ * command via {@link SandboxConformanceOptions.guestListenerCommand}.
218
+ */
219
+ export function nodeGuestListener(): GuestListenerCommand {
220
+ const script = [
221
+ "const net = require('node:net');",
222
+ 'const server = net.createServer((socket) => {',
223
+ " socket.once('data', (chunk) => {",
224
+ " socket.end(Buffer.concat([Buffer.from('conformance-reply:'), chunk]));",
225
+ ' });',
226
+ '});',
227
+ "server.listen(0, '127.0.0.1', () => {",
228
+ ` process.stdout.write(${JSON.stringify(NODE_LISTENER_MARKER)} + server.address().port + '\\n');`,
229
+ '});',
230
+ ].join('\n')
231
+ return {
232
+ command: 'node',
233
+ args: ['-e', script],
234
+ parsePort(output) {
235
+ const marker = output.indexOf(NODE_LISTENER_MARKER)
236
+ if (marker === -1) return undefined
237
+ const match = /\d+/.exec(output.slice(marker + NODE_LISTENER_MARKER.length))
238
+ return match ? Number(match[0]) : undefined
239
+ },
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Start `listener` through `openTerminal`, and resolve once it has reported
245
+ * the port it bound.
246
+ *
247
+ * The returned `stop()` kills the terminal's owned process tree — the exact
248
+ * ownership guarantee the `openTerminal` section above already proves
249
+ * `destroy()` gets for free, used here to tear the listener down without
250
+ * waiting for the whole sandbox to go away.
251
+ *
252
+ * Takes `openTerminal` as a plain function rather than a `Sandbox`, so a
253
+ * unit test can exercise the port-parsing and exit-races above without a
254
+ * `Sandbox` fixture — see `__tests__/guest-listener.test.ts`.
255
+ */
256
+ export async function startGuestListener(
257
+ openTerminal: (options: OpenTerminalOptions) => Promise<TerminalSession>,
258
+ listener: GuestListenerCommand,
259
+ ): Promise<{ readonly port: number; stop(): Promise<void> }> {
260
+ const terminal = await openTerminal({
261
+ command: listener.command,
262
+ args: listener.args,
263
+ size: { cols: 80, rows: 24 },
264
+ })
265
+
266
+ let output = ''
267
+ const port = await new Promise<number>((resolve, reject) => {
268
+ const unsubscribe = terminal.onData((chunk) => {
269
+ output += chunk
270
+ const found = listener.parsePort(output)
271
+ if (found !== undefined) {
272
+ unsubscribe()
273
+ resolve(found)
274
+ }
275
+ })
276
+ void terminal.exited.then((result) => {
277
+ // A settled promise ignores a later resolve/reject, so this is a
278
+ // no-op on the path where the port was already found and `stop()`
279
+ // is what causes this exit — it only fires the rejection when the
280
+ // listener died before ever reporting a port.
281
+ if (listener.parsePort(output) === undefined) {
282
+ unsubscribe()
283
+ reject(
284
+ new Error(
285
+ `guest listener exited before reporting a port (exit code ${result.exitCode}): ${
286
+ output || '<no output>'
287
+ }`,
288
+ ),
289
+ )
290
+ }
291
+ })
292
+ })
293
+
294
+ return {
295
+ port,
296
+ async stop() {
297
+ terminal.kill()
298
+ await terminal.exited.catch(() => {})
299
+ },
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Register the {@link Sandbox} contract against one backend.
305
+ *
306
+ * Call it once per backend. It registers cases through the supplied
307
+ * `describe`/`it`; it does not run them.
308
+ */
309
+ export function defineSandboxConformance(options: SandboxConformanceOptions): void {
310
+ const { describe, it, expect, makeSandbox } = options
311
+ const label = options.label ?? 'sandbox'
312
+ const guestCanRunNode = options.guestCanRunNode ?? true
313
+ const guestListenerCommand = options.guestListenerCommand ?? nodeGuestListener
314
+
315
+ /**
316
+ * Run `body` against a sandbox built for this case alone.
317
+ *
318
+ * Destroys the sandbox itself (idempotent, so a body that already
319
+ * destroyed it costs nothing extra) before calling `dispose`, so a
320
+ * case that forgets to release a pod/microVM does not leak one — the
321
+ * same reasoning `withStore`'s `finally` states for a leaked temp
322
+ * directory: a suite that is expensive to run red is a suite people
323
+ * stop running.
324
+ */
325
+ const withSandbox = (body: (sandbox: Sandbox) => Promise<void>) => async () => {
326
+ const handle = await makeSandbox()
327
+ try {
328
+ await body(handle.sandbox)
329
+ } finally {
330
+ await handle.sandbox.destroy().catch(() => {})
331
+ await handle.dispose?.()
332
+ }
333
+ }
334
+
335
+ describe(`${label} — sandbox contract v${SANDBOX_CONTRACT_VERSION}`, () => {
336
+ describe('exec', () => {
337
+ it(
338
+ 'reports the exit code and streams stdout/stderr as the command runs',
339
+ withSandbox(async (sandbox) => {
340
+ const chunks: { stream: string; data: string }[] = []
341
+ const result = await sandbox.exec(
342
+ '/bin/sh',
343
+ ['-c', 'echo conformance-out; echo conformance-err 1>&2; exit 7'],
344
+ { onOutput: (chunk) => chunks.push({ ...chunk }) },
345
+ )
346
+
347
+ expect(result.exitCode).toBe(7)
348
+ expect(result.timedOut).toBe(false)
349
+ expect(result.stdout).toMatch(/conformance-out/)
350
+ expect(result.stderr).toMatch(/conformance-err/)
351
+ // Streamed, not just present in the final string: a backend
352
+ // that buffers everything until exit and calls `onOutput`
353
+ // once at the end would satisfy the two checks above and
354
+ // fail this one.
355
+ expect(
356
+ chunks.some((c) => c.stream === 'stdout' && c.data.includes('conformance-out')),
357
+ ).toBe(true)
358
+ expect(
359
+ chunks.some((c) => c.stream === 'stderr' && c.data.includes('conformance-err')),
360
+ ).toBe(true)
361
+ }),
362
+ )
363
+
364
+ it(
365
+ 'reports busy while a command is in flight and ready once it settles',
366
+ withSandbox(async (sandbox) => {
367
+ let observedBusy = false
368
+ await sandbox.exec('/bin/sh', ['-c', 'echo started; sleep 0.2'], {
369
+ onOutput: (chunk) => {
370
+ if (chunk.data.includes('started')) observedBusy = sandbox.status === 'busy'
371
+ },
372
+ })
373
+ expect(observedBusy).toBe(true)
374
+ expect(sandbox.status).toBe('ready')
375
+ }),
376
+ )
377
+
378
+ it(
379
+ 'honours an AbortSignal: the process is really terminated, never a partial success',
380
+ withSandbox(async (sandbox) => {
381
+ // The contract (`SandboxExecOptions.signal`'s own doc comment):
382
+ // a backend that accepts the signal must terminate the process
383
+ // it owns, or prove admission never happened; it must never
384
+ // silently ignore the signal and let the command run to
385
+ // completion while reporting as though it had been cancelled.
386
+ // The command below writes a marker file a moment after
387
+ // printing "ready" — if the process is genuinely killed on
388
+ // abort, that write never happens. That is the decisive
389
+ // check; whatever the settled promise looks like is a second,
390
+ // weaker one.
391
+ const marker = 'conformance-abort-marker.txt'
392
+ const caller = new AbortController()
393
+ let signalReady: (() => void) | undefined
394
+ const ready = new Promise<void>((resolve) => {
395
+ signalReady = resolve
396
+ })
397
+
398
+ const running = sandbox.exec(
399
+ '/bin/sh',
400
+ [
401
+ '-c',
402
+ `trap '' TERM; (trap '' TERM; sleep 0.4; printf late > ${marker}) & echo ready; wait`,
403
+ ],
404
+ {
405
+ signal: caller.signal,
406
+ onOutput: (chunk) => {
407
+ if (chunk.stream === 'stdout' && chunk.data.includes('ready')) signalReady?.()
408
+ },
409
+ },
410
+ )
411
+ await ready
412
+ caller.abort(new Error('conformance suite cancelled this command'))
413
+
414
+ // Resolve OR reject are both compliant — a backend that cannot
415
+ // confirm the kill may refuse instead of reporting a result it
416
+ // is not sure of. What is never compliant is reporting a clean,
417
+ // unaborted-looking success.
418
+ let settled: { exitCode: number; signal?: string } | undefined
419
+ try {
420
+ settled = await running
421
+ } catch {
422
+ settled = undefined
423
+ }
424
+ if (settled !== undefined) {
425
+ expect(settled.exitCode === 0 && settled.signal === undefined).toBe(false)
426
+ }
427
+
428
+ // Long enough that an un-killed process would have finished its
429
+ // sleep and written the file.
430
+ await sleep(900)
431
+ await expectRejects(expect, () => sandbox.readFile(marker))
432
+ }),
433
+ )
434
+ })
435
+
436
+ describe('file IO', () => {
437
+ it(
438
+ 'round-trips a UTF-8 string through writeFile/readFile',
439
+ withSandbox(async (sandbox) => {
440
+ await sandbox.writeFile('conformance-notes.txt', 'héllo wörld')
441
+ const read = await sandbox.readFile('conformance-notes.txt')
442
+ expect(read.toString('utf8')).toBe('héllo wörld')
443
+ }),
444
+ )
445
+
446
+ it(
447
+ 'round-trips arbitrary binary content byte for byte',
448
+ withSandbox(async (sandbox) => {
449
+ const payload = Buffer.from([0x00, 0xff, 0x10, 0x00, 0x42, 0xfe, 0x7f, 0x80, 0x01])
450
+ await sandbox.writeFile('nested/conformance/blob.bin', payload)
451
+ const read = await sandbox.readFile('nested/conformance/blob.bin')
452
+ // Compared as base64 rather than through a deep-equality
453
+ // matcher: the four matchers this suite is allowed to assume
454
+ // (`toBe`/`toEqual`/`toBeGreaterThan`/`toMatch`) do not
455
+ // guarantee byte-exact `Buffer` comparison across every
456
+ // runner a caller might wire in, and a corrupted byte belongs
457
+ // in the string this failure prints.
458
+ expect(read.toString('base64')).toBe(payload.toString('base64'))
459
+ }),
460
+ )
461
+ })
462
+
463
+ describe('listFiles', () => {
464
+ it(
465
+ 'lists written files as absolute paths with their sizes',
466
+ withSandbox(async (sandbox) => {
467
+ const contentA = '123456789'
468
+ const contentB = '42 bytes worth of fixed content!!'
469
+ await sandbox.writeFile('conformance-list/a.txt', contentA)
470
+ await sandbox.writeFile('conformance-list/b.txt', contentB)
471
+ const dir = `${sandbox.rootDir}/conformance-list`
472
+ const files = await sandbox.listFiles(dir)
473
+ const byPath = new Map(files.map((f) => [f.path, f.size]))
474
+ expect(byPath.get(`${dir}/a.txt`)).toBe(Buffer.byteLength(contentA))
475
+ expect(byPath.get(`${dir}/b.txt`)).toBe(Buffer.byteLength(contentB))
476
+ }),
477
+ )
478
+
479
+ it(
480
+ 'reports a root that does not exist as empty rather than failing',
481
+ withSandbox(async (sandbox) => {
482
+ const files = await sandbox.listFiles(`${sandbox.rootDir}/conformance-never-created`)
483
+ expect(files.length).toBe(0)
484
+ }),
485
+ )
486
+ })
487
+
488
+ /**
489
+ * Optional on {@link Sandbox} by the SDK's own contract: a backend that
490
+ * cannot provide a real pseudo-terminal must OMIT the method rather
491
+ * than hand back a pipe masquerading as one. So a factory whose
492
+ * sandbox has no `openTerminal` is not in violation of anything — the
493
+ * case below passes vacuously for it, which is the documented
494
+ * skip-if-unavailable this suite promises rather than a silent hole:
495
+ * both shipped backends (kubernetes, firecracker) DO implement it, so
496
+ * in CI this case only ever runs vacuously against a fixture that
497
+ * deliberately declines the capability.
498
+ */
499
+ describe('openTerminal', () => {
500
+ it(
501
+ 'is owned by the sandbox: destroy() kills and awaits every terminal it returned',
502
+ withSandbox(async (sandbox) => {
503
+ if (!sandbox.openTerminal) return
504
+
505
+ const terminal = await sandbox.openTerminal({
506
+ command: '/bin/sh',
507
+ args: ['-c', 'sleep 30'],
508
+ size: { cols: 80, rows: 24 },
509
+ })
510
+
511
+ let exited = false
512
+ void terminal.exited.then(() => {
513
+ exited = true
514
+ })
515
+
516
+ await sandbox.destroy()
517
+ // Nothing awaited in between: awaiting `terminal.exited` here
518
+ // would rescue a `destroy()` that only fired the kill and
519
+ // returned without waiting for it, which is exactly the
520
+ // defect this case exists to catch.
521
+ expect(exited).toBe(true)
522
+ await terminal.exited
523
+ }),
524
+ )
525
+ })
526
+
527
+ /**
528
+ * Same optionality and the same documented skip as `openTerminal`,
529
+ * above: a factory whose sandbox has no `openTcpConnection` passes
530
+ * vacuously. The positive case below adds a second, independent skip
531
+ * axis on top of that — see `guestCanRunNode` and
532
+ * `guestListenerCommand` on {@link SandboxConformanceOptions} — because
533
+ * proving the forward really crosses into a REMOTE guest needs a
534
+ * listener running there, and not every guest can start one the same
535
+ * way.
536
+ */
537
+ describe('openTcpConnection', () => {
538
+ // The reason for a title, rather than a console message, printing
539
+ // the skip: `ConformanceIt` promises only `(name, body) => unknown`
540
+ // (`contract-suite.mjs`'s own flat recorder has no skip concept
541
+ // either), so the one channel a skip can travel through every
542
+ // runner this suite is ever handed is the case's own name — decided
543
+ // once, here, from options given synchronously to
544
+ // `defineSandboxConformance`, not from anything discovered at run
545
+ // time.
546
+ const positiveCaseTitle = guestCanRunNode
547
+ ? 'forwards a bidirectional stream to a service started inside the guest'
548
+ : 'forwards a bidirectional stream to a service started inside the guest (skipped: guestCanRunNode is false)'
549
+
550
+ it(
551
+ positiveCaseTitle,
552
+ withSandbox(async (sandbox) => {
553
+ if (!sandbox.openTcpConnection) return
554
+ if (!guestCanRunNode) return
555
+ const openTerminal = sandbox.openTerminal
556
+ if (!openTerminal) {
557
+ // A backend offering `openTcpConnection` without
558
+ // `openTerminal` has no portable way for this suite to
559
+ // start a guest-side listener — declare the skip
560
+ // explicitly (`guestCanRunNode: false`) rather than
561
+ // leaving the default to discover it here as a failure.
562
+ throw new Error(
563
+ 'openTcpConnection conformance: starting a guest-side listener needs openTerminal, ' +
564
+ 'which this sandbox does not implement. Pass guestCanRunNode: false to ' +
565
+ 'defineSandboxConformance to skip this case with a stated reason, or supply ' +
566
+ 'guestListenerCommand for a guest that can run a listener some other way.',
567
+ )
568
+ }
569
+
570
+ // Called through `.call(sandbox, …)` rather than passed as
571
+ // a bare reference: `openTerminal` may be an ordinary
572
+ // method relying on `this` (a class-based fixture, for
573
+ // instance), and detaching it from `sandbox` would drop
574
+ // that binding.
575
+ const listener = await startGuestListener(
576
+ (terminalOptions) => openTerminal.call(sandbox, terminalOptions),
577
+ guestListenerCommand(),
578
+ )
579
+ try {
580
+ const connection = await sandbox.openTcpConnection({ port: listener.port })
581
+ let received = ''
582
+ const unsubscribe = connection.onData((chunk) => {
583
+ received += Buffer.from(chunk).toString('utf8')
584
+ })
585
+ connection.write('conformance-hello')
586
+ await connection.closed
587
+ expect(received).toBe('conformance-reply:conformance-hello')
588
+ unsubscribe()
589
+ } finally {
590
+ await listener.stop()
591
+ }
592
+ }),
593
+ )
594
+
595
+ it(
596
+ 'refuses a non-loopback host',
597
+ withSandbox(async (sandbox) => {
598
+ if (!sandbox.openTcpConnection) return
599
+
600
+ // `SandboxTcpConnectOptions.host` types as loopback-only; the
601
+ // cast is deliberate — this proves the refusal is enforced at
602
+ // RUNTIME, not merely by the type checker a compliant caller
603
+ // could route around with the same cast.
604
+ const nonLoopback = {
605
+ port: 9,
606
+ host: '203.0.113.10',
607
+ } as unknown as SandboxTcpConnectOptions
608
+ await expectRejects(
609
+ expect,
610
+ () =>
611
+ sandbox.openTcpConnection?.(nonLoopback) ?? Promise.reject(new Error('unreachable')),
612
+ )
613
+ }),
614
+ )
615
+ })
616
+
617
+ describe('destroy', () => {
618
+ it(
619
+ 'is idempotent, however many times or however concurrently it is called',
620
+ withSandbox(async (sandbox) => {
621
+ await Promise.all([sandbox.destroy(), sandbox.destroy()])
622
+ expect(sandbox.status).toBe('destroyed')
623
+ await expectResolves(expect, () => sandbox.destroy())
624
+ expect(sandbox.status).toBe('destroyed')
625
+ }),
626
+ )
627
+
628
+ it(
629
+ 'refuses every call once destroyed, rather than admitting one',
630
+ withSandbox(async (sandbox) => {
631
+ await sandbox.destroy()
632
+
633
+ await expectRejects(expect, () => sandbox.exec('/bin/sh', ['-c', 'true']))
634
+ await expectRejects(expect, () => sandbox.writeFile('x.txt', 'x'))
635
+ await expectRejects(expect, () => sandbox.readFile('x.txt'))
636
+ await expectRejects(expect, () => sandbox.listFiles(sandbox.rootDir))
637
+ // `?.()` rather than an `if` guard around the assertion: it is
638
+ // type-correct whether or not the capability exists, and when
639
+ // it does not exist there is nothing to refuse — the
640
+ // documented skip-if-unavailable this suite promises for
641
+ // every optional capability.
642
+ if (sandbox.openTerminal) {
643
+ await expectRejects(
644
+ expect,
645
+ () =>
646
+ sandbox.openTerminal?.({ size: { cols: 80, rows: 24 } }) ??
647
+ Promise.reject(new Error('unreachable')),
648
+ )
649
+ }
650
+ if (sandbox.openTcpConnection) {
651
+ await expectRejects(
652
+ expect,
653
+ () =>
654
+ sandbox.openTcpConnection?.({ port: 9 }) ??
655
+ Promise.reject(new Error('unreachable')),
656
+ )
657
+ }
658
+ }),
659
+ )
660
+ })
661
+ })
662
+ }
663
+
664
+ // Re-exported so a caller building a recording harness (as this suite's own
665
+ // negative test does) can type it without reaching into `@namzu/sdk/testing`
666
+ // a second time.
667
+ export type { ConformanceAssertion, ConformanceDescribe, ConformanceExpect, ConformanceIt }