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