@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/README.md ADDED
@@ -0,0 +1,148 @@
1
+ # @namzu/sandbox
2
+
3
+ Pluggable sandbox provider for [`@namzu/sdk`](../sdk). Four tiers,
4
+ each backed by the industrial-standard primitive for that
5
+ deployment shape. Same `SandboxProvider` surface the SDK consumes
6
+ across all of them — swapping tiers is a config change, not an
7
+ integration rewrite.
8
+
9
+ ## Tier matrix (2026 industrial standard)
10
+
11
+ | Tier | Use case | Primitive | Cold-start | Local dev |
12
+ |---|---|---|---|---|
13
+ | `process` | Agent runs on the developer's own host (Claude Code-style "don't read `~/.ssh`") | bubblewrap (Linux/WSL2) or Seatbelt (macOS), via [`@anthropic-ai/sandbox-runtime`](https://github.com/anthropic-experimental/sandbox-runtime) | Process spawn (~ms) | Native — no infra |
14
+ | `container` (`docker`) | App in `docker compose` locally or single-tenant prod replica | OCI container, seccomp default profile, tmpfs workdir, no-network default | 0.5–2s | `docker compose up` |
15
+ | `container` (`runsc`) | Trusted-tenant SaaS — what OpenAI Code Interpreter and [Modal](https://modal.com/blog/gvisor-savings-article) ship | Google [gVisor](https://gvisor.dev/docs) userspace kernel as Docker runtime | container start + ~100ms | Linux Docker only (no Docker Desktop on macOS) |
16
+ | `microvm` (`e2b`) | Adversarial multi-tenant SaaS, Python-REPL workloads | Firecracker microVM via [E2B](https://e2b.dev/docs/sandbox) managed service | ~150ms (snapshot/restore) | E2B API key from any laptop |
17
+ | `microvm` (`fly-machines`) | Adversarial multi-tenant SaaS, arbitrary tool-call workloads | Firecracker microVM via [Fly Machines](https://fly.io/docs/machines) | 250ms–1s | Fly API token from any laptop |
18
+ | `microvm` (`self-hosted`) | Same threat model, host insists on owning the scheduler | [`firecracker-containerd`](https://github.com/firecracker-microvm/firecracker-containerd) on KVM-enabled Linux | <300ms with snapshot restore | Lima/Colima Linux VM on macOS |
19
+ | `passthrough` | Tests and explicitly trusted environments | Direct host process — no isolation | n/a | n/a |
20
+
21
+ ## Why these tiers (and not others)
22
+
23
+ The 2026 consensus across production agent platforms (AWS
24
+ Lambda/Fargate, Fly Machines, Replit, E2B, Modal, OpenAI Code
25
+ Interpreter, Anthropic Code Execution, Daytona) bifurcates cleanly
26
+ along the **trust boundary**:
27
+
28
+ - **Adversarial multi-tenant code execution → Firecracker microVMs.**
29
+ AWS, Fly, Replit, E2B, Daytona all converged here. The argument
30
+ is in [Fly's "Sandboxing and Workload Isolation"](https://fly.io/blog/sandboxing-and-workload-isolation)
31
+ and the [original Firecracker paper](https://www.usenix.org/conference/nsdi20/presentation/agache):
32
+ KVM-backed VMs are the only mainstream primitive with a
33
+ kernel-level trust boundary, and `jailer` plus snapshot/restore
34
+ makes them boot in 125ms.
35
+ - **Trusted-tenant or first-party workloads → gVisor.** Google's
36
+ GKE Sandbox, Modal, OpenAI Code Interpreter run gVisor's `runsc`.
37
+ Near-zero cold-start, runs on commodity Linux without nested
38
+ virt. Tradeoff: a userspace-kernel CVE is a tenant escape; a
39
+ Firecracker CVE generally is not.
40
+ - **Single-user dev workstation → bubblewrap / Seatbelt.** What
41
+ Anthropic itself ships with Claude Code via
42
+ `@anthropic-ai/sandbox-runtime`. The threat model is "don't let
43
+ the agent read `~/.ssh` or run `rm -rf ~`," not "tenant A vs
44
+ tenant B." Process-spawn cold-start.
45
+ - **Single-tenant or co-trusted tenants → plain Docker + seccomp.**
46
+ Northflank, Railway, Render, Compass-platform, GitHub Actions
47
+ runners. Adequate when the model is your model and the user is
48
+ your customer; insufficient when the prompt is the attacker.
49
+
50
+ `@namzu/sandbox` exposes all four as separate tiers so the host
51
+ picks the trust boundary that matches its threat model.
52
+
53
+ **What we deliberately do NOT build** is yet-another Firecracker
54
+ scheduler. That is E2B's and Fly's entire product, and writing
55
+ our own would be a years-long detour. We adapt to theirs and
56
+ reserve the `self-hosted` option for hosts that need to own the
57
+ scheduler for compliance or air-gap reasons.
58
+
59
+ ## Cloud portability
60
+
61
+ The interface is cloud-agnostic. `docker` works on every cloud,
62
+ `e2b` and `fly-machines` are managed services not tied to any
63
+ cloud, `runsc` and `firecracker:self-hosted` need infrastructure
64
+ the host chooses (GKE Sandbox, AWS Fargate, self-hosted KVM, etc.).
65
+ Picking a stronger backend may imply picking a different cloud —
66
+ that's the host's call, not the SDK's.
67
+
68
+ ## Egress allowlist policy
69
+
70
+ Every backend supports the same `EgressPolicy` shape:
71
+
72
+ ```ts
73
+ type EgressPolicy =
74
+ | { kind: 'deny-all' } // default
75
+ | { kind: 'allow-all' } // tests only
76
+ | { kind: 'static'; allowedHosts: readonly string[] }
77
+ | { kind: 'resolver'; resolve: () => Promise<readonly string[]> }
78
+ ```
79
+
80
+ The `resolver` shape is **parameterless on purpose**. Hosts that
81
+ need per-tenant policies bake the tenant identity into the closure
82
+ that constructs the provider — exactly how compass-platform's
83
+ JWT-minting flow already works (the server knows the tenant when
84
+ it issues the JWT, the allowlist claim is baked in there). This
85
+ avoids the "where does the resolver get its context from"
86
+ plumbing problem; the host owns the closure, the SDK runtime
87
+ doesn't have to forward identity through `provider.create`.
88
+
89
+ ## Status
90
+
91
+ This package is being built out across the `ses_004-native-agentic-runtime-and-sandbox`
92
+ design session in phases. Each phase ships one tier, fully
93
+ implemented + tested + documented:
94
+
95
+ - ✅ **P3.0** — Public surface (this commit). Backend interfaces,
96
+ tier discriminator, egress policy. Factory throws
97
+ `SandboxBackendNotImplementedError` until backends land.
98
+ - ⏳ **P3.1** — `container:docker` backend. Universal local-dev
99
+ default; ships first.
100
+ - ⏳ **P3.2** — `EgressPolicy` plumbing + reference egress proxy
101
+ (compass-platform pattern: HTTP CONNECT tunnel + JWT-claim
102
+ allowlist).
103
+ - ⏳ **P3.3** — `microvm:e2b` and `microvm:fly-machines` adapters.
104
+ Phase 2 production tier.
105
+ - ⏳ **P3.4** — `process` backend (Anthropic sandbox-runtime
106
+ adapter — bubblewrap/Seatbelt).
107
+ - ⏳ **P3.5** — `container:runsc` (gVisor) and
108
+ `microvm:self-hosted` (firecracker-containerd). Phase 3
109
+ adversarial-multi-tenant.
110
+
111
+ The interface here is what every backend implements; the staged
112
+ rollout is purely about turning each tier on, not about reshaping
113
+ the contract.
114
+
115
+ ## Usage (post-implementation)
116
+
117
+ ```ts
118
+ import { createSandboxProvider } from '@namzu/sandbox'
119
+
120
+ // Phase 1: ship now, works on every dev's laptop
121
+ const sandbox = createSandboxProvider({
122
+ backend: { tier: 'container', runtime: 'docker', image: 'namzu-worker:latest' },
123
+ defaultEgress: { kind: 'static', allowedHosts: ['api.openai.com', 'api.anthropic.com'] },
124
+ })
125
+
126
+ // Phase 2: production, adversarial multi-tenant, managed Firecracker
127
+ const sandbox = createSandboxProvider({
128
+ backend: { tier: 'microvm', service: 'e2b', apiKey: process.env.E2B_API_KEY! },
129
+ defaultEgress: {
130
+ kind: 'resolver',
131
+ resolve: async () => fetchAllowlistForTenant(tenantId),
132
+ },
133
+ })
134
+
135
+ // Phase 3: adversarial multi-tenant, self-hosted Firecracker on KVM
136
+ const sandbox = createSandboxProvider({
137
+ backend: {
138
+ tier: 'microvm',
139
+ service: 'self-hosted',
140
+ firecrackerBinary: '/usr/local/bin/firecracker',
141
+ kernelImage: '/var/lib/namzu/vmlinux',
142
+ rootfsImage: '/var/lib/namzu/rootfs.ext4',
143
+ },
144
+ })
145
+
146
+ // Wire into drainQuery / agent run config:
147
+ // sandboxProvider: sandbox
148
+ ```
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Azure Container Instances Standby Pool backend.
3
+ *
4
+ * Sibling of `docker/` — same {@link SandboxBackend} surface, same
5
+ * worker-HTTP contract, different shipping mechanism. Where docker
6
+ * `docker run`s a container on a local daemon, this backend PUTs an
7
+ * `Microsoft.ContainerInstance/containerGroups` resource that
8
+ * references a pre-warmed `Microsoft.StandbyPool/standbyContainerGroupPools`
9
+ * resource — Azure hands back a warm ACI in ~1.5 s instead of a
10
+ * cold 10-30 s spawn. Refill is automatic per the pool's
11
+ * `refillPolicy`.
12
+ *
13
+ * Workspace shipping:
14
+ * - Docker uses bind-mounts (`hostDir` source variant).
15
+ * - ACI has no host filesystem; this backend ONLY accepts
16
+ * `azureFileShare` source variants and translates them to ACI's
17
+ * `properties.volumes[] + container.properties.volumeMounts[]`
18
+ * shape. The Vandal-side (or any host) provisions per-task
19
+ * shares upstream and hands them in via the layout.
20
+ *
21
+ * Authentication:
22
+ * - Caller supplies a `getArmToken()` async function. Sandbox
23
+ * keeps zero auth dependencies (`@azure/identity` etc.) — the
24
+ * consumer's runtime owns Managed-Identity / AzureCLI / federated
25
+ * credential picking. Token is fetched on every ARM call so a
26
+ * short-lived token survives a long-running sandbox.
27
+ *
28
+ * Trust model:
29
+ * - ACI runs the container in a Microsoft-owned isolation host;
30
+ * inside, the worker is a non-root user (image's `USER namzu`).
31
+ * - The container group can be subnet-injected (no public IP) when
32
+ * `subnetId` is supplied. Without it the IP is public — fine for
33
+ * benchmarking, NOT acceptable for production. Caller decides.
34
+ * - The Confidential variant of Standby Pools (AMD SEV-SNP TEE) is
35
+ * a pool-side knob, not a backend knob — the backend never
36
+ * chooses; it just PUTs against whichever pool the caller named.
37
+ */
38
+ import type { ResolvedContainerSandboxLayout } from '@namzu/sdk';
39
+ import type { SandboxBackend } from '../../index.js';
40
+ /**
41
+ * Authentication callback. Caller returns a fresh Azure Resource
42
+ * Manager bearer token (audience `https://management.azure.com/`).
43
+ * Backend invokes this on every ARM call so a long-running sandbox
44
+ * survives token rotation.
45
+ */
46
+ export type ArmTokenProvider = () => Promise<string>;
47
+ export interface ACIStandbyPoolBackendInternalConfig {
48
+ readonly subscriptionId: string;
49
+ readonly resourceGroup: string;
50
+ readonly location: string;
51
+ /**
52
+ * Fully-qualified resource ID of the Standby Pool to claim from.
53
+ * Example:
54
+ * /subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.StandbyPool/standbyContainerGroupPools/<pool>
55
+ */
56
+ readonly standbyPoolResourceId: string;
57
+ /**
58
+ * Fully-qualified resource ID of the Container Group Profile the
59
+ * pool was created against.
60
+ */
61
+ readonly containerGroupProfileResourceId: string;
62
+ /**
63
+ * Container Group Profile revision the pool's warm instances were
64
+ * built from. Defaults to 1.
65
+ */
66
+ readonly containerGroupProfileRevision?: number;
67
+ /**
68
+ * Pre-resolved layout. The backend requires every mount source to
69
+ * be `azureFileShare`; any other variant throws.
70
+ */
71
+ readonly layout: ResolvedContainerSandboxLayout;
72
+ /**
73
+ * Authentication callback (see {@link ArmTokenProvider}).
74
+ */
75
+ readonly getArmToken: ArmTokenProvider;
76
+ /**
77
+ * Optional subnet to inject the container group into (no public IP).
78
+ * Strongly recommended for production. When omitted, ACI assigns a
79
+ * public IP — fine for benchmarks, attack-surface for prod.
80
+ */
81
+ readonly subnetId?: string;
82
+ readonly readyPollIntervalMs?: number;
83
+ readonly readyTimeoutMs?: number;
84
+ /**
85
+ * Worker HTTP port (matches the image's listening port). Default 2024.
86
+ */
87
+ readonly workerPort?: number;
88
+ readonly armApiVersion?: string;
89
+ /**
90
+ * Prefix for the ACI container group name and the inner worker
91
+ * container. Defaults to a Namzu-branded label; consumers (e.g.
92
+ * Vandal) override via env / config to brand their own
93
+ * deployments. The runtime appends a sandbox id suffix; the
94
+ * combined name is sanitised to ARM's allowed character set.
95
+ */
96
+ readonly containerNamePrefix?: string;
97
+ }
98
+ /**
99
+ * Build a {@link SandboxBackend} backed by Azure Container Instances
100
+ * Standby Pool. Construction is synchronous; the ARM PUT happens on
101
+ * the first `create()`.
102
+ */
103
+ export declare function buildAciStandbyPoolBackend(config: ACIStandbyPoolBackendInternalConfig): SandboxBackend;
104
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/backends/aci-standby-pool/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAEH,OAAO,KAAK,EAEX,8BAA8B,EAQ9B,MAAM,YAAY,CAAA;AAEnB,OAAO,KAAK,EAAE,cAAc,EAAyB,MAAM,gBAAgB,CAAA;AAE3E;;;;;GAKG;AACH,MAAM,MAAM,gBAAgB,GAAG,MAAM,OAAO,CAAC,MAAM,CAAC,CAAA;AAEpD,MAAM,WAAW,mCAAmC;IACnD,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAA;IAC/B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;IAC9B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB;;;;OAIG;IACH,QAAQ,CAAC,qBAAqB,EAAE,MAAM,CAAA;IACtC;;;OAGG;IACH,QAAQ,CAAC,+BAA+B,EAAE,MAAM,CAAA;IAChD;;;OAGG;IACH,QAAQ,CAAC,6BAA6B,CAAC,EAAE,MAAM,CAAA;IAC/C;;;OAGG;IACH,QAAQ,CAAC,MAAM,EAAE,8BAA8B,CAAA;IAC/C;;OAEG;IACH,QAAQ,CAAC,WAAW,EAAE,gBAAgB,CAAA;IACtC;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,mBAAmB,CAAC,EAAE,MAAM,CAAA;IACrC,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAA;IAChC;;OAEG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;IAC5B,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAA;IAC/B;;;;;;OAMG;IACH,QAAQ,CAAC,mBAAmB,CAAC,EAAE,MAAM,CAAA;CACrC;AASD;;;;GAIG;AACH,wBAAgB,0BAA0B,CACzC,MAAM,EAAE,mCAAmC,GACzC,cAAc,CAQhB"}
@@ -0,0 +1,425 @@
1
+ /**
2
+ * Azure Container Instances Standby Pool backend.
3
+ *
4
+ * Sibling of `docker/` — same {@link SandboxBackend} surface, same
5
+ * worker-HTTP contract, different shipping mechanism. Where docker
6
+ * `docker run`s a container on a local daemon, this backend PUTs an
7
+ * `Microsoft.ContainerInstance/containerGroups` resource that
8
+ * references a pre-warmed `Microsoft.StandbyPool/standbyContainerGroupPools`
9
+ * resource — Azure hands back a warm ACI in ~1.5 s instead of a
10
+ * cold 10-30 s spawn. Refill is automatic per the pool's
11
+ * `refillPolicy`.
12
+ *
13
+ * Workspace shipping:
14
+ * - Docker uses bind-mounts (`hostDir` source variant).
15
+ * - ACI has no host filesystem; this backend ONLY accepts
16
+ * `azureFileShare` source variants and translates them to ACI's
17
+ * `properties.volumes[] + container.properties.volumeMounts[]`
18
+ * shape. The Vandal-side (or any host) provisions per-task
19
+ * shares upstream and hands them in via the layout.
20
+ *
21
+ * Authentication:
22
+ * - Caller supplies a `getArmToken()` async function. Sandbox
23
+ * keeps zero auth dependencies (`@azure/identity` etc.) — the
24
+ * consumer's runtime owns Managed-Identity / AzureCLI / federated
25
+ * credential picking. Token is fetched on every ARM call so a
26
+ * short-lived token survives a long-running sandbox.
27
+ *
28
+ * Trust model:
29
+ * - ACI runs the container in a Microsoft-owned isolation host;
30
+ * inside, the worker is a non-root user (image's `USER namzu`).
31
+ * - The container group can be subnet-injected (no public IP) when
32
+ * `subnetId` is supplied. Without it the IP is public — fine for
33
+ * benchmarking, NOT acceptable for production. Caller decides.
34
+ * - The Confidential variant of Standby Pools (AMD SEV-SNP TEE) is
35
+ * a pool-side knob, not a backend knob — the backend never
36
+ * chooses; it just PUTs against whichever pool the caller named.
37
+ */
38
+ const DEFAULT_READY_POLL_MS = 500;
39
+ const DEFAULT_READY_TIMEOUT_MS = 60_000;
40
+ const DEFAULT_WORKER_PORT = 2024;
41
+ const DEFAULT_ARM_API_VERSION = '2024-05-01-preview';
42
+ const ARM_BASE = 'https://management.azure.com';
43
+ const DEFAULT_CONTAINER_NAME_PREFIX = 'namzu-task';
44
+ /**
45
+ * Build a {@link SandboxBackend} backed by Azure Container Instances
46
+ * Standby Pool. Construction is synchronous; the ARM PUT happens on
47
+ * the first `create()`.
48
+ */
49
+ export function buildAciStandbyPoolBackend(config) {
50
+ return {
51
+ tier: 'container',
52
+ name: 'aci-standby-pool',
53
+ async create(options) {
54
+ return await spawnAciSandbox(config, options);
55
+ },
56
+ };
57
+ }
58
+ /**
59
+ * Interpret one mount source. ACI accepts two source variants:
60
+ * - `azureFileShare` → emit an ACI `volume.azureFile` + matching `volumeMount`.
61
+ * - `inImage` → emit NOTHING; the container's own filesystem carries the path.
62
+ *
63
+ * Standby-Pool-warm flows MUST use `inImage` because Standby Pool's
64
+ * claim-time API rejects every `volumes[]` override (the volume set
65
+ * is profile-baked across all warm instances). Cold-spawn ACI flows
66
+ * can use either.
67
+ *
68
+ * The `hostDir` variant is for docker backends and is rejected here.
69
+ */
70
+ function interpretSource(source, label) {
71
+ if (source.type === 'azureFileShare') {
72
+ return {
73
+ kind: 'azureFile',
74
+ source: {
75
+ storageAccountName: source.storageAccountName,
76
+ shareName: source.shareName,
77
+ storageAccountKey: source.storageAccountKey,
78
+ },
79
+ };
80
+ }
81
+ if (source.type === 'inImage') {
82
+ return { kind: 'inImage' };
83
+ }
84
+ throw new Error(`aci-standby-pool backend cannot consume mount source type ${JSON.stringify(source.type)} for ${label}; expected 'azureFileShare' or 'inImage'. The hostDir variant belongs to the docker backend.`);
85
+ }
86
+ function buildAzureFileVolumesFromLayout(layout) {
87
+ const volumes = [];
88
+ const volumeMounts = [];
89
+ let counter = 0;
90
+ function add(mount, label, readOnly) {
91
+ const interpreted = interpretSource(mount.source, label);
92
+ // `inImage` is a no-op — the image's own filesystem provides
93
+ // the path. The Standby-Pool-warm flow lives on this branch.
94
+ if (interpreted.kind === 'inImage')
95
+ return;
96
+ const source = interpreted.source;
97
+ const name = `vol-${label}-${counter++}`;
98
+ volumes.push({
99
+ name,
100
+ azureFile: {
101
+ shareName: source.shareName,
102
+ storageAccountName: source.storageAccountName,
103
+ storageAccountKey: source.storageAccountKey,
104
+ readOnly,
105
+ },
106
+ });
107
+ volumeMounts.push({
108
+ name,
109
+ mountPath: mount.containerPath,
110
+ readOnly,
111
+ });
112
+ }
113
+ add(layout.outputs, 'outputs', false);
114
+ if (layout.uploads)
115
+ add(layout.uploads, 'uploads', true);
116
+ if (layout.scratch)
117
+ add(layout.scratch, 'scratch', false);
118
+ if (layout.toolResults)
119
+ add(layout.toolResults, 'toolResults', true);
120
+ if (layout.transcripts)
121
+ add(layout.transcripts, 'transcripts', true);
122
+ if (layout.skills) {
123
+ for (const skill of layout.skills) {
124
+ add({ source: skill.source, containerPath: skill.containerPath }, `skill-${skill.id}`, true);
125
+ }
126
+ }
127
+ return { volumes, volumeMounts };
128
+ }
129
+ function detectEnvironment() {
130
+ // ACI containers run Linux. The SandboxEnvironment enum is host-
131
+ // platform shape, not container internals — we pick the variant
132
+ // the consumer's code paths expect for a Linux namespace-isolated
133
+ // worker.
134
+ return 'linux-namespace';
135
+ }
136
+ let _sandboxIdCounter = 0;
137
+ function generateSandboxId() {
138
+ const ts = Date.now().toString(36);
139
+ const rand = Math.random().toString(36).slice(2, 8);
140
+ _sandboxIdCounter += 1;
141
+ return `sbx_${ts}_${rand}_${_sandboxIdCounter}`;
142
+ }
143
+ async function armCall(url, method, getToken, body) {
144
+ const token = await getToken();
145
+ const init = {
146
+ method,
147
+ headers: {
148
+ Authorization: `Bearer ${token}`,
149
+ 'content-type': 'application/json',
150
+ },
151
+ };
152
+ if (body !== undefined) {
153
+ init.body = JSON.stringify(body);
154
+ }
155
+ const res = await fetch(url, init);
156
+ if (!res.ok) {
157
+ const text = await res.text();
158
+ throw new Error(`ARM ${method} ${url} → ${res.status}: ${text}`);
159
+ }
160
+ if (res.status === 204 || res.status === 202)
161
+ return undefined;
162
+ const ct = res.headers.get('content-type') ?? '';
163
+ if (ct.includes('application/json')) {
164
+ return (await res.json());
165
+ }
166
+ return undefined;
167
+ }
168
+ async function spawnAciSandbox(config, _options) {
169
+ const id = generateSandboxId();
170
+ const prefix = config.containerNamePrefix ?? DEFAULT_CONTAINER_NAME_PREFIX;
171
+ const cgName = `${prefix}-${id
172
+ .replace(/[^a-z0-9-]/gi, '')
173
+ .toLowerCase()
174
+ .slice(0, 50)}`;
175
+ const apiVersion = config.armApiVersion ?? DEFAULT_ARM_API_VERSION;
176
+ const workerPort = config.workerPort ?? DEFAULT_WORKER_PORT;
177
+ const armUrl = `${ARM_BASE}/subscriptions/${config.subscriptionId}/resourceGroups/${config.resourceGroup}/providers/Microsoft.ContainerInstance/containerGroups/${cgName}?api-version=${apiVersion}`;
178
+ const { volumes, volumeMounts } = buildAzureFileVolumesFromLayout(config.layout);
179
+ // Standby Pool's claim API rejects every property override that
180
+ // is NOT a `configMap`. The empty / no-mount cases (every source
181
+ // is `inImage`) MUST therefore omit `containers`, `volumes`, and
182
+ // `volumeMounts` entirely from the PUT body — even an empty
183
+ // array trips the BadRequest "ContainerGroup properties other
184
+ // than config map are not allowed" check. The fields land only
185
+ // when something real needs to ride through (e.g. cold-spawn ACI
186
+ // with per-task azureFileShare mounts, future flow).
187
+ const properties = {
188
+ containerGroupProfile: {
189
+ id: config.containerGroupProfileResourceId,
190
+ revision: config.containerGroupProfileRevision ?? 1,
191
+ },
192
+ standbyPoolProfile: {
193
+ id: config.standbyPoolResourceId,
194
+ },
195
+ ...(config.subnetId ? { subnetIds: [{ id: config.subnetId }] } : {}),
196
+ };
197
+ if (volumes.length > 0) {
198
+ properties.volumes = volumes;
199
+ properties.containers = [
200
+ {
201
+ name: `${prefix}-worker`,
202
+ properties: { volumeMounts },
203
+ },
204
+ ];
205
+ }
206
+ const body = {
207
+ location: config.location,
208
+ properties,
209
+ };
210
+ let claimed;
211
+ try {
212
+ claimed = await armCall(armUrl, 'PUT', config.getArmToken, body);
213
+ }
214
+ catch (err) {
215
+ throw new Error(`aci-standby-pool: failed to claim from pool — ${err instanceof Error ? err.message : String(err)}`);
216
+ }
217
+ const initialIp = claimed?.properties?.ipAddress?.ip;
218
+ let ip = initialIp;
219
+ try {
220
+ if (!ip) {
221
+ ip = await pollForRunningIp(armUrl, config.getArmToken, config.readyPollIntervalMs ?? DEFAULT_READY_POLL_MS, config.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS);
222
+ }
223
+ const baseUrl = `http://${ip}:${workerPort}`;
224
+ await waitForWorkerReady(baseUrl, config.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS, config.readyPollIntervalMs ?? DEFAULT_READY_POLL_MS);
225
+ let status = 'ready';
226
+ const rootDir = config.layout.outputs.containerPath;
227
+ return {
228
+ id,
229
+ get status() {
230
+ return status;
231
+ },
232
+ rootDir,
233
+ environment: detectEnvironment(),
234
+ async exec(command, argv, opts) {
235
+ status = 'busy';
236
+ try {
237
+ return await execViaWorker(baseUrl, command, argv, opts);
238
+ }
239
+ finally {
240
+ status = 'ready';
241
+ }
242
+ },
243
+ async writeFile(path, content) {
244
+ const buf = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8');
245
+ const res = await fetch(`${baseUrl}/write-file`, {
246
+ method: 'POST',
247
+ headers: { 'content-type': 'application/json' },
248
+ body: JSON.stringify({
249
+ path,
250
+ content: buf.toString('base64'),
251
+ encoding: 'base64',
252
+ }),
253
+ });
254
+ if (!res.ok) {
255
+ throw new Error(`write-file failed: HTTP ${res.status} ${await res.text()}`);
256
+ }
257
+ },
258
+ async readFile(path) {
259
+ const res = await fetch(`${baseUrl}/read-file`, {
260
+ method: 'POST',
261
+ headers: { 'content-type': 'application/json' },
262
+ body: JSON.stringify({ path, encoding: 'base64' }),
263
+ });
264
+ if (!res.ok) {
265
+ throw new Error(`read-file failed: HTTP ${res.status} ${await res.text()}`);
266
+ }
267
+ const json = (await res.json());
268
+ if (!json.ok || typeof json.content !== 'string') {
269
+ throw new Error(json.error ?? 'read-file: no content');
270
+ }
271
+ return Buffer.from(json.content, 'base64');
272
+ },
273
+ async listFiles(rootPath) {
274
+ return await listFilesViaWorker(baseUrl, rootPath);
275
+ },
276
+ async destroy() {
277
+ status = 'destroyed';
278
+ // ARM DELETE — let failures propagate. The Vandal-side
279
+ // lifecycle wraps this in its own try/catch with logging,
280
+ // so a silently swallowed error here means orphan ACI
281
+ // container groups pile up under the resource group with
282
+ // no observability handle. The Standby Pool's refill keeps
283
+ // the WARM side topped up; that has nothing to do with
284
+ // cleaning up a CLAIMED instance, which is exclusively the
285
+ // claimer's responsibility.
286
+ await armCall(armUrl, 'DELETE', config.getArmToken);
287
+ },
288
+ };
289
+ }
290
+ catch (err) {
291
+ try {
292
+ await armCall(armUrl, 'DELETE', config.getArmToken);
293
+ }
294
+ catch {
295
+ // Preserve the readiness failure as the primary error; the
296
+ // caller cannot use a Sandbox handle yet, so best-effort ARM
297
+ // cleanup is the only reliable orphan-prevention hook here.
298
+ }
299
+ throw err;
300
+ }
301
+ }
302
+ async function pollForRunningIp(armUrl, getToken, pollIntervalMs, timeoutMs) {
303
+ const deadline = Date.now() + timeoutMs;
304
+ while (Date.now() < deadline) {
305
+ const cg = await armCall(armUrl, 'GET', getToken);
306
+ const state = cg?.properties?.provisioningState;
307
+ const ip = cg?.properties?.ipAddress?.ip;
308
+ if (state === 'Succeeded' && ip)
309
+ return ip;
310
+ if (state === 'Failed') {
311
+ throw new Error('aci-standby-pool: container group provisioning failed');
312
+ }
313
+ await new Promise((r) => setTimeout(r, pollIntervalMs));
314
+ }
315
+ throw new Error(`aci-standby-pool: timed out waiting for container group IP (${timeoutMs}ms)`);
316
+ }
317
+ async function waitForWorkerReady(baseUrl, timeoutMs, pollIntervalMs) {
318
+ const deadline = Date.now() + timeoutMs;
319
+ while (Date.now() < deadline) {
320
+ try {
321
+ const res = await fetch(`${baseUrl}/healthz`, {
322
+ signal: AbortSignal.timeout(2000),
323
+ });
324
+ if (res.ok)
325
+ return;
326
+ }
327
+ catch {
328
+ // Network not ready yet, try again.
329
+ }
330
+ await new Promise((r) => setTimeout(r, pollIntervalMs));
331
+ }
332
+ throw new Error(`aci-standby-pool: worker /healthz never responded (${timeoutMs}ms)`);
333
+ }
334
+ async function execViaWorker(baseUrl, command, argv, opts) {
335
+ const start = Date.now();
336
+ const res = await fetch(`${baseUrl}/execute`, {
337
+ method: 'POST',
338
+ headers: { 'content-type': 'application/json' },
339
+ body: JSON.stringify({
340
+ command,
341
+ args: argv ?? [],
342
+ cwd: opts?.cwd,
343
+ env: opts?.env,
344
+ timeoutMs: opts?.timeout,
345
+ }),
346
+ });
347
+ if (!res.ok || !res.body) {
348
+ throw new Error(`execute failed: HTTP ${res.status} ${await res.text()}`);
349
+ }
350
+ let stdout = '';
351
+ let stderr = '';
352
+ let exitCode = -1;
353
+ let timedOut = false;
354
+ const decoder = new TextDecoder();
355
+ const reader = res.body.getReader();
356
+ let buffered = '';
357
+ for (;;) {
358
+ const { value, done } = await reader.read();
359
+ if (done)
360
+ break;
361
+ buffered += decoder.decode(value, { stream: true });
362
+ let newlineIdx = buffered.indexOf('\n');
363
+ while (newlineIdx !== -1) {
364
+ const line = buffered.slice(0, newlineIdx).trim();
365
+ buffered = buffered.slice(newlineIdx + 1);
366
+ if (line) {
367
+ try {
368
+ const event = JSON.parse(line);
369
+ if (event.type === 'stdout_delta')
370
+ stdout += event.data;
371
+ else if (event.type === 'stderr_delta')
372
+ stderr += event.data;
373
+ else if (event.type === 'result') {
374
+ exitCode = event.exitCode;
375
+ timedOut = event.timedOut;
376
+ }
377
+ else if (event.type === 'error') {
378
+ throw new Error(event.error);
379
+ }
380
+ }
381
+ catch (err) {
382
+ if (!(err instanceof SyntaxError))
383
+ throw err;
384
+ }
385
+ }
386
+ newlineIdx = buffered.indexOf('\n');
387
+ }
388
+ }
389
+ return {
390
+ stdout,
391
+ stderr,
392
+ exitCode,
393
+ timedOut,
394
+ durationMs: Date.now() - start,
395
+ };
396
+ }
397
+ /**
398
+ * Recursively list regular files under `rootPath` by shelling out to
399
+ * the worker's `find` (GNU find on the Debian-based reference image).
400
+ * `-printf` emits one `<path>\t<size>` line per file; any other
401
+ * non-zero exit (notably `find: '<root>': No such file or directory`)
402
+ * is mapped to "empty listing" because the agent legitimately may not
403
+ * have produced anything in `rootPath` yet.
404
+ */
405
+ async function listFilesViaWorker(baseUrl, rootPath) {
406
+ const result = await execViaWorker(baseUrl, 'find', [rootPath, '-type', 'f', '-printf', '%p\t%s\n'], undefined);
407
+ if (result.exitCode !== 0) {
408
+ return [];
409
+ }
410
+ const entries = [];
411
+ for (const rawLine of result.stdout.split('\n')) {
412
+ if (!rawLine)
413
+ continue;
414
+ const tab = rawLine.indexOf('\t');
415
+ if (tab < 0)
416
+ continue;
417
+ const path = rawLine.slice(0, tab);
418
+ const size = Number.parseInt(rawLine.slice(tab + 1), 10);
419
+ if (!path || !Number.isFinite(size))
420
+ continue;
421
+ entries.push({ path, size });
422
+ }
423
+ return entries;
424
+ }
425
+ //# sourceMappingURL=index.js.map