@namzu/sandbox 1.1.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 (69) hide show
  1. package/CHANGELOG.md +474 -0
  2. package/LICENSE.md +110 -0
  3. package/README.md +148 -0
  4. package/dist/backends/aci-standby-pool/index.d.ts +104 -0
  5. package/dist/backends/aci-standby-pool/index.d.ts.map +1 -0
  6. package/dist/backends/aci-standby-pool/index.js +425 -0
  7. package/dist/backends/aci-standby-pool/index.js.map +1 -0
  8. package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.d.ts +40 -0
  9. package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.d.ts.map +1 -0
  10. package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.js +157 -0
  11. package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.js.map +1 -0
  12. package/dist/backends/docker/index.d.ts +118 -0
  13. package/dist/backends/docker/index.d.ts.map +1 -0
  14. package/dist/backends/docker/index.js +645 -0
  15. package/dist/backends/docker/index.js.map +1 -0
  16. package/dist/backends/firecracker/__tests__/backend.test.d.ts +13 -0
  17. package/dist/backends/firecracker/__tests__/backend.test.d.ts.map +1 -0
  18. package/dist/backends/firecracker/__tests__/backend.test.js +353 -0
  19. package/dist/backends/firecracker/__tests__/backend.test.js.map +1 -0
  20. package/dist/backends/firecracker/__tests__/control-plane-mtls.test.d.ts +19 -0
  21. package/dist/backends/firecracker/__tests__/control-plane-mtls.test.d.ts.map +1 -0
  22. package/dist/backends/firecracker/__tests__/control-plane-mtls.test.js +201 -0
  23. package/dist/backends/firecracker/__tests__/control-plane-mtls.test.js.map +1 -0
  24. package/dist/backends/firecracker/__tests__/fixtures/mtls-pki.d.ts +39 -0
  25. package/dist/backends/firecracker/__tests__/fixtures/mtls-pki.d.ts.map +1 -0
  26. package/dist/backends/firecracker/__tests__/fixtures/mtls-pki.js +149 -0
  27. package/dist/backends/firecracker/__tests__/fixtures/mtls-pki.js.map +1 -0
  28. package/dist/backends/firecracker/__tests__/protocol.test.d.ts +6 -0
  29. package/dist/backends/firecracker/__tests__/protocol.test.d.ts.map +1 -0
  30. package/dist/backends/firecracker/__tests__/protocol.test.js +77 -0
  31. package/dist/backends/firecracker/__tests__/protocol.test.js.map +1 -0
  32. package/dist/backends/firecracker/__tests__/transport.test.d.ts +20 -0
  33. package/dist/backends/firecracker/__tests__/transport.test.d.ts.map +1 -0
  34. package/dist/backends/firecracker/__tests__/transport.test.js +449 -0
  35. package/dist/backends/firecracker/__tests__/transport.test.js.map +1 -0
  36. package/dist/backends/firecracker/index.d.ts +124 -0
  37. package/dist/backends/firecracker/index.d.ts.map +1 -0
  38. package/dist/backends/firecracker/index.js +334 -0
  39. package/dist/backends/firecracker/index.js.map +1 -0
  40. package/dist/backends/firecracker/protocol.d.ts +132 -0
  41. package/dist/backends/firecracker/protocol.d.ts.map +1 -0
  42. package/dist/backends/firecracker/protocol.js +112 -0
  43. package/dist/backends/firecracker/protocol.js.map +1 -0
  44. package/dist/backends/firecracker/transport.d.ts +251 -0
  45. package/dist/backends/firecracker/transport.d.ts.map +1 -0
  46. package/dist/backends/firecracker/transport.js +524 -0
  47. package/dist/backends/firecracker/transport.js.map +1 -0
  48. package/dist/index.d.ts +611 -0
  49. package/dist/index.d.ts.map +1 -0
  50. package/dist/index.js +376 -0
  51. package/dist/index.js.map +1 -0
  52. package/dist/index.test.d.ts +28 -0
  53. package/dist/index.test.d.ts.map +1 -0
  54. package/dist/index.test.js +670 -0
  55. package/dist/index.test.js.map +1 -0
  56. package/package.json +54 -0
  57. package/src/backends/aci-standby-pool/index.ts +602 -0
  58. package/src/backends/docker/__tests__/leaf-permissions.smoke.test.ts +169 -0
  59. package/src/backends/docker/index.ts +826 -0
  60. package/src/backends/firecracker/__tests__/backend.test.ts +418 -0
  61. package/src/backends/firecracker/__tests__/control-plane-mtls.test.ts +253 -0
  62. package/src/backends/firecracker/__tests__/fixtures/mtls-pki.ts +166 -0
  63. package/src/backends/firecracker/__tests__/protocol.test.ts +90 -0
  64. package/src/backends/firecracker/__tests__/transport.test.ts +526 -0
  65. package/src/backends/firecracker/index.ts +528 -0
  66. package/src/backends/firecracker/protocol.ts +191 -0
  67. package/src/backends/firecracker/transport.ts +667 -0
  68. package/src/index.test.ts +731 -0
  69. package/src/index.ts +930 -0
