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