@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,193 @@
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
+ import type { OpenTerminalOptions, Sandbox, TerminalSession } from '@namzu/sdk';
64
+ import type { ConformanceAssertion, ConformanceDescribe, ConformanceExpect, ConformanceIt } from '@namzu/sdk/testing';
65
+ /**
66
+ * The contract revision these assertions express. Carried on the describe
67
+ * label so a failure is legible on sight as "the sandbox contract", the
68
+ * same convention `PROVIDER_DRIVER_CONTRACT_VERSION` uses — raised only
69
+ * when a case is ADDED or TIGHTENED, never on a rewording.
70
+ */
71
+ export declare const SANDBOX_CONTRACT_VERSION = 1;
72
+ /** A sandbox to test, plus whatever teardown building it required. */
73
+ export interface SandboxConformanceHandle {
74
+ readonly sandbox: Sandbox;
75
+ /**
76
+ * Called after each case, pass or fail — closes fixture servers, restores
77
+ * environment variables, removes temp directories. Distinct from
78
+ * `sandbox.destroy()`, which the suite calls itself (idempotently) as
79
+ * part of every case's teardown; `dispose` is for what `makeSandbox`
80
+ * itself stood up, not for the sandbox's own lifecycle.
81
+ */
82
+ dispose?(): void | Promise<void>;
83
+ }
84
+ /**
85
+ * Build one fresh {@link Sandbox}. Called once per case, so no case can be
86
+ * affected by another's writes, aborts or destroys — the suite never
87
+ * assumes a shared instance and never reuses one across cases.
88
+ */
89
+ export type MakeSandbox = () => SandboxConformanceHandle | Promise<SandboxConformanceHandle>;
90
+ export interface SandboxConformanceOptions {
91
+ readonly describe: ConformanceDescribe;
92
+ readonly it: ConformanceIt;
93
+ readonly expect: ConformanceExpect;
94
+ readonly makeSandbox: MakeSandbox;
95
+ /** Names the backend in test output. Defaults to `sandbox`. */
96
+ readonly label?: string;
97
+ /**
98
+ * Whether this backend's guest can run the `openTcpConnection` positive
99
+ * case's listener at all — by default {@link nodeGuestListener}, `node
100
+ * -e`. Defaults to `true`: every backend this suite ships against runs
101
+ * `agent/agent.cjs` in the guest, and that agent IS node, so node on the
102
+ * guest's own `PATH` is a precondition of the agent existing rather than
103
+ * an extra capability this suite demands.
104
+ *
105
+ * Set `false` for a guest that cannot run a listener this way at all
106
+ * (no `openTerminal`, or an image with neither node nor a substitute) —
107
+ * the case then SKIPS, its own title stating why, rather than failing a
108
+ * backend for a capability its contract never promised. A backend that
109
+ * can run *some* listener, just not node, keeps this `true` (or omits
110
+ * it) and supplies {@link SandboxConformanceOptions.guestListenerCommand}
111
+ * instead.
112
+ */
113
+ readonly guestCanRunNode?: boolean;
114
+ /**
115
+ * Overrides the program the `openTcpConnection` positive case starts
116
+ * inside the guest. Defaults to {@link nodeGuestListener}. A guest
117
+ * without node but with, say, busybox `nc` can supply its own command as
118
+ * long as it reports the bound port the way
119
+ * {@link GuestListenerCommand.parsePort} expects.
120
+ */
121
+ readonly guestListenerCommand?: () => GuestListenerCommand;
122
+ }
123
+ /**
124
+ * A program the `openTcpConnection` positive case can start INSIDE a guest
125
+ * through {@link Sandbox.openTerminal}, and dial back into over
126
+ * `openTcpConnection` itself.
127
+ *
128
+ * Starting the listener in the guest — rather than in the orchestrator/test
129
+ * process, which is what this case used to do — is the whole point: a
130
+ * listener on the HOST'S loopback only ever proves anything for a backend
131
+ * whose "guest" happens to share that loopback (a Firecracker fixture over a
132
+ * local socket, a fake-agent-in-this-process kubernetes test). It never
133
+ * proves anything for a real remote guest, which cannot dial the
134
+ * orchestrator's loopback at all — that gap is exactly what let the case
135
+ * pass in every colocated fixture and fail the one time it ran against a
136
+ * live cluster.
137
+ */
138
+ export interface GuestListenerCommand {
139
+ /** The program `openTerminal` runs as the session's top-level process. */
140
+ readonly command: string;
141
+ readonly args: readonly string[];
142
+ /**
143
+ * Reads the port the listener bound out of everything it has printed to
144
+ * its terminal so far. Returns `undefined` until the listener has
145
+ * reported one — the suite polls this as output arrives rather than
146
+ * parsing a single chunk, because a pty may deliver the report split
147
+ * across reads.
148
+ */
149
+ parsePort(output: string): number | undefined;
150
+ }
151
+ /**
152
+ * The default {@link GuestListenerCommand}: `node -e` binding an ephemeral
153
+ * port on the GUEST's own loopback, echoing `conformance-reply:<payload>`
154
+ * back for the first chunk of the one connection it accepts, then reporting
155
+ * the bound port on its own stdout — the only way the host, which cannot
156
+ * inspect a real remote guest's open ports any other way, learns which port
157
+ * to dial.
158
+ *
159
+ * Every backend this suite ships against runs `agent/agent.cjs` in the
160
+ * guest, which is itself node — so node on the guest's `PATH` is not an
161
+ * extra requirement this suite invents, it is a precondition of the agent
162
+ * existing at all. A backend whose guest genuinely cannot run node (or
163
+ * cannot run `openTerminal`) declares that through
164
+ * {@link SandboxConformanceOptions.guestCanRunNode} or supplies its own
165
+ * command via {@link SandboxConformanceOptions.guestListenerCommand}.
166
+ */
167
+ export declare function nodeGuestListener(): GuestListenerCommand;
168
+ /**
169
+ * Start `listener` through `openTerminal`, and resolve once it has reported
170
+ * the port it bound.
171
+ *
172
+ * The returned `stop()` kills the terminal's owned process tree — the exact
173
+ * ownership guarantee the `openTerminal` section above already proves
174
+ * `destroy()` gets for free, used here to tear the listener down without
175
+ * waiting for the whole sandbox to go away.
176
+ *
177
+ * Takes `openTerminal` as a plain function rather than a `Sandbox`, so a
178
+ * unit test can exercise the port-parsing and exit-races above without a
179
+ * `Sandbox` fixture — see `__tests__/guest-listener.test.ts`.
180
+ */
181
+ export declare function startGuestListener(openTerminal: (options: OpenTerminalOptions) => Promise<TerminalSession>, listener: GuestListenerCommand): Promise<{
182
+ readonly port: number;
183
+ stop(): Promise<void>;
184
+ }>;
185
+ /**
186
+ * Register the {@link Sandbox} contract against one backend.
187
+ *
188
+ * Call it once per backend. It registers cases through the supplied
189
+ * `describe`/`it`; it does not run them.
190
+ */
191
+ export declare function defineSandboxConformance(options: SandboxConformanceOptions): void;
192
+ export type { ConformanceAssertion, ConformanceDescribe, ConformanceExpect, ConformanceIt };
193
+ //# sourceMappingURL=sandbox-conformance.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sandbox-conformance.d.ts","sourceRoot":"","sources":["../../src/testing/sandbox-conformance.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6DG;AAEH,OAAO,KAAK,EACX,mBAAmB,EACnB,OAAO,EAEP,eAAe,EACf,MAAM,YAAY,CAAA;AACnB,OAAO,KAAK,EACX,oBAAoB,EACpB,mBAAmB,EACnB,iBAAiB,EACjB,aAAa,EACb,MAAM,oBAAoB,CAAA;AAE3B;;;;;GAKG;AACH,eAAO,MAAM,wBAAwB,IAAI,CAAA;AAEzC,sEAAsE;AACtE,MAAM,WAAW,wBAAwB;IACxC,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB;;;;;;OAMG;IACH,OAAO,CAAC,IAAI,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CAChC;AAED;;;;GAIG;AACH,MAAM,MAAM,WAAW,GAAG,MAAM,wBAAwB,GAAG,OAAO,CAAC,wBAAwB,CAAC,CAAA;AAE5F,MAAM,WAAW,yBAAyB;IACzC,QAAQ,CAAC,QAAQ,EAAE,mBAAmB,CAAA;IACtC,QAAQ,CAAC,EAAE,EAAE,aAAa,CAAA;IAC1B,QAAQ,CAAC,MAAM,EAAE,iBAAiB,CAAA;IAClC,QAAQ,CAAC,WAAW,EAAE,WAAW,CAAA;IACjC,+DAA+D;IAC/D,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;IACvB;;;;;;;;;;;;;;;OAeG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,OAAO,CAAA;IAClC;;;;;;OAMG;IACH,QAAQ,CAAC,oBAAoB,CAAC,EAAE,MAAM,oBAAoB,CAAA;CAC1D;AAkCD;;;;;;;;;;;;;;GAcG;AACH,MAAM,WAAW,oBAAoB;IACpC,0EAA0E;IAC1E,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAA;IAChC;;;;;;OAMG;IACH,SAAS,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAA;CAC7C;AAKD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,iBAAiB,IAAI,oBAAoB,CAsBxD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,kBAAkB,CACvC,YAAY,EAAE,CAAC,OAAO,EAAE,mBAAmB,KAAK,OAAO,CAAC,eAAe,CAAC,EACxE,QAAQ,EAAE,oBAAoB,GAC5B,OAAO,CAAC;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;CAAE,CAAC,CA0C3D;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,yBAAyB,GAAG,IAAI,CAiWjF;AAKD,YAAY,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,aAAa,EAAE,CAAA"}
@@ -0,0 +1,465 @@
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
+ * The contract revision these assertions express. Carried on the describe
65
+ * label so a failure is legible on sight as "the sandbox contract", the
66
+ * same convention `PROVIDER_DRIVER_CONTRACT_VERSION` uses — raised only
67
+ * when a case is ADDED or TIGHTENED, never on a rewording.
68
+ */
69
+ export const SANDBOX_CONTRACT_VERSION = 1;
70
+ /** Assert `call()` rejects. The contract cares that admission was refused, never the message. */
71
+ async function expectRejects(expect, call) {
72
+ let rejected = false;
73
+ try {
74
+ await call();
75
+ }
76
+ catch {
77
+ rejected = true;
78
+ }
79
+ expect(rejected).toBe(true);
80
+ }
81
+ /** Assert `call()` resolves — the inverse check, for the rare case a rejection is the defect. */
82
+ async function expectResolves(expect, call) {
83
+ let threw;
84
+ try {
85
+ await call();
86
+ }
87
+ catch (error) {
88
+ threw = error;
89
+ }
90
+ expect(threw === undefined).toBe(true);
91
+ }
92
+ function sleep(ms) {
93
+ return new Promise((resolve) => setTimeout(resolve, ms));
94
+ }
95
+ /** What {@link nodeGuestListener} has its script print once it is bound. */
96
+ const NODE_LISTENER_MARKER = 'namzu-conformance-listening:';
97
+ /**
98
+ * The default {@link GuestListenerCommand}: `node -e` binding an ephemeral
99
+ * port on the GUEST's own loopback, echoing `conformance-reply:<payload>`
100
+ * back for the first chunk of the one connection it accepts, then reporting
101
+ * the bound port on its own stdout — the only way the host, which cannot
102
+ * inspect a real remote guest's open ports any other way, learns which port
103
+ * to dial.
104
+ *
105
+ * Every backend this suite ships against runs `agent/agent.cjs` in the
106
+ * guest, which is itself node — so node on the guest's `PATH` is not an
107
+ * extra requirement this suite invents, it is a precondition of the agent
108
+ * existing at all. A backend whose guest genuinely cannot run node (or
109
+ * cannot run `openTerminal`) declares that through
110
+ * {@link SandboxConformanceOptions.guestCanRunNode} or supplies its own
111
+ * command via {@link SandboxConformanceOptions.guestListenerCommand}.
112
+ */
113
+ export function nodeGuestListener() {
114
+ const script = [
115
+ "const net = require('node:net');",
116
+ 'const server = net.createServer((socket) => {',
117
+ " socket.once('data', (chunk) => {",
118
+ " socket.end(Buffer.concat([Buffer.from('conformance-reply:'), chunk]));",
119
+ ' });',
120
+ '});',
121
+ "server.listen(0, '127.0.0.1', () => {",
122
+ ` process.stdout.write(${JSON.stringify(NODE_LISTENER_MARKER)} + server.address().port + '\\n');`,
123
+ '});',
124
+ ].join('\n');
125
+ return {
126
+ command: 'node',
127
+ args: ['-e', script],
128
+ parsePort(output) {
129
+ const marker = output.indexOf(NODE_LISTENER_MARKER);
130
+ if (marker === -1)
131
+ return undefined;
132
+ const match = /\d+/.exec(output.slice(marker + NODE_LISTENER_MARKER.length));
133
+ return match ? Number(match[0]) : undefined;
134
+ },
135
+ };
136
+ }
137
+ /**
138
+ * Start `listener` through `openTerminal`, and resolve once it has reported
139
+ * the port it bound.
140
+ *
141
+ * The returned `stop()` kills the terminal's owned process tree — the exact
142
+ * ownership guarantee the `openTerminal` section above already proves
143
+ * `destroy()` gets for free, used here to tear the listener down without
144
+ * waiting for the whole sandbox to go away.
145
+ *
146
+ * Takes `openTerminal` as a plain function rather than a `Sandbox`, so a
147
+ * unit test can exercise the port-parsing and exit-races above without a
148
+ * `Sandbox` fixture — see `__tests__/guest-listener.test.ts`.
149
+ */
150
+ export async function startGuestListener(openTerminal, listener) {
151
+ const terminal = await openTerminal({
152
+ command: listener.command,
153
+ args: listener.args,
154
+ size: { cols: 80, rows: 24 },
155
+ });
156
+ let output = '';
157
+ const port = await new Promise((resolve, reject) => {
158
+ const unsubscribe = terminal.onData((chunk) => {
159
+ output += chunk;
160
+ const found = listener.parsePort(output);
161
+ if (found !== undefined) {
162
+ unsubscribe();
163
+ resolve(found);
164
+ }
165
+ });
166
+ void terminal.exited.then((result) => {
167
+ // A settled promise ignores a later resolve/reject, so this is a
168
+ // no-op on the path where the port was already found and `stop()`
169
+ // is what causes this exit — it only fires the rejection when the
170
+ // listener died before ever reporting a port.
171
+ if (listener.parsePort(output) === undefined) {
172
+ unsubscribe();
173
+ reject(new Error(`guest listener exited before reporting a port (exit code ${result.exitCode}): ${output || '<no output>'}`));
174
+ }
175
+ });
176
+ });
177
+ return {
178
+ port,
179
+ async stop() {
180
+ terminal.kill();
181
+ await terminal.exited.catch(() => { });
182
+ },
183
+ };
184
+ }
185
+ /**
186
+ * Register the {@link Sandbox} contract against one backend.
187
+ *
188
+ * Call it once per backend. It registers cases through the supplied
189
+ * `describe`/`it`; it does not run them.
190
+ */
191
+ export function defineSandboxConformance(options) {
192
+ const { describe, it, expect, makeSandbox } = options;
193
+ const label = options.label ?? 'sandbox';
194
+ const guestCanRunNode = options.guestCanRunNode ?? true;
195
+ const guestListenerCommand = options.guestListenerCommand ?? nodeGuestListener;
196
+ /**
197
+ * Run `body` against a sandbox built for this case alone.
198
+ *
199
+ * Destroys the sandbox itself (idempotent, so a body that already
200
+ * destroyed it costs nothing extra) before calling `dispose`, so a
201
+ * case that forgets to release a pod/microVM does not leak one — the
202
+ * same reasoning `withStore`'s `finally` states for a leaked temp
203
+ * directory: a suite that is expensive to run red is a suite people
204
+ * stop running.
205
+ */
206
+ const withSandbox = (body) => async () => {
207
+ const handle = await makeSandbox();
208
+ try {
209
+ await body(handle.sandbox);
210
+ }
211
+ finally {
212
+ await handle.sandbox.destroy().catch(() => { });
213
+ await handle.dispose?.();
214
+ }
215
+ };
216
+ describe(`${label} — sandbox contract v${SANDBOX_CONTRACT_VERSION}`, () => {
217
+ describe('exec', () => {
218
+ it('reports the exit code and streams stdout/stderr as the command runs', withSandbox(async (sandbox) => {
219
+ const chunks = [];
220
+ const result = await sandbox.exec('/bin/sh', ['-c', 'echo conformance-out; echo conformance-err 1>&2; exit 7'], { onOutput: (chunk) => chunks.push({ ...chunk }) });
221
+ expect(result.exitCode).toBe(7);
222
+ expect(result.timedOut).toBe(false);
223
+ expect(result.stdout).toMatch(/conformance-out/);
224
+ expect(result.stderr).toMatch(/conformance-err/);
225
+ // Streamed, not just present in the final string: a backend
226
+ // that buffers everything until exit and calls `onOutput`
227
+ // once at the end would satisfy the two checks above and
228
+ // fail this one.
229
+ expect(chunks.some((c) => c.stream === 'stdout' && c.data.includes('conformance-out'))).toBe(true);
230
+ expect(chunks.some((c) => c.stream === 'stderr' && c.data.includes('conformance-err'))).toBe(true);
231
+ }));
232
+ it('reports busy while a command is in flight and ready once it settles', withSandbox(async (sandbox) => {
233
+ let observedBusy = false;
234
+ await sandbox.exec('/bin/sh', ['-c', 'echo started; sleep 0.2'], {
235
+ onOutput: (chunk) => {
236
+ if (chunk.data.includes('started'))
237
+ observedBusy = sandbox.status === 'busy';
238
+ },
239
+ });
240
+ expect(observedBusy).toBe(true);
241
+ expect(sandbox.status).toBe('ready');
242
+ }));
243
+ it('honours an AbortSignal: the process is really terminated, never a partial success', withSandbox(async (sandbox) => {
244
+ // The contract (`SandboxExecOptions.signal`'s own doc comment):
245
+ // a backend that accepts the signal must terminate the process
246
+ // it owns, or prove admission never happened; it must never
247
+ // silently ignore the signal and let the command run to
248
+ // completion while reporting as though it had been cancelled.
249
+ // The command below writes a marker file a moment after
250
+ // printing "ready" — if the process is genuinely killed on
251
+ // abort, that write never happens. That is the decisive
252
+ // check; whatever the settled promise looks like is a second,
253
+ // weaker one.
254
+ const marker = 'conformance-abort-marker.txt';
255
+ const caller = new AbortController();
256
+ let signalReady;
257
+ const ready = new Promise((resolve) => {
258
+ signalReady = resolve;
259
+ });
260
+ const running = sandbox.exec('/bin/sh', [
261
+ '-c',
262
+ `trap '' TERM; (trap '' TERM; sleep 0.4; printf late > ${marker}) & echo ready; wait`,
263
+ ], {
264
+ signal: caller.signal,
265
+ onOutput: (chunk) => {
266
+ if (chunk.stream === 'stdout' && chunk.data.includes('ready'))
267
+ signalReady?.();
268
+ },
269
+ });
270
+ await ready;
271
+ caller.abort(new Error('conformance suite cancelled this command'));
272
+ // Resolve OR reject are both compliant — a backend that cannot
273
+ // confirm the kill may refuse instead of reporting a result it
274
+ // is not sure of. What is never compliant is reporting a clean,
275
+ // unaborted-looking success.
276
+ let settled;
277
+ try {
278
+ settled = await running;
279
+ }
280
+ catch {
281
+ settled = undefined;
282
+ }
283
+ if (settled !== undefined) {
284
+ expect(settled.exitCode === 0 && settled.signal === undefined).toBe(false);
285
+ }
286
+ // Long enough that an un-killed process would have finished its
287
+ // sleep and written the file.
288
+ await sleep(900);
289
+ await expectRejects(expect, () => sandbox.readFile(marker));
290
+ }));
291
+ });
292
+ describe('file IO', () => {
293
+ it('round-trips a UTF-8 string through writeFile/readFile', withSandbox(async (sandbox) => {
294
+ await sandbox.writeFile('conformance-notes.txt', 'héllo wörld');
295
+ const read = await sandbox.readFile('conformance-notes.txt');
296
+ expect(read.toString('utf8')).toBe('héllo wörld');
297
+ }));
298
+ it('round-trips arbitrary binary content byte for byte', withSandbox(async (sandbox) => {
299
+ const payload = Buffer.from([0x00, 0xff, 0x10, 0x00, 0x42, 0xfe, 0x7f, 0x80, 0x01]);
300
+ await sandbox.writeFile('nested/conformance/blob.bin', payload);
301
+ const read = await sandbox.readFile('nested/conformance/blob.bin');
302
+ // Compared as base64 rather than through a deep-equality
303
+ // matcher: the four matchers this suite is allowed to assume
304
+ // (`toBe`/`toEqual`/`toBeGreaterThan`/`toMatch`) do not
305
+ // guarantee byte-exact `Buffer` comparison across every
306
+ // runner a caller might wire in, and a corrupted byte belongs
307
+ // in the string this failure prints.
308
+ expect(read.toString('base64')).toBe(payload.toString('base64'));
309
+ }));
310
+ });
311
+ describe('listFiles', () => {
312
+ it('lists written files as absolute paths with their sizes', withSandbox(async (sandbox) => {
313
+ const contentA = '123456789';
314
+ const contentB = '42 bytes worth of fixed content!!';
315
+ await sandbox.writeFile('conformance-list/a.txt', contentA);
316
+ await sandbox.writeFile('conformance-list/b.txt', contentB);
317
+ const dir = `${sandbox.rootDir}/conformance-list`;
318
+ const files = await sandbox.listFiles(dir);
319
+ const byPath = new Map(files.map((f) => [f.path, f.size]));
320
+ expect(byPath.get(`${dir}/a.txt`)).toBe(Buffer.byteLength(contentA));
321
+ expect(byPath.get(`${dir}/b.txt`)).toBe(Buffer.byteLength(contentB));
322
+ }));
323
+ it('reports a root that does not exist as empty rather than failing', withSandbox(async (sandbox) => {
324
+ const files = await sandbox.listFiles(`${sandbox.rootDir}/conformance-never-created`);
325
+ expect(files.length).toBe(0);
326
+ }));
327
+ });
328
+ /**
329
+ * Optional on {@link Sandbox} by the SDK's own contract: a backend that
330
+ * cannot provide a real pseudo-terminal must OMIT the method rather
331
+ * than hand back a pipe masquerading as one. So a factory whose
332
+ * sandbox has no `openTerminal` is not in violation of anything — the
333
+ * case below passes vacuously for it, which is the documented
334
+ * skip-if-unavailable this suite promises rather than a silent hole:
335
+ * both shipped backends (kubernetes, firecracker) DO implement it, so
336
+ * in CI this case only ever runs vacuously against a fixture that
337
+ * deliberately declines the capability.
338
+ */
339
+ describe('openTerminal', () => {
340
+ it('is owned by the sandbox: destroy() kills and awaits every terminal it returned', withSandbox(async (sandbox) => {
341
+ if (!sandbox.openTerminal)
342
+ return;
343
+ const terminal = await sandbox.openTerminal({
344
+ command: '/bin/sh',
345
+ args: ['-c', 'sleep 30'],
346
+ size: { cols: 80, rows: 24 },
347
+ });
348
+ let exited = false;
349
+ void terminal.exited.then(() => {
350
+ exited = true;
351
+ });
352
+ await sandbox.destroy();
353
+ // Nothing awaited in between: awaiting `terminal.exited` here
354
+ // would rescue a `destroy()` that only fired the kill and
355
+ // returned without waiting for it, which is exactly the
356
+ // defect this case exists to catch.
357
+ expect(exited).toBe(true);
358
+ await terminal.exited;
359
+ }));
360
+ });
361
+ /**
362
+ * Same optionality and the same documented skip as `openTerminal`,
363
+ * above: a factory whose sandbox has no `openTcpConnection` passes
364
+ * vacuously. The positive case below adds a second, independent skip
365
+ * axis on top of that — see `guestCanRunNode` and
366
+ * `guestListenerCommand` on {@link SandboxConformanceOptions} — because
367
+ * proving the forward really crosses into a REMOTE guest needs a
368
+ * listener running there, and not every guest can start one the same
369
+ * way.
370
+ */
371
+ describe('openTcpConnection', () => {
372
+ // The reason for a title, rather than a console message, printing
373
+ // the skip: `ConformanceIt` promises only `(name, body) => unknown`
374
+ // (`contract-suite.mjs`'s own flat recorder has no skip concept
375
+ // either), so the one channel a skip can travel through every
376
+ // runner this suite is ever handed is the case's own name — decided
377
+ // once, here, from options given synchronously to
378
+ // `defineSandboxConformance`, not from anything discovered at run
379
+ // time.
380
+ const positiveCaseTitle = guestCanRunNode
381
+ ? 'forwards a bidirectional stream to a service started inside the guest'
382
+ : 'forwards a bidirectional stream to a service started inside the guest (skipped: guestCanRunNode is false)';
383
+ it(positiveCaseTitle, withSandbox(async (sandbox) => {
384
+ if (!sandbox.openTcpConnection)
385
+ return;
386
+ if (!guestCanRunNode)
387
+ return;
388
+ const openTerminal = sandbox.openTerminal;
389
+ if (!openTerminal) {
390
+ // A backend offering `openTcpConnection` without
391
+ // `openTerminal` has no portable way for this suite to
392
+ // start a guest-side listener — declare the skip
393
+ // explicitly (`guestCanRunNode: false`) rather than
394
+ // leaving the default to discover it here as a failure.
395
+ throw new Error('openTcpConnection conformance: starting a guest-side listener needs openTerminal, ' +
396
+ 'which this sandbox does not implement. Pass guestCanRunNode: false to ' +
397
+ 'defineSandboxConformance to skip this case with a stated reason, or supply ' +
398
+ 'guestListenerCommand for a guest that can run a listener some other way.');
399
+ }
400
+ // Called through `.call(sandbox, …)` rather than passed as
401
+ // a bare reference: `openTerminal` may be an ordinary
402
+ // method relying on `this` (a class-based fixture, for
403
+ // instance), and detaching it from `sandbox` would drop
404
+ // that binding.
405
+ const listener = await startGuestListener((terminalOptions) => openTerminal.call(sandbox, terminalOptions), guestListenerCommand());
406
+ try {
407
+ const connection = await sandbox.openTcpConnection({ port: listener.port });
408
+ let received = '';
409
+ const unsubscribe = connection.onData((chunk) => {
410
+ received += Buffer.from(chunk).toString('utf8');
411
+ });
412
+ connection.write('conformance-hello');
413
+ await connection.closed;
414
+ expect(received).toBe('conformance-reply:conformance-hello');
415
+ unsubscribe();
416
+ }
417
+ finally {
418
+ await listener.stop();
419
+ }
420
+ }));
421
+ it('refuses a non-loopback host', withSandbox(async (sandbox) => {
422
+ if (!sandbox.openTcpConnection)
423
+ return;
424
+ // `SandboxTcpConnectOptions.host` types as loopback-only; the
425
+ // cast is deliberate — this proves the refusal is enforced at
426
+ // RUNTIME, not merely by the type checker a compliant caller
427
+ // could route around with the same cast.
428
+ const nonLoopback = {
429
+ port: 9,
430
+ host: '203.0.113.10',
431
+ };
432
+ await expectRejects(expect, () => sandbox.openTcpConnection?.(nonLoopback) ?? Promise.reject(new Error('unreachable')));
433
+ }));
434
+ });
435
+ describe('destroy', () => {
436
+ it('is idempotent, however many times or however concurrently it is called', withSandbox(async (sandbox) => {
437
+ await Promise.all([sandbox.destroy(), sandbox.destroy()]);
438
+ expect(sandbox.status).toBe('destroyed');
439
+ await expectResolves(expect, () => sandbox.destroy());
440
+ expect(sandbox.status).toBe('destroyed');
441
+ }));
442
+ it('refuses every call once destroyed, rather than admitting one', withSandbox(async (sandbox) => {
443
+ await sandbox.destroy();
444
+ await expectRejects(expect, () => sandbox.exec('/bin/sh', ['-c', 'true']));
445
+ await expectRejects(expect, () => sandbox.writeFile('x.txt', 'x'));
446
+ await expectRejects(expect, () => sandbox.readFile('x.txt'));
447
+ await expectRejects(expect, () => sandbox.listFiles(sandbox.rootDir));
448
+ // `?.()` rather than an `if` guard around the assertion: it is
449
+ // type-correct whether or not the capability exists, and when
450
+ // it does not exist there is nothing to refuse — the
451
+ // documented skip-if-unavailable this suite promises for
452
+ // every optional capability.
453
+ if (sandbox.openTerminal) {
454
+ await expectRejects(expect, () => sandbox.openTerminal?.({ size: { cols: 80, rows: 24 } }) ??
455
+ Promise.reject(new Error('unreachable')));
456
+ }
457
+ if (sandbox.openTcpConnection) {
458
+ await expectRejects(expect, () => sandbox.openTcpConnection?.({ port: 9 }) ??
459
+ Promise.reject(new Error('unreachable')));
460
+ }
461
+ }));
462
+ });
463
+ });
464
+ }
465
+ //# sourceMappingURL=sandbox-conformance.js.map