@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,826 @@
1
+ /**
2
+ * `container:docker` backend.
3
+ *
4
+ * Spawns one Docker container per `Sandbox` instance via the
5
+ * `docker` CLI (no node-docker SDK dependency — keeps the package
6
+ * thin). The container runs the small HTTP worker shipped under
7
+ * `packages/sandbox/worker/server.js`; the host adapter talks to
8
+ * it on `127.0.0.1:<random-port>`.
9
+ *
10
+ * One container per sandbox, not one per `exec` call: keeps cold-
11
+ * start out of the hot path. The container goes away in
12
+ * `destroy()`.
13
+ *
14
+ * Trust model:
15
+ * - Container is the trust boundary; everything inside is treated
16
+ * as untrusted code.
17
+ * - Worker only listens on loopback inside its own netns; the
18
+ * host adapter reaches it via Docker's port-forward.
19
+ * - Outbound network from the worker is restricted by host-side
20
+ * firewall config (see {@link DockerBackendConfig.network}) plus
21
+ * the egress proxy when one is configured (P3.2).
22
+ */
23
+
24
+ import { spawn } from 'node:child_process'
25
+
26
+ import {
27
+ type ContainerSandboxLayout,
28
+ type ContainerSandboxLayoutMount,
29
+ type ResolvedContainerSandboxLayout,
30
+ SANDBOX_DEFAULT_OUTPUTS_PATH,
31
+ SANDBOX_DEFAULT_SCRATCH_PATH,
32
+ SANDBOX_DEFAULT_SKILLS_PARENT,
33
+ SANDBOX_DEFAULT_TOOL_RESULTS_PATH,
34
+ SANDBOX_DEFAULT_TRANSCRIPTS_PATH,
35
+ SANDBOX_DEFAULT_UPLOADS_PATH,
36
+ type Sandbox,
37
+ type SandboxEnvironment,
38
+ type SandboxExecOptions,
39
+ type SandboxExecResult,
40
+ type SandboxFileEntry,
41
+ type SandboxId,
42
+ type SandboxStatus,
43
+ } from '@namzu/sdk'
44
+
45
+ import {
46
+ ContainerSandboxLayoutValidationError,
47
+ type SandboxBackend,
48
+ type SandboxBackendOptions,
49
+ } from '../../index.js'
50
+
51
+ /**
52
+ * Backend-specific tuning. Most hosts use the defaults; advanced
53
+ * deployments override `image` to point at their own pre-built
54
+ * image, or pin `dockerBinary` for non-standard installs.
55
+ *
56
+ * The container's mount layout is baked in at provider construction
57
+ * via {@link DockerBackendInternalConfig.layout} — every `create()`
58
+ * call inherits the same layout. This is by design: per-task hosts
59
+ * call `createSandboxProvider` once per task, with that task's
60
+ * layout. There is no per-call layout argument, so the SDK runtime
61
+ * cannot accidentally call a docker provider without one.
62
+ */
63
+ export interface DockerBackendInternalConfig {
64
+ readonly image: string
65
+ /**
66
+ * Pre-resolved layout. Construction-time `resolveLayout` validates
67
+ * and applies defaults; the docker backend renders mount flags
68
+ * directly from this without re-validating.
69
+ */
70
+ readonly layout: ResolvedContainerSandboxLayout
71
+ readonly dockerBinary?: string
72
+ readonly network?: 'none' | 'bridge' | string
73
+ readonly readyPollIntervalMs?: number
74
+ readonly readyTimeoutMs?: number
75
+ /**
76
+ * Docker runtime to launch the container under. Default `runc`
77
+ * (vanilla Docker namespaces, what Docker Desktop ships). Linux
78
+ * production deployments that have registered gVisor on the host
79
+ * daemon can pass `runsc` to upgrade to a userspace-kernel trust
80
+ * boundary — same primitive Modal Labs and OpenAI Code Interpreter
81
+ * ship. Hosts can also pass a custom runtime name registered in
82
+ * `daemon.json`. macOS Docker Desktop has no `runsc` runtime, so
83
+ * the default `runc` is the only option there; that's documented
84
+ * as the local-dev tier in the package README.
85
+ */
86
+ readonly runtime?: 'runc' | 'runsc' | string
87
+ /**
88
+ * How the SDK consumer reaches the in-container worker:
89
+ *
90
+ * - `'host-port'` (default): publish the worker port on the
91
+ * host loopback (`127.0.0.1::<random>`) and connect by host
92
+ * port. Works when the SDK runs ON the docker host (CLI,
93
+ * direct dev). Backward-compatible — the original behaviour.
94
+ *
95
+ * - `'container-network'`: skip `--publish` entirely, attach
96
+ * the spawned container to a shared docker bridge that the
97
+ * SDK consumer is also on, and connect by container DNS name
98
+ * (`http://<containerName>:2024`). Required when the SDK
99
+ * runs INSIDE a container (e.g. Vandal's app container
100
+ * spawning sibling sandbox containers via the host's Docker
101
+ * daemon — `127.0.0.1` inside the app is the app, not the
102
+ * sandbox). The shared bridge name comes from `config.network`.
103
+ */
104
+ readonly hostReachability?: 'host-port' | 'container-network'
105
+ /**
106
+ * Optional `--label key=value` pairs applied to the spawned
107
+ * container at `docker run` time. Used by hosts that need to
108
+ * find their containers from out-of-band code paths (reaper jobs,
109
+ * monitoring filters) via `docker ps --filter label=…`. Keys
110
+ * containing `=` or empty names throw at spawn time — the docker
111
+ * CLI accepts them but the resulting label split is ambiguous.
112
+ */
113
+ readonly labels?: Readonly<Record<string, string>>
114
+ }
115
+
116
+ const DEFAULT_DOCKER_BINARY = 'docker'
117
+ const DEFAULT_READY_POLL_MS = 100
118
+ const DEFAULT_READY_TIMEOUT_MS = 30_000
119
+ const WORKER_PORT_INSIDE_CONTAINER = 2024
120
+
121
+ /**
122
+ * Build a {@link SandboxBackend} backed by Docker. Construction is
123
+ * synchronous; the actual container spawns on the first
124
+ * `create()` call.
125
+ */
126
+ export function buildDockerBackend(config: DockerBackendInternalConfig): SandboxBackend {
127
+ return {
128
+ tier: 'container',
129
+ name: 'docker',
130
+ async create(options: SandboxBackendOptions) {
131
+ return await spawnDockerSandbox(config, options)
132
+ },
133
+ }
134
+ }
135
+
136
+ async function spawnDockerSandbox(
137
+ config: DockerBackendInternalConfig,
138
+ options: SandboxBackendOptions,
139
+ ): Promise<Sandbox> {
140
+ const resolvedLayout = config.layout
141
+ const id = generateSandboxId()
142
+ const docker = config.dockerBinary ?? DEFAULT_DOCKER_BINARY
143
+ const network = config.network ?? 'none'
144
+ const runtime = config.runtime
145
+ const hostReachability = config.hostReachability ?? 'host-port'
146
+ const containerName = `namzu-sandbox-${id}`
147
+
148
+ // All bind sources come from the consumer-supplied layout. The
149
+ // backend never allocates host directories and never removes them
150
+ // — that pre-existing single-mount mkdtemp path was the source of
151
+ // the EACCES bug in sibling-container setups (the consumer owns
152
+ // the host filesystem, the spawned backend can't reach it from
153
+ // inside its own container's mount namespace). Clean break.
154
+ let containerStarted = false
155
+
156
+ async function cleanupOnFailure() {
157
+ if (containerStarted) {
158
+ await runOnceQuiet(docker, ['rm', '-f', containerName])
159
+ }
160
+ }
161
+
162
+ let hostPort: number
163
+ let baseUrl: string
164
+ // `outputs` is required by validation, so its containerPath is
165
+ // always available — the worker uses it as its workspace root.
166
+ const rootDir = resolvedLayout.outputs.containerPath
167
+
168
+ try {
169
+ // Let Docker pick the host port instead of pre-reserving one
170
+ // in this process. The reservePort()-then-publish-fixed-port
171
+ // pattern had a TOCTOU window: the OS could hand the port to
172
+ // another process between our `server.close()` and Docker's
173
+ // `bind()`. Letting Docker pick (`--publish-all`) and reading
174
+ // the mapping back via `docker inspect` removes the race.
175
+ const args: string[] = [
176
+ 'run',
177
+ '--detach',
178
+ '--rm',
179
+ '--name',
180
+ containerName,
181
+ '--network',
182
+ network,
183
+ ]
184
+
185
+ // `--label key=value` flags. Validate first — an empty key or
186
+ // a key containing `=` would silently produce a malformed
187
+ // label that downstream `docker ps --filter label=…` queries
188
+ // could not match reliably. Throw before the spawn so misuse
189
+ // surfaces during construction, not as a mysterious "container
190
+ // has no labels" later.
191
+ if (config.labels) {
192
+ for (const [key, value] of Object.entries(config.labels)) {
193
+ if (!key || key.includes('=')) {
194
+ throw new Error(
195
+ `docker label key ${JSON.stringify(key)} is invalid (empty or contains '=')`,
196
+ )
197
+ }
198
+ args.push('--label', `${key}=${value}`)
199
+ }
200
+ }
201
+
202
+ args.push(...renderLayoutMountArgs(resolvedLayout))
203
+ // Forward only the workspace root so the worker's lexical
204
+ // resolver agrees with the bind target. The full layout used
205
+ // to ride along as `NAMZU_SANDBOX_LAYOUT`, but the worker
206
+ // never branched on it; the manifest's only consumer was a
207
+ // log line. A skill loader that needs the manifest will
208
+ // write it to a bind path the worker reads at startup —
209
+ // avoids env-size limits, keeps the wire shape minimal.
210
+ args.push('--env', `NAMZU_SANDBOX_WORKSPACE=${rootDir}`)
211
+ args.push('--env', `NAMZU_SANDBOX_READ_ROOTS=${renderLayoutReadRootsEnv(resolvedLayout)}`)
212
+ args.push('--env', `NAMZU_SANDBOX_WRITE_ROOTS=${renderLayoutWriteRootsEnv(resolvedLayout)}`)
213
+
214
+ // Only publish a host port when the consumer is going to reach
215
+ // the worker through the docker host's loopback (CLI / direct
216
+ // dev). For `container-network` reachability we leave the port
217
+ // unpublished — sibling containers reach the worker by its DNS
218
+ // name on the shared bridge, no host port required.
219
+ if (hostReachability === 'host-port') {
220
+ args.push('--publish', `127.0.0.1::${WORKER_PORT_INSIDE_CONTAINER}`)
221
+ }
222
+
223
+ if (runtime) {
224
+ args.push('--runtime', runtime)
225
+ }
226
+
227
+ if (options.memoryLimitMb && options.memoryLimitMb > 0) {
228
+ args.push('--memory', `${options.memoryLimitMb}m`)
229
+ }
230
+ if (options.maxProcesses && options.maxProcesses > 0) {
231
+ args.push('--pids-limit', String(options.maxProcesses))
232
+ }
233
+
234
+ for (const [key, value] of Object.entries(options.env ?? {})) {
235
+ args.push('--env', `${key}=${value}`)
236
+ }
237
+
238
+ args.push(config.image)
239
+
240
+ await runOnce(docker, args)
241
+ containerStarted = true
242
+
243
+ if (hostReachability === 'host-port') {
244
+ hostPort = await readMappedPort(docker, containerName)
245
+ baseUrl = `http://127.0.0.1:${hostPort}`
246
+ await waitForWorkerReady(
247
+ baseUrl,
248
+ config.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS,
249
+ config.readyPollIntervalMs ?? DEFAULT_READY_POLL_MS,
250
+ )
251
+ } else {
252
+ // container-network: connect by container DNS name on the
253
+ // shared bridge. No host port to read; the SDK consumer is
254
+ // itself a container on the same bridge.
255
+ baseUrl = `http://${containerName}:${WORKER_PORT_INSIDE_CONTAINER}`
256
+ await waitForWorkerReady(
257
+ baseUrl,
258
+ config.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS,
259
+ config.readyPollIntervalMs ?? DEFAULT_READY_POLL_MS,
260
+ )
261
+ }
262
+ } catch (err) {
263
+ await cleanupOnFailure()
264
+ throw err
265
+ }
266
+
267
+ let status: SandboxStatus = 'ready'
268
+
269
+ return {
270
+ id,
271
+ get status(): SandboxStatus {
272
+ return status
273
+ },
274
+ rootDir,
275
+ environment: detectEnvironment(),
276
+
277
+ async exec(
278
+ command: string,
279
+ argv?: string[],
280
+ opts?: SandboxExecOptions,
281
+ ): Promise<SandboxExecResult> {
282
+ status = 'busy'
283
+ try {
284
+ return await execViaWorker(baseUrl, command, argv, opts)
285
+ } finally {
286
+ status = 'ready'
287
+ }
288
+ },
289
+
290
+ async writeFile(path: string, content: string | Buffer): Promise<void> {
291
+ const buf = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8')
292
+ let res: Response
293
+ try {
294
+ res = await fetch(`${baseUrl}/write-file`, {
295
+ method: 'POST',
296
+ headers: { 'content-type': 'application/json' },
297
+ body: JSON.stringify({
298
+ path,
299
+ content: buf.toString('base64'),
300
+ encoding: 'base64',
301
+ }),
302
+ })
303
+ } catch (err) {
304
+ const cause = err instanceof Error ? err.cause : undefined
305
+ const causeMsg =
306
+ cause instanceof Error
307
+ ? `${cause.message}${(cause as Error & { code?: string }).code ? ` (${(cause as Error & { code?: string }).code})` : ''}`
308
+ : cause
309
+ ? String(cause)
310
+ : 'unknown'
311
+ throw new Error(
312
+ `namzu-sandbox /write-file fetch failed (baseUrl=${baseUrl}, path=${path}): ${err instanceof Error ? err.message : String(err)} — cause: ${causeMsg}`,
313
+ { cause: err },
314
+ )
315
+ }
316
+ if (!res.ok) {
317
+ throw new Error(`write-file failed: HTTP ${res.status} ${await res.text()}`)
318
+ }
319
+ },
320
+
321
+ async readFile(path: string): Promise<Buffer> {
322
+ const res = await fetch(`${baseUrl}/read-file`, {
323
+ method: 'POST',
324
+ headers: { 'content-type': 'application/json' },
325
+ body: JSON.stringify({ path, encoding: 'base64' }),
326
+ })
327
+ if (!res.ok) {
328
+ throw new Error(`read-file failed: HTTP ${res.status} ${await res.text()}`)
329
+ }
330
+ const json = (await res.json()) as { ok: boolean; content?: string; error?: string }
331
+ if (!json.ok || typeof json.content !== 'string') {
332
+ throw new Error(json.error ?? 'read-file: no content')
333
+ }
334
+ return Buffer.from(json.content, 'base64')
335
+ },
336
+
337
+ async listFiles(rootPath: string): Promise<readonly SandboxFileEntry[]> {
338
+ return await listFilesViaWorker(baseUrl, rootPath)
339
+ },
340
+
341
+ async destroy(): Promise<void> {
342
+ status = 'destroyed'
343
+ await runOnceQuiet(docker, ['rm', '-f', containerName])
344
+ // Backend never allocates host paths — every bind source
345
+ // comes from the consumer-supplied layout. Container
346
+ // teardown is sufficient; the consumer's own lifecycle
347
+ // owns each `hostPath`.
348
+ },
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Ask Docker which host port it bound to the worker port. Used
354
+ * instead of the pre-reserve-then-publish pattern (which had a
355
+ * TOCTOU race window between this process closing the listening
356
+ * socket and Docker's bind picking the same port — another
357
+ * process could grab it in the meantime). Letting Docker
358
+ * allocate and reading the mapping back is race-free.
359
+ */
360
+ async function readMappedPort(docker: string, containerName: string): Promise<number> {
361
+ const inspectOutput = await runOnce(docker, [
362
+ 'inspect',
363
+ '--format',
364
+ `{{(index (index .NetworkSettings.Ports "${WORKER_PORT_INSIDE_CONTAINER}/tcp") 0).HostPort}}`,
365
+ containerName,
366
+ ])
367
+ const port = Number(inspectOutput.trim())
368
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
369
+ throw new Error(
370
+ `docker inspect returned no usable host port mapping for ${containerName}: '${inspectOutput}'`,
371
+ )
372
+ }
373
+ return port
374
+ }
375
+
376
+ async function execViaWorker(
377
+ baseUrl: string,
378
+ command: string,
379
+ argv: string[] | undefined,
380
+ opts: SandboxExecOptions | undefined,
381
+ ): Promise<SandboxExecResult> {
382
+ const start = Date.now()
383
+ let res: Response
384
+ try {
385
+ res = await fetch(`${baseUrl}/execute`, {
386
+ method: 'POST',
387
+ headers: { 'content-type': 'application/json' },
388
+ body: JSON.stringify({
389
+ command,
390
+ args: argv ?? [],
391
+ cwd: opts?.cwd,
392
+ env: opts?.env,
393
+ timeoutMs: opts?.timeout,
394
+ }),
395
+ })
396
+ } catch (err) {
397
+ // Surface the underlying transport error (DNS, ECONNREFUSED,
398
+ // socket-hangup, …) instead of the generic "fetch failed" the
399
+ // undici client throws. Without `cause`, ops cannot tell whether
400
+ // the worker died, the bridge dropped, or something else.
401
+ const cause = err instanceof Error ? err.cause : undefined
402
+ const causeMsg =
403
+ cause instanceof Error
404
+ ? `${cause.message}${(cause as Error & { code?: string }).code ? ` (${(cause as Error & { code?: string }).code})` : ''}`
405
+ : cause
406
+ ? String(cause)
407
+ : 'unknown'
408
+ throw new Error(
409
+ `namzu-sandbox /execute fetch failed (baseUrl=${baseUrl}): ${err instanceof Error ? err.message : String(err)} — cause: ${causeMsg}`,
410
+ { cause: err },
411
+ )
412
+ }
413
+ if (!res.ok || !res.body) {
414
+ throw new Error(`execute failed: HTTP ${res.status} ${await res.text()}`)
415
+ }
416
+
417
+ let stdout = ''
418
+ let stderr = ''
419
+ let exitCode = -1
420
+ let timedOut = false
421
+ let signal: string | undefined
422
+
423
+ const decoder = new TextDecoder()
424
+ const reader = res.body.getReader()
425
+ let buffered = ''
426
+ for (;;) {
427
+ const { value, done } = await reader.read()
428
+ if (done) break
429
+ buffered += decoder.decode(value, { stream: true })
430
+ let newlineIdx = buffered.indexOf('\n')
431
+ while (newlineIdx !== -1) {
432
+ const line = buffered.slice(0, newlineIdx).trim()
433
+ buffered = buffered.slice(newlineIdx + 1)
434
+ if (line) {
435
+ try {
436
+ const event = JSON.parse(line) as
437
+ | { type: 'stdout_delta'; data: string }
438
+ | { type: 'stderr_delta'; data: string }
439
+ | {
440
+ type: 'result'
441
+ exitCode: number
442
+ timedOut: boolean
443
+ durationMs: number
444
+ }
445
+ | { type: 'error'; error: string }
446
+ if (event.type === 'stdout_delta') stdout += event.data
447
+ else if (event.type === 'stderr_delta') stderr += event.data
448
+ else if (event.type === 'result') {
449
+ exitCode = event.exitCode
450
+ timedOut = event.timedOut
451
+ } else if (event.type === 'error') {
452
+ throw new Error(event.error)
453
+ }
454
+ } catch (err) {
455
+ if (err instanceof SyntaxError) {
456
+ // Ignore malformed lines from the worker.
457
+ } else {
458
+ throw err
459
+ }
460
+ }
461
+ }
462
+ newlineIdx = buffered.indexOf('\n')
463
+ }
464
+ }
465
+
466
+ return {
467
+ exitCode,
468
+ stdout,
469
+ stderr,
470
+ ...(signal ? { signal } : {}),
471
+ timedOut,
472
+ durationMs: Date.now() - start,
473
+ }
474
+ }
475
+
476
+ /**
477
+ * Recursively list regular files under `rootPath` by shelling out to
478
+ * the worker's `find` (GNU find on the Debian-based reference image).
479
+ * `-printf` emits one `<path>\t<size>` line per file; any other
480
+ * non-zero exit (notably `find: '<root>': No such file or directory`)
481
+ * is mapped to "empty listing" because the agent legitimately may not
482
+ * have produced anything in `rootPath` yet.
483
+ */
484
+ async function listFilesViaWorker(
485
+ baseUrl: string,
486
+ rootPath: string,
487
+ ): Promise<readonly SandboxFileEntry[]> {
488
+ const result = await execViaWorker(
489
+ baseUrl,
490
+ 'find',
491
+ [rootPath, '-type', 'f', '-printf', '%p\t%s\n'],
492
+ undefined,
493
+ )
494
+ if (result.exitCode !== 0) {
495
+ // `find` returns non-zero when the root is missing — that just
496
+ // means "no outputs yet". Other failures (permission errors,
497
+ // the rare case `find` itself is missing) also fall through to
498
+ // the empty listing rather than blowing up the caller's drain
499
+ // flow; the deliverables collector treats absence as "done".
500
+ return []
501
+ }
502
+ const entries: SandboxFileEntry[] = []
503
+ for (const rawLine of result.stdout.split('\n')) {
504
+ if (!rawLine) continue
505
+ const tab = rawLine.indexOf('\t')
506
+ if (tab < 0) continue
507
+ const path = rawLine.slice(0, tab)
508
+ const size = Number.parseInt(rawLine.slice(tab + 1), 10)
509
+ if (!path || !Number.isFinite(size)) continue
510
+ entries.push({ path, size })
511
+ }
512
+ return entries
513
+ }
514
+
515
+ function detectEnvironment(): SandboxEnvironment {
516
+ const platform = process.platform
517
+ if (platform === 'darwin') return 'macos-seatbelt'
518
+ if (platform === 'linux') return 'linux-namespace'
519
+ return 'basic'
520
+ }
521
+
522
+ function generateSandboxId(): SandboxId {
523
+ const random = Math.random().toString(36).slice(2, 10)
524
+ return `sandbox_${Date.now().toString(36)}_${random}` as SandboxId
525
+ }
526
+
527
+ async function waitForWorkerReady(
528
+ baseUrl: string,
529
+ timeoutMs: number,
530
+ pollMs: number,
531
+ ): Promise<void> {
532
+ const deadline = Date.now() + timeoutMs
533
+ let lastError: unknown
534
+ while (Date.now() < deadline) {
535
+ try {
536
+ const res = await fetch(`${baseUrl}/healthz`)
537
+ if (res.ok) return
538
+ lastError = new Error(`healthz HTTP ${res.status}`)
539
+ } catch (err) {
540
+ lastError = err
541
+ }
542
+ await new Promise((resolve) => setTimeout(resolve, pollMs))
543
+ }
544
+ throw new Error(
545
+ `namzu-sandbox worker did not become ready within ${timeoutMs}ms: ${
546
+ lastError instanceof Error ? lastError.message : String(lastError)
547
+ }`,
548
+ )
549
+ }
550
+
551
+ function runOnce(binary: string, args: string[]): Promise<string> {
552
+ return new Promise((resolve, reject) => {
553
+ const child = spawn(binary, args, { stdio: ['ignore', 'pipe', 'pipe'] })
554
+ let stdout = ''
555
+ let stderr = ''
556
+ child.stdout.on('data', (chunk: Buffer) => {
557
+ stdout += chunk.toString('utf8')
558
+ })
559
+ child.stderr.on('data', (chunk: Buffer) => {
560
+ stderr += chunk.toString('utf8')
561
+ })
562
+ child.on('error', reject)
563
+ child.on('close', (code) => {
564
+ if (code === 0) resolve(stdout.trim())
565
+ else reject(new Error(`${binary} ${args.join(' ')} exited ${code}: ${stderr.trim()}`))
566
+ })
567
+ })
568
+ }
569
+
570
+ function runOnceQuiet(binary: string, args: string[]): Promise<void> {
571
+ return new Promise((resolve) => {
572
+ const child = spawn(binary, args, { stdio: 'ignore' })
573
+ child.on('error', () => resolve())
574
+ child.on('close', () => resolve())
575
+ })
576
+ }
577
+
578
+ /**
579
+ * Skill IDs are user-controlled strings that end up in the in-
580
+ * container path (`/mnt/skills/<id>`) and on a `--volume` flag the
581
+ * shell does not see (we use `spawn` argv, not a shell pipeline). So
582
+ * the regex doesn't have to defend against shell metacharacters — it
583
+ * exists to keep paths legible (no whitespace, no `..`, no slashes
584
+ * to escape the `/mnt/skills` prefix). The set is the same shape git
585
+ * accepts for ref names: alphanumerics, `_`, `-`, `.`. Letting `.`
586
+ * through enables `pdf-tools.v2`-style versioning; rejecting `..`
587
+ * specifically guards path traversal even though Docker's bind
588
+ * resolution doesn't follow it.
589
+ */
590
+ const SKILL_ID_REGEX = /^[a-zA-Z0-9_.-]+$/
591
+
592
+ /**
593
+ * Validate and resolve a {@link ContainerSandboxLayout}. Returns a
594
+ * {@link ResolvedContainerSandboxLayout} with every container path
595
+ * filled in; throws {@link ContainerSandboxLayoutValidationError}
596
+ * collecting every violation in one pass.
597
+ *
598
+ * Called once at provider construction (`createSandboxProvider`).
599
+ * Validation surfaces synchronously during host wiring; nothing
600
+ * downstream re-validates per `provider.create()` call.
601
+ *
602
+ * Exported for tests so the validation rules are pinned by golden-
603
+ * value assertions rather than only exercised through the spawn path.
604
+ */
605
+ export function resolveLayout(layout: ContainerSandboxLayout): ResolvedContainerSandboxLayout {
606
+ const reasons: string[] = []
607
+
608
+ // Outputs is required — without it the model has no place to
609
+ // persist work past container teardown, and the worker has no
610
+ // rooted workspace for its path resolver. The SDK type marks
611
+ // outputs required too, but the public type can be circumvented
612
+ // with `as` casts; runtime check is the contract.
613
+ if (!layout.outputs) {
614
+ reasons.push(
615
+ '`outputs` is required (deliverables surface). Pass `layout.outputs.source = { type: "hostDir", hostPath: "..." }`.',
616
+ )
617
+ }
618
+
619
+ // Skill IDs: regex + substring `..` reject + duplicate check.
620
+ // Run even if `outputs` is missing so the consumer sees every
621
+ // problem in one pass — fix-then-rerun loops at this layer are
622
+ // cheap to avoid.
623
+ //
624
+ // Why the substring `..` reject on top of the regex: the regex
625
+ // `[a-zA-Z0-9_.-]` legitimately allows `.` (so ids like
626
+ // `pdf-tools.v2` work), but `..` (or any embedded `..` like
627
+ // `foo..bar`) is a path-traversal segment that, when
628
+ // interpolated into the default container path
629
+ // `/mnt/skills/<id>`, lifts the bind out of the skills parent.
630
+ // Reject any `..` substring outright — there is no legitimate
631
+ // skill-id shape with consecutive dots.
632
+ const skillIds = new Set<string>()
633
+ if (layout.skills) {
634
+ for (const skill of layout.skills) {
635
+ if (!SKILL_ID_REGEX.test(skill.id)) {
636
+ reasons.push(
637
+ `skill id ${JSON.stringify(skill.id)} contains characters outside [a-zA-Z0-9_.-]`,
638
+ )
639
+ } else if (skill.id.includes('..')) {
640
+ reasons.push(
641
+ `skill id ${JSON.stringify(skill.id)} contains a path-traversal segment ('..')`,
642
+ )
643
+ } else if (skillIds.has(skill.id)) {
644
+ reasons.push(`duplicate skill id ${JSON.stringify(skill.id)}`)
645
+ } else {
646
+ skillIds.add(skill.id)
647
+ }
648
+ }
649
+ }
650
+
651
+ // Resolve container paths now (before duplicate check) so
652
+ // duplicate detection sees the actual mount targets, including
653
+ // defaults applied when `containerPath` is omitted. Defaults
654
+ // come from `@namzu/sdk`'s exported constants so a Vandal prompt
655
+ // template generator and the backend agree on a single source of
656
+ // truth.
657
+ const resolvedOutputs = layout.outputs
658
+ ? {
659
+ source: layout.outputs.source,
660
+ containerPath: layout.outputs.containerPath ?? SANDBOX_DEFAULT_OUTPUTS_PATH,
661
+ }
662
+ : undefined
663
+ const resolvedUploads = layout.uploads
664
+ ? {
665
+ source: layout.uploads.source,
666
+ containerPath: layout.uploads.containerPath ?? SANDBOX_DEFAULT_UPLOADS_PATH,
667
+ }
668
+ : undefined
669
+ const resolvedScratch = layout.scratch
670
+ ? {
671
+ source: layout.scratch.source,
672
+ containerPath: layout.scratch.containerPath ?? SANDBOX_DEFAULT_SCRATCH_PATH,
673
+ }
674
+ : undefined
675
+ const resolvedToolResults = layout.toolResults
676
+ ? {
677
+ source: layout.toolResults.source,
678
+ containerPath: layout.toolResults.containerPath ?? SANDBOX_DEFAULT_TOOL_RESULTS_PATH,
679
+ }
680
+ : undefined
681
+ const resolvedTranscripts = layout.transcripts
682
+ ? {
683
+ source: layout.transcripts.source,
684
+ containerPath: layout.transcripts.containerPath ?? SANDBOX_DEFAULT_TRANSCRIPTS_PATH,
685
+ }
686
+ : undefined
687
+ const resolvedSkills = layout.skills?.map((s) => ({
688
+ id: s.id,
689
+ source: s.source,
690
+ containerPath: s.containerPath ?? `${SANDBOX_DEFAULT_SKILLS_PARENT}/${s.id}`,
691
+ }))
692
+
693
+ // Duplicate `containerPath` detection across every mount. Two
694
+ // binds at the same path is a Docker error at the daemon level,
695
+ // but the daemon's error surfaces inside the container creation
696
+ // failure mode — much later, with less context. Catch it here.
697
+ const containerPathOwners = new Map<string, string>()
698
+ function track(label: string, p: string | undefined) {
699
+ if (!p) return
700
+ const prior = containerPathOwners.get(p)
701
+ if (prior) {
702
+ reasons.push(
703
+ `duplicate containerPath ${JSON.stringify(p)} declared by both ${prior} and ${label}`,
704
+ )
705
+ } else {
706
+ containerPathOwners.set(p, label)
707
+ }
708
+ }
709
+ track('outputs', resolvedOutputs?.containerPath)
710
+ track('uploads', resolvedUploads?.containerPath)
711
+ track('scratch', resolvedScratch?.containerPath)
712
+ track('toolResults', resolvedToolResults?.containerPath)
713
+ track('transcripts', resolvedTranscripts?.containerPath)
714
+ if (resolvedSkills) {
715
+ for (const skill of resolvedSkills) {
716
+ track(`skill:${skill.id}`, skill.containerPath)
717
+ }
718
+ }
719
+
720
+ if (reasons.length > 0) {
721
+ throw new ContainerSandboxLayoutValidationError(reasons)
722
+ }
723
+
724
+ // `outputs` presence was checked above; the non-null assertion is
725
+ // safe because the validation throws on missing.
726
+ const resolved: ResolvedContainerSandboxLayout = {
727
+ // biome-ignore lint/style/noNonNullAssertion: validation enforces presence
728
+ outputs: resolvedOutputs!,
729
+ ...(resolvedUploads ? { uploads: resolvedUploads } : {}),
730
+ ...(resolvedScratch ? { scratch: resolvedScratch } : {}),
731
+ ...(resolvedToolResults ? { toolResults: resolvedToolResults } : {}),
732
+ ...(resolvedTranscripts ? { transcripts: resolvedTranscripts } : {}),
733
+ ...(resolvedSkills && resolvedSkills.length > 0 ? { skills: resolvedSkills } : {}),
734
+ }
735
+ return resolved
736
+ }
737
+
738
+ /**
739
+ * Render `--volume` flags for a {@link ResolvedContainerSandboxLayout}. Order
740
+ * is stable (outputs rw, uploads ro, toolResults ro, skills ro,
741
+ * transcripts ro) so the test golden values stay deterministic.
742
+ *
743
+ * Today every `ContainerSandboxMountSource` is `{ type: 'hostDir', hostPath }`.
744
+ * When future variants land (squashfs / managed volumes), this
745
+ * function gains a discriminator switch; the single-variant union
746
+ * keeps tomorrow's exhaustiveness check honest by giving us a
747
+ * `type` field to switch on without renaming the call sites.
748
+ */
749
+ /**
750
+ * Narrow a {@link ContainerSandboxMountSource} to the `hostDir`
751
+ * variant for backends that only know how to bind-mount from a host
752
+ * filesystem path (docker, podman, plain Firecracker virtio-fs). Any
753
+ * other variant (e.g. `azureFileShare` consumed by the ACI backend)
754
+ * is a hard configuration mismatch — throw at spawn time rather than
755
+ * render a malformed `--volume` flag the daemon would reject with a
756
+ * confusing message.
757
+ */
758
+ function requireHostDir(
759
+ source: ContainerSandboxLayoutMount['source'],
760
+ label: string,
761
+ ): { readonly hostPath: string } {
762
+ if (source.type !== 'hostDir') {
763
+ throw new Error(
764
+ `docker backend cannot consume mount source type ${JSON.stringify(source.type)} for ${label}; expected 'hostDir'. The non-hostDir variants (e.g. 'azureFileShare') belong to managed-container backends.`,
765
+ )
766
+ }
767
+ return source
768
+ }
769
+
770
+ export function renderLayoutMountArgs(layout: ResolvedContainerSandboxLayout): string[] {
771
+ const args: string[] = []
772
+ const outputs = requireHostDir(layout.outputs.source, 'outputs')
773
+ args.push('--volume', `${outputs.hostPath}:${layout.outputs.containerPath}:rw`)
774
+ if (layout.uploads) {
775
+ const uploads = requireHostDir(layout.uploads.source, 'uploads')
776
+ args.push('--volume', `${uploads.hostPath}:${layout.uploads.containerPath}:ro`)
777
+ }
778
+ if (layout.scratch) {
779
+ // Scratch is RW so the agent can read its own intermediate
780
+ // drafts back. It is NOT visible to the deliverables collector
781
+ // because the host directory it binds is a sibling of, not a
782
+ // child of, the outputs hostPath.
783
+ const scratch = requireHostDir(layout.scratch.source, 'scratch')
784
+ args.push('--volume', `${scratch.hostPath}:${layout.scratch.containerPath}:rw`)
785
+ }
786
+ if (layout.toolResults) {
787
+ const toolResults = requireHostDir(layout.toolResults.source, 'toolResults')
788
+ args.push('--volume', `${toolResults.hostPath}:${layout.toolResults.containerPath}:ro`)
789
+ }
790
+ if (layout.skills) {
791
+ for (const skill of layout.skills) {
792
+ const skillSrc = requireHostDir(skill.source, `skill ${skill.id}`)
793
+ args.push('--volume', `${skillSrc.hostPath}:${skill.containerPath}:ro`)
794
+ }
795
+ }
796
+ if (layout.transcripts) {
797
+ const transcripts = requireHostDir(layout.transcripts.source, 'transcripts')
798
+ args.push('--volume', `${transcripts.hostPath}:${layout.transcripts.containerPath}:ro`)
799
+ }
800
+ return args
801
+ }
802
+
803
+ export function renderLayoutReadRootsEnv(layout: ResolvedContainerSandboxLayout): string {
804
+ const roots = [
805
+ layout.outputs.containerPath,
806
+ layout.uploads?.containerPath,
807
+ layout.scratch?.containerPath,
808
+ layout.toolResults?.containerPath,
809
+ layout.transcripts?.containerPath,
810
+ ...(layout.skills?.map((skill) => skill.containerPath) ?? []),
811
+ ].filter((root): root is string => Boolean(root))
812
+ return Array.from(new Set(roots)).join(':')
813
+ }
814
+
815
+ /**
816
+ * Writable container roots. Only the RW mounts go here — uploads,
817
+ * tool-results, transcripts, and skills are read-only and must stay
818
+ * out of WRITE_ROOTS or the agent's `write`/`append` could clobber
819
+ * source files the host considers immutable.
820
+ */
821
+ export function renderLayoutWriteRootsEnv(layout: ResolvedContainerSandboxLayout): string {
822
+ const roots = [layout.outputs.containerPath, layout.scratch?.containerPath].filter(
823
+ (root): root is string => Boolean(root),
824
+ )
825
+ return Array.from(new Set(roots)).join(':')
826
+ }