@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
package/src/index.ts
ADDED
|
@@ -0,0 +1,930 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @namzu/sandbox — pluggable sandbox provider for @namzu/sdk.
|
|
3
|
+
*
|
|
4
|
+
* The SDK already declares a `SandboxProvider` shape in
|
|
5
|
+
* `@namzu/sdk` (`packages/sdk/src/types/sandbox/index.ts`). This
|
|
6
|
+
* package implements that shape with concrete BACKENDS picked at
|
|
7
|
+
* construction time. The set is aligned with the 2026 industrial
|
|
8
|
+
* standard for AI-agent code-execution sandboxes:
|
|
9
|
+
*
|
|
10
|
+
* • `docker` — plain OCI container per task, seccomp default
|
|
11
|
+
* profile, tmpfs workdir, no-network-by-default. The universal
|
|
12
|
+
* fallback every namzu host gets locally with `docker compose`
|
|
13
|
+
* and on every Linux replica in any cloud. What Northflank /
|
|
14
|
+
* Railway / Render / Compass-platform / GitHub Actions runners
|
|
15
|
+
* actually ship for code execution. Trust boundary: namespaces.
|
|
16
|
+
*
|
|
17
|
+
* • `e2b` — adapter for E2B's managed Firecracker microVM service
|
|
18
|
+
* (`e2b.dev`). Sub-second cold-start via snapshot/restore, full
|
|
19
|
+
* kernel-level trust boundary. The SaaS-friendly path to real
|
|
20
|
+
* Firecracker isolation without running our own scheduler.
|
|
21
|
+
*
|
|
22
|
+
* • `fly-machines` — adapter for Fly Machines (`fly.io/docs/machines`).
|
|
23
|
+
* Also Firecracker microVMs; closer to bare-metal control than
|
|
24
|
+
* E2B, useful when the workload is more "arbitrary tool calls"
|
|
25
|
+
* than "Python REPL".
|
|
26
|
+
*
|
|
27
|
+
* • `firecracker` — self-hosted `firecracker-containerd` on bare
|
|
28
|
+
* metal (or KVM-enabled cloud instance). For hosts that need
|
|
29
|
+
* Firecracker isolation AND insist on running the scheduler
|
|
30
|
+
* themselves. Tier 3 isolation, highest operational cost.
|
|
31
|
+
*
|
|
32
|
+
* • `gvisor` — adapter for `runsc` runtime (Google's userspace
|
|
33
|
+
* kernel). What OpenAI Code Interpreter and Modal Labs ship.
|
|
34
|
+
* Trusted-tenant tier with near-zero cold-start; runs on
|
|
35
|
+
* commodity Linux without nested virt. Locally via Linux Docker
|
|
36
|
+
* runtime; not available on macOS Docker Desktop.
|
|
37
|
+
*
|
|
38
|
+
* • `passthrough` — no isolation, runs commands directly. For
|
|
39
|
+
* tests and trusted environments only. Off by default; opt-in.
|
|
40
|
+
*
|
|
41
|
+
* **What we deliberately do NOT build** is yet-another Firecracker
|
|
42
|
+
* scheduler — that is E2B's and Fly's entire product, and writing
|
|
43
|
+
* our own is a years-long detour. We adapt to theirs.
|
|
44
|
+
*
|
|
45
|
+
* **Cloud portability:** the backend interface is cloud-agnostic.
|
|
46
|
+
* `docker` works on every cloud; `e2b` and `fly-machines` are
|
|
47
|
+
* managed services not tied to any cloud; `firecracker` and
|
|
48
|
+
* `gvisor` need infrastructure the host chooses (GKE Sandbox, AWS
|
|
49
|
+
* Fargate, self-hosted KVM, etc.). Picking a stronger backend may
|
|
50
|
+
* imply moving cloud — that's the host's call, not the SDK's.
|
|
51
|
+
*
|
|
52
|
+
* **Local dev story:** Phase 1 (`docker`) runs everywhere. Phase 2
|
|
53
|
+
* (`e2b` / `fly-machines`) hits the managed service from a dev
|
|
54
|
+
* laptop with no infra setup. Phase 3 (`firecracker` / `gvisor`)
|
|
55
|
+
* needs Lima/Colima on macOS or native KVM on Linux — only the
|
|
56
|
+
* adversarial-multi-tenant prod path needs that and it's clearly
|
|
57
|
+
* documented as such.
|
|
58
|
+
*
|
|
59
|
+
* This file is the public surface. Concrete backend implementations
|
|
60
|
+
* land under `./backends/<kind>/` in subsequent commits — the
|
|
61
|
+
* `SandboxBackend` interface here is the contract they implement.
|
|
62
|
+
*
|
|
63
|
+
* Refs: `e2b.dev/docs/sandbox`, `fly.io/docs/machines`,
|
|
64
|
+
* `firecracker-microvm.github.io`, `gvisor.dev/docs`,
|
|
65
|
+
* `cloud.google.com/kubernetes-engine/docs/concepts/sandbox-pods`,
|
|
66
|
+
* `aws.amazon.com/blogs/aws/firecracker-lightweight-virtualization-for-serverless-computing`.
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
import type {
|
|
70
|
+
ContainerSandboxLayout,
|
|
71
|
+
Sandbox,
|
|
72
|
+
SandboxCreateConfig,
|
|
73
|
+
SandboxProvider,
|
|
74
|
+
} from '@namzu/sdk'
|
|
75
|
+
|
|
76
|
+
import { buildAciStandbyPoolBackend } from './backends/aci-standby-pool/index.js'
|
|
77
|
+
import { buildDockerBackend, resolveLayout } from './backends/docker/index.js'
|
|
78
|
+
import { buildFirecrackerBackend } from './backends/firecracker/index.js'
|
|
79
|
+
|
|
80
|
+
// Re-export the layout types so consumers of `@namzu/sandbox` can
|
|
81
|
+
// import them without also depending on `@namzu/sdk`. The canonical
|
|
82
|
+
// home of the types is the SDK; this is a convenience pass-through.
|
|
83
|
+
export type {
|
|
84
|
+
ContainerSandboxLayout,
|
|
85
|
+
ContainerSandboxLayoutMount,
|
|
86
|
+
ContainerSandboxMountSource,
|
|
87
|
+
ContainerSandboxSkillMount,
|
|
88
|
+
ResolvedContainerSandboxLayout,
|
|
89
|
+
} from '@namzu/sdk'
|
|
90
|
+
|
|
91
|
+
// Re-export the default container-path constants the prompt-template
|
|
92
|
+
// generator side wants to import without also depending on
|
|
93
|
+
// `@namzu/sdk` directly. Single source of truth: a Vandal prompt
|
|
94
|
+
// saying "write outputs to `/mnt/user-data/outputs`" imports
|
|
95
|
+
// `SANDBOX_DEFAULT_OUTPUTS_PATH` instead of hard-coding the string.
|
|
96
|
+
export {
|
|
97
|
+
SANDBOX_DEFAULT_OUTPUTS_PATH,
|
|
98
|
+
SANDBOX_DEFAULT_SKILLS_PARENT,
|
|
99
|
+
SANDBOX_DEFAULT_TOOL_RESULTS_PATH,
|
|
100
|
+
SANDBOX_DEFAULT_TRANSCRIPTS_PATH,
|
|
101
|
+
SANDBOX_DEFAULT_UPLOADS_PATH,
|
|
102
|
+
} from '@namzu/sdk'
|
|
103
|
+
|
|
104
|
+
// Firecracker (owned Azure platform) public surface. The Vandal-side
|
|
105
|
+
// `firecracker-lifecycle.ts` imports the agent-handle shape + the
|
|
106
|
+
// transport so it can mint the orchestrator handle and run the vsock
|
|
107
|
+
// heartbeat probe without reaching into `backends/`.
|
|
108
|
+
export type {
|
|
109
|
+
FirecrackerBackendInternalConfig,
|
|
110
|
+
OrchestratorTokenProvider,
|
|
111
|
+
} from './backends/firecracker/index.js'
|
|
112
|
+
export {
|
|
113
|
+
type SandboxAgentHandle,
|
|
114
|
+
type VsockTransportOptions,
|
|
115
|
+
VsockAgentTransport,
|
|
116
|
+
} from './backends/firecracker/transport.js'
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Backend strategy
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Top-level sandbox tier. Each tier is a use-case bucket:
|
|
124
|
+
*
|
|
125
|
+
* - `process` — the agent runs on the developer's own host;
|
|
126
|
+
* the sandbox keeps it from reading `~/.ssh` or running
|
|
127
|
+
* `rm -rf ~`. Single-user; no multi-tenancy. What Anthropic
|
|
128
|
+
* ships with Claude Code via `@anthropic-ai/sandbox-runtime`.
|
|
129
|
+
*
|
|
130
|
+
* - `container` — the agent runs inside an OCI container per
|
|
131
|
+
* task. Same code path locally (`docker compose`) and on
|
|
132
|
+
* Linux replicas in any cloud. The default for "trusted
|
|
133
|
+
* prompts, contained workloads" — Northflank, Railway,
|
|
134
|
+
* Render, Compass-platform, GitHub Actions runners all
|
|
135
|
+
* ship this tier.
|
|
136
|
+
*
|
|
137
|
+
* - `microvm` — the agent runs inside a Firecracker microVM
|
|
138
|
+
* per task. Hardware-virtualization trust boundary; the
|
|
139
|
+
* industry standard for adversarial multi-tenancy
|
|
140
|
+
* (AWS Lambda/Fargate, Fly Machines, Replit, E2B, Daytona
|
|
141
|
+
* all converged here). Sub-second cold-start via
|
|
142
|
+
* snapshot/restore.
|
|
143
|
+
*
|
|
144
|
+
* - `passthrough` — no isolation; for tests and explicitly
|
|
145
|
+
* trusted environments.
|
|
146
|
+
*
|
|
147
|
+
* The concrete implementation inside a tier is picked via the
|
|
148
|
+
* tier-specific config (see {@link ProcessBackendConfig},
|
|
149
|
+
* {@link ContainerBackendConfig}, {@link MicroVMBackendConfig}).
|
|
150
|
+
*/
|
|
151
|
+
export type SandboxTier = 'process' | 'container' | 'microvm' | 'passthrough'
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Discriminated union of sandbox backend configurations. Each
|
|
155
|
+
* tier has its own configuration shape — picking a tier picks the
|
|
156
|
+
* shape automatically via TS narrowing.
|
|
157
|
+
*/
|
|
158
|
+
export type SandboxBackendConfig =
|
|
159
|
+
| ProcessBackendConfig
|
|
160
|
+
| ContainerBackendConfig
|
|
161
|
+
| ACIStandbyPoolBackendConfig
|
|
162
|
+
| MicroVMBackendConfig
|
|
163
|
+
| PassthroughBackendConfig
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Azure Container Instances Standby Pool backend. Container tier,
|
|
167
|
+
* managed-microvm-ish: every claim is a fresh ACI container group
|
|
168
|
+
* pre-warmed in an Azure-managed standby pool (`Microsoft.StandbyPool`).
|
|
169
|
+
* ~1.5 s claim latency vs ~10-30 s for cold ACI spawn. Trust boundary
|
|
170
|
+
* = Microsoft's ACI isolation host (gVisor-equivalent depending on
|
|
171
|
+
* SKU; AMD SEV-SNP TEE when the pool is created with sku=Confidential).
|
|
172
|
+
*
|
|
173
|
+
* No host filesystem — workspace mounts ride `azureFileShare` sources
|
|
174
|
+
* (the host provisions a per-task Azure Files share upstream and
|
|
175
|
+
* threads it into the layout). Auth via a caller-supplied
|
|
176
|
+
* `getArmToken()` callback so the sandbox package stays free of
|
|
177
|
+
* Azure SDK dependencies; the host runtime owns Managed Identity /
|
|
178
|
+
* AzureCLI / federated credential picking.
|
|
179
|
+
*
|
|
180
|
+
* Use this when (a) running on Azure Container Apps and you cannot
|
|
181
|
+
* mount the docker socket, (b) you want per-task container
|
|
182
|
+
* isolation without operating a Firecracker host yourself, and
|
|
183
|
+
* (c) sub-2-second claim latency is acceptable.
|
|
184
|
+
*/
|
|
185
|
+
export interface ACIStandbyPoolBackendConfig {
|
|
186
|
+
readonly tier: 'container'
|
|
187
|
+
readonly runtime: 'aci-standby-pool'
|
|
188
|
+
readonly subscriptionId: string
|
|
189
|
+
readonly resourceGroup: string
|
|
190
|
+
readonly location: string
|
|
191
|
+
readonly standbyPoolResourceId: string
|
|
192
|
+
readonly containerGroupProfileResourceId: string
|
|
193
|
+
readonly containerGroupProfileRevision?: number
|
|
194
|
+
/**
|
|
195
|
+
* Async callback returning a fresh ARM bearer token (audience
|
|
196
|
+
* `https://management.azure.com/`). Invoked on every ARM call.
|
|
197
|
+
*/
|
|
198
|
+
readonly getArmToken: () => Promise<string>
|
|
199
|
+
readonly subnetId?: string
|
|
200
|
+
readonly readyPollIntervalMs?: number
|
|
201
|
+
readonly readyTimeoutMs?: number
|
|
202
|
+
readonly workerPort?: number
|
|
203
|
+
readonly armApiVersion?: string
|
|
204
|
+
/**
|
|
205
|
+
* Prefix for the ACI container group name and the inner worker
|
|
206
|
+
* container. Combined with a generated sandbox id and
|
|
207
|
+
* sanitised to ARM's allowed character set. Default
|
|
208
|
+
* `namzu-task`; consumers (e.g. Vandal) override to brand
|
|
209
|
+
* their own deployments.
|
|
210
|
+
*/
|
|
211
|
+
readonly containerNamePrefix?: string
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* `process` tier. Auto-detects the platform's native primitive
|
|
216
|
+
* unless overridden:
|
|
217
|
+
*
|
|
218
|
+
* - `bubblewrap` on Linux / WSL2 (`bwrap`)
|
|
219
|
+
* - `seatbelt` on macOS (`sandbox-exec`)
|
|
220
|
+
*
|
|
221
|
+
* Both are what `@anthropic-ai/sandbox-runtime` ships. Cold-start
|
|
222
|
+
* is process spawn (~ms). Use this when the agent runs on the
|
|
223
|
+
* end-user's developer machine — Claude Code's deployment model.
|
|
224
|
+
*/
|
|
225
|
+
export interface ProcessBackendConfig {
|
|
226
|
+
readonly tier: 'process'
|
|
227
|
+
readonly engine?: 'auto' | 'bubblewrap' | 'seatbelt'
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* `container` tier. Two runtime options:
|
|
232
|
+
*
|
|
233
|
+
* - `docker` (default) — plain OCI container on the host's
|
|
234
|
+
* Docker daemon. No special runtime required.
|
|
235
|
+
* - `runsc` — Google's gVisor userspace-kernel runtime. Stronger
|
|
236
|
+
* isolation (syscall-table separation), runs on commodity
|
|
237
|
+
* Linux without nested virt. Trusted-tenant tier; what OpenAI
|
|
238
|
+
* Code Interpreter and Modal Labs ship. Requires the
|
|
239
|
+
* `runsc` runtime installed on the Docker daemon (Linux only;
|
|
240
|
+
* Docker Desktop on macOS does not support it). See
|
|
241
|
+
* `gvisor.dev/docs/user_guide/quick_start/docker`.
|
|
242
|
+
*
|
|
243
|
+
* `image` is the container image to spawn per task. The package
|
|
244
|
+
* ships a reference Dockerfile (compass-platform pattern) with
|
|
245
|
+
* Python doc-gen libraries, LibreOffice, pandoc, Chromium, and
|
|
246
|
+
* `tesseract` pre-installed; hosts that want a leaner image
|
|
247
|
+
* supply their own.
|
|
248
|
+
*/
|
|
249
|
+
export interface ContainerBackendConfig {
|
|
250
|
+
readonly tier: 'container'
|
|
251
|
+
readonly runtime?: 'docker' | 'runsc'
|
|
252
|
+
readonly image: string
|
|
253
|
+
/**
|
|
254
|
+
* How the SDK consumer reaches the in-container worker. Default
|
|
255
|
+
* `'host-port'` — the original loopback host-port flow, works
|
|
256
|
+
* when the consumer runs ON the docker host. Set
|
|
257
|
+
* `'container-network'` when the consumer is itself a container
|
|
258
|
+
* spawning siblings via the host's Docker daemon: the worker is
|
|
259
|
+
* reachable at `http://<containerName>:2024` over the docker
|
|
260
|
+
* bridge named in `network`.
|
|
261
|
+
*/
|
|
262
|
+
readonly hostReachability?: 'host-port' | 'container-network'
|
|
263
|
+
/**
|
|
264
|
+
* Docker network the spawned container attaches to. Default
|
|
265
|
+
* `'none'` (no inbound or outbound network). Set to a docker
|
|
266
|
+
* bridge name when `hostReachability='container-network'` so the
|
|
267
|
+
* SDK consumer (also on that bridge) can reach the worker by
|
|
268
|
+
* container DNS name. Egress from the sandbox is governed
|
|
269
|
+
* separately by `EgressPolicy`.
|
|
270
|
+
*/
|
|
271
|
+
readonly network?: 'none' | 'bridge' | string
|
|
272
|
+
/**
|
|
273
|
+
* Optional `--label key=value` pairs applied to the spawned
|
|
274
|
+
* container. Hosts use this to make the container findable from
|
|
275
|
+
* out-of-band cleanup paths (reaper jobs, monitoring filters)
|
|
276
|
+
* via `docker ps --filter label=...`. Keys with `=` or empty
|
|
277
|
+
* names are rejected at construction; values are passed verbatim
|
|
278
|
+
* to the docker CLI argv (no shell interpolation — `spawn` argv
|
|
279
|
+
* not a shell pipeline). Default unset (no extra labels).
|
|
280
|
+
*
|
|
281
|
+
* Convention for namzu hosts: namespace your keys
|
|
282
|
+
* (`vandal.sandbox=true`, `vandal.task-id=<id>`, …) to avoid
|
|
283
|
+
* collisions with Docker / orchestrator labels.
|
|
284
|
+
*/
|
|
285
|
+
readonly labels?: Readonly<Record<string, string>>
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* `microvm` tier. Three concrete services, all Firecracker under
|
|
290
|
+
* the hood:
|
|
291
|
+
*
|
|
292
|
+
* - `e2b` — adapter for E2B's managed sandbox service
|
|
293
|
+
* (`e2b.dev`). TS SDK does the scheduler work; namzu wraps it.
|
|
294
|
+
* ~150ms cold-start (snapshot/restore). Apache-2.0 server side,
|
|
295
|
+
* so the same code path can run against self-hosted E2B if
|
|
296
|
+
* the host eventually wants to leave the managed service.
|
|
297
|
+
* - `fly-machines` — adapter for Fly Machines
|
|
298
|
+
* (`fly.io/docs/machines`). Closer to bare-metal control than
|
|
299
|
+
* E2B; the right tier when the workload is "arbitrary tool
|
|
300
|
+
* calls" rather than "Python REPL".
|
|
301
|
+
* - `self-hosted` — direct `firecracker-containerd` against a
|
|
302
|
+
* KVM-enabled host. For deployments where E2B and Fly are
|
|
303
|
+
* both off the table for policy reasons. Operationally the
|
|
304
|
+
* heaviest path; everything below ships first.
|
|
305
|
+
*
|
|
306
|
+
* Local dev: `e2b` and `fly-machines` work from any laptop with
|
|
307
|
+
* an API key (no infra setup). `self-hosted` requires Linux + KVM
|
|
308
|
+
* (Lima/Colima on macOS).
|
|
309
|
+
*/
|
|
310
|
+
export type MicroVMBackendConfig =
|
|
311
|
+
| {
|
|
312
|
+
readonly tier: 'microvm'
|
|
313
|
+
readonly service: 'e2b'
|
|
314
|
+
readonly apiKey: string
|
|
315
|
+
readonly template?: string
|
|
316
|
+
}
|
|
317
|
+
| {
|
|
318
|
+
readonly tier: 'microvm'
|
|
319
|
+
readonly service: 'fly-machines'
|
|
320
|
+
readonly apiToken: string
|
|
321
|
+
readonly app: string
|
|
322
|
+
readonly image: string
|
|
323
|
+
readonly region?: string
|
|
324
|
+
}
|
|
325
|
+
| {
|
|
326
|
+
readonly tier: 'microvm'
|
|
327
|
+
readonly service: 'self-hosted'
|
|
328
|
+
readonly firecrackerBinary: string
|
|
329
|
+
readonly kernelImage: string
|
|
330
|
+
readonly rootfsImage: string
|
|
331
|
+
/**
|
|
332
|
+
* The owned-platform seam (ses_051). When these are present the
|
|
333
|
+
* `self-hosted` arm targets the OWNED Azure Firecracker
|
|
334
|
+
* orchestrator (`backends/firecracker/`), not a local
|
|
335
|
+
* `firecracker-containerd`: `orchestratorEndpoint` is the
|
|
336
|
+
* control-plane base URL, `getToken` mints a bearer for it
|
|
337
|
+
* (the ACI `getArmToken` closure pattern, so this package keeps
|
|
338
|
+
* zero Azure-SDK deps), and `template` selects the golden
|
|
339
|
+
* snapshot revision to CoW-resume.
|
|
340
|
+
*
|
|
341
|
+
* The legacy local-`firecracker-containerd` shape
|
|
342
|
+
* (`firecrackerBinary`/`kernelImage`/`rootfsImage` only) is
|
|
343
|
+
* still NOT implemented and throws
|
|
344
|
+
* {@link SandboxBackendNotImplementedError}; supplying
|
|
345
|
+
* `orchestratorEndpoint` is what routes to the owned backend.
|
|
346
|
+
*/
|
|
347
|
+
readonly orchestratorEndpoint?: string
|
|
348
|
+
readonly getToken?: () => Promise<string>
|
|
349
|
+
readonly template?: string
|
|
350
|
+
/**
|
|
351
|
+
* Resume this per-agent captured snapshot (layered on its base
|
|
352
|
+
* golden) INSTEAD of a fresh golden boot. Tier-agnostic, additive,
|
|
353
|
+
* optional: the backend that supports it (the owned firecracker
|
|
354
|
+
* backend) honors it; others ignore it. Absent ⇒ the create body is
|
|
355
|
+
* byte-identical and the generic golden-resume hot path is unchanged
|
|
356
|
+
* (the field is only ever set by the host's per-agent trigger path).
|
|
357
|
+
* Sibling to `template` (base-golden selector) — see
|
|
358
|
+
* {@link AgentSnapshotRef}.
|
|
359
|
+
*/
|
|
360
|
+
readonly agentSnapshot?: AgentSnapshotRef
|
|
361
|
+
/** Fixed guest AF_VSOCK port the in-VM agent listens on. */
|
|
362
|
+
readonly agentVsockPort?: number
|
|
363
|
+
readonly readyTimeoutMs?: number
|
|
364
|
+
readonly readyPollIntervalMs?: number
|
|
365
|
+
/**
|
|
366
|
+
* NETWORK-mode mTLS client material (ses_051 P4 client-proxy
|
|
367
|
+
* bridge). When present, the orchestrator returns an `mtls` agent
|
|
368
|
+
* handle (host/port/sandboxId, NO cert material) and this CA/cert/key
|
|
369
|
+
* is MERGED onto that handle before the transport dials the per-host
|
|
370
|
+
* relay over mTLS. Injected by the consumer's runtime (the Vandal
|
|
371
|
+
* host layer reads it from `VANDAL_SANDBOX_FC_TLS_*`), NEVER fetched
|
|
372
|
+
* inside this package — same dependency boundary as `getToken`, so
|
|
373
|
+
* `@namzu/sandbox` stays Azure-SDK-free. Absent for the single-host
|
|
374
|
+
* VSOCK default (the live proofs).
|
|
375
|
+
*/
|
|
376
|
+
readonly mtls?: {
|
|
377
|
+
readonly ca: string | Buffer
|
|
378
|
+
readonly cert: string | Buffer
|
|
379
|
+
readonly key: string | Buffer
|
|
380
|
+
readonly servername?: string
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* CONTROL-plane mTLS client material. When present, the orchestrator
|
|
384
|
+
* control-plane calls (create/destroy POSTs to `orchestratorEndpoint`)
|
|
385
|
+
* dial over mTLS — presenting this client cert and pinning this CA —
|
|
386
|
+
* instead of plain `fetch`. Secures the control plane when
|
|
387
|
+
* `orchestratorEndpoint` is an `https://` URL reached over the PUBLIC
|
|
388
|
+
* internet (the non-VNet-integrated caller→FC-host hop), where the
|
|
389
|
+
* shared-secret bearer alone would be exposed. The bearer is STILL sent
|
|
390
|
+
* (defense in depth). Same `{ca,cert,key,servername}` shape + the same
|
|
391
|
+
* consumer-injected dependency boundary as `mtls` (the one fleet CA
|
|
392
|
+
* secures both planes). Absent → plain `fetch` control plane (the
|
|
393
|
+
* single-host VSOCK default, unchanged).
|
|
394
|
+
*/
|
|
395
|
+
readonly controlPlaneMtls?: {
|
|
396
|
+
readonly ca: string | Buffer
|
|
397
|
+
readonly cert: string | Buffer
|
|
398
|
+
readonly key: string | Buffer
|
|
399
|
+
readonly servername?: string
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* A reference to a per-agent captured snapshot, layered on top of a base
|
|
405
|
+
* golden revision. Provider-AGNOSTIC: this is a sandbox-spec concept, a
|
|
406
|
+
* sibling to {@link MicroVMBackendConfig}'s `template` (which selects a
|
|
407
|
+
* base golden), not a provider-specific shape — hence no provider prefix
|
|
408
|
+
* in the name. A microVM backend that supports per-agent resume (the owned
|
|
409
|
+
* Firecracker backend) honors it by resuming this agent's captured diff
|
|
410
|
+
* INSTEAD of a fresh golden boot; backends that do not support it ignore it.
|
|
411
|
+
*
|
|
412
|
+
* The triple identifies exactly one captured snapshot: the owning tenant
|
|
413
|
+
* (`orgId`), the agent registry row (`agentId`), and the registry version
|
|
414
|
+
* (`version`, a decimal string so the whole triple is a set of path
|
|
415
|
+
* segments). The host constructs this server-side from its own registry;
|
|
416
|
+
* `@namzu/sandbox` only forwards it.
|
|
417
|
+
*/
|
|
418
|
+
export interface AgentSnapshotRef {
|
|
419
|
+
readonly orgId: string
|
|
420
|
+
readonly agentId: string
|
|
421
|
+
readonly version: string
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* `passthrough` tier. No isolation — runs commands directly in
|
|
426
|
+
* the host process. Tests and trusted environments only.
|
|
427
|
+
*/
|
|
428
|
+
export interface PassthroughBackendConfig {
|
|
429
|
+
readonly tier: 'passthrough'
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Egress allowlist resolution. Host-supplied policy decides whether
|
|
434
|
+
* an outbound request is allowed before the proxy opens a socket.
|
|
435
|
+
*
|
|
436
|
+
* Four shapes:
|
|
437
|
+
*
|
|
438
|
+
* - `deny-all` — default. Reject every outbound request.
|
|
439
|
+
* - `allow-all` — accept every outbound request. Tests only.
|
|
440
|
+
* - `static` — fixed allowlist of hostnames at construction.
|
|
441
|
+
* - `resolver` — async closure returning the allowlist.
|
|
442
|
+
* Parameterless **on purpose**: the resolver is a closure that
|
|
443
|
+
* captures whatever context the host has (tenantId, runId,
|
|
444
|
+
* auth token, etc.) at provider-construction time. Compass-
|
|
445
|
+
* platform's JWT-minting flow already works this way: the
|
|
446
|
+
* server knows the tenant when it issues the JWT, and the
|
|
447
|
+
* allowlist claim is baked in there. This avoids the
|
|
448
|
+
* "where does the resolver get its context from" plumbing
|
|
449
|
+
* problem — the host owns the closure, the SDK runtime
|
|
450
|
+
* doesn't have to forward identity through `provider.create`.
|
|
451
|
+
*/
|
|
452
|
+
export type EgressPolicy =
|
|
453
|
+
| { readonly kind: 'deny-all' }
|
|
454
|
+
| { readonly kind: 'allow-all' }
|
|
455
|
+
| { readonly kind: 'static'; readonly allowedHosts: readonly string[] }
|
|
456
|
+
| { readonly kind: 'resolver'; readonly resolve: () => Promise<readonly string[]> }
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Backend strategy. Each tier × concrete-service combination ships
|
|
460
|
+
* an implementation of this interface in its own subfolder under
|
|
461
|
+
* `src/backends/`.
|
|
462
|
+
*
|
|
463
|
+
* Backends are responsible for:
|
|
464
|
+
* - turning {@link SandboxBackendOptions} into a concrete
|
|
465
|
+
* {@link Sandbox} instance the SDK can use,
|
|
466
|
+
* - wiring {@link EgressPolicy} into whatever proxy / network
|
|
467
|
+
* primitive the backend has,
|
|
468
|
+
* - cleaning up host resources on `destroy()` (process-level
|
|
469
|
+
* cleanup, container teardown, microVM stop+delete, etc.).
|
|
470
|
+
*
|
|
471
|
+
* Tier-specific concepts (bind-mount layout for container, microVM
|
|
472
|
+
* volume id, process-tier seccomp profile) are NOT carried on
|
|
473
|
+
* `SandboxBackendOptions`. They are baked into the backend at
|
|
474
|
+
* construction time via the tier-specific config (see
|
|
475
|
+
* {@link SandboxProviderConfig.layout} for the container tier). This
|
|
476
|
+
* keeps `provider.create()` symmetric across tiers and prevents the
|
|
477
|
+
* SDK runtime from accidentally calling a container backend without
|
|
478
|
+
* a layout — the binding is at construction, not per-call.
|
|
479
|
+
*
|
|
480
|
+
* The backend does NOT see the agent or its tools — the SDK
|
|
481
|
+
* composes them at the runtime layer. Backends are pure isolation
|
|
482
|
+
* primitives.
|
|
483
|
+
*/
|
|
484
|
+
export interface SandboxBackend {
|
|
485
|
+
readonly tier: SandboxTier
|
|
486
|
+
readonly name: string
|
|
487
|
+
|
|
488
|
+
create(options: SandboxBackendOptions): Promise<Sandbox>
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Per-call options handed to a backend's `create()`. Tier-agnostic
|
|
493
|
+
* host knobs only:
|
|
494
|
+
*
|
|
495
|
+
* - `workingDirectory` — the per-task root where the sandbox is
|
|
496
|
+
* rooted (e.g. `/tmp/<tenant>/<run>/`). Backends bind-mount or
|
|
497
|
+
* chroot this depending on platform.
|
|
498
|
+
* - `egress` — the allowlist policy applied to outbound network
|
|
499
|
+
* inside the sandbox. Backends translate this into proxy /
|
|
500
|
+
* iptables / domain-allowlist plumbing.
|
|
501
|
+
* - `timeoutMs`, `memoryLimitMb`, `maxProcesses` — resource caps
|
|
502
|
+
* applied per spawned process inside the sandbox.
|
|
503
|
+
* - `env` — environment variables added to the inside of the
|
|
504
|
+
* sandbox (NOT host process env). Used to forward
|
|
505
|
+
* `HTTP_PROXY` / `HTTPS_PROXY` to the egress proxy when one
|
|
506
|
+
* is in play.
|
|
507
|
+
*
|
|
508
|
+
* `layout` is **not** here — see the type-level note on
|
|
509
|
+
* {@link SandboxBackend}. Identity-aware fields (tenantId / runId /
|
|
510
|
+
* agentId) are deliberately NOT in this shape either; hosts that
|
|
511
|
+
* need per-tenant sandbox config bake the tenant into the closure
|
|
512
|
+
* that constructs the provider — see the `EgressPolicy` resolver
|
|
513
|
+
* shape.
|
|
514
|
+
*/
|
|
515
|
+
export interface SandboxBackendOptions {
|
|
516
|
+
readonly workingDirectory: string
|
|
517
|
+
readonly egress?: EgressPolicy
|
|
518
|
+
readonly timeoutMs?: number
|
|
519
|
+
readonly memoryLimitMb?: number
|
|
520
|
+
readonly maxProcesses?: number
|
|
521
|
+
readonly env?: Record<string, string>
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// ---------------------------------------------------------------------------
|
|
525
|
+
// Provider factory (public)
|
|
526
|
+
// ---------------------------------------------------------------------------
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Configuration for {@link createSandboxProvider}. The host picks
|
|
530
|
+
* a tier-specific backend config (process / container / microvm /
|
|
531
|
+
* passthrough) and supplies cross-tier defaults that
|
|
532
|
+
* `provider.create()` calls can override.
|
|
533
|
+
*
|
|
534
|
+
* Container-tier backends require a per-task
|
|
535
|
+
* {@link ContainerSandboxLayout} captured at construction time (see
|
|
536
|
+
* the discriminated union). The layout is per-task — different
|
|
537
|
+
* `hostPath`s for different runs — so hosts call
|
|
538
|
+
* `createSandboxProvider` once per task with the task-specific
|
|
539
|
+
* layout baked in. The `Sandbox` instance returned by
|
|
540
|
+
* `provider.create()` then inherits that layout. This is the only
|
|
541
|
+
* path: there is no per-call layout argument that could be silently
|
|
542
|
+
* omitted by the SDK runtime.
|
|
543
|
+
*/
|
|
544
|
+
export type SandboxProviderConfig =
|
|
545
|
+
| (SandboxProviderConfigBase & {
|
|
546
|
+
readonly backend: ContainerBackendConfig
|
|
547
|
+
readonly layout: ContainerSandboxLayout
|
|
548
|
+
})
|
|
549
|
+
| (SandboxProviderConfigBase & {
|
|
550
|
+
readonly backend: ProcessBackendConfig | MicroVMBackendConfig | PassthroughBackendConfig
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
interface SandboxProviderConfigBase {
|
|
554
|
+
readonly defaultEgress?: EgressPolicy
|
|
555
|
+
readonly defaultTimeoutMs?: number
|
|
556
|
+
readonly defaultMemoryLimitMb?: number
|
|
557
|
+
readonly defaultMaxProcesses?: number
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Build a {@link SandboxProvider} the SDK can wire into
|
|
562
|
+
* `drainQuery`'s `sandboxProvider` field. Selects the backend at
|
|
563
|
+
* construction time; subsequent `provider.create()` calls all use
|
|
564
|
+
* the chosen backend.
|
|
565
|
+
*
|
|
566
|
+
* Backends are loaded lazily — the package only imports the
|
|
567
|
+
* platform-specific modules (Anthropic's sandbox-runtime, the
|
|
568
|
+
* Docker SDK, the E2B SDK, …) when the corresponding backend is
|
|
569
|
+
* requested. That keeps `@namzu/sandbox` reasonable to install in
|
|
570
|
+
* environments where one backend is genuinely impossible.
|
|
571
|
+
*
|
|
572
|
+
* **Not implemented in this commit** — this file declares the
|
|
573
|
+
* surface; backends arrive in subsequent commits per the ses_004
|
|
574
|
+
* phase plan:
|
|
575
|
+
*
|
|
576
|
+
* - **P3.1** — `container` (docker runtime). Phase 1: ship now.
|
|
577
|
+
* - **P3.2** — `EgressPolicy` plumbing + reference egress proxy.
|
|
578
|
+
* - **P3.3** — `microvm` (E2B + Fly Machines adapters). Phase 2.
|
|
579
|
+
* - **P3.4** — `process` (Anthropic sandbox-runtime adapter).
|
|
580
|
+
* - **P3.5** — `microvm` (self-hosted firecracker-containerd) +
|
|
581
|
+
* `container` (gVisor runtime). Phase 3 adversarial-multi-tenant.
|
|
582
|
+
*
|
|
583
|
+
* Calling this function now throws
|
|
584
|
+
* {@link SandboxBackendNotImplementedError} so consumers get a
|
|
585
|
+
* clear signal during the staged rollout.
|
|
586
|
+
*/
|
|
587
|
+
export function createSandboxProvider(config: SandboxProviderConfig): SandboxProvider {
|
|
588
|
+
const backend = pickBackend(config)
|
|
589
|
+
const id = `namzu-${backend.tier}-${backend.name}`
|
|
590
|
+
const name = `@namzu/sandbox: ${describeBackend(config.backend)}`
|
|
591
|
+
return {
|
|
592
|
+
id,
|
|
593
|
+
name,
|
|
594
|
+
environment: 'basic',
|
|
595
|
+
async create(perCall?: SandboxCreateConfig): Promise<Sandbox> {
|
|
596
|
+
return await backend.create({
|
|
597
|
+
workingDirectory: perCall?.workingDirectory ?? '/workspace',
|
|
598
|
+
...(config.defaultEgress !== undefined ? { egress: config.defaultEgress } : {}),
|
|
599
|
+
...(perCall?.timeoutMs !== undefined
|
|
600
|
+
? { timeoutMs: perCall.timeoutMs }
|
|
601
|
+
: config.defaultTimeoutMs !== undefined
|
|
602
|
+
? { timeoutMs: config.defaultTimeoutMs }
|
|
603
|
+
: {}),
|
|
604
|
+
...(perCall?.memoryLimitMb !== undefined
|
|
605
|
+
? { memoryLimitMb: perCall.memoryLimitMb }
|
|
606
|
+
: config.defaultMemoryLimitMb !== undefined
|
|
607
|
+
? { memoryLimitMb: config.defaultMemoryLimitMb }
|
|
608
|
+
: {}),
|
|
609
|
+
...(perCall?.maxProcesses !== undefined
|
|
610
|
+
? { maxProcesses: perCall.maxProcesses }
|
|
611
|
+
: config.defaultMaxProcesses !== undefined
|
|
612
|
+
? { maxProcesses: config.defaultMaxProcesses }
|
|
613
|
+
: {}),
|
|
614
|
+
...(perCall?.env !== undefined ? { env: perCall.env } : {}),
|
|
615
|
+
})
|
|
616
|
+
},
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function pickBackend(config: SandboxProviderConfig): SandboxBackend {
|
|
621
|
+
const backend = config.backend
|
|
622
|
+
if (backend.tier === 'container' && (backend.runtime ?? 'docker') === 'docker') {
|
|
623
|
+
// `layout` is required for container-tier backends by the
|
|
624
|
+
// discriminated union — narrow safely without a non-null
|
|
625
|
+
// assertion.
|
|
626
|
+
const layout = (config as Extract<SandboxProviderConfig, { layout: ContainerSandboxLayout }>)
|
|
627
|
+
.layout
|
|
628
|
+
// Resolve once at construction. Validation throws synchronously
|
|
629
|
+
// here, before the provider is returned, so any layout error
|
|
630
|
+
// surfaces during host wiring rather than mid-run.
|
|
631
|
+
const resolved = resolveLayout(layout)
|
|
632
|
+
return buildDockerBackend({
|
|
633
|
+
image: backend.image,
|
|
634
|
+
layout: resolved,
|
|
635
|
+
...(backend.hostReachability !== undefined
|
|
636
|
+
? { hostReachability: backend.hostReachability }
|
|
637
|
+
: {}),
|
|
638
|
+
...(backend.network !== undefined ? { network: backend.network } : {}),
|
|
639
|
+
...(backend.labels !== undefined ? { labels: backend.labels } : {}),
|
|
640
|
+
})
|
|
641
|
+
}
|
|
642
|
+
if (
|
|
643
|
+
backend.tier === 'container' &&
|
|
644
|
+
(backend as unknown as { runtime?: string }).runtime === 'aci-standby-pool'
|
|
645
|
+
) {
|
|
646
|
+
const aciBackend = backend as unknown as ACIStandbyPoolBackendConfig
|
|
647
|
+
const layout = (config as Extract<SandboxProviderConfig, { layout: ContainerSandboxLayout }>)
|
|
648
|
+
.layout
|
|
649
|
+
const resolved = resolveLayout(layout)
|
|
650
|
+
return buildAciStandbyPoolBackend({
|
|
651
|
+
subscriptionId: aciBackend.subscriptionId,
|
|
652
|
+
resourceGroup: aciBackend.resourceGroup,
|
|
653
|
+
location: aciBackend.location,
|
|
654
|
+
standbyPoolResourceId: aciBackend.standbyPoolResourceId,
|
|
655
|
+
containerGroupProfileResourceId: aciBackend.containerGroupProfileResourceId,
|
|
656
|
+
...(aciBackend.containerGroupProfileRevision !== undefined
|
|
657
|
+
? { containerGroupProfileRevision: aciBackend.containerGroupProfileRevision }
|
|
658
|
+
: {}),
|
|
659
|
+
layout: resolved,
|
|
660
|
+
getArmToken: aciBackend.getArmToken,
|
|
661
|
+
...(aciBackend.subnetId !== undefined ? { subnetId: aciBackend.subnetId } : {}),
|
|
662
|
+
...(aciBackend.readyPollIntervalMs !== undefined
|
|
663
|
+
? { readyPollIntervalMs: aciBackend.readyPollIntervalMs }
|
|
664
|
+
: {}),
|
|
665
|
+
...(aciBackend.readyTimeoutMs !== undefined
|
|
666
|
+
? { readyTimeoutMs: aciBackend.readyTimeoutMs }
|
|
667
|
+
: {}),
|
|
668
|
+
...(aciBackend.workerPort !== undefined ? { workerPort: aciBackend.workerPort } : {}),
|
|
669
|
+
...(aciBackend.armApiVersion !== undefined
|
|
670
|
+
? { armApiVersion: aciBackend.armApiVersion }
|
|
671
|
+
: {}),
|
|
672
|
+
...(aciBackend.containerNamePrefix !== undefined
|
|
673
|
+
? { containerNamePrefix: aciBackend.containerNamePrefix }
|
|
674
|
+
: {}),
|
|
675
|
+
})
|
|
676
|
+
}
|
|
677
|
+
if (backend.tier === 'container' && backend.runtime === 'runsc') {
|
|
678
|
+
const layout = (config as Extract<SandboxProviderConfig, { layout: ContainerSandboxLayout }>)
|
|
679
|
+
.layout
|
|
680
|
+
const resolved = resolveLayout(layout)
|
|
681
|
+
return buildDockerBackend({
|
|
682
|
+
image: backend.image,
|
|
683
|
+
layout: resolved,
|
|
684
|
+
runtime: 'runsc',
|
|
685
|
+
...(backend.hostReachability !== undefined
|
|
686
|
+
? { hostReachability: backend.hostReachability }
|
|
687
|
+
: {}),
|
|
688
|
+
...(backend.network !== undefined ? { network: backend.network } : {}),
|
|
689
|
+
...(backend.labels !== undefined ? { labels: backend.labels } : {}),
|
|
690
|
+
})
|
|
691
|
+
}
|
|
692
|
+
// `microvm:self-hosted` targeting the OWNED Azure Firecracker
|
|
693
|
+
// orchestrator (ses_051). The presence of `orchestratorEndpoint` +
|
|
694
|
+
// `getToken` distinguishes the owned-platform shape from the legacy
|
|
695
|
+
// local `firecracker-containerd` shape (still unimplemented → throws
|
|
696
|
+
// below). No layout: FC is a remote-copy backend (archive-sync over
|
|
697
|
+
// vsock, like ACI), so it carries no host bind-mount layout.
|
|
698
|
+
if (
|
|
699
|
+
backend.tier === 'microvm' &&
|
|
700
|
+
backend.service === 'self-hosted' &&
|
|
701
|
+
backend.orchestratorEndpoint !== undefined &&
|
|
702
|
+
backend.getToken !== undefined
|
|
703
|
+
) {
|
|
704
|
+
return buildFirecrackerBackend({
|
|
705
|
+
orchestratorEndpoint: backend.orchestratorEndpoint,
|
|
706
|
+
getToken: backend.getToken,
|
|
707
|
+
...(backend.template !== undefined ? { template: backend.template } : {}),
|
|
708
|
+
...(backend.agentSnapshot !== undefined ? { agentSnapshot: backend.agentSnapshot } : {}),
|
|
709
|
+
...(backend.agentVsockPort !== undefined ? { agentVsockPort: backend.agentVsockPort } : {}),
|
|
710
|
+
...(backend.readyTimeoutMs !== undefined ? { readyTimeoutMs: backend.readyTimeoutMs } : {}),
|
|
711
|
+
...(backend.readyPollIntervalMs !== undefined
|
|
712
|
+
? { readyPollIntervalMs: backend.readyPollIntervalMs }
|
|
713
|
+
: {}),
|
|
714
|
+
...(backend.mtls !== undefined ? { mtls: backend.mtls } : {}),
|
|
715
|
+
...(backend.controlPlaneMtls !== undefined
|
|
716
|
+
? { controlPlaneMtls: backend.controlPlaneMtls }
|
|
717
|
+
: {}),
|
|
718
|
+
})
|
|
719
|
+
}
|
|
720
|
+
throw new SandboxBackendNotImplementedError(describeBackend(backend))
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Human-readable backend label for error messages. Returns the
|
|
725
|
+
* tier plus the concrete service / runtime when present, e.g.
|
|
726
|
+
* `'microvm:e2b'` or `'container:runsc'`.
|
|
727
|
+
*/
|
|
728
|
+
function describeBackend(config: SandboxBackendConfig): string {
|
|
729
|
+
if (config.tier === 'microvm') return `microvm:${config.service}`
|
|
730
|
+
if (config.tier === 'container') return `container:${config.runtime ?? 'docker'}`
|
|
731
|
+
if (config.tier === 'process') return `process:${config.engine ?? 'auto'}`
|
|
732
|
+
return config.tier
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// ---------------------------------------------------------------------------
|
|
736
|
+
// Errors
|
|
737
|
+
// ---------------------------------------------------------------------------
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* Thrown by the factory when a backend is requested before its
|
|
741
|
+
* implementation has landed. Makes the staged rollout legible —
|
|
742
|
+
* consumers see exactly which backend is missing rather than a
|
|
743
|
+
* generic `TypeError: foo is not a function`.
|
|
744
|
+
*
|
|
745
|
+
* Subclasses Error so existing host error handling (instanceof
|
|
746
|
+
* checks, JSON.stringify, etc.) keeps working.
|
|
747
|
+
*/
|
|
748
|
+
export class SandboxBackendNotImplementedError extends Error {
|
|
749
|
+
override readonly name = 'SandboxBackendNotImplementedError'
|
|
750
|
+
|
|
751
|
+
constructor(public readonly backend: string) {
|
|
752
|
+
super(
|
|
753
|
+
`Sandbox backend '${backend}' is not implemented yet. Track progress in vendor/namzu/docs.local/sessions/ses_004-native-agentic-runtime-and-sandbox.`,
|
|
754
|
+
)
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Thrown when a {@link ContainerSandboxLayout} fails validation:
|
|
760
|
+
* missing required `outputs` mount, malformed skill id, duplicate
|
|
761
|
+
* skill id, duplicate `containerPath` across mounts. The `reasons`
|
|
762
|
+
* array carries one entry per violation so consumers can surface
|
|
763
|
+
* every problem in one round-trip rather than fix-then-rerun.
|
|
764
|
+
*
|
|
765
|
+
* **Transport caveat.** `JSON.stringify(err)` works because
|
|
766
|
+
* `toJSON()` returns a plain object with `reasons` preserved. But
|
|
767
|
+
* `structuredClone(err)` on the Error object itself drops the
|
|
768
|
+
* subclass name and any non-enumerable fields. For transport
|
|
769
|
+
* boundaries (postMessage, worker IPC, log shippers) call
|
|
770
|
+
* {@link serializeSandboxError} which returns a plain object that
|
|
771
|
+
* is `structuredClone`-safe and `JSON.stringify`-safe in one shape.
|
|
772
|
+
*/
|
|
773
|
+
export class ContainerSandboxLayoutValidationError extends Error {
|
|
774
|
+
override readonly name = 'ContainerSandboxLayoutValidationError'
|
|
775
|
+
|
|
776
|
+
constructor(
|
|
777
|
+
public readonly reasons: readonly string[],
|
|
778
|
+
options?: { cause?: unknown },
|
|
779
|
+
) {
|
|
780
|
+
super(
|
|
781
|
+
`Invalid ContainerSandboxLayout: ${reasons.join('; ')}`,
|
|
782
|
+
options?.cause !== undefined ? { cause: options.cause } : undefined,
|
|
783
|
+
)
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
toJSON(): {
|
|
787
|
+
name: string
|
|
788
|
+
message: string
|
|
789
|
+
reasons: readonly string[]
|
|
790
|
+
cause?: unknown
|
|
791
|
+
} {
|
|
792
|
+
return {
|
|
793
|
+
name: this.name,
|
|
794
|
+
message: this.message,
|
|
795
|
+
reasons: this.reasons,
|
|
796
|
+
...(this.cause !== undefined ? { cause: this.cause } : {}),
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Transport-safe serialisation for any error this package raises
|
|
803
|
+
* (and any nested `cause` chain). Returns a plain object with
|
|
804
|
+
* `name`, `message`, optional `stack`, optional `cause`
|
|
805
|
+
* (recursively serialised into the same envelope shape), and — for
|
|
806
|
+
* {@link ContainerSandboxLayoutValidationError} — the `reasons`
|
|
807
|
+
* array. The result is **uniformly safe** through
|
|
808
|
+
* `structuredClone`, `postMessage`, and `JSON.stringify`:
|
|
809
|
+
*
|
|
810
|
+
* - No function / Symbol / BigInt / non-finite-number values
|
|
811
|
+
* leak into the envelope; non-Error causes (and non-Error
|
|
812
|
+
* inputs) are converted to a typed envelope by
|
|
813
|
+
* {@link serializeNonErrorCause}.
|
|
814
|
+
* - Cycles (`a.cause = a`, `a.cause = b; b.cause = a`) are
|
|
815
|
+
* detected via a `WeakSet` and replaced with a
|
|
816
|
+
* `{ name: 'CircularReference', message: '[circular]' }`
|
|
817
|
+
* sentinel — no stack overflow, no `JSON.stringify` throw.
|
|
818
|
+
* - Deep chains are walked in full (no arbitrary depth cap); the
|
|
819
|
+
* cycle guard, not depth, is what bounds the recursion.
|
|
820
|
+
*
|
|
821
|
+
* Why this helper exists: `Error` subclasses don't survive any
|
|
822
|
+
* structured-clone-like channel — `structuredClone(err)` drops the
|
|
823
|
+
* subclass name and non-enumerable fields, `postMessage` follows
|
|
824
|
+
* the same rules, and most log shippers serialise via JSON which
|
|
825
|
+
* calls the unhelpful default `toJSON`. Vandal's supervisor
|
|
826
|
+
* architecture crosses every one of those boundaries; explicit
|
|
827
|
+
* serialisation keeps the `reasons[]` discoverable downstream.
|
|
828
|
+
*
|
|
829
|
+
* Use:
|
|
830
|
+
* ```ts
|
|
831
|
+
* try { ... }
|
|
832
|
+
* catch (err) {
|
|
833
|
+
* logger.error(serializeSandboxError(err))
|
|
834
|
+
* parent.postMessage(serializeSandboxError(err))
|
|
835
|
+
* }
|
|
836
|
+
* ```
|
|
837
|
+
*/
|
|
838
|
+
export interface SerializedSandboxError {
|
|
839
|
+
readonly name: string
|
|
840
|
+
readonly message: string
|
|
841
|
+
readonly stack?: string
|
|
842
|
+
readonly reasons?: readonly string[]
|
|
843
|
+
/**
|
|
844
|
+
* Recursively serialised cause envelope. Always the same shape;
|
|
845
|
+
* non-Error causes go through {@link serializeNonErrorCause}
|
|
846
|
+
* before they reach this slot, so values that `JSON.stringify`
|
|
847
|
+
* or `structuredClone` would choke on (Function, Symbol,
|
|
848
|
+
* BigInt, NaN, ±Infinity, undefined) never appear here.
|
|
849
|
+
*/
|
|
850
|
+
readonly cause?: SerializedSandboxError
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Convert a non-Error `cause` value into a typed envelope that is
|
|
855
|
+
* safe through every transport channel. Categorises the input by
|
|
856
|
+
* runtime type so the receiver can tell e.g. "this was a Symbol"
|
|
857
|
+
* apart from "this was a string" without inspecting the message
|
|
858
|
+
* format.
|
|
859
|
+
*/
|
|
860
|
+
function serializeNonErrorCause(value: unknown): SerializedSandboxError {
|
|
861
|
+
if (value === null) return { name: 'NonError', message: 'null' }
|
|
862
|
+
if (value === undefined) return { name: 'NonError', message: 'undefined' }
|
|
863
|
+
if (typeof value === 'function') return { name: 'Function', message: '[function]' }
|
|
864
|
+
if (typeof value === 'symbol') return { name: 'Symbol', message: value.toString() }
|
|
865
|
+
if (typeof value === 'bigint') return { name: 'BigInt', message: value.toString() }
|
|
866
|
+
if (typeof value === 'number' && !Number.isFinite(value)) {
|
|
867
|
+
return { name: 'NonFiniteNumber', message: String(value) }
|
|
868
|
+
}
|
|
869
|
+
if (typeof value === 'string') return { name: 'NonError', message: value }
|
|
870
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
871
|
+
return { name: 'NonError', message: String(value) }
|
|
872
|
+
}
|
|
873
|
+
// Plain objects / arrays — JSON-stringify with a fallback so
|
|
874
|
+
// values that contain non-JSON-safe leaves (Symbol-keyed props,
|
|
875
|
+
// BigInt, …) still produce a printable message.
|
|
876
|
+
return { name: 'NonError', message: safeStringify(value) }
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
export function serializeSandboxError(err: unknown): SerializedSandboxError {
|
|
880
|
+
return serializeWithGuard(err, new WeakSet())
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function serializeWithGuard(err: unknown, seen: WeakSet<object>): SerializedSandboxError {
|
|
884
|
+
// Non-Error inputs go through the typed-envelope path. Primitive
|
|
885
|
+
// values can't participate in a cycle so the WeakSet is a no-op
|
|
886
|
+
// for them; object inputs (plain objects, arrays) DO need the
|
|
887
|
+
// cycle guard before `safeStringify` is reached.
|
|
888
|
+
if (!(err instanceof Error)) {
|
|
889
|
+
if (typeof err === 'object' && err !== null) {
|
|
890
|
+
if (seen.has(err)) return { name: 'CircularReference', message: '[circular]' }
|
|
891
|
+
seen.add(err)
|
|
892
|
+
}
|
|
893
|
+
return serializeNonErrorCause(err)
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
if (seen.has(err)) {
|
|
897
|
+
return { name: 'CircularReference', message: '[circular]' }
|
|
898
|
+
}
|
|
899
|
+
seen.add(err)
|
|
900
|
+
|
|
901
|
+
const out: {
|
|
902
|
+
name: string
|
|
903
|
+
message: string
|
|
904
|
+
stack?: string
|
|
905
|
+
reasons?: readonly string[]
|
|
906
|
+
cause?: SerializedSandboxError
|
|
907
|
+
} = {
|
|
908
|
+
name: err.name,
|
|
909
|
+
message: err.message,
|
|
910
|
+
}
|
|
911
|
+
if (err.stack !== undefined) out.stack = err.stack
|
|
912
|
+
if (err instanceof ContainerSandboxLayoutValidationError) {
|
|
913
|
+
out.reasons = err.reasons
|
|
914
|
+
}
|
|
915
|
+
// Walk the cause chain. The same `seen` set is threaded through
|
|
916
|
+
// the recursion so a cycle detected at any depth replaces the
|
|
917
|
+
// offending node with the sentinel rather than blowing the stack.
|
|
918
|
+
if ('cause' in err && err.cause !== undefined) {
|
|
919
|
+
out.cause = serializeWithGuard(err.cause, seen)
|
|
920
|
+
}
|
|
921
|
+
return out
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function safeStringify(value: unknown): string {
|
|
925
|
+
try {
|
|
926
|
+
return JSON.stringify(value)
|
|
927
|
+
} catch {
|
|
928
|
+
return String(value)
|
|
929
|
+
}
|
|
930
|
+
}
|