@namzu/sandbox 2.0.2 → 2.0.3

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