@namzu/sandbox 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +474 -0
  2. package/LICENSE.md +110 -0
  3. package/README.md +148 -0
  4. package/dist/backends/aci-standby-pool/index.d.ts +104 -0
  5. package/dist/backends/aci-standby-pool/index.d.ts.map +1 -0
  6. package/dist/backends/aci-standby-pool/index.js +425 -0
  7. package/dist/backends/aci-standby-pool/index.js.map +1 -0
  8. package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.d.ts +40 -0
  9. package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.d.ts.map +1 -0
  10. package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.js +157 -0
  11. package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.js.map +1 -0
  12. package/dist/backends/docker/index.d.ts +118 -0
  13. package/dist/backends/docker/index.d.ts.map +1 -0
  14. package/dist/backends/docker/index.js +645 -0
  15. package/dist/backends/docker/index.js.map +1 -0
  16. package/dist/backends/firecracker/__tests__/backend.test.d.ts +13 -0
  17. package/dist/backends/firecracker/__tests__/backend.test.d.ts.map +1 -0
  18. package/dist/backends/firecracker/__tests__/backend.test.js +353 -0
  19. package/dist/backends/firecracker/__tests__/backend.test.js.map +1 -0
  20. package/dist/backends/firecracker/__tests__/control-plane-mtls.test.d.ts +19 -0
  21. package/dist/backends/firecracker/__tests__/control-plane-mtls.test.d.ts.map +1 -0
  22. package/dist/backends/firecracker/__tests__/control-plane-mtls.test.js +201 -0
  23. package/dist/backends/firecracker/__tests__/control-plane-mtls.test.js.map +1 -0
  24. package/dist/backends/firecracker/__tests__/fixtures/mtls-pki.d.ts +39 -0
  25. package/dist/backends/firecracker/__tests__/fixtures/mtls-pki.d.ts.map +1 -0
  26. package/dist/backends/firecracker/__tests__/fixtures/mtls-pki.js +149 -0
  27. package/dist/backends/firecracker/__tests__/fixtures/mtls-pki.js.map +1 -0
  28. package/dist/backends/firecracker/__tests__/protocol.test.d.ts +6 -0
  29. package/dist/backends/firecracker/__tests__/protocol.test.d.ts.map +1 -0
  30. package/dist/backends/firecracker/__tests__/protocol.test.js +77 -0
  31. package/dist/backends/firecracker/__tests__/protocol.test.js.map +1 -0
  32. package/dist/backends/firecracker/__tests__/transport.test.d.ts +20 -0
  33. package/dist/backends/firecracker/__tests__/transport.test.d.ts.map +1 -0
  34. package/dist/backends/firecracker/__tests__/transport.test.js +449 -0
  35. package/dist/backends/firecracker/__tests__/transport.test.js.map +1 -0
  36. package/dist/backends/firecracker/index.d.ts +124 -0
  37. package/dist/backends/firecracker/index.d.ts.map +1 -0
  38. package/dist/backends/firecracker/index.js +334 -0
  39. package/dist/backends/firecracker/index.js.map +1 -0
  40. package/dist/backends/firecracker/protocol.d.ts +132 -0
  41. package/dist/backends/firecracker/protocol.d.ts.map +1 -0
  42. package/dist/backends/firecracker/protocol.js +112 -0
  43. package/dist/backends/firecracker/protocol.js.map +1 -0
  44. package/dist/backends/firecracker/transport.d.ts +251 -0
  45. package/dist/backends/firecracker/transport.d.ts.map +1 -0
  46. package/dist/backends/firecracker/transport.js +524 -0
  47. package/dist/backends/firecracker/transport.js.map +1 -0
  48. package/dist/index.d.ts +611 -0
  49. package/dist/index.d.ts.map +1 -0
  50. package/dist/index.js +376 -0
  51. package/dist/index.js.map +1 -0
  52. package/dist/index.test.d.ts +28 -0
  53. package/dist/index.test.d.ts.map +1 -0
  54. package/dist/index.test.js +670 -0
  55. package/dist/index.test.js.map +1 -0
  56. package/package.json +54 -0
  57. package/src/backends/aci-standby-pool/index.ts +602 -0
  58. package/src/backends/docker/__tests__/leaf-permissions.smoke.test.ts +169 -0
  59. package/src/backends/docker/index.ts +826 -0
  60. package/src/backends/firecracker/__tests__/backend.test.ts +418 -0
  61. package/src/backends/firecracker/__tests__/control-plane-mtls.test.ts +253 -0
  62. package/src/backends/firecracker/__tests__/fixtures/mtls-pki.ts +166 -0
  63. package/src/backends/firecracker/__tests__/protocol.test.ts +90 -0
  64. package/src/backends/firecracker/__tests__/transport.test.ts +526 -0
  65. package/src/backends/firecracker/index.ts +528 -0
  66. package/src/backends/firecracker/protocol.ts +191 -0
  67. package/src/backends/firecracker/transport.ts +667 -0
  68. package/src/index.test.ts +731 -0
  69. package/src/index.ts +930 -0
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
+ }