@namzu/sandbox 2.0.2 → 2.0.3

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 (72) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/package.json +12 -2
  3. package/dist/backends/aci-standby-pool/__tests__/unenforceable-controls.test.d.ts +0 -2
  4. package/dist/backends/aci-standby-pool/__tests__/unenforceable-controls.test.d.ts.map +0 -1
  5. package/dist/backends/aci-standby-pool/__tests__/unenforceable-controls.test.js +0 -61
  6. package/dist/backends/aci-standby-pool/__tests__/unenforceable-controls.test.js.map +0 -1
  7. package/dist/backends/docker/__tests__/hardening.test.d.ts +0 -2
  8. package/dist/backends/docker/__tests__/hardening.test.d.ts.map +0 -1
  9. package/dist/backends/docker/__tests__/hardening.test.js +0 -32
  10. package/dist/backends/docker/__tests__/hardening.test.js.map +0 -1
  11. package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.d.ts +0 -40
  12. package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.d.ts.map +0 -1
  13. package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.js +0 -157
  14. package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.js.map +0 -1
  15. package/dist/backends/firecracker/__tests__/agent-timeout-clamp.test.d.ts +0 -16
  16. package/dist/backends/firecracker/__tests__/agent-timeout-clamp.test.d.ts.map +0 -1
  17. package/dist/backends/firecracker/__tests__/agent-timeout-clamp.test.js +0 -37
  18. package/dist/backends/firecracker/__tests__/agent-timeout-clamp.test.js.map +0 -1
  19. package/dist/backends/firecracker/__tests__/backend.test.d.ts +0 -13
  20. package/dist/backends/firecracker/__tests__/backend.test.d.ts.map +0 -1
  21. package/dist/backends/firecracker/__tests__/backend.test.js +0 -361
  22. package/dist/backends/firecracker/__tests__/backend.test.js.map +0 -1
  23. package/dist/backends/firecracker/__tests__/control-plane-mtls.test.d.ts +0 -19
  24. package/dist/backends/firecracker/__tests__/control-plane-mtls.test.d.ts.map +0 -1
  25. package/dist/backends/firecracker/__tests__/control-plane-mtls.test.js +0 -209
  26. package/dist/backends/firecracker/__tests__/control-plane-mtls.test.js.map +0 -1
  27. package/dist/backends/firecracker/__tests__/egress-policy.test.d.ts +0 -2
  28. package/dist/backends/firecracker/__tests__/egress-policy.test.d.ts.map +0 -1
  29. package/dist/backends/firecracker/__tests__/egress-policy.test.js +0 -67
  30. package/dist/backends/firecracker/__tests__/egress-policy.test.js.map +0 -1
  31. package/dist/backends/firecracker/__tests__/fixtures/ipc-path.d.ts +0 -21
  32. package/dist/backends/firecracker/__tests__/fixtures/ipc-path.d.ts.map +0 -1
  33. package/dist/backends/firecracker/__tests__/fixtures/ipc-path.js +0 -30
  34. package/dist/backends/firecracker/__tests__/fixtures/ipc-path.js.map +0 -1
  35. package/dist/backends/firecracker/__tests__/fixtures/mtls-pki.d.ts +0 -39
  36. package/dist/backends/firecracker/__tests__/fixtures/mtls-pki.d.ts.map +0 -1
  37. package/dist/backends/firecracker/__tests__/fixtures/mtls-pki.js +0 -149
  38. package/dist/backends/firecracker/__tests__/fixtures/mtls-pki.js.map +0 -1
  39. package/dist/backends/firecracker/__tests__/protocol.test.d.ts +0 -6
  40. package/dist/backends/firecracker/__tests__/protocol.test.d.ts.map +0 -1
  41. package/dist/backends/firecracker/__tests__/protocol.test.js +0 -67
  42. package/dist/backends/firecracker/__tests__/protocol.test.js.map +0 -1
  43. package/dist/backends/firecracker/__tests__/transport.test.d.ts +0 -20
  44. package/dist/backends/firecracker/__tests__/transport.test.d.ts.map +0 -1
  45. package/dist/backends/firecracker/__tests__/transport.test.js +0 -457
  46. package/dist/backends/firecracker/__tests__/transport.test.js.map +0 -1
  47. package/dist/egress/__tests__/allowlist.test.d.ts +0 -2
  48. package/dist/egress/__tests__/allowlist.test.d.ts.map +0 -1
  49. package/dist/egress/__tests__/allowlist.test.js +0 -85
  50. package/dist/egress/__tests__/allowlist.test.js.map +0 -1
  51. package/dist/egress/__tests__/proxy.test.d.ts +0 -2
  52. package/dist/egress/__tests__/proxy.test.d.ts.map +0 -1
  53. package/dist/egress/__tests__/proxy.test.js +0 -177
  54. package/dist/egress/__tests__/proxy.test.js.map +0 -1
  55. package/dist/index.test.d.ts +0 -28
  56. package/dist/index.test.d.ts.map +0 -1
  57. package/dist/index.test.js +0 -649
  58. package/dist/index.test.js.map +0 -1
  59. package/src/backends/aci-standby-pool/__tests__/unenforceable-controls.test.ts +0 -69
  60. package/src/backends/docker/__tests__/hardening.test.ts +0 -43
  61. package/src/backends/docker/__tests__/leaf-permissions.smoke.test.ts +0 -169
  62. package/src/backends/firecracker/__tests__/agent-timeout-clamp.test.ts +0 -48
  63. package/src/backends/firecracker/__tests__/backend.test.ts +0 -429
  64. package/src/backends/firecracker/__tests__/control-plane-mtls.test.ts +0 -261
  65. package/src/backends/firecracker/__tests__/egress-policy.test.ts +0 -91
  66. package/src/backends/firecracker/__tests__/fixtures/ipc-path.ts +0 -31
  67. package/src/backends/firecracker/__tests__/fixtures/mtls-pki.ts +0 -166
  68. package/src/backends/firecracker/__tests__/protocol.test.ts +0 -75
  69. package/src/backends/firecracker/__tests__/transport.test.ts +0 -534
  70. package/src/egress/__tests__/allowlist.test.ts +0 -103
  71. package/src/egress/__tests__/proxy.test.ts +0 -212
  72. package/src/index.test.ts +0 -709