@@ -0,0 +1,528 @@
1
+ /**
2
+ * `microvm:self-hosted` (Firecracker) backend.
3
+ *
4
+ * Sibling of `docker/` and `aci-standby-pool/`: same
5
+ * {@link SandboxBackend} surface, same NDJSON exec-stream + base64
6
+ * file-IO **wire contract**, different transport and shipping
7
+ * mechanism.
8
+ *
9
+ * - docker `docker run`s a container and reaches an HTTP worker on a
10
+ * host loopback port.
11
+ * - aci PUTs an ACI container group and reaches the same HTTP worker
12
+ * over the group's IP.
13
+ * - firecracker POSTs the owned Azure **orchestrator** to CoW-resume a
14
+ * microVM off the golden snapshot, then reaches a **custom vsock
15
+ * agent** baked into the golden rootfs over the **vsock transport**
16
+ * (`transport.ts`). The wire FORMAT is identical (see `protocol.ts`);
17
+ * only the transport differs — HTTP for docker/aci, framed-over-vsock
18
+ * for FC, because across an FC snapshot resume a TCP control channel
19
+ * is dead-on-arrival while the vsock LISTEN socket survives
20
+ * (FC `snapshot-support.md`).
21
+ *
22
+ * ## Why this is a remote-copy backend (no host bind-mounts)
23
+ * A sibling process on the FC host cannot see the microVM's
24
+ * filesystem, so — exactly like ACI — the workspace is seeded by
25
+ * archive-sync over the control channel (tar.gz `writeFile` +
26
+ * in-sandbox `tar -xzf` via `exec`), driven by the Vandal-side
27
+ * lifecycle (`workspace-sync.ts`). This backend therefore exposes no
28
+ * `layout` mount rendering; it only needs the `outputs` container path
29
+ * as the workspace root, which the orchestrator returns as `rootDir`.
30
+ *
31
+ * ## Trust model
32
+ * The microVM is a hardware-virtualization (KVM) trust boundary —
33
+ * stronger than docker namespaces or ACI's managed isolation. The
34
+ * vsock control channel never traverses the guest's egress netns; the
35
+ * agent listens on a fixed AF_VSOCK port captured warm in the golden
36
+ * snapshot. Auth to the orchestrator rides a caller-supplied
37
+ * `getToken()` closure (the ACI `getArmToken` pattern) so
38
+ * `@namzu/sandbox` keeps zero Azure-SDK dependencies.
39
+ *
40
+ * ## Auth / SDK-dependency boundary
41
+ * `getToken()` mirrors ACI's `getArmToken`: the consumer's runtime
42
+ * owns Managed-Identity / federated-credential picking; this package
43
+ * only calls the closure on every orchestrator HTTP call so a
44
+ * long-running sandbox survives token rotation.
45
+ */
46
+
47
+ import https from 'node:https'
48
+
49
+ import type {
50
+ Sandbox,
51
+ SandboxEnvironment,
52
+ SandboxExecOptions,
53
+ SandboxExecResult,
54
+ SandboxFileEntry,
55
+ SandboxId,
56
+ SandboxStatus,
57
+ } from '@namzu/sdk'
58
+
59
+ import type { AgentSnapshotRef, SandboxBackend, SandboxBackendOptions } from '../../index.js'
60
+ import type {
61
+ MtlsClientMaterial,
62
+ SandboxAgentHandle,
63
+ VsockTransportOptions,
64
+ WireSandboxAgentHandle,
65
+ } from './transport.js'
66
+ import { VsockAgentTransport } from './transport.js'
67
+
68
+ /**
69
+ * Async callback returning a fresh bearer token for
70
+ * {@link FirecrackerBackendInternalConfig.orchestratorEndpoint}.
71
+ * Invoked on every orchestrator HTTP call (mirrors ACI's
72
+ * `ArmTokenProvider`).
73
+ */
74
+ export type OrchestratorTokenProvider = () => Promise<string>
75
+
76
+ export interface FirecrackerBackendInternalConfig {
77
+ /** Base URL of the owned Azure control plane / orchestrator. */
78
+ readonly orchestratorEndpoint: string
79
+ /** Bearer token provider for `orchestratorEndpoint`. */
80
+ readonly getToken: OrchestratorTokenProvider
81
+ /** Golden template / snapshot revision id to resume from. */
82
+ readonly template?: string
83
+ /**
84
+ * Per-agent captured snapshot to resume INSTEAD of a fresh golden boot
85
+ * (layered on its base golden). Optional + additive: absent ⇒ the
86
+ * orchestrator create body is byte-identical and the generic golden-resume
87
+ * path is unchanged. The orchestrator honors it (routing the claim to a
88
+ * per-agent resume) only when its own per-agent flag is on; it is the WIRE
89
+ * carrier the host's per-agent trigger path sets. See {@link AgentSnapshotRef}.
90
+ */
91
+ readonly agentSnapshot?: AgentSnapshotRef
92
+ /** Fixed guest AF_VSOCK port the agent listens on (contract port). */
93
+ readonly agentVsockPort?: number
94
+ readonly readyTimeoutMs?: number
95
+ readonly readyPollIntervalMs?: number
96
+ /** Transport tuning forwarded to {@link VsockAgentTransport}. */
97
+ readonly transport?: VsockTransportOptions
98
+ /**
99
+ * NETWORK-mode mTLS client material (ses_051 P4). When present AND the
100
+ * orchestrator returns an `mtls` agent handle, this CA/cert/key is MERGED
101
+ * onto the handle's `tls` block before the transport dials the per-host
102
+ * relay. The consumer's runtime injects it (mirrors `getToken`); this
103
+ * package never reads it from disk or fetches it, so it stays Azure-SDK
104
+ * free. Absent for the single-host VSOCK default (the live proofs).
105
+ */
106
+ readonly mtls?: MtlsClientMaterial
107
+ /**
108
+ * CONTROL-plane mTLS client material. When present, the orchestrator
109
+ * control-plane calls (`POST /sandboxes`, `DELETE /sandboxes/{id}:delete`,
110
+ * `GET /capacity`) dial over mTLS — presenting this client cert and
111
+ * verifying the orchestrator's server cert against this CA — INSTEAD of the
112
+ * plain `fetch`. This secures the control plane when `orchestratorEndpoint`
113
+ * is reached over the PUBLIC internet (the `ca-vandal-app` → FC-host hop is
114
+ * not VNet-integrated), where the shared-secret bearer alone would be
115
+ * exposed. The bearer is STILL sent (defense in depth on top of mTLS).
116
+ *
117
+ * Reuses the SAME `{ca,cert,key,servername}` shape as the relay's `mtls`
118
+ * material — one fleet CA secures both planes. The consumer's runtime
119
+ * injects the bytes (mirrors `getToken` / `mtls`); the package never reads
120
+ * them from disk. Absent → the control plane stays plain `fetch` (the
121
+ * single-host DEFAULT, byte-for-byte unchanged).
122
+ */
123
+ readonly controlPlaneMtls?: MtlsClientMaterial
124
+ }
125
+
126
+ const DEFAULT_AGENT_VSOCK_PORT = 1024
127
+ const DEFAULT_READY_TIMEOUT_MS = 60_000
128
+ const DEFAULT_READY_POLL_MS = 250
129
+
130
+ /**
131
+ * Build a {@link SandboxBackend} backed by the owned Firecracker
132
+ * platform. Construction is synchronous; the orchestrator POST happens
133
+ * on the first `create()`.
134
+ */
135
+ export function buildFirecrackerBackend(config: FirecrackerBackendInternalConfig): SandboxBackend {
136
+ return {
137
+ tier: 'microvm',
138
+ name: 'firecracker',
139
+ async create(options: SandboxBackendOptions): Promise<Sandbox> {
140
+ return await spawnFirecrackerSandbox(config, options)
141
+ },
142
+ }
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Orchestrator wire (the create/destroy control plane)
147
+ // ---------------------------------------------------------------------------
148
+
149
+ interface OrchestratorCreateRequest {
150
+ readonly template?: string
151
+ readonly memoryLimitMb?: number
152
+ readonly maxProcesses?: number
153
+ readonly timeoutMs?: number
154
+ /**
155
+ * Resolved egress allowlist for the run, materialised by the host
156
+ * into per-VM nftables rules (deny-all default). The backend just
157
+ * forwards the resolved hostnames; it does not own the firewall.
158
+ */
159
+ readonly egressAllowlist?: readonly string[]
160
+ /**
161
+ * Per-agent captured snapshot to resume (layered on the base golden).
162
+ * Optional + additive: omitted from the POST body when undefined (the
163
+ * spread below drops it), so a generic create is byte-identical to the
164
+ * pre-field wire shape. The orchestrator routes the claim to a per-agent
165
+ * resume only when present AND its own per-agent flag is on; otherwise it
166
+ * ignores it and runs the generic golden claim. See {@link AgentSnapshotRef}.
167
+ */
168
+ readonly agentSnapshot?: AgentSnapshotRef
169
+ }
170
+
171
+ /**
172
+ * Orchestrator `create` response. Carries the sandbox id, the
173
+ * addressable vsock endpoint (see {@link SandboxAgentHandle}), and the
174
+ * workspace root path the agent resolves against.
175
+ */
176
+ interface OrchestratorCreateResponse {
177
+ readonly sandboxId: string
178
+ readonly agent: WireSandboxAgentHandle
179
+ readonly rootDir: string
180
+ }
181
+
182
+ /** A transport-agnostic view of the orchestrator response the dispatch needs. */
183
+ interface OrchestratorRawResponse {
184
+ readonly status: number
185
+ readonly contentType: string
186
+ readonly text: () => Promise<string>
187
+ }
188
+
189
+ async function orchestratorCall<T>(
190
+ endpoint: string,
191
+ pathSuffix: string,
192
+ method: 'POST' | 'DELETE',
193
+ getToken: OrchestratorTokenProvider,
194
+ body?: unknown,
195
+ mtls?: MtlsClientMaterial,
196
+ ): Promise<T | undefined> {
197
+ const token = await getToken()
198
+ const url = `${endpoint.replace(/\/+$/, '')}${pathSuffix}`
199
+ const payload = body !== undefined ? JSON.stringify(body) : undefined
200
+ const headers: Record<string, string> = {
201
+ Authorization: `Bearer ${token}`,
202
+ 'content-type': 'application/json',
203
+ }
204
+
205
+ let res: OrchestratorRawResponse
206
+ try {
207
+ // When CONTROL-plane mTLS material is injected the call dials over a
208
+ // node:https request presenting the client cert + pinning the server CA
209
+ // (the public-internet hop). Without it the EXISTING plain `fetch` path
210
+ // runs unchanged (the single-host default). node:https is used rather
211
+ // than fetch+undici-dispatcher because the package declares no undici
212
+ // dependency — node:https is always importable and needs nothing added.
213
+ res = mtls
214
+ ? await httpsOrchestratorRequest(url, method, headers, payload, mtls)
215
+ : await fetchOrchestratorRequest(url, method, headers, payload)
216
+ } catch (err) {
217
+ const cause = err instanceof Error ? err.cause : undefined
218
+ throw new Error(
219
+ `firecracker orchestrator ${method} ${url} failed: ${
220
+ err instanceof Error ? err.message : String(err)
221
+ }${cause ? ` — cause: ${cause instanceof Error ? cause.message : String(cause)}` : ''}`,
222
+ { cause: err },
223
+ )
224
+ }
225
+ if (res.status < 200 || res.status >= 300) {
226
+ throw new Error(
227
+ `firecracker orchestrator ${method} ${url} → ${res.status}: ${await res.text()}`,
228
+ )
229
+ }
230
+ if (res.status === 204) return undefined
231
+ if (res.contentType.includes('application/json')) return JSON.parse(await res.text()) as T
232
+ return undefined
233
+ }
234
+
235
+ /** The plain-`fetch` control-plane path — the single-host DEFAULT, unchanged. */
236
+ async function fetchOrchestratorRequest(
237
+ url: string,
238
+ method: 'POST' | 'DELETE',
239
+ headers: Record<string, string>,
240
+ payload: string | undefined,
241
+ ): Promise<OrchestratorRawResponse> {
242
+ const init: RequestInit = { method, headers }
243
+ if (payload !== undefined) init.body = payload
244
+ const res = await fetch(url, init)
245
+ return {
246
+ status: res.status,
247
+ contentType: res.headers.get('content-type') ?? '',
248
+ text: () => res.text(),
249
+ }
250
+ }
251
+
252
+ /**
253
+ * The mTLS control-plane path: a node:https request presenting the injected
254
+ * client cert and verifying the orchestrator's server cert against the injected
255
+ * CA (`rejectUnauthorized: true`). Used only when `controlPlaneMtls` is present;
256
+ * the plain-`fetch` path above stays the default for single-host.
257
+ */
258
+ function httpsOrchestratorRequest(
259
+ url: string,
260
+ method: 'POST' | 'DELETE',
261
+ headers: Record<string, string>,
262
+ payload: string | undefined,
263
+ mtls: MtlsClientMaterial,
264
+ ): Promise<OrchestratorRawResponse> {
265
+ const target = new URL(url)
266
+ return new Promise<OrchestratorRawResponse>((resolve, reject) => {
267
+ const req = https.request(
268
+ {
269
+ protocol: target.protocol,
270
+ hostname: target.hostname,
271
+ port: target.port !== '' ? Number(target.port) : 443,
272
+ path: `${target.pathname}${target.search}`,
273
+ method,
274
+ headers,
275
+ ca: mtls.ca,
276
+ cert: mtls.cert,
277
+ key: mtls.key,
278
+ rejectUnauthorized: true,
279
+ minVersion: 'TLSv1.3',
280
+ ...(mtls.servername !== undefined ? { servername: mtls.servername } : {}),
281
+ },
282
+ (res) => {
283
+ const chunks: Buffer[] = []
284
+ res.on('data', (chunk: Buffer) => chunks.push(chunk))
285
+ res.on('end', () => {
286
+ const ct = res.headers['content-type']
287
+ resolve({
288
+ status: res.statusCode ?? 0,
289
+ contentType: Array.isArray(ct) ? (ct[0] ?? '') : (ct ?? ''),
290
+ text: async () => Buffer.concat(chunks).toString('utf8'),
291
+ })
292
+ })
293
+ res.on('error', reject)
294
+ },
295
+ )
296
+ req.on('error', reject)
297
+ if (payload !== undefined) req.write(payload)
298
+ req.end()
299
+ })
300
+ }
301
+
302
+ // ---------------------------------------------------------------------------
303
+ // create()
304
+ // ---------------------------------------------------------------------------
305
+
306
+ async function spawnFirecrackerSandbox(
307
+ config: FirecrackerBackendInternalConfig,
308
+ options: SandboxBackendOptions,
309
+ ): Promise<Sandbox> {
310
+ const endpoint = config.orchestratorEndpoint
311
+ const createBody: OrchestratorCreateRequest = {
312
+ ...(config.template !== undefined ? { template: config.template } : {}),
313
+ ...(config.agentSnapshot !== undefined ? { agentSnapshot: config.agentSnapshot } : {}),
314
+ ...(options.memoryLimitMb !== undefined ? { memoryLimitMb: options.memoryLimitMb } : {}),
315
+ ...(options.maxProcesses !== undefined ? { maxProcesses: options.maxProcesses } : {}),
316
+ ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
317
+ ...(resolveEgressAllowlist(options) !== undefined
318
+ ? { egressAllowlist: resolveEgressAllowlist(options) }
319
+ : {}),
320
+ }
321
+
322
+ let created: OrchestratorCreateResponse | undefined
323
+ try {
324
+ created = await orchestratorCall<OrchestratorCreateResponse>(
325
+ endpoint,
326
+ '/sandboxes',
327
+ 'POST',
328
+ config.getToken,
329
+ createBody,
330
+ config.controlPlaneMtls,
331
+ )
332
+ } catch (err) {
333
+ throw new Error(
334
+ `firecracker: failed to create microVM sandbox — ${
335
+ err instanceof Error ? err.message : String(err)
336
+ }`,
337
+ { cause: err },
338
+ )
339
+ }
340
+ if (!created || !created.sandboxId || !created.agent) {
341
+ throw new Error('firecracker: orchestrator create returned no sandboxId / agent handle')
342
+ }
343
+
344
+ const id = created.sandboxId as SandboxId
345
+ const rootDir = created.rootDir
346
+ // Normalise the orchestrator handle: fill the contract vsock port when a
347
+ // vsock handle omits it, and MERGE the consumer-injected mTLS material onto
348
+ // a network (`mtls`) handle so the transport can dial the relay. The
349
+ // orchestrator returns an `mtls` handle WITHOUT cert material (host/port/
350
+ // sandboxId only); the certs are injected here from `config.mtls`, never
351
+ // shipped by the control plane.
352
+ const handle = normalizeHandle(
353
+ created.agent,
354
+ config.agentVsockPort ?? DEFAULT_AGENT_VSOCK_PORT,
355
+ config.mtls,
356
+ )
357
+ const transport = new VsockAgentTransport(handle, config.transport ?? {})
358
+
359
+ const destroy = async (): Promise<void> => {
360
+ await orchestratorCall(
361
+ endpoint,
362
+ `/sandboxes/${encodeURIComponent(id)}:delete`,
363
+ 'DELETE',
364
+ config.getToken,
365
+ undefined,
366
+ config.controlPlaneMtls,
367
+ )
368
+ }
369
+
370
+ try {
371
+ // Readiness fence: the orchestrator's resume returns BEFORE the
372
+ // guest agent has reseeded entropy and re-listened on vsock. Stop
373
+ // the clock on the agent's healthz, exactly as the HTTP backends
374
+ // wait on `/healthz` — never on the orchestrator's 2xx, which
375
+ // fires before the guest runs (§5 clock semantics).
376
+ await transport.waitForReady(
377
+ config.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS,
378
+ config.readyPollIntervalMs ?? DEFAULT_READY_POLL_MS,
379
+ )
380
+ } catch (err) {
381
+ // Best-effort orchestrator teardown so a readiness failure does not
382
+ // orphan a microVM/netns/UFFD handler (the reaper backstops, but
383
+ // surfacing the delete failure keeps the leak observable).
384
+ try {
385
+ await destroy()
386
+ } catch {
387
+ // Preserve the readiness error as primary.
388
+ }
389
+ throw err
390
+ }
391
+
392
+ let status: SandboxStatus = 'ready'
393
+
394
+ return {
395
+ id,
396
+ get status(): SandboxStatus {
397
+ return status
398
+ },
399
+ rootDir,
400
+ environment: detectEnvironment(),
401
+
402
+ async exec(
403
+ command: string,
404
+ argv?: string[],
405
+ opts?: SandboxExecOptions,
406
+ ): Promise<SandboxExecResult> {
407
+ status = 'busy'
408
+ try {
409
+ return await transport.execute({
410
+ command,
411
+ args: argv ?? [],
412
+ ...(opts?.cwd !== undefined ? { cwd: opts.cwd } : {}),
413
+ ...(opts?.env !== undefined ? { env: opts.env } : {}),
414
+ ...(opts?.timeout !== undefined ? { timeoutMs: opts.timeout } : {}),
415
+ })
416
+ } finally {
417
+ status = 'ready'
418
+ }
419
+ },
420
+
421
+ async writeFile(path: string, content: string | Buffer): Promise<void> {
422
+ const buf = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8')
423
+ await transport.writeFile(path, buf)
424
+ },
425
+
426
+ async readFile(path: string): Promise<Buffer> {
427
+ return await transport.readFile(path)
428
+ },
429
+
430
+ async listFiles(rootPath: string): Promise<readonly SandboxFileEntry[]> {
431
+ // Same wire as docker/aci: `find -printf '%p\t%s\n'`, parse
432
+ // line-by-line, map a non-zero exit (missing root) to "empty".
433
+ const result = await transport.execute({
434
+ command: 'find',
435
+ args: [rootPath, '-type', 'f', '-printf', '%p\t%s\n'],
436
+ })
437
+ if (result.exitCode !== 0) return []
438
+ const entries: SandboxFileEntry[] = []
439
+ for (const rawLine of result.stdout.split('\n')) {
440
+ if (!rawLine) continue
441
+ const tab = rawLine.indexOf('\t')
442
+ if (tab < 0) continue
443
+ const filePath = rawLine.slice(0, tab)
444
+ const size = Number.parseInt(rawLine.slice(tab + 1), 10)
445
+ if (!filePath || !Number.isFinite(size)) continue
446
+ entries.push({ path: filePath, size })
447
+ }
448
+ return entries
449
+ },
450
+
451
+ async destroy(): Promise<void> {
452
+ status = 'destroyed'
453
+ // Let the orchestrator DELETE failure propagate — the
454
+ // Vandal-side lifecycle wraps this with logging, and a
455
+ // swallowed error here means orphaned microVMs (and their
456
+ // netns / UFFD handlers) pile up with no observability handle.
457
+ await destroy()
458
+ },
459
+ }
460
+ }
461
+
462
+ // ---------------------------------------------------------------------------
463
+ // Helpers
464
+ // ---------------------------------------------------------------------------
465
+
466
+ function resolveEgressAllowlist(options: SandboxBackendOptions): readonly string[] | undefined {
467
+ const egress = options.egress
468
+ if (!egress) return undefined
469
+ if (egress.kind === 'static') return egress.allowedHosts
470
+ // `deny-all` → empty allowlist (explicit). `allow-all` / `resolver`
471
+ // are resolved by the host before create when they apply; the
472
+ // backend forwards only the static, already-resolved shape. A
473
+ // resolver-shaped policy is the Vandal lifecycle's job to resolve
474
+ // upstream and pass as `static`.
475
+ if (egress.kind === 'deny-all') return []
476
+ return undefined
477
+ }
478
+
479
+ /**
480
+ * Turn the orchestrator's WIRE handle into the transport handle the dialer
481
+ * uses. Two transforms:
482
+ *
483
+ * - `vsock` with a missing/<=0 port → fill the contract port.
484
+ * - `mtls` → MERGE the consumer-injected `mtls` cert material onto the
485
+ * wire handle (which carries only host/port/sandboxId). The orchestrator
486
+ * never ships cert material; without an injected `mtls` block a network
487
+ * handle cannot dial, so this throws loud rather than constructing a
488
+ * transport that would fail at connect time.
489
+ *
490
+ * `unix` + already-correct `vsock` handles pass through untouched.
491
+ */
492
+ export function normalizeHandle(
493
+ handle: WireSandboxAgentHandle,
494
+ contractPort: number,
495
+ mtls: MtlsClientMaterial | undefined,
496
+ ): SandboxAgentHandle {
497
+ if (handle.kind === 'mtls') {
498
+ if (!mtls) {
499
+ throw new Error(
500
+ 'firecracker: orchestrator returned an mtls agent handle but no client cert material was injected ' +
501
+ '(the Vandal host layer must supply VANDAL_SANDBOX_FC_TLS_CA/_CERT/_KEY in network mode)',
502
+ )
503
+ }
504
+ return {
505
+ kind: 'mtls',
506
+ host: handle.host,
507
+ port: handle.port,
508
+ sandboxId: handle.sandboxId,
509
+ tls: {
510
+ ca: mtls.ca,
511
+ cert: mtls.cert,
512
+ key: mtls.key,
513
+ ...(mtls.servername !== undefined ? { servername: mtls.servername } : {}),
514
+ },
515
+ }
516
+ }
517
+ if (handle.kind === 'vsock' && (!handle.port || handle.port <= 0)) {
518
+ return { kind: 'vsock', udsPath: handle.udsPath, port: contractPort }
519
+ }
520
+ return handle
521
+ }
522
+
523
+ function detectEnvironment(): SandboxEnvironment {
524
+ // Firecracker guests run Linux; the agent is a Linux-namespace-style
525
+ // worker from the SDK's perspective (the enum is host-shape, not
526
+ // guest internals).
527
+ return 'linux-namespace'
528
+ }