@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,731 @@
1
+ /**
2
+ * Behavioural contract for `createSandboxProvider`, `resolveLayout`,
3
+ * and `renderLayoutMountArgs`:
4
+ *
5
+ * - Builds a `SandboxProvider` for the `container:docker` tier
6
+ * without spawning anything (the container only spawns on
7
+ * `provider.create()`, not at construction time). The provider
8
+ * carries a layout baked in at construction; per-task hosts
9
+ * construct one provider per task.
10
+ * - Throws `SandboxBackendNotImplementedError` for tiers the docker
11
+ * backend has not landed yet, with a label that names the missing
12
+ * tier and concrete service.
13
+ * - Layout validation happens synchronously inside
14
+ * `createSandboxProvider`; `ContainerSandboxLayoutValidationError` exposes
15
+ * a `reasons` array so consumers see every violation in one
16
+ * round-trip.
17
+ * - Every `--volume` source rendered by the docker backend traces
18
+ * back to a layout `hostDir.hostPath` declared by the consumer —
19
+ * pinned here as a regression guard against the old `mkdtemp`-
20
+ * allocated workspace path that hit EACCES in sibling-container
21
+ * deployments.
22
+ *
23
+ * Docker-touching integration tests live under
24
+ * `backends/docker/__tests__/` and skip when `docker` isn't
25
+ * available; this file stays pure-unit.
26
+ */
27
+
28
+ import { describe, expect, it } from 'vitest'
29
+
30
+ import type { ContainerSandboxLayout } from '@namzu/sdk'
31
+
32
+ import {
33
+ renderLayoutMountArgs,
34
+ renderLayoutReadRootsEnv,
35
+ resolveLayout,
36
+ } from './backends/docker/index.js'
37
+ import {
38
+ ContainerSandboxLayoutValidationError,
39
+ SandboxBackendNotImplementedError,
40
+ type SerializedSandboxError,
41
+ createSandboxProvider,
42
+ serializeSandboxError,
43
+ } from './index.js'
44
+
45
+ // Convenience: a complete, valid layout the tests reuse and mutate.
46
+ function validLayout(overrides: Partial<ContainerSandboxLayout> = {}): ContainerSandboxLayout {
47
+ return {
48
+ outputs: { source: { type: 'hostDir', hostPath: '/host/out' } },
49
+ ...overrides,
50
+ }
51
+ }
52
+
53
+ const DOCKER_BACKEND = {
54
+ tier: 'container',
55
+ runtime: 'docker',
56
+ image: 'namzu-worker:latest',
57
+ } as const
58
+
59
+ describe('createSandboxProvider', () => {
60
+ it('builds a provider for container:docker without spawning anything', () => {
61
+ const provider = createSandboxProvider({
62
+ backend: DOCKER_BACKEND,
63
+ layout: validLayout(),
64
+ })
65
+
66
+ expect(provider.id).toContain('container')
67
+ expect(provider.id).toContain('docker')
68
+ expect(provider.name).toContain('container:docker')
69
+ })
70
+
71
+ it('treats container with no runtime as docker (default)', () => {
72
+ const provider = createSandboxProvider({
73
+ backend: { tier: 'container', image: 'namzu-worker:latest' },
74
+ layout: validLayout(),
75
+ })
76
+
77
+ expect(provider.id).toContain('docker')
78
+ })
79
+
80
+ it('throws SandboxBackendNotImplementedError for microvm:e2b until P3.3 lands', () => {
81
+ expect(() =>
82
+ createSandboxProvider({
83
+ backend: { tier: 'microvm', service: 'e2b', apiKey: 'test' },
84
+ }),
85
+ ).toThrow(SandboxBackendNotImplementedError)
86
+
87
+ try {
88
+ createSandboxProvider({
89
+ backend: { tier: 'microvm', service: 'fly-machines', apiToken: 't', app: 'a', image: 'i' },
90
+ })
91
+ } catch (err) {
92
+ expect(err).toBeInstanceOf(SandboxBackendNotImplementedError)
93
+ expect((err as SandboxBackendNotImplementedError).backend).toBe('microvm:fly-machines')
94
+ }
95
+ })
96
+
97
+ it('throws for process tier until P3.4 lands, naming the engine in the label', () => {
98
+ try {
99
+ createSandboxProvider({ backend: { tier: 'process', engine: 'bubblewrap' } })
100
+ } catch (err) {
101
+ expect(err).toBeInstanceOf(SandboxBackendNotImplementedError)
102
+ expect((err as SandboxBackendNotImplementedError).backend).toBe('process:bubblewrap')
103
+ }
104
+ })
105
+
106
+ it('throws for container:runsc until P3.5 lands', () => {
107
+ try {
108
+ createSandboxProvider({
109
+ backend: { tier: 'container', runtime: 'runsc', image: 'i' },
110
+ layout: validLayout(),
111
+ })
112
+ } catch (err) {
113
+ expect(err).toBeInstanceOf(SandboxBackendNotImplementedError)
114
+ expect((err as SandboxBackendNotImplementedError).backend).toBe('container:runsc')
115
+ }
116
+ })
117
+
118
+ it('throws for passthrough tier until it lands', () => {
119
+ try {
120
+ createSandboxProvider({ backend: { tier: 'passthrough' } })
121
+ } catch (err) {
122
+ expect(err).toBeInstanceOf(SandboxBackendNotImplementedError)
123
+ expect((err as SandboxBackendNotImplementedError).backend).toBe('passthrough')
124
+ }
125
+ })
126
+
127
+ it('rejects construction when the layout fails validation — surfaces during host wiring', () => {
128
+ const bad = {} as ContainerSandboxLayout
129
+ expect(() =>
130
+ createSandboxProvider({
131
+ backend: DOCKER_BACKEND,
132
+ layout: bad,
133
+ }),
134
+ ).toThrow(ContainerSandboxLayoutValidationError)
135
+ })
136
+ })
137
+
138
+ describe('resolveLayout', () => {
139
+ it('applies Anthropic-style default container paths to declared mounts', () => {
140
+ const resolved = resolveLayout({
141
+ outputs: { source: { type: 'hostDir', hostPath: '/host/out' } },
142
+ uploads: { source: { type: 'hostDir', hostPath: '/host/up' } },
143
+ toolResults: { source: { type: 'hostDir', hostPath: '/host/tr' } },
144
+ transcripts: { source: { type: 'hostDir', hostPath: '/host/ts' } },
145
+ skills: [
146
+ { id: 'pdf-tools', source: { type: 'hostDir', hostPath: '/host/skills/pdf-tools' } },
147
+ { id: 'data-viz', source: { type: 'hostDir', hostPath: '/host/skills/data-viz' } },
148
+ ],
149
+ })
150
+
151
+ expect(resolved.outputs.containerPath).toBe('/mnt/user-data/outputs')
152
+ expect(resolved.uploads?.containerPath).toBe('/mnt/user-data/uploads')
153
+ expect(resolved.toolResults?.containerPath).toBe('/mnt/user-data/tool_results')
154
+ expect(resolved.transcripts?.containerPath).toBe('/mnt/transcripts')
155
+ expect(resolved.skills?.map((s) => s.containerPath)).toEqual([
156
+ '/mnt/skills/pdf-tools',
157
+ '/mnt/skills/data-viz',
158
+ ])
159
+ })
160
+
161
+ it('honours explicit container path overrides', () => {
162
+ const resolved = resolveLayout({
163
+ outputs: {
164
+ source: { type: 'hostDir', hostPath: '/h/o' },
165
+ containerPath: '/work/out',
166
+ },
167
+ uploads: {
168
+ source: { type: 'hostDir', hostPath: '/h/u' },
169
+ containerPath: '/work/up',
170
+ },
171
+ skills: [
172
+ {
173
+ id: 'k',
174
+ source: { type: 'hostDir', hostPath: '/h/s' },
175
+ containerPath: '/opt/skills/custom',
176
+ },
177
+ ],
178
+ })
179
+ expect(resolved.outputs.containerPath).toBe('/work/out')
180
+ expect(resolved.uploads?.containerPath).toBe('/work/up')
181
+ expect(resolved.skills?.[0]?.containerPath).toBe('/opt/skills/custom')
182
+ })
183
+
184
+ it('omits fields the host did not declare', () => {
185
+ const resolved = resolveLayout(validLayout())
186
+ expect(resolved.outputs).toBeDefined()
187
+ expect(resolved.uploads).toBeUndefined()
188
+ expect(resolved.toolResults).toBeUndefined()
189
+ expect(resolved.transcripts).toBeUndefined()
190
+ expect(resolved.skills).toBeUndefined()
191
+ })
192
+
193
+ it('drops an empty skills array (treated as no skills)', () => {
194
+ const resolved = resolveLayout(validLayout({ skills: [] }))
195
+ expect(resolved.skills).toBeUndefined()
196
+ })
197
+
198
+ it('does not expose a scratchpad field — image-bake responsibility, not a public knob', () => {
199
+ const resolved = resolveLayout(validLayout())
200
+ expect((resolved as unknown as Record<string, unknown>).scratchpad).toBeUndefined()
201
+ })
202
+
203
+ it('rejects a layout with no outputs (deliverables surface required)', () => {
204
+ const bad = {} as ContainerSandboxLayout
205
+ expect(() => resolveLayout(bad)).toThrow(ContainerSandboxLayoutValidationError)
206
+ try {
207
+ resolveLayout(bad)
208
+ } catch (err) {
209
+ expect(err).toBeInstanceOf(ContainerSandboxLayoutValidationError)
210
+ const reasons = (err as ContainerSandboxLayoutValidationError).reasons
211
+ expect(reasons.some((r) => r.includes('outputs'))).toBe(true)
212
+ }
213
+ })
214
+
215
+ it('rejects malformed skill ids (whitespace, slashes)', () => {
216
+ const layout: ContainerSandboxLayout = {
217
+ ...validLayout(),
218
+ skills: [
219
+ { id: 'has space', source: { type: 'hostDir', hostPath: '/a' } },
220
+ { id: 'has/slash', source: { type: 'hostDir', hostPath: '/b' } },
221
+ { id: 'good_one.v2', source: { type: 'hostDir', hostPath: '/c' } },
222
+ ],
223
+ }
224
+ try {
225
+ resolveLayout(layout)
226
+ throw new Error('expected ContainerSandboxLayoutValidationError')
227
+ } catch (err) {
228
+ expect(err).toBeInstanceOf(ContainerSandboxLayoutValidationError)
229
+ const reasons = (err as ContainerSandboxLayoutValidationError).reasons
230
+ expect(reasons.length).toBe(2)
231
+ expect(reasons.join('\n')).toContain('"has space"')
232
+ expect(reasons.join('\n')).toContain('"has/slash"')
233
+ }
234
+ })
235
+
236
+ it('rejects every form of `..` in skill ids — not just the bare segment', () => {
237
+ const layout: ContainerSandboxLayout = {
238
+ ...validLayout(),
239
+ skills: [
240
+ { id: '..', source: { type: 'hostDir', hostPath: '/a' } },
241
+ { id: 'foo..bar', source: { type: 'hostDir', hostPath: '/b' } },
242
+ { id: '..foo', source: { type: 'hostDir', hostPath: '/c' } },
243
+ { id: 'foo..', source: { type: 'hostDir', hostPath: '/d' } },
244
+ ],
245
+ }
246
+ try {
247
+ resolveLayout(layout)
248
+ throw new Error('expected ContainerSandboxLayoutValidationError')
249
+ } catch (err) {
250
+ expect(err).toBeInstanceOf(ContainerSandboxLayoutValidationError)
251
+ const reasons = (err as ContainerSandboxLayoutValidationError).reasons
252
+ expect(reasons.length).toBe(4)
253
+ for (const id of ['..', 'foo..bar', '..foo', 'foo..']) {
254
+ expect(reasons.join('\n')).toContain(JSON.stringify(id))
255
+ }
256
+ }
257
+ })
258
+
259
+ it('accepts isolated dots in skill ids (versioned skills like pdf-tools.v2)', () => {
260
+ const resolved = resolveLayout({
261
+ ...validLayout(),
262
+ skills: [
263
+ { id: 'pdf-tools.v2', source: { type: 'hostDir', hostPath: '/a' } },
264
+ { id: 'data.viz.legacy', source: { type: 'hostDir', hostPath: '/b' } },
265
+ ],
266
+ })
267
+ expect(resolved.skills?.map((s) => s.id)).toEqual(['pdf-tools.v2', 'data.viz.legacy'])
268
+ })
269
+
270
+ it('rejects duplicate skill ids', () => {
271
+ const layout: ContainerSandboxLayout = {
272
+ ...validLayout(),
273
+ skills: [
274
+ { id: 'pdf-tools', source: { type: 'hostDir', hostPath: '/a' } },
275
+ { id: 'pdf-tools', source: { type: 'hostDir', hostPath: '/b' } },
276
+ ],
277
+ }
278
+ try {
279
+ resolveLayout(layout)
280
+ throw new Error('expected ContainerSandboxLayoutValidationError')
281
+ } catch (err) {
282
+ expect(err).toBeInstanceOf(ContainerSandboxLayoutValidationError)
283
+ expect((err as ContainerSandboxLayoutValidationError).reasons.join('\n')).toContain(
284
+ 'duplicate skill id "pdf-tools"',
285
+ )
286
+ }
287
+ })
288
+
289
+ it('rejects duplicate containerPaths across mounts', () => {
290
+ const layout: ContainerSandboxLayout = {
291
+ outputs: {
292
+ source: { type: 'hostDir', hostPath: '/a' },
293
+ containerPath: '/work',
294
+ },
295
+ uploads: {
296
+ source: { type: 'hostDir', hostPath: '/b' },
297
+ containerPath: '/work',
298
+ },
299
+ }
300
+ try {
301
+ resolveLayout(layout)
302
+ throw new Error('expected ContainerSandboxLayoutValidationError')
303
+ } catch (err) {
304
+ expect(err).toBeInstanceOf(ContainerSandboxLayoutValidationError)
305
+ const reasons = (err as ContainerSandboxLayoutValidationError).reasons
306
+ expect(reasons.some((r) => r.includes('"/work"'))).toBe(true)
307
+ }
308
+ })
309
+
310
+ it('rejects duplicate containerPaths between a skill and a top-level mount', () => {
311
+ const layout: ContainerSandboxLayout = {
312
+ outputs: {
313
+ source: { type: 'hostDir', hostPath: '/a' },
314
+ containerPath: '/mnt/skills/x',
315
+ },
316
+ skills: [
317
+ {
318
+ id: 'x',
319
+ source: { type: 'hostDir', hostPath: '/b' },
320
+ // default `/mnt/skills/x` collides with outputs above
321
+ },
322
+ ],
323
+ }
324
+ expect(() => resolveLayout(layout)).toThrow(ContainerSandboxLayoutValidationError)
325
+ })
326
+
327
+ it('collects every violation in one pass — fix-then-rerun loops are wasted', () => {
328
+ const bad = {
329
+ // missing outputs
330
+ skills: [
331
+ { id: 'has space', source: { type: 'hostDir', hostPath: '/a' } },
332
+ { id: 'has space', source: { type: 'hostDir', hostPath: '/b' } },
333
+ ],
334
+ } as unknown as ContainerSandboxLayout
335
+ try {
336
+ resolveLayout(bad)
337
+ throw new Error('expected ContainerSandboxLayoutValidationError')
338
+ } catch (err) {
339
+ expect(err).toBeInstanceOf(ContainerSandboxLayoutValidationError)
340
+ const reasons = (err as ContainerSandboxLayoutValidationError).reasons
341
+ // Three distinct violations: missing outputs, malformed id, duplicate id.
342
+ expect(reasons.length).toBeGreaterThanOrEqual(3)
343
+ }
344
+ })
345
+ })
346
+
347
+ describe('ContainerSandboxLayoutValidationError', () => {
348
+ it('serialises through JSON.stringify with reasons preserved', () => {
349
+ const err = new ContainerSandboxLayoutValidationError([
350
+ '`outputs` is required (deliverables surface).',
351
+ 'duplicate skill id "pdf-tools"',
352
+ ])
353
+ const json = JSON.parse(JSON.stringify(err))
354
+ expect(json.name).toBe('ContainerSandboxLayoutValidationError')
355
+ expect(json.reasons).toEqual([
356
+ '`outputs` is required (deliverables surface).',
357
+ 'duplicate skill id "pdf-tools"',
358
+ ])
359
+ expect(json.message).toContain('Invalid ContainerSandboxLayout:')
360
+ })
361
+
362
+ it('keeps `reasons` as a stable property after construction', () => {
363
+ const err = new ContainerSandboxLayoutValidationError(['reason a', 'reason b'])
364
+ expect(err.reasons).toEqual(['reason a', 'reason b'])
365
+ expect(err.name).toBe('ContainerSandboxLayoutValidationError')
366
+ expect(err.message).toBe('Invalid ContainerSandboxLayout: reason a; reason b')
367
+ })
368
+
369
+ it('accepts a `cause` and exposes it on `toJSON()`', () => {
370
+ const root = new Error('underlying cause')
371
+ const err = new ContainerSandboxLayoutValidationError(['x'], { cause: root })
372
+ expect(err.cause).toBe(root)
373
+ const json = err.toJSON()
374
+ expect(json.cause).toBe(root)
375
+ })
376
+ })
377
+
378
+ describe('serializeSandboxError — transport-safe error envelope', () => {
379
+ it('serialises a ContainerSandboxLayoutValidationError with reasons preserved', () => {
380
+ const err = new ContainerSandboxLayoutValidationError(['a', 'b'])
381
+ const out = serializeSandboxError(err)
382
+ expect(out.name).toBe('ContainerSandboxLayoutValidationError')
383
+ expect(out.reasons).toEqual(['a', 'b'])
384
+ expect(out.message).toContain('Invalid ContainerSandboxLayout: a; b')
385
+ expect(typeof out.stack === 'string' || typeof out.stack === 'undefined').toBe(true)
386
+ })
387
+
388
+ it('survives JSON.stringify → JSON.parse round-trip with reasons intact', () => {
389
+ const err = new ContainerSandboxLayoutValidationError(['a', 'b'])
390
+ const wire = JSON.parse(JSON.stringify(serializeSandboxError(err)))
391
+ expect(wire.name).toBe('ContainerSandboxLayoutValidationError')
392
+ expect(wire.reasons).toEqual(['a', 'b'])
393
+ })
394
+
395
+ it('survives structuredClone with reasons intact', () => {
396
+ const err = new ContainerSandboxLayoutValidationError(['a', 'b'])
397
+ const cloned = structuredClone(serializeSandboxError(err))
398
+ expect(cloned.name).toBe('ContainerSandboxLayoutValidationError')
399
+ expect(cloned.reasons).toEqual(['a', 'b'])
400
+ })
401
+
402
+ it('preserves the cause chain — nested validation error in an outer Error', () => {
403
+ const inner = new ContainerSandboxLayoutValidationError(['inner reason'])
404
+ const outer = new Error('outer message', { cause: inner })
405
+ const out = serializeSandboxError(outer)
406
+ expect(out.name).toBe('Error')
407
+ expect(out.message).toBe('outer message')
408
+ // `cause` should itself be a serialised envelope, not the raw Error.
409
+ const cause = out.cause as { name: string; reasons?: readonly string[] }
410
+ expect(cause.name).toBe('ContainerSandboxLayoutValidationError')
411
+ expect(cause.reasons).toEqual(['inner reason'])
412
+ })
413
+
414
+ it('wraps a plain-object cause in a NonError envelope (no verbatim leak)', () => {
415
+ const outer = new Error('outer', { cause: { kind: 'opaque', code: 42 } })
416
+ const out = serializeSandboxError(outer)
417
+ const cause = out.cause
418
+ expect(cause).toBeDefined()
419
+ expect(cause?.name).toBe('NonError')
420
+ // The receiver gets a string envelope rather than the raw object;
421
+ // every leaf is JSON-safe + structuredClone-safe.
422
+ expect(typeof cause?.message).toBe('string')
423
+ expect(cause?.message).toContain('opaque')
424
+ })
425
+
426
+ it('wraps non-Error inputs in a NonError envelope', () => {
427
+ expect(serializeSandboxError('plain string').name).toBe('NonError')
428
+ expect(serializeSandboxError('plain string').message).toBe('plain string')
429
+ expect(serializeSandboxError({ x: 1 }).name).toBe('NonError')
430
+ expect(serializeSandboxError(null).name).toBe('NonError')
431
+ expect(serializeSandboxError(null).message).toBe('null')
432
+ expect(serializeSandboxError(undefined).name).toBe('NonError')
433
+ expect(serializeSandboxError(undefined).message).toBe('undefined')
434
+ expect(serializeSandboxError(42).name).toBe('NonError')
435
+ expect(serializeSandboxError(42).message).toBe('42')
436
+ expect(serializeSandboxError(true).name).toBe('NonError')
437
+ expect(serializeSandboxError(true).message).toBe('true')
438
+ })
439
+
440
+ // Codex round 5 (C): the envelope must defend against values that
441
+ // JSON.stringify either drops silently or that structuredClone
442
+ // throws on. Each of these cases asserts a specific envelope shape
443
+ // AND that the result survives both transport channels.
444
+ it('encodes Function causes as { name: "Function", message: "[function]" }', () => {
445
+ const outer = new Error('outer', { cause: () => undefined })
446
+ const out = serializeSandboxError(outer)
447
+ expect(out.cause).toEqual({ name: 'Function', message: '[function]' })
448
+ // Both transports succeed.
449
+ expect(() => JSON.stringify(out)).not.toThrow()
450
+ expect(() => structuredClone(out)).not.toThrow()
451
+ })
452
+
453
+ it('encodes Symbol causes with the description preserved', () => {
454
+ const outer = new Error('outer', { cause: Symbol('x') })
455
+ const out = serializeSandboxError(outer)
456
+ expect(out.cause?.name).toBe('Symbol')
457
+ expect(out.cause?.message).toBe('Symbol(x)')
458
+ expect(() => JSON.stringify(out)).not.toThrow()
459
+ expect(() => structuredClone(out)).not.toThrow()
460
+ })
461
+
462
+ it('encodes BigInt causes via .toString()', () => {
463
+ const outer = new Error('outer', { cause: 42n })
464
+ const out = serializeSandboxError(outer)
465
+ expect(out.cause).toEqual({ name: 'BigInt', message: '42' })
466
+ expect(() => JSON.stringify(out)).not.toThrow()
467
+ expect(() => structuredClone(out)).not.toThrow()
468
+ })
469
+
470
+ it('encodes NaN / ±Infinity as NonFiniteNumber instead of letting JSON drop them', () => {
471
+ for (const [v, msg] of [
472
+ [Number.NaN, 'NaN'],
473
+ [Number.POSITIVE_INFINITY, 'Infinity'],
474
+ [Number.NEGATIVE_INFINITY, '-Infinity'],
475
+ ] as const) {
476
+ const outer = new Error('outer', { cause: v })
477
+ const out = serializeSandboxError(outer)
478
+ expect(out.cause).toEqual({ name: 'NonFiniteNumber', message: msg })
479
+ // JSON.stringify silently turns NaN/Infinity into `null` if
480
+ // they leak in raw; the envelope keeps them as a string
481
+ // message so the receiver sees the truth.
482
+ const wire = JSON.parse(JSON.stringify(out))
483
+ expect(wire.cause.message).toBe(msg)
484
+ }
485
+ })
486
+
487
+ // Codex round 5 (B): cycle guard. The previous round-4 implementation
488
+ // recursed unconditionally on `cause`, so a cycle would either
489
+ // stack-overflow (synchronous) or trip JSON.stringify's circular
490
+ // detection (async via the receiver). The WeakSet path replaces
491
+ // the offending node with a sentinel envelope.
492
+ it('breaks self-cycles without stack overflow', () => {
493
+ const a = new Error('self-loop')
494
+ // Direct self-reference: cause === a.
495
+ ;(a as Error & { cause?: unknown }).cause = a
496
+ const out = serializeSandboxError(a)
497
+ expect(out.name).toBe('Error')
498
+ expect(out.message).toBe('self-loop')
499
+ expect(out.cause?.name).toBe('CircularReference')
500
+ expect(out.cause?.message).toBe('[circular]')
501
+ expect(() => JSON.stringify(out)).not.toThrow()
502
+ })
503
+
504
+ it('breaks two-node cycles (a.cause = b; b.cause = a)', () => {
505
+ const a = new Error('a')
506
+ const b = new Error('b')
507
+ ;(a as Error & { cause?: unknown }).cause = b
508
+ ;(b as Error & { cause?: unknown }).cause = a
509
+ const out = serializeSandboxError(a)
510
+ expect(out.name).toBe('Error')
511
+ expect(out.message).toBe('a')
512
+ // First step: a → b; b's cause loops back to the already-seen a,
513
+ // so the deepest serialised cause is the CircularReference sentinel.
514
+ const aCause = out.cause
515
+ expect(aCause?.name).toBe('Error')
516
+ expect(aCause?.message).toBe('b')
517
+ expect(aCause?.cause?.name).toBe('CircularReference')
518
+ expect(() => JSON.stringify(out)).not.toThrow()
519
+ })
520
+
521
+ it('walks long causal chains without depth-cap truncation', () => {
522
+ // Build a 20-deep chain. The WeakSet-based guard does not
523
+ // truncate by depth; only cycles trigger the sentinel.
524
+ const chain: Error[] = []
525
+ for (let i = 0; i < 20; i++) {
526
+ const e = new Error(`step-${i}`)
527
+ if (i > 0) {
528
+ ;(e as Error & { cause?: unknown }).cause = chain[i - 1]
529
+ }
530
+ chain.push(e)
531
+ }
532
+ const top = chain[chain.length - 1] as Error
533
+ const out = serializeSandboxError(top)
534
+ // Walk the cause chain and count the depth.
535
+ let cursor: SerializedSandboxError | undefined = out
536
+ let depth = 0
537
+ while (cursor) {
538
+ depth++
539
+ cursor = cursor.cause
540
+ }
541
+ expect(depth).toBe(20)
542
+ // No CircularReference sentinel should appear in a non-cyclic chain.
543
+ const wire = JSON.stringify(out)
544
+ expect(wire).not.toContain('CircularReference')
545
+ })
546
+
547
+ it('detects a cycle via plain-object cause (a.cause = a where a is a plain object)', () => {
548
+ // The cycle guard also runs for plain-object inputs, not just
549
+ // Error instances, because a non-Error cause can still close a
550
+ // loop with itself.
551
+ const obj: { kind: string; cause?: unknown } = { kind: 'self' }
552
+ obj.cause = obj
553
+ const out = serializeSandboxError(obj)
554
+ // Plain-object input → NonError envelope. The cycle-guard
555
+ // path prevents safeStringify from getting a recursive object
556
+ // in the first place, but the contract for non-Error inputs
557
+ // stays a single NonError envelope (no nested cause walk for
558
+ // non-Error values today).
559
+ expect(out.name).toBe('NonError')
560
+ })
561
+ })
562
+
563
+ describe('public exports — runtime import paths', () => {
564
+ // Codex round 4 (E): Vandal-side prompt template generators must
565
+ // be able to import the default-path constants from the sandbox
566
+ // package via the SDK's root barrel. `@namzu/sdk` exposes only
567
+ // `"."` in its package.json `exports`; subpath imports like
568
+ // `@namzu/sdk/constants/sandbox` would fail at runtime. This
569
+ // test exercises the actual import paths the host will use, so a
570
+ // future package.json change that breaks the root re-export
571
+ // surfaces here.
572
+ it('@namzu/sandbox re-exports the SANDBOX_DEFAULT_*_PATH constants', async () => {
573
+ const mod = await import('./index.js')
574
+ expect(mod.SANDBOX_DEFAULT_OUTPUTS_PATH).toBe('/mnt/user-data/outputs')
575
+ expect(mod.SANDBOX_DEFAULT_UPLOADS_PATH).toBe('/mnt/user-data/uploads')
576
+ expect(mod.SANDBOX_DEFAULT_TOOL_RESULTS_PATH).toBe('/mnt/user-data/tool_results')
577
+ expect(mod.SANDBOX_DEFAULT_TRANSCRIPTS_PATH).toBe('/mnt/transcripts')
578
+ expect(mod.SANDBOX_DEFAULT_SKILLS_PARENT).toBe('/mnt/skills')
579
+ })
580
+
581
+ it('@namzu/sdk root barrel re-exports the SANDBOX_DEFAULT_*_PATH constants', async () => {
582
+ const mod = await import('@namzu/sdk')
583
+ expect(mod.SANDBOX_DEFAULT_OUTPUTS_PATH).toBe('/mnt/user-data/outputs')
584
+ expect(mod.SANDBOX_DEFAULT_UPLOADS_PATH).toBe('/mnt/user-data/uploads')
585
+ expect(mod.SANDBOX_DEFAULT_TOOL_RESULTS_PATH).toBe('/mnt/user-data/tool_results')
586
+ expect(mod.SANDBOX_DEFAULT_TRANSCRIPTS_PATH).toBe('/mnt/transcripts')
587
+ expect(mod.SANDBOX_DEFAULT_SKILLS_PARENT).toBe('/mnt/skills')
588
+ })
589
+ })
590
+
591
+ describe('renderLayoutMountArgs', () => {
592
+ it('emits one --volume per declared mount with rw on outputs and ro on the rest', () => {
593
+ const args = renderLayoutMountArgs({
594
+ outputs: {
595
+ source: { type: 'hostDir', hostPath: '/h/o' },
596
+ containerPath: '/mnt/user-data/outputs',
597
+ },
598
+ uploads: {
599
+ source: { type: 'hostDir', hostPath: '/h/u' },
600
+ containerPath: '/mnt/user-data/uploads',
601
+ },
602
+ toolResults: {
603
+ source: { type: 'hostDir', hostPath: '/h/tr' },
604
+ containerPath: '/mnt/user-data/tool_results',
605
+ },
606
+ skills: [
607
+ {
608
+ id: 'a',
609
+ source: { type: 'hostDir', hostPath: '/h/s/a' },
610
+ containerPath: '/mnt/skills/a',
611
+ },
612
+ {
613
+ id: 'b',
614
+ source: { type: 'hostDir', hostPath: '/h/s/b' },
615
+ containerPath: '/mnt/skills/b',
616
+ },
617
+ ],
618
+ transcripts: {
619
+ source: { type: 'hostDir', hostPath: '/h/ts' },
620
+ containerPath: '/mnt/transcripts',
621
+ },
622
+ })
623
+
624
+ expect(args).toEqual([
625
+ '--volume',
626
+ '/h/o:/mnt/user-data/outputs:rw',
627
+ '--volume',
628
+ '/h/u:/mnt/user-data/uploads:ro',
629
+ '--volume',
630
+ '/h/tr:/mnt/user-data/tool_results:ro',
631
+ '--volume',
632
+ '/h/s/a:/mnt/skills/a:ro',
633
+ '--volume',
634
+ '/h/s/b:/mnt/skills/b:ro',
635
+ '--volume',
636
+ '/h/ts:/mnt/transcripts:ro',
637
+ ])
638
+ })
639
+
640
+ it('emits a single --volume for outputs only when nothing else is declared', () => {
641
+ const args = renderLayoutMountArgs({
642
+ outputs: {
643
+ source: { type: 'hostDir', hostPath: '/h/o' },
644
+ containerPath: '/mnt/user-data/outputs',
645
+ },
646
+ })
647
+ expect(args).toEqual(['--volume', '/h/o:/mnt/user-data/outputs:rw'])
648
+ })
649
+
650
+ it('emits read roots for outputs plus read-only mounts', () => {
651
+ const env = renderLayoutReadRootsEnv({
652
+ outputs: {
653
+ source: { type: 'hostDir', hostPath: '/h/o' },
654
+ containerPath: '/mnt/user-data/outputs',
655
+ },
656
+ uploads: {
657
+ source: { type: 'hostDir', hostPath: '/h/u' },
658
+ containerPath: '/mnt/user-data/uploads',
659
+ },
660
+ transcripts: {
661
+ source: { type: 'hostDir', hostPath: '/h/ts' },
662
+ containerPath: '/mnt/transcripts',
663
+ },
664
+ })
665
+
666
+ expect(env).toBe('/mnt/user-data/outputs:/mnt/user-data/uploads:/mnt/transcripts')
667
+ })
668
+ })
669
+
670
+ describe('renderLayoutMountArgs — regression: no tmpdir-allocated bind sources', () => {
671
+ // Pins the post-mkdtemp-removal contract: every `--volume`
672
+ // source must trace back to a `hostDir.hostPath` the consumer
673
+ // passed in. The old code path mkdtemp'd a workspace under the
674
+ // OS tmpdir and bind-mounted it; that's the EACCES-in-sibling-
675
+ // container bug. If a future refactor reintroduces backend-side
676
+ // host-path allocation, this test fails.
677
+ it('every host-side source comes from a layout-declared hostDir.hostPath', () => {
678
+ const declared = {
679
+ outputs: '/host/declared/outputs',
680
+ uploads: '/host/declared/uploads',
681
+ toolResults: '/host/declared/tool_results',
682
+ skill1: '/host/declared/skill-a',
683
+ skill2: '/host/declared/skill-b',
684
+ transcripts: '/host/declared/transcripts',
685
+ }
686
+ const args = renderLayoutMountArgs({
687
+ outputs: {
688
+ source: { type: 'hostDir', hostPath: declared.outputs },
689
+ containerPath: '/mnt/user-data/outputs',
690
+ },
691
+ uploads: {
692
+ source: { type: 'hostDir', hostPath: declared.uploads },
693
+ containerPath: '/mnt/user-data/uploads',
694
+ },
695
+ toolResults: {
696
+ source: { type: 'hostDir', hostPath: declared.toolResults },
697
+ containerPath: '/mnt/user-data/tool_results',
698
+ },
699
+ skills: [
700
+ {
701
+ id: 'a',
702
+ source: { type: 'hostDir', hostPath: declared.skill1 },
703
+ containerPath: '/mnt/skills/a',
704
+ },
705
+ {
706
+ id: 'b',
707
+ source: { type: 'hostDir', hostPath: declared.skill2 },
708
+ containerPath: '/mnt/skills/b',
709
+ },
710
+ ],
711
+ transcripts: {
712
+ source: { type: 'hostDir', hostPath: declared.transcripts },
713
+ containerPath: '/mnt/transcripts',
714
+ },
715
+ })
716
+
717
+ // Extract every `<host>:<container>:<mode>` triple and assert
718
+ // the host side is one of the declared paths. The positive
719
+ // declaredSet check is the load-bearing assertion; the negative
720
+ // regex assertions on tmpdir prefixes that earlier rounds
721
+ // carried were dropped because the positive set is exhaustive
722
+ // — no `hostPath` from outside the consumer's declared map can
723
+ // satisfy this.
724
+ const tripleArgs = args.filter((_, i) => i % 2 === 1)
725
+ const declaredSet = new Set(Object.values(declared))
726
+ for (const triple of tripleArgs) {
727
+ const [hostPath] = triple.split(':')
728
+ expect(declaredSet.has(hostPath ?? '')).toBe(true)
729
+ }
730
+ })
731
+ })