@@ -1,429 +0,0 @@
1
- /**
2
- * Firecracker `SandboxBackend` end-to-end over the loopback agent.
3
- *
4
- * Stubs the orchestrator control plane (`fetch` for `/sandboxes` create
5
- * + `:delete`) and points the returned vsock handle at a unix-socket
6
- * loopback running the REAL `agent/agent.cjs`. Proves the full
7
- * {@link Sandbox} handle the SDK consumes — `exec` / `writeFile` /
8
- * `readFile` / `listFiles` / `destroy` / `status` — speaks the NDJSON
9
- * wire over the vsock transport, and that the readiness fence waits on
10
- * the agent's healthz (not the orchestrator 2xx).
11
- */
12
-
13
- import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
14
- import { createRequire } from 'node:module'
15
- import { type Server, type Socket, createServer } from 'node:net'
16
- import { tmpdir } from 'node:os'
17
- import { join } from 'node:path'
18
-
19
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
20
-
21
- import { buildFirecrackerBackend, normalizeHandle } from '../index.js'
22
- import type { WireSandboxAgentHandle } from '../transport.js'
23
- import { localIpcPath } from './fixtures/ipc-path.js'
24
- import {
25
- CA_CRT,
26
- CLIENT_CRT,
27
- CLIENT_KEY,
28
- type RelayHandle,
29
- startMtlsRelay,
30
- } from './fixtures/mtls-pki.js'
31
- // The loopback agent under test is the guest-side agent for a Linux microVM:
32
- // it spawns `/bin/sh` to run commands, and that binary does not exist on
33
- // Windows. These cases assert behavior the platform cannot produce, so they
34
- // skip there rather than leaving the suite permanently red for Windows
35
- // contributors. The socket-address fixture IS platform-correct, so the
36
- // transport is still exercised wherever it can be.
37
- const IS_WINDOWS = process.platform === 'win32'
38
-
39
- const require_ = createRequire(import.meta.url)
40
-
41
- interface AgentModule {
42
- handleConnection(socket: Socket): void
43
- }
44
-
45
- let workDir: string
46
- let sockPath: string
47
- let server: Server | undefined
48
- let relay: RelayHandle | undefined
49
- let agent: AgentModule
50
- const realFetch = globalThis.fetch
51
- let realPath: string | undefined
52
-
53
- beforeEach(() => {
54
- workDir = mkdtempSync(join(tmpdir(), 'fc-backend-test-'))
55
- sockPath = localIpcPath(workDir)
56
- realPath = process.env.PATH
57
- process.env.NAMZU_SANDBOX_WORKSPACE = workDir
58
- delete require_.cache[require_.resolve('../../../../agent/agent.cjs')]
59
- agent = require_('../../../../agent/agent.cjs') as AgentModule
60
- })
61
-
62
- afterEach(async () => {
63
- globalThis.fetch = realFetch
64
- process.env.PATH = realPath
65
- if (relay) {
66
- await new Promise<void>((r) => relay?.server.close(() => r()))
67
- relay = undefined
68
- }
69
- if (server) {
70
- await new Promise<void>((r) => server?.close(() => r()))
71
- server = undefined
72
- }
73
- rmSync(workDir, { recursive: true, force: true })
74
- })
75
-
76
- function startAgent(): Promise<Server> {
77
- return new Promise((resolve, reject) => {
78
- const s = createServer(agent.handleConnection)
79
- s.on('error', reject)
80
- s.listen(sockPath, () => resolve(s))
81
- })
82
- }
83
-
84
- /**
85
- * Stub the orchestrator: `POST /sandboxes` returns a handle pointing at
86
- * the loopback unix socket; `:delete` returns 204. Records calls so the
87
- * test can assert the create body + destroy round-trip.
88
- */
89
- function stubOrchestrator(handle: WireSandboxAgentHandle): {
90
- calls: Array<{ url: string; method: string; body?: unknown }>
91
- } {
92
- const calls: Array<{ url: string; method: string; body?: unknown }> = []
93
- globalThis.fetch = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
94
- const url = String(input)
95
- const method = init?.method ?? 'GET'
96
- const body = init?.body ? JSON.parse(String(init.body)) : undefined
97
- calls.push({ url, method, body })
98
- if (method === 'POST' && url.endsWith('/sandboxes')) {
99
- return new Response(
100
- JSON.stringify({ sandboxId: 'sbx_fc_test', agent: handle, rootDir: workDir }),
101
- { status: 200, headers: { 'content-type': 'application/json' } },
102
- )
103
- }
104
- if (method === 'DELETE' && url.includes(':delete')) {
105
- return new Response(null, { status: 204 })
106
- }
107
- return new Response('unexpected', { status: 500 })
108
- }) as typeof fetch
109
- return { calls }
110
- }
111
-
112
- describe.skipIf(IS_WINDOWS)('buildFirecrackerBackend (loopback agent)', () => {
113
- it('creates a Sandbox handle and round-trips exec/write/read/listFiles/destroy', async () => {
114
- server = await startAgent()
115
- const { calls } = stubOrchestrator({ kind: 'unix', path: sockPath })
116
-
117
- const backend = buildFirecrackerBackend({
118
- orchestratorEndpoint: 'https://orchestrator.test/',
119
- getToken: async () => 'token-123',
120
- template: 'golden-rev-7',
121
- readyTimeoutMs: 5_000,
122
- readyPollIntervalMs: 50,
123
- })
124
-
125
- const sandbox = await backend.create({
126
- workingDirectory: workDir,
127
- memoryLimitMb: 512,
128
- maxProcesses: 64,
129
- timeoutMs: 60_000,
130
- egress: { kind: 'static', allowedHosts: ['api.example.com'] },
131
- })
132
-
133
- // Handle shape.
134
- expect(sandbox.id).toBe('sbx_fc_test')
135
- expect(sandbox.rootDir).toBe(workDir)
136
- expect(sandbox.environment).toBe('linux-namespace')
137
- expect(sandbox.status).toBe('ready')
138
-
139
- // Create body forwarded the resolved knobs + egress allowlist.
140
- const createCall = calls.find((c) => c.method === 'POST')
141
- expect(createCall?.body).toMatchObject({
142
- template: 'golden-rev-7',
143
- memoryLimitMb: 512,
144
- maxProcesses: 64,
145
- timeoutMs: 60_000,
146
- egressAllowlist: ['api.example.com'],
147
- })
148
-
149
- // exec over the wire.
150
- const r = await sandbox.exec('/bin/sh', ['-c', 'echo hello-fc'])
151
- expect(r.stdout).toContain('hello-fc')
152
- expect(r.exitCode).toBe(0)
153
-
154
- // writeFile + readFile round-trip.
155
- await sandbox.writeFile('out/result.txt', 'persisted')
156
- const read = await sandbox.readFile('out/result.txt')
157
- expect(read.toString('utf8')).toBe('persisted')
158
-
159
- // listFiles uses the same `find -printf '%p\t%s\n'` wire as
160
- // docker/aci. On a GNU-find host (the Ubuntu golden rootfs) it
161
- // enumerates the file we wrote; on a BSD-find host (macOS CI
162
- // runners) `-printf` is unsupported, find exits non-zero, and the
163
- // contract maps that to an empty listing — exactly as docker/aci
164
- // do. Assert the call path returns the SDK shape either way, and
165
- // the file when GNU find is present.
166
- const files = await sandbox.listFiles(workDir)
167
- expect(Array.isArray(files)).toBe(true)
168
- if (files.length > 0) {
169
- expect(files.some((f) => f.path.endsWith('out/result.txt'))).toBe(true)
170
- expect(files.every((f) => Number.isFinite(f.size))).toBe(true)
171
- }
172
-
173
- // destroy calls the orchestrator :delete and flips status.
174
- await sandbox.destroy()
175
- expect(sandbox.status).toBe('destroyed')
176
- expect(calls.some((c) => c.method === 'DELETE' && c.url.includes(':delete'))).toBe(true)
177
- })
178
-
179
- it('tears down the microVM when the readiness fence times out (no orphan)', async () => {
180
- // No agent listening → healthz never succeeds → readiness fence
181
- // times out → backend must DELETE to avoid orphaning the microVM.
182
- const { calls } = stubOrchestrator({ kind: 'unix', path: sockPath })
183
- const backend = buildFirecrackerBackend({
184
- orchestratorEndpoint: 'https://orchestrator.test/',
185
- getToken: async () => 'tok',
186
- readyTimeoutMs: 300,
187
- readyPollIntervalMs: 50,
188
- transport: { connectRetryBudgetMs: 100, connectTimeoutMs: 80, connectRetryIntervalMs: 30 },
189
- })
190
- await expect(backend.create({ workingDirectory: workDir })).rejects.toThrow(
191
- /did not become ready/,
192
- )
193
- expect(calls.some((c) => c.method === 'DELETE')).toBe(true)
194
- })
195
-
196
- it('parses listFiles output into {path,size} entries (GNU-find-shaped wire)', async () => {
197
- // Make `find` deterministic across host platforms: shim a `find`
198
- // on PATH that emits the GNU `-printf '%p\t%s\n'` wire the Ubuntu
199
- // golden rootfs produces, so the backend's tab-split parser is
200
- // proven without depending on the host having GNU findutils.
201
- const binDir = mkdtempSync(join(tmpdir(), 'fc-bin-'))
202
- const findShim = join(binDir, 'find')
203
- writeFileSync(
204
- findShim,
205
- [
206
- '#!/bin/sh',
207
- `printf '%s\\t%s\\n' '${workDir}/out/result.txt' 9`,
208
- `printf '%s\\t%s\\n' '${workDir}/out/other.bin' 42`,
209
- ].join('\n'),
210
- { mode: 0o755 },
211
- )
212
- chmodSync(findShim, 0o755)
213
- process.env.PATH = `${binDir}:${process.env.PATH}`
214
-
215
- server = await startAgent()
216
- stubOrchestrator({ kind: 'unix', path: sockPath })
217
- const backend = buildFirecrackerBackend({
218
- orchestratorEndpoint: 'https://orchestrator.test/',
219
- getToken: async () => 'tok',
220
- readyTimeoutMs: 3_000,
221
- readyPollIntervalMs: 50,
222
- })
223
- const sandbox = await backend.create({ workingDirectory: workDir })
224
- const files = await sandbox.listFiles(workDir)
225
- expect(files).toEqual([
226
- { path: `${workDir}/out/result.txt`, size: 9 },
227
- { path: `${workDir}/out/other.bin`, size: 42 },
228
- ])
229
- await sandbox.destroy()
230
- rmSync(binDir, { recursive: true, force: true })
231
- })
232
-
233
- it('fills the contract vsock port when the orchestrator omits it', async () => {
234
- // A vsock handle whose port is 0 should be normalized to the
235
- // configured agentVsockPort. We can't bind a real AF_VSOCK here, so
236
- // assert the normalization path via a unix handle is unaffected and
237
- // that a portless vsock handle does not crash create's pre-dial.
238
- // (Full vsock dial is the live increment.)
239
- server = await startAgent()
240
- stubOrchestrator({ kind: 'unix', path: sockPath })
241
- const backend = buildFirecrackerBackend({
242
- orchestratorEndpoint: 'https://orchestrator.test/',
243
- getToken: async () => 'tok',
244
- agentVsockPort: 1024,
245
- readyTimeoutMs: 3_000,
246
- readyPollIntervalMs: 50,
247
- })
248
- const sandbox = await backend.create({ workingDirectory: workDir })
249
- expect(sandbox.status).toBe('ready')
250
- await sandbox.destroy()
251
- })
252
-
253
- it('carries agentSnapshot in the create body when the backend config sets it', async () => {
254
- // OPEN-2 — the per-agent snapshot ref is a first-class, provider-agnostic
255
- // field on the create contract (sibling to `template`). When set it must
256
- // reach the orchestrator POST body verbatim.
257
- server = await startAgent()
258
- const { calls } = stubOrchestrator({ kind: 'unix', path: sockPath })
259
- const backend = buildFirecrackerBackend({
260
- orchestratorEndpoint: 'https://orchestrator.test/',
261
- getToken: async () => 'tok',
262
- template: 'golden-rev-7',
263
- agentSnapshot: { orgId: 'org1', agentId: 'agent9', version: '3' },
264
- readyTimeoutMs: 3_000,
265
- readyPollIntervalMs: 50,
266
- })
267
- const sandbox = await backend.create({ workingDirectory: workDir })
268
- const createCall = calls.find((c) => c.method === 'POST')
269
- expect(createCall?.body).toMatchObject({
270
- template: 'golden-rev-7',
271
- agentSnapshot: { orgId: 'org1', agentId: 'agent9', version: '3' },
272
- })
273
- await sandbox.destroy()
274
- })
275
-
276
- it('OMITS agentSnapshot from the create body when absent (byte-identical generic path)', async () => {
277
- // DORMANT contract: absent ⇒ the POST body must NOT carry an
278
- // `agentSnapshot` key at all (a JSON `undefined` would not serialise, but
279
- // the conditional spread must drop the key entirely so the body is
280
- // byte-identical to the pre-field generic create).
281
- server = await startAgent()
282
- const { calls } = stubOrchestrator({ kind: 'unix', path: sockPath })
283
- const backend = buildFirecrackerBackend({
284
- orchestratorEndpoint: 'https://orchestrator.test/',
285
- getToken: async () => 'tok',
286
- template: 'golden-rev-7',
287
- readyTimeoutMs: 3_000,
288
- readyPollIntervalMs: 50,
289
- })
290
- const sandbox = await backend.create({ workingDirectory: workDir })
291
- const createCall = calls.find((c) => c.method === 'POST')
292
- expect(createCall?.body).toEqual({ template: 'golden-rev-7' })
293
- expect(Object.prototype.hasOwnProperty.call(createCall?.body ?? {}, 'agentSnapshot')).toBe(
294
- false,
295
- )
296
- await sandbox.destroy()
297
- })
298
- })
299
-
300
- // ---------------------------------------------------------------------------
301
- // Cert-injection seam (ses_051 P4 Track C) — the orchestrator returns a WIRE
302
- // `mtls` handle (host/port/sandboxId, NO certs); the consumer injects the
303
- // client CA/cert/key via `config.mtls`, which `normalizeHandle` MERGES onto
304
- // the handle before the transport dials the relay. Certs NEVER transit the
305
- // control plane.
306
- // ---------------------------------------------------------------------------
307
-
308
- describe('normalizeHandle (mtls cert injection)', () => {
309
- const WIRE_MTLS = {
310
- kind: 'mtls',
311
- host: '10.60.1.7',
312
- port: 8443,
313
- sandboxId: 'sb-net',
314
- } as const satisfies WireSandboxAgentHandle
315
-
316
- it('merges the injected cert material onto a wire mtls handle', () => {
317
- const handle = normalizeHandle(WIRE_MTLS, 1024, {
318
- ca: CA_CRT,
319
- cert: CLIENT_CRT,
320
- key: CLIENT_KEY,
321
- servername: 'sandbox.fc.internal',
322
- })
323
- expect(handle).toEqual({
324
- kind: 'mtls',
325
- host: '10.60.1.7',
326
- port: 8443,
327
- sandboxId: 'sb-net',
328
- tls: {
329
- ca: CA_CRT,
330
- cert: CLIENT_CRT,
331
- key: CLIENT_KEY,
332
- servername: 'sandbox.fc.internal',
333
- },
334
- })
335
- })
336
-
337
- it('throws when an mtls handle arrives with NO injected cert material', () => {
338
- // A network-mode backend that cannot present a client cert would be
339
- // rejected by the relay — fail loud at handle-normalization instead.
340
- expect(() => normalizeHandle(WIRE_MTLS, 1024, undefined)).toThrow(
341
- /no client cert material was injected/,
342
- )
343
- })
344
-
345
- it('leaves vsock + unix handles untouched (cert material is ignored)', () => {
346
- expect(normalizeHandle({ kind: 'unix', path: '/tmp/a.sock' }, 1024, undefined)).toEqual({
347
- kind: 'unix',
348
- path: '/tmp/a.sock',
349
- })
350
- // A vsock handle with a 0 port still normalizes to the contract port.
351
- expect(
352
- normalizeHandle({ kind: 'vsock', udsPath: '/v.sock', port: 0 }, 1024, {
353
- ca: CA_CRT,
354
- cert: CLIENT_CRT,
355
- key: CLIENT_KEY,
356
- }),
357
- ).toEqual({ kind: 'vsock', udsPath: '/v.sock', port: 1024 })
358
- })
359
- })
360
-
361
- describe.skipIf(IS_WINDOWS)(
362
- 'buildFirecrackerBackend (network mode over a loopback mTLS relay)',
363
- () => {
364
- it('injects the client cert + round-trips exec/file-IO through the relay', async () => {
365
- // The agent on a unix socket; a loopback mTLS relay in front of it.
366
- server = await startAgent()
367
- relay = await startMtlsRelay(sockPath)
368
- // The orchestrator returns a WIRE mtls handle (NO cert material) whose
369
- // host:port point at the relay; the relay resolves the sandboxId.
370
- const { calls } = stubOrchestrator({
371
- kind: 'mtls',
372
- host: '127.0.0.1',
373
- port: relay.port,
374
- sandboxId: 'sb-net',
375
- })
376
-
377
- const backend = buildFirecrackerBackend({
378
- orchestratorEndpoint: 'https://orchestrator.test/',
379
- getToken: async () => 'tok',
380
- readyTimeoutMs: 5_000,
381
- readyPollIntervalMs: 50,
382
- // The CONSUMER-injected client material — never returned by the
383
- // orchestrator. Merged onto the handle's `tls` block.
384
- mtls: {
385
- ca: CA_CRT,
386
- cert: CLIENT_CRT,
387
- key: CLIENT_KEY,
388
- servername: 'sandbox.fc.internal',
389
- },
390
- })
391
-
392
- const sandbox = await backend.create({ workingDirectory: workDir })
393
- expect(sandbox.status).toBe('ready')
394
-
395
- // The dialer wrote the `SANDBOX <id>` routing preamble (the relay's key).
396
- expect(await relay.preamble()).toBe('sb-net')
397
-
398
- // Full exec + file-IO round-trip over the mTLS tunnel.
399
- const r = await sandbox.exec('/bin/sh', ['-c', 'echo hello-mtls'])
400
- expect(r.stdout).toContain('hello-mtls')
401
- await sandbox.writeFile('out/r.txt', 'via-relay')
402
- expect((await sandbox.readFile('out/r.txt')).toString('utf8')).toBe('via-relay')
403
-
404
- await sandbox.destroy()
405
- expect(calls.some((c) => c.method === 'DELETE')).toBe(true)
406
- })
407
-
408
- it('rejects a create when the orchestrator returns mtls but no cert material is injected', async () => {
409
- server = await startAgent()
410
- relay = await startMtlsRelay(sockPath)
411
- stubOrchestrator({
412
- kind: 'mtls',
413
- host: '127.0.0.1',
414
- port: relay.port,
415
- sandboxId: 'sb-net',
416
- })
417
- const backend = buildFirecrackerBackend({
418
- orchestratorEndpoint: 'https://orchestrator.test/',
419
- getToken: async () => 'tok',
420
- readyTimeoutMs: 2_000,
421
- readyPollIntervalMs: 50,
422
- // No `mtls` material → normalizeHandle must throw at create time.
423
- })
424
- await expect(backend.create({ workingDirectory: workDir })).rejects.toThrow(
425
- /no client cert material was injected/,
426
- )
427
- })
428
- },
429
- )
@@ -1,261 +0,0 @@
1
- /**
2
- * Control-plane mTLS dial for the Firecracker backend (additive
3
- * `config.controlPlaneMtls`).
4
- *
5
- * A loopback `https.createServer` with `requestCert + rejectUnauthorized`
6
- * (pinned to the test CA) stands in for the orchestrator over the public hop.
7
- * It serves the real `POST /sandboxes` + `:delete` routes; the create response
8
- * points the agent handle at a unix-socket loopback running the REAL
9
- * `agent/agent.cjs`, so the FULL create flow (control-plane POST over mTLS →
10
- * vsock readiness fence → exec) runs end to end.
11
- *
12
- * Proves:
13
- * - with `controlPlaneMtls` injected, the create POST succeeds over mTLS and
14
- * the sandbox round-trips an exec.
15
- * - WITHOUT the client cert (no `controlPlaneMtls`, so the backend uses plain
16
- * `fetch` against the https listener), create is rejected at the TLS layer.
17
- */
18
-
19
- import { mkdtempSync, rmSync } from 'node:fs'
20
- import {
21
- type Server as HttpsServer,
22
- createServer as createHttpsServer,
23
- request as httpsRequest,
24
- } from 'node:https'
25
- import { createRequire } from 'node:module'
26
- import { type Server, type Socket, createServer } from 'node:net'
27
- import { tmpdir } from 'node:os'
28
- import { join } from 'node:path'
29
-
30
- import { afterEach, beforeEach, describe, expect, it } from 'vitest'
31
-
32
- import { buildFirecrackerBackend } from '../index.js'
33
- import { localIpcPath } from './fixtures/ipc-path.js'
34
- import {
35
- CA_CRT,
36
- CLIENT_CRT,
37
- CLIENT_KEY,
38
- ROGUE_CLIENT_CRT,
39
- ROGUE_CLIENT_KEY,
40
- SERVER_CRT,
41
- SERVER_KEY,
42
- } from './fixtures/mtls-pki.js'
43
- // The loopback agent under test is the guest-side agent for a Linux microVM:
44
- // it spawns `/bin/sh` to run commands, and that binary does not exist on
45
- // Windows. These cases assert behavior the platform cannot produce, so they
46
- // skip there rather than leaving the suite permanently red for Windows
47
- // contributors. The socket-address fixture IS platform-correct, so the
48
- // transport is still exercised wherever it can be.
49
- const IS_WINDOWS = process.platform === 'win32'
50
-
51
- const require_ = createRequire(import.meta.url)
52
-
53
- interface AgentModule {
54
- handleConnection(socket: Socket): void
55
- }
56
-
57
- let workDir: string
58
- let sockPath: string
59
- let agentServer: Server | undefined
60
- let orchestrator: HttpsServer | undefined
61
- let agent: AgentModule
62
-
63
- beforeEach(() => {
64
- workDir = mkdtempSync(join(tmpdir(), 'fc-cp-mtls-'))
65
- sockPath = localIpcPath(workDir)
66
- process.env.NAMZU_SANDBOX_WORKSPACE = workDir
67
- delete require_.cache[require_.resolve('../../../../agent/agent.cjs')]
68
- agent = require_('../../../../agent/agent.cjs') as AgentModule
69
- })
70
-
71
- afterEach(async () => {
72
- if (orchestrator) {
73
- await new Promise<void>((r) => orchestrator?.close(() => r()))
74
- orchestrator = undefined
75
- }
76
- if (agentServer) {
77
- await new Promise<void>((r) => agentServer?.close(() => r()))
78
- agentServer = undefined
79
- }
80
- rmSync(workDir, { recursive: true, force: true })
81
- })
82
-
83
- function startAgent(): Promise<Server> {
84
- return new Promise((resolve, reject) => {
85
- const s = createServer(agent.handleConnection)
86
- s.on('error', reject)
87
- s.listen(sockPath, () => resolve(s))
88
- })
89
- }
90
-
91
- /**
92
- * A loopback mTLS orchestrator. Requires + verifies a client cert chaining to
93
- * the test CA, then serves the control-plane routes: `POST /sandboxes` returns
94
- * a unix agent handle, `:delete` returns 204. Records calls so the test can
95
- * assert the create round-trip.
96
- */
97
- function startMtlsOrchestrator(handlePath: string): Promise<{
98
- server: HttpsServer
99
- port: number
100
- calls: Array<{ method: string; url: string }>
101
- }> {
102
- const calls: Array<{ method: string; url: string }> = []
103
- return new Promise((resolve, reject) => {
104
- const server = createHttpsServer(
105
- {
106
- cert: SERVER_CRT,
107
- key: SERVER_KEY,
108
- ca: CA_CRT,
109
- requestCert: true,
110
- rejectUnauthorized: true,
111
- minVersion: 'TLSv1.3',
112
- },
113
- (req, res) => {
114
- const method = req.method ?? 'GET'
115
- const url = req.url ?? '/'
116
- calls.push({ method, url })
117
- // Drain the body, then respond per route.
118
- req.on('data', () => {})
119
- req.on('end', () => {
120
- if (method === 'POST' && url.endsWith('/sandboxes')) {
121
- res.statusCode = 200
122
- res.setHeader('content-type', 'application/json')
123
- res.end(
124
- JSON.stringify({
125
- sandboxId: 'sbx_cp_mtls',
126
- agent: { kind: 'unix', path: handlePath },
127
- rootDir: workDir,
128
- }),
129
- )
130
- return
131
- }
132
- if (method === 'DELETE' && url.includes(':delete')) {
133
- res.statusCode = 204
134
- res.end()
135
- return
136
- }
137
- res.statusCode = 500
138
- res.end('unexpected')
139
- })
140
- },
141
- )
142
- server.on('error', reject)
143
- server.listen(0, '127.0.0.1', () => {
144
- const addr = server.address()
145
- if (addr === null || typeof addr === 'string') {
146
- reject(new Error('orchestrator: no TCP port'))
147
- return
148
- }
149
- resolve({ server, port: addr.port, calls })
150
- })
151
- })
152
- }
153
-
154
- describe.skipIf(IS_WINDOWS)('buildFirecrackerBackend (control-plane mTLS dial)', () => {
155
- it('create POST succeeds over mTLS and round-trips an exec', async () => {
156
- agentServer = await startAgent()
157
- const orch = await startMtlsOrchestrator(sockPath)
158
- orchestrator = orch.server
159
-
160
- const backend = buildFirecrackerBackend({
161
- // An https:// endpoint over the (loopback stand-in for the) public hop.
162
- orchestratorEndpoint: `https://127.0.0.1:${orch.port}/`,
163
- getToken: async () => 'tok',
164
- readyTimeoutMs: 5_000,
165
- readyPollIntervalMs: 50,
166
- // CONTROL-plane client material — injected by the consumer, never
167
- // shipped by the orchestrator. Drives the node:https mTLS dial.
168
- controlPlaneMtls: {
169
- ca: CA_CRT,
170
- cert: CLIENT_CRT,
171
- key: CLIENT_KEY,
172
- servername: 'sandbox.fc.internal',
173
- },
174
- })
175
-
176
- const sandbox = await backend.create({ workingDirectory: workDir })
177
- expect(sandbox.id).toBe('sbx_cp_mtls')
178
- expect(sandbox.status).toBe('ready')
179
-
180
- // The control-plane POST reached the mTLS orchestrator.
181
- expect(orch.calls.some((c) => c.method === 'POST' && c.url.endsWith('/sandboxes'))).toBe(true)
182
-
183
- // Exec over the vsock tunnel (the data plane), proving the whole flow.
184
- const r = await sandbox.exec('/bin/sh', ['-c', 'echo hello-cp-mtls'])
185
- expect(r.stdout).toContain('hello-cp-mtls')
186
-
187
- // destroy DELETE also rides the mTLS control plane.
188
- await sandbox.destroy()
189
- expect(orch.calls.some((c) => c.method === 'DELETE' && c.url.includes(':delete'))).toBe(true)
190
- })
191
-
192
- it('the backend without controlPlaneMtls cannot reach the mTLS orchestrator (plain fetch fails the handshake)', async () => {
193
- agentServer = await startAgent()
194
- const orch = await startMtlsOrchestrator(sockPath)
195
- orchestrator = orch.server
196
-
197
- // No `controlPlaneMtls` → the backend uses plain `fetch`, which neither
198
- // presents a client cert nor trusts the test CA, so the TLS handshake
199
- // fails and create rejects BEFORE any route runs.
200
- const backend = buildFirecrackerBackend({
201
- orchestratorEndpoint: `https://127.0.0.1:${orch.port}/`,
202
- getToken: async () => 'tok',
203
- readyTimeoutMs: 2_000,
204
- readyPollIntervalMs: 50,
205
- })
206
-
207
- await expect(backend.create({ workingDirectory: workDir })).rejects.toThrow(
208
- /failed to create microVM sandbox/,
209
- )
210
- expect(orch.calls.some((c) => c.method === 'POST' && c.url.endsWith('/sandboxes'))).toBe(false)
211
- })
212
-
213
- it("the orchestrator's requestCert rejects a client presenting NO cert (server-side proof)", async () => {
214
- // Isolate the SERVER's requestCert+rejectUnauthorized from any client-side
215
- // CA distrust: dial directly with node:https TRUSTING the server CA but
216
- // presenting NO client cert. The handshake must be rejected by the server.
217
- const orch = await startMtlsOrchestrator(sockPath)
218
- orchestrator = orch.server
219
- const err = await new Promise<Error | undefined>((resolve) => {
220
- const req = httpsRequest(
221
- {
222
- host: '127.0.0.1',
223
- port: orch.port,
224
- method: 'POST',
225
- path: '/sandboxes',
226
- ca: CA_CRT,
227
- servername: 'sandbox.fc.internal',
228
- },
229
- () => resolve(undefined),
230
- )
231
- req.on('error', (e: Error) => resolve(e))
232
- req.end()
233
- })
234
- expect(err).toBeInstanceOf(Error)
235
- expect(orch.calls.some((c) => c.method === 'POST')).toBe(false)
236
- })
237
-
238
- it("the orchestrator's requestCert rejects a client cert from a DIFFERENT CA", async () => {
239
- const orch = await startMtlsOrchestrator(sockPath)
240
- orchestrator = orch.server
241
- const err = await new Promise<Error | undefined>((resolve) => {
242
- const req = httpsRequest(
243
- {
244
- host: '127.0.0.1',
245
- port: orch.port,
246
- method: 'POST',
247
- path: '/sandboxes',
248
- ca: CA_CRT,
249
- cert: ROGUE_CLIENT_CRT,
250
- key: ROGUE_CLIENT_KEY,
251
- servername: 'sandbox.fc.internal',
252
- },
253
- () => resolve(undefined),
254
- )
255
- req.on('error', (e: Error) => resolve(e))
256
- req.end()
257
- })
258
- expect(err).toBeInstanceOf(Error)
259
- expect(orch.calls.some((c) => c.method === 'POST')).toBe(false)
260
- })
261
- })