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