@agent-compose/sdk 0.6.0 → 0.8.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 (126) hide show
  1. package/README.md +66 -39
  2. package/dist/agent/__tests__/runtime-json-schema.test.d.ts +10 -0
  3. package/dist/agent/agent-context.d.ts +21 -1
  4. package/dist/agent/agent-loop.d.ts +24 -1
  5. package/dist/client.d.ts +338 -534
  6. package/dist/directives.d.ts +112 -0
  7. package/dist/display.d.ts +242 -0
  8. package/dist/errors.d.ts +24 -1
  9. package/dist/index.d.ts +34 -13
  10. package/dist/index.js +2984 -861
  11. package/dist/pause/wrappers.d.ts +31 -9
  12. package/dist/processors/ask-human.d.ts +30 -0
  13. package/dist/processors/ask-human.test.d.ts +1 -0
  14. package/dist/processors/index.d.ts +1 -0
  15. package/dist/runtimes/_acp-client.d.ts +46 -1
  16. package/dist/runtimes/_cli-agent.d.ts +58 -4
  17. package/dist/runtimes/_jsonl-guard.d.ts +103 -0
  18. package/dist/runtimes/amp.d.ts +2 -2
  19. package/dist/runtimes/claude-code.d.ts +59 -0
  20. package/dist/runtimes/claude-code.test.d.ts +14 -0
  21. package/dist/runtimes/claude.d.ts +16 -0
  22. package/dist/runtimes/claude.test.d.ts +8 -0
  23. package/dist/runtimes/codex.d.ts +9 -3
  24. package/dist/runtimes/cursor.d.ts +9 -0
  25. package/dist/runtimes/droid.d.ts +9 -0
  26. package/dist/runtimes/jsonl-guard.test.d.ts +19 -0
  27. package/dist/runtimes/openai-desktop.js +2922 -861
  28. package/dist/runtimes/opencode.d.ts +25 -0
  29. package/dist/runtimes/vercel.js +22 -1
  30. package/dist/sandbox/devbox.d.ts +42 -0
  31. package/dist/sandbox/exec-stream.d.ts +14 -0
  32. package/dist/sandbox/network-policy.d.ts +100 -0
  33. package/dist/sandbox/provider-def.d.ts +79 -0
  34. package/dist/sandbox/providers/desktop.d.ts +10 -0
  35. package/dist/sandbox/providers/e2b.d.ts +17 -0
  36. package/dist/sandbox/providers/local.d.ts +11 -0
  37. package/dist/sandbox/providers/vercel.d.ts +18 -0
  38. package/dist/sandbox/registry.d.ts +45 -0
  39. package/dist/sandbox/sizes.d.ts +68 -0
  40. package/dist/sandbox.d.ts +24 -299
  41. package/dist/step-invocation/__tests__/foreground-recovery.test.d.ts +1 -0
  42. package/dist/step-invocation/invoker.d.ts +24 -1
  43. package/dist/step-invocation/protocol.d.ts +13 -0
  44. package/dist/types/api-compliance.d.ts +71 -0
  45. package/dist/types/api-conversations.d.ts +492 -0
  46. package/dist/types/api-factory.d.ts +309 -0
  47. package/dist/types/api-projects.d.ts +131 -0
  48. package/dist/types/api-runs.d.ts +377 -0
  49. package/dist/types/api-scopes.d.ts +102 -0
  50. package/dist/types/conversation-stream.d.ts +191 -0
  51. package/dist/types/execution-context.d.ts +12 -2
  52. package/dist/types/protocol.d.ts +30 -1
  53. package/dist/types/sandbox-environment.d.ts +8 -5
  54. package/dist/types/sandbox.d.ts +79 -0
  55. package/dist/types/workflow-metadata.d.ts +33 -8
  56. package/dist/types/workflow-plan.d.ts +10 -0
  57. package/dist/types/workflow.d.ts +18 -193
  58. package/dist/utils/bundler.d.ts +12 -1
  59. package/dist/utils/errors.d.ts +9 -1
  60. package/dist/workflow-steps/index.d.ts +1 -1
  61. package/dist/workflow-steps/observability.d.ts +8 -1
  62. package/dist/workflow-steps/runner.d.ts +3 -3
  63. package/dist/workflow-steps/step.d.ts +15 -1
  64. package/dist/workflow-steps/types.d.ts +19 -5
  65. package/dist/workflow-steps/workflow.d.ts +22 -1
  66. package/dist/workflows/engine.d.ts +3 -2
  67. package/dist/workflows/invoke-child.d.ts +2 -2
  68. package/package.json +1 -1
  69. package/src/agent/agent-context.ts +206 -16
  70. package/src/agent/agent-loop.ts +40 -4
  71. package/src/agent/run-agent.ts +9 -1
  72. package/src/client.ts +909 -621
  73. package/src/directives.ts +184 -0
  74. package/src/display.ts +788 -0
  75. package/src/errors.ts +39 -0
  76. package/src/index.ts +117 -10
  77. package/src/pause/wrappers.ts +44 -9
  78. package/src/processors/ask-human.ts +136 -0
  79. package/src/processors/index.ts +5 -0
  80. package/src/runtimes/_acp-client.ts +72 -3
  81. package/src/runtimes/_cli-agent.ts +171 -38
  82. package/src/runtimes/_jsonl-guard.ts +219 -0
  83. package/src/runtimes/claude-code.ts +246 -0
  84. package/src/runtimes/claude.ts +32 -2
  85. package/src/runtimes/codex.ts +55 -3
  86. package/src/runtimes/cursor.ts +59 -0
  87. package/src/runtimes/droid.ts +63 -0
  88. package/src/runtimes/openai-desktop.ts +59 -14
  89. package/src/runtimes/opencode.ts +61 -0
  90. package/src/sandbox/devbox.ts +48 -0
  91. package/src/sandbox/exec-stream.ts +48 -0
  92. package/src/sandbox/network-policy.ts +181 -0
  93. package/src/sandbox/provider-def.ts +94 -0
  94. package/src/sandbox/providers/desktop.ts +57 -0
  95. package/src/sandbox/providers/e2b.ts +354 -0
  96. package/src/sandbox/providers/local.ts +106 -0
  97. package/src/sandbox/providers/vercel.ts +331 -0
  98. package/src/sandbox/registry.ts +198 -0
  99. package/src/sandbox/sizes.ts +95 -0
  100. package/src/sandbox.ts +59 -1263
  101. package/src/step-invocation/invoker.ts +319 -34
  102. package/src/step-invocation/protocol.ts +19 -0
  103. package/src/types/api-compliance.ts +79 -0
  104. package/src/types/api-conversations.ts +522 -0
  105. package/src/types/api-factory.ts +336 -0
  106. package/src/types/api-projects.ts +140 -0
  107. package/src/types/api-runs.ts +412 -0
  108. package/src/types/api-scopes.ts +102 -0
  109. package/src/types/conversation-stream.ts +231 -0
  110. package/src/types/execution-context.ts +10 -2
  111. package/src/types/protocol.ts +33 -0
  112. package/src/types/sandbox-environment.ts +28 -9
  113. package/src/types/sandbox.ts +78 -0
  114. package/src/types/workflow-metadata.ts +35 -8
  115. package/src/types/workflow-plan.ts +11 -0
  116. package/src/types/workflow.ts +25 -280
  117. package/src/utils/bundler.ts +32 -5
  118. package/src/utils/errors.ts +34 -2
  119. package/src/workflow-steps/index.ts +1 -0
  120. package/src/workflow-steps/observability.ts +19 -8
  121. package/src/workflow-steps/runner.ts +4 -4
  122. package/src/workflow-steps/step.ts +49 -1
  123. package/src/workflow-steps/types.ts +20 -5
  124. package/src/workflow-steps/workflow.ts +22 -1
  125. package/src/workflows/engine.ts +3 -2
  126. package/src/workflows/invoke-child.ts +2 -2
package/src/sandbox.ts CHANGED
@@ -1,1269 +1,65 @@
1
1
  /**
2
- * Sandbox provider registry — creates and manages isolated execution environments.
2
+ * Sandbox module — public surface.
3
3
  *
4
- * Providers: "vercel" (Vercel Sandbox), "e2b" (E2B), "e2b-desktop" (E2B Desktop).
5
- * Each provider validates its required env vars at creation time.
4
+ * Implementation lives in `sandbox/`:
5
+ * - `sandbox/network-policy.ts` — egress policy shape + per-provider translators
6
+ * - `sandbox/sizes.ts` — machine sizes + E2B template aliases
7
+ * - `sandbox/devbox.ts` — the ADR-0038 per-member desktop machine alias
8
+ * - `sandbox/provider-def.ts` — provider contract, create opts, fleet tag
9
+ * - `sandbox/exec-stream.ts` — SSE exec-stream parser (broker clients)
10
+ * - `sandbox/providers/*` — vercel / e2b / e2b-desktop / local providers
11
+ * - `sandbox/registry.ts` — provider registry + fleet management
6
12
  */
7
13
 
8
- import { promises as fs } from "node:fs";
9
- import { dirname } from "node:path";
10
- import { spawn } from "node:child_process";
11
- import { Readable, Writable } from "node:stream";
12
- import { Sandbox, SandboxNotFoundError, RateLimitError } from "e2b";
13
- import type { SandboxNetworkOpts as E2bNetworkOpts, SandboxNetworkRule as E2bNetworkRule, CommandHandle } from "e2b";
14
- import { Sandbox as Desktop } from "@e2b/desktop";
15
- import pRetry from "p-retry";
16
- import type { FailedAttemptError } from "p-retry";
17
- import { SandboxUnavailableError } from "./sandbox-errors.js";
18
- import type { SandboxProvider, DesktopSandboxProvider, SandboxCommandResult, SandboxCommandRunOptions, SandboxBackgroundProcess } from "./types/sandbox.js";
19
- import type { ConnectorRequestRules } from "./types/workflow-metadata.js";
20
- import type { NetworkPolicy as VercelNetworkPolicy, NetworkPolicyRule as VercelNetworkPolicyRule } from "@vercel/sandbox";
21
-
22
14
  export type { SandboxProvider, DesktopSandboxProvider, SandboxCommandRunOptions, SandboxCommandResult, SandboxDuplexProcess, SandboxSpawnDuplexOptions } from "./types/sandbox.js";
23
15
 
24
- export type SandboxProviderName = "vercel" | "e2b" | "e2b-desktop";
25
-
26
- // Fleet-wide tag, NOT machine-scoped. Previously we embedded FLY_MACHINE_ID
27
- // so each replica scoped its own E2B sandboxes at boot, but that made
28
- // cross-replica orphan reconciliation impossible: when a replica hard-died,
29
- // its E2B sandboxes became invisible to every surviving replica and stayed
30
- // running until E2B's own lifetime cap. With a fleet-wide tag the provider
31
- // reconciler (`listOwned` + DB cross-ref) handles cleanup correctly across
32
- // all replicas. Boot-time `killAllSandboxes` was deliberately removed when
33
- // this changed — killing at boot with a shared tag would nuke live sandboxes
34
- // owned by other replicas.
35
- export const AGENT_COMPOSE_TAG = process.env.AGENT_COMPOSE_TAG ??
36
- `agent-compose-${process.env.AGENT_COMPOSE_ENV ?? "dev"}`;
37
-
38
- /** Upper bound for how old a live Vercel sandbox can be: Pro/Enterprise plan
39
- * cap (5h) + 1h slack for clock skew and `extendTimeout()` calls. */
40
- const VERCEL_VM_LIFETIME_WINDOW_MS = 6 * 60 * 60 * 1000;
41
-
42
- /**
43
- * Network policy for outbound HTTPS requests — ONE shape for every provider;
44
- * only the enforcement point differs. When a sandbox makes a request matching
45
- * a domain in `allow`, the egress layer injects the specified headers before
46
- * forwarding — credentials never exist inside the VM. Both providers enforce
47
- * this natively at the platform edge from the resolved policy passed at
48
- * create: Vercel via its firewall (`requestRules` translate to native `match`
49
- * rules), E2B via its native network firewall (`toE2bNetwork` →
50
- * `allowOut`/`denyOut`/`rules`). See `toVercelNetworkPolicy` / `toE2bNetwork`.
51
- */
52
- export interface SandboxNetworkHeaderTransform {
53
- headers?: Record<string, string>;
54
- }
55
-
56
- export interface SandboxNetworkAllowRule {
57
- transform?: SandboxNetworkHeaderTransform[];
58
- /** Tier-2 request gate (method/path) — the transform only applies when the
59
- * request matches. Honoured on Vercel (translated to native `match` rules).
60
- * NOT honoured on E2B: its native rules carry only a `transform`, no
61
- * method/path matcher, so a brokered credential rides ALL requests to an
62
- * allowed host there (see `toE2bNetwork`). Present on connector-auth rules;
63
- * see `ConnectorRequestRules`. */
64
- requestRules?: ConnectorRequestRules;
65
- }
66
-
67
- export interface SandboxNetworkSubnetPolicy {
68
- allow?: string[];
69
- deny?: string[];
70
- }
71
-
72
- export type SandboxNetworkPolicy =
73
- | "allow-all"
74
- | "deny-all"
75
- | {
76
- allow?: string[] | Record<string, SandboxNetworkAllowRule[]>;
77
- subnets?: SandboxNetworkSubnetPolicy;
78
- };
79
-
80
- /** Sandbox machine size. A coarse small/medium/large knob that maps to
81
- * provider machine specs at create time. Vercel honours it natively via
82
- * `resources.vcpus` (2048 MB RAM per vCPU). E2B sizing is baked into the
83
- * template, so E2B ignores this field. Default: "small". */
84
- /** Sandbox hardware SKU. Named for the actual machine spec (vCPU + RAM) rather
85
- * than abstract t-shirt sizes. Memory is always 2048 MB per vCPU:
86
- * 2vcpu-4gb = 2 vCPU / 4 GiB (Vercel's own default machine)
87
- * 4vcpu-8gb = 4 vCPU / 8 GiB
88
- * 8vcpu-16gb = 8 vCPU / 16 GiB (per-sandbox ceiling on STANDARD accounts —
89
- * probed live: 16 & 32 vCPU 400 on dev)
90
- * 32vcpu-64gb = 32 vCPU / 64 GiB (ENTERPRISE ONLY — standard accounts reject >8 vCPU) */
91
- export type SandboxSize = "2vcpu-4gb" | "4vcpu-8gb" | "8vcpu-16gb" | "32vcpu-64gb";
92
-
93
- /** SKU → Vercel vCPU count (RAM follows at 2048 MB/vCPU). */
94
- export const SANDBOX_VCPUS: Record<SandboxSize, number> = {
95
- "2vcpu-4gb": 2,
96
- "4vcpu-8gb": 4,
97
- "8vcpu-16gb": 8,
98
- "32vcpu-64gb": 32,
99
- };
100
-
101
- /** SDK fallback size when neither the caller nor the deployment specifies one.
102
- * Deliberately conservative — the OPERATIONAL default is the server's
103
- * `SANDBOX_DEFAULT_SIZE` env var (now also `2vcpu-4gb`). Keeping the default
104
- * small matters because Vercel rate-limits creation by vCPUs-per-window
105
- * (`api-sandboxes-vcpus-creation`); a large default 429s bursty/simultaneous
106
- * creates. Workloads that need more RAM/CPU declare `resources.size` on the
107
- * workflow rather than inflating the default for everyone. */
108
- export const DEFAULT_SANDBOX_SIZE: SandboxSize = "2vcpu-4gb";
109
-
110
- /** The E2B sizes we pre-build a template for. E2B sizing is template-baked
111
- * (no per-create cpu/mem knob), so honouring `resources.size` on E2B means
112
- * ONE pre-built template per size. `32vcpu-64gb` is absent (E2B has no
113
- * >8-vCPU equivalent). `8vcpu-16gb` is also absent: it needs 16 GiB RAM, but
114
- * the E2B account caps memory at 8 GiB (`Template.build` 400s with
115
- * "Memory can't be higher than 8192 MiB"). Add it back here (and rebuild the
116
- * templates) only once the account's memory limit is raised. The register/
117
- * invoke guards reject an unsupported E2B size before it can reach here. */
118
- export const E2B_TEMPLATE_SIZES: readonly SandboxSize[] = [
119
- "2vcpu-4gb",
120
- "4vcpu-8gb",
121
- ];
122
-
123
- /** Is `size` one E2B can be built/booted at? `32vcpu-64gb` (no >8-vCPU E2B
124
- * equivalent) and `8vcpu-16gb` (exceeds the account's 8 GiB memory cap) are
125
- * not — the guards lean on this so the "no E2B equivalent" decision lives in
126
- * exactly one place. */
127
- export function isE2bSupportedSize(size: SandboxSize): boolean {
128
- return E2B_TEMPLATE_SIZES.includes(size);
129
- }
130
-
131
- /** Machine spec for a SandboxSize, in the shape `Template.build` wants. RAM is
132
- * always 2048 MB/vCPU, matching the size name + Vercel parity
133
- * (`SANDBOX_VCPUS` × 2048). Used by `infra/e2b-template/build.ts` to stamp the
134
- * per-size base + agent-env templates. */
135
- export function e2bMachineSpec(size: SandboxSize): { cpuCount: number; memoryMB: number } {
136
- const cpuCount = SANDBOX_VCPUS[size];
137
- return { cpuCount, memoryMB: cpuCount * 2048 };
138
- }
139
-
140
- /** Stable E2B template ALIAS for the platform base at a given size
141
- * (`agent-compose-base-<size>`). Aliases — not snapshot ids — so the refs are
142
- * multi-account-clean: the same string resolves in any E2B account that built
143
- * the templates. Built by `infra/e2b-template/build.ts`; the E2B provider
144
- * boots this when a run on E2B declares no explicit `bootFrom`/template. */
145
- export function e2bBaseTemplate(size: SandboxSize): string {
146
- return `agent-compose-base-${size}`;
147
- }
148
-
149
- /** Stable E2B template ALIAS for the agent runtime (base + claude binary) at a
150
- * given size (`agent-env-<size>`). The agent default templates boot from this
151
- * via `bootFrom: { snapshotId: e2bAgentEnvTemplate(size) }`. Multi-account-clean
152
- * for the same reason as `e2bBaseTemplate`. */
153
- export function e2bAgentEnvTemplate(size: SandboxSize): string {
154
- return `agent-env-${size}`;
155
- }
156
-
157
- /** Is `id` one of the platform-managed E2B template aliases — a
158
- * `agent-compose-base-<size>` or `agent-env-<size>` for a supported size?
159
- *
160
- * These are stable, platform-built, multi-account-clean strings (NOT tenant
161
- * captures), so any workflow may boot from them: the same alias resolves to
162
- * the same platform base in every account, and there is no tenant data behind
163
- * it to leak. The dispatch snapshot gate uses this to vouch a platform E2B
164
- * boot alias without it having to be a team-owned capture or a published
165
- * template's bootFrom. */
166
- export function isPlatformE2bTemplateAlias(id: string): boolean {
167
- return E2B_TEMPLATE_SIZES.some(
168
- (size) => id === e2bBaseTemplate(size) || id === e2bAgentEnvTemplate(size),
169
- );
170
- }
171
-
172
- export interface SandboxCreateOpts {
173
- envs: Record<string, string>;
174
- metadata: Record<string, string>;
175
- timeoutMs: number;
176
- /** Provider-specific template/snapshot identifier. E2B: template id or
177
- * snapshot id (omit → E2B's default base). Vercel: snapshot id (omit → node24). */
178
- template?: string;
179
- /** Machine size. Vercel maps it to `resources.vcpus`; E2B ignores it
180
- * (size is template-defined). Omit → `DEFAULT_SANDBOX_SIZE`. */
181
- size?: SandboxSize;
182
- /** Outbound request policy with header transforms — ONE shape for every
183
- * provider; only the enforcement point differs. Both providers enforce +
184
- * inject natively at create from this value: Vercel via its firewall
185
- * (`toVercelNetworkPolicy`), E2B via its native network firewall
186
- * (`toE2bNetwork` → `network: { allowOut, denyOut, rules }`). */
187
- networkPolicy?: SandboxNetworkPolicy;
188
- }
189
-
190
- /**
191
- * What the provider reports as "currently alive" — the single input to orphan
192
- * reconciliation. `metadata` is best-effort: E2B populates it from sandbox
193
- * labels, Vercel from sandbox tags (max 5, stamped at create). Callers
194
- * that need runId correlation cross-reference `sandboxId` against their own
195
- * state (server: `workflow_runs.sandbox_id` + `run_agent_sandboxes.provider_sandbox_id`).
196
- */
197
- export interface OwnedSandbox {
198
- sandboxId: string;
199
- createdAt: Date;
200
- metadata: Record<string, string>;
201
- }
202
-
203
- interface SandboxProviderDef {
204
- requiredEnv: Record<string, string>;
205
- create: (opts: SandboxCreateOpts, env: Record<string, string>) => Promise<SandboxProvider>;
206
- reconnect?: (sandboxId: string) => Promise<SandboxProvider>;
207
- killAll?: (env: Record<string, string>) => Promise<void>;
208
- /**
209
- * Returns the number of sandboxes currently running against this provider.
210
- * Used for quota observability — account-wide, not per-instance.
211
- *
212
- * Both providers scope by our AGENT_COMPOSE_TAG (E2B metadata labels,
213
- * Vercel tags) so each fleet only reports its own sandboxes; DD aggregates
214
- * across instances with `sum by {provider}`.
215
- */
216
- getActiveCount?: (env: Record<string, string>) => Promise<number>;
217
- /**
218
- * Provider's view of what's currently alive for our account/fleet.
219
- * The reconciliation SoT — a sandbox missing from this list IS dead,
220
- * regardless of what our DB says. Implemented by listing the provider's
221
- * running sandboxes; E2B filters by metadata tag, Vercel by the `executor`
222
- * sandbox tag.
223
- */
224
- listOwned?: (env: Record<string, string>) => Promise<OwnedSandbox[]>;
225
- /**
226
- * Delete a snapshot by id. Called from the customer-initiated
227
- * `DELETE /workflows/:runId/snapshot` route. Best-effort — the DB is
228
- * source of truth; an orphan on the provider side is benign and cleaned
229
- * up eventually by the provider's own retention.
230
- */
231
- deleteSnapshot?: (snapshotId: string, env: Record<string, string>) => Promise<void>;
232
- /**
233
- * Does this snapshot id resolve on the provider for this account? A cheap
234
- * metadata lookup — NEVER provisions a sandbox. Used at server boot to
235
- * validate that the platform base snapshots the default templates boot from
236
- * actually exist for this deployment (catching e.g. a dev-account snapshot
237
- * baked into prod). Returns true if it resolves, false if the provider
238
- * reports it does not exist. Transport/credential errors PROPAGATE — an
239
- * indeterminate result must not be reported as "missing".
240
- */
241
- snapshotExists?: (snapshotId: string, env: Record<string, string>) => Promise<boolean>;
242
- }
243
-
244
- // ── E2B helpers ───────────────────────────────────────────────────────────────
245
-
246
- export function makeSandboxProvider(sb: Sandbox | Desktop): SandboxProvider {
247
- const commands = sb.commands as { run: (cmd: string, opts?: unknown) => Promise<{ exitCode: number; stdout: string; stderr?: string }> };
248
- return {
249
- sandboxId: sb.sandboxId,
250
- commands: {
251
- async run(cmd, opts) {
252
- let stderr = "";
253
- // SandboxProvider exposes `sudo: true`; E2B has no `sudo` flag — it runs
254
- // a command as root via `user: "root"` (Vercel/local map sudo to their own
255
- // mechanism). Passing `sudo` straight through means E2B ignores it and runs
256
- // as the non-root default user, so a root-only command (e.g. `archil mount`,
257
- // which REQUIRES root) silently fails. Translate sudo → user:"root".
258
- const { sudo, ...rest } = (opts ?? {}) as { sudo?: boolean } & Record<string, unknown>;
259
- const runOpts = {
260
- ...rest,
261
- ...(sudo ? { user: "root" } : {}),
262
- onStderr: (chunk: string) => {
263
- stderr += chunk;
264
- opts?.onStderr?.(chunk);
265
- },
266
- };
267
- // E2B's commands.run THROWS CommandExitError on a non-zero exit, but the
268
- // SandboxProvider contract (and the Vercel provider) RETURNS a result with
269
- // `exitCode` so callers can branch on it (e.g. the archil-mount degrade
270
- // path inspects res.exitCode/res.stderr; a thrown bare "exit status N"
271
- // loses the command's stderr). Normalize the throw back into a result;
272
- // only a genuine failure (spawn error, timeout, dead sandbox — no numeric
273
- // exitCode on the error) propagates.
274
- try {
275
- const result = await commands.run(cmd, runOpts);
276
- return {
277
- exitCode: result.exitCode,
278
- stdout: result.stdout,
279
- stderr: typeof (result as { stderr?: unknown }).stderr === "string"
280
- ? (result as { stderr: string }).stderr
281
- : stderr,
282
- };
283
- } catch (e) {
284
- const ce = e as { exitCode?: unknown; stdout?: unknown; stderr?: unknown };
285
- if (typeof ce.exitCode === "number") {
286
- return {
287
- exitCode: ce.exitCode,
288
- stdout: typeof ce.stdout === "string" ? ce.stdout : "",
289
- stderr: typeof ce.stderr === "string" ? ce.stderr : stderr,
290
- };
291
- }
292
- throw e;
293
- }
294
- },
295
- },
296
- files: {
297
- // e2b 2.30 overloads `files.write` (single `(path, data)` and batch
298
- // `(WriteEntry[])`); TS can't resolve the union from a bare two-arg call,
299
- // so bind the single-file overload explicitly. The `WriteInfo` return is
300
- // discarded — our provider contract is `Promise<void>`.
301
- async write(path, content) {
302
- await (sb.files.write as (p: string, d: string) => Promise<unknown>)(path, content);
303
- },
304
- },
305
- // e2b 2.30 `kill()` returns Promise<boolean>; our provider contract is
306
- // Promise<void>, so discard the result.
307
- async kill() { await sb.kill(); },
308
- };
309
- }
310
-
311
- /** E2B caps a sandbox's lifetime per plan (Hobby 1h, Pro 24h) and 400s when the
312
- * create timeout exceeds it — and our `AC_SANDBOX_DEADLINE` (4.5h) tops Hobby.
313
- * Clamp the E2B create timeout to this max (raise on Pro via `E2B_MAX_SANDBOX_MS`).
314
- * Read at call time so the workflow bundle stays env-free. */
315
- function e2bMaxSandboxMs(): number {
316
- return Number(process.env.E2B_MAX_SANDBOX_MS) || 60 * 60 * 1000;
317
- }
318
-
319
- /** Is this a FLEETING sandbox-provider race worth retrying — the sandbox being
320
- * paused / reclaimed / stopped under load, an upstream 5xx/429, or a dropped
321
- * connection — vs a PERMANENT failure that must fail fast?
322
- *
323
- * E2B raises TYPED errors, so prefer `instanceof` (upgrade-stable — survives any
324
- * message rewording): a reconnect/snapshot that hits a sandbox mid-pause throws
325
- * `SandboxNotFoundError`, rate limits throw `RateLimitError`. (Permanent
326
- * template/snapshot-not-found only occurs on CREATE, which is no longer retried.)
327
- * The message regex is the FALLBACK for the untyped Vercel transport + raw socket
328
- * errors — and deliberately omits bare "timeout"/"network": E2B's permanent
329
- * plan-cap 400 ("timeout … exceeds the maximum sandbox lifetime") contains
330
- * "timeout" and must fail fast, not retry 4×. */
331
- function isTransientSandboxError(error: unknown): boolean {
332
- if (error instanceof SandboxNotFoundError || error instanceof RateLimitError) return true;
333
- const message = error instanceof Error ? error.message : String(error ?? "");
334
- // The 5xx match is anchored to a status-code context ("status 503",
335
- // "502 Bad Gateway", …) — a bare \b5\d\d\b would classify ANY standalone
336
- // 500-599 number in a message (durations, row counts) as retryable.
337
- return /paus|stopp|resum|transition|status(?:\s+code)?[:\s]+5\d\d\b|\b5\d\d\s+(?:internal|bad gateway|service|gateway)|\b429\b|ECONNRESET|ECONNREFUSED|ETIMEDOUT|socket hang up|fetch failed/i
338
- .test(message);
339
- }
340
-
341
- /** The ONE place the sandbox retry policy lives. Provisioning, reconnecting, and
342
- * snapshotting all race with the sandbox being paused/reclaimed; this runs the call
343
- * through p-retry's generic backoff, retrying ONLY the transient race (and failing
344
- * fast otherwise). reconnectSandbox and provider.snapshot() all go through it, so
345
- * no provider re-implements the pattern or can forget it. `shouldRetry` is
346
- * overridable for call sites where the default classification is wrong (see
347
- * reconnectSandbox's SandboxNotFoundError handling). */
348
- function withSandboxRetry<T>(
349
- fn: () => Promise<T>,
350
- shouldRetry: (error: FailedAttemptError) => boolean = isTransientSandboxError,
351
- ): Promise<T> {
352
- return pRetry(fn, { retries: 4, minTimeout: 400, factor: 2, shouldRetry });
353
- }
354
-
355
- /** Add snapshot-retry to a freshly-obtained provider. create/reconnect are retried at
356
- * the registry call (`createSandbox`/`reconnectSandbox`); snapshot is a method, so it
357
- * gets the same `withSandboxRetry` here. `commands.run` is deliberately NOT wrapped —
358
- * retrying user-code execution could double-apply side effects, so step execution
359
- * relies on the engine's pre-step sandbox recovery instead. No-op without a snapshot. */
360
- function withSnapshotRetry(p: SandboxProvider): SandboxProvider {
361
- if (!p.snapshot) return p;
362
- const snapshot = p.snapshot.bind(p);
363
- return { ...p, snapshot: () => withSandboxRetry(snapshot) };
364
- }
365
-
366
- /** Wrap an E2B `CommandHandle` as a provider-agnostic background process.
367
- * `wait()` is normalised NOT to throw on a non-zero exit (mirroring the
368
- * `commands.run` contract in `makeSandboxProvider`) so callers branch on
369
- * `exitCode` instead of catching. */
370
- function wrapE2bBackgroundProcess(handle: CommandHandle): SandboxBackgroundProcess {
371
- return {
372
- pid: handle.pid,
373
- async wait() {
374
- try {
375
- const r = await handle.wait();
376
- return { exitCode: r.exitCode ?? 0, stdout: r.stdout, stderr: r.stderr };
377
- } catch (e) {
378
- const ce = e as { exitCode?: unknown; stdout?: unknown; stderr?: unknown };
379
- if (typeof ce.exitCode === "number") {
380
- return {
381
- exitCode: ce.exitCode,
382
- stdout: typeof ce.stdout === "string" ? ce.stdout : "",
383
- stderr: typeof ce.stderr === "string" ? ce.stderr : "",
384
- };
385
- }
386
- throw e;
387
- }
388
- },
389
- async kill() { await handle.kill(); },
390
- };
391
- }
392
-
393
- /** E2B base provider + live-filesystem snapshot. E2B's `createSnapshot()` captures
394
- * the running sandbox as a persistent snapshot whose id is usable as a
395
- * `Sandbox.create()` source and outlives the origin sandbox — so E2B reaches
396
- * snapshot / `bootFrom` parity with Vercel, and snapshot-backed `ctx.pause` works
397
- * on E2B. (Desktop intentionally omits this — it is registry-only, not selectable.) */
398
- function makeE2bSandboxProvider(sb: Sandbox): SandboxProvider {
399
- const base = makeSandboxProvider(sb);
400
- return {
401
- ...base,
402
- commands: {
403
- ...base.commands,
404
- // ADR-0028: background launch + reconnect-by-pid. The step runner runs as
405
- // a background command so a server-driven pause can freeze it mid-turn
406
- // (pauseProcess) and the resume activity can re-attach by pid and await
407
- // its exit — continuing the SAME process, no re-run. E2B-only; Vercel's
408
- // provider omits these and pauses via snapshot + re-run.
409
- async runBackground(cmd, opts) {
410
- const { sudo, onStdout, onStderr, ...rest } = (opts ?? {}) as SandboxCommandRunOptions;
411
- const handle = await sb.commands.run(cmd, {
412
- ...rest,
413
- background: true,
414
- ...(sudo ? { user: "root" } : {}),
415
- ...(onStdout ? { onStdout } : {}),
416
- ...(onStderr ? { onStderr } : {}),
417
- });
418
- return wrapE2bBackgroundProcess(handle);
419
- },
420
- async connectProcess(pid, opts) {
421
- const handle = await sb.commands.connect(pid, {
422
- ...(opts?.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
423
- ...(opts?.onStdout ? { onStdout: opts.onStdout } : {}),
424
- ...(opts?.onStderr ? { onStderr: opts.onStderr } : {}),
425
- });
426
- return wrapE2bBackgroundProcess(handle);
427
- },
428
- },
429
- async snapshot() {
430
- // Raw capture — the transient pause/reclaim race is retried centrally:
431
- // createSandbox/reconnectSandbox wrap every provider's snapshot() in
432
- // withSandboxRetry (via withSnapshotRetry). create itself is deliberately
433
- // NOT retried — see createSandbox.
434
- const { snapshotId } = await sb.createSnapshot();
435
- return { snapshotId };
436
- },
437
- // ADR-0027: native VM-suspend. Freezes the live process in place (zero
438
- // compute) and returns the sandbox id as the resume handle — resume is
439
- // `reconnectSandbox(...)` (Sandbox.connect), which auto-resumes a paused VM.
440
- // Distinct from snapshot(): no FS image, no kill, no re-run from the top.
441
- async pauseProcess() {
442
- await sb.pause();
443
- return { resumeHandle: sb.sandboxId };
444
- },
445
- // Push a freshly-resolved egress policy onto the live sandbox via E2B's
446
- // native `updateNetwork` — the E2B analogue of Vercel's `update({
447
- // networkPolicy })`. Lets the server re-resolve the run policy (re-minting
448
- // connector access tokens) before each step instead of living with the
449
- // policy baked at create. The update endpoint replaces egress rules
450
- // atomically (omitted fields are cleared), so we always pass the full
451
- // translated network config.
452
- async updateNetworkPolicy(policy) {
453
- await sb.updateNetwork(toE2bNetwork(policy));
454
- },
455
- };
456
- }
457
-
458
- export function makeDesktopSandboxProvider(sb: Desktop): DesktopSandboxProvider {
459
- return {
460
- ...makeSandboxProvider(sb),
461
- screenshot: () => sb.screenshot() as Promise<Buffer>,
462
- leftClick: (x, y) => sb.leftClick(x, y),
463
- doubleClick: (x, y) => sb.doubleClick(x, y),
464
- rightClick: (x, y) => sb.rightClick(x, y),
465
- middleClick: (x, y) => sb.middleClick(x, y),
466
- moveMouse: (x, y) => sb.moveMouse(x, y),
467
- write: (text) => sb.write(text),
468
- press: (key) => sb.press(key),
469
- scroll: (dir, ticks) => sb.scroll(dir, ticks),
470
- drag: (from, to) => sb.drag(from, to),
471
- };
472
- }
473
-
474
- // ── SSE helper (used by broker clients in runner.ts) ─────────────────────────
475
-
476
- type SseEvent = { type: string; data?: string; exitCode?: number };
477
-
478
- export interface ParseSseExecStreamOptions {
479
- onStdout?: (data: string) => void;
480
- onStderr?: (data: string) => void;
481
- }
482
-
483
- /**
484
- * Parse an SSE exec stream from a ReadableStream.
485
- * Used by the agent sandbox broker in runner.ts.
486
- */
487
- export async function parseSseExecStream(
488
- body: ReadableStream<Uint8Array>,
489
- opts?: ParseSseExecStreamOptions,
490
- ): Promise<SandboxCommandResult> {
491
- let stdout = "", stderr = "", exitCode = 0, exited = false;
492
- const reader = body.getReader(), decoder = new TextDecoder();
493
- let buf = "";
494
- while (true) {
495
- const { done, value } = await reader.read();
496
- if (done) break;
497
- buf += decoder.decode(value, { stream: true });
498
- const lines = buf.replace(/\r\n/g, "\n").split("\n");
499
- buf = lines.pop() ?? "";
500
- for (const line of lines) {
501
- if (!line.startsWith("data: ")) continue;
502
- let event: SseEvent;
503
- try { event = JSON.parse(line.slice(6)) as SseEvent; }
504
- catch (err) { throw new Error(`Malformed sandbox exec event: ${err instanceof Error ? err.message : String(err)}`); }
505
- if (event.type === "stdout") { stdout += event.data ?? ""; opts?.onStdout?.(event.data ?? ""); }
506
- else if (event.type === "stderr") { stderr += event.data ?? ""; opts?.onStderr?.(event.data ?? ""); }
507
- else if (event.type === "exit") { exitCode = event.exitCode ?? 0; exited = true; }
508
- else if (event.type === "error") { throw new Error(`Sandbox exec error: ${event.data}`); }
509
- }
510
- // Stop reading as soon as the exit event is received — don't wait for stream close.
511
- // QUIC tunnels can delay the stream-end signal even after all data has arrived.
512
- if (exited) break;
513
- }
514
- if (!exited) throw new Error("Sandbox exec stream ended without an exit event");
515
- return { exitCode, stdout, stderr };
516
- }
517
-
518
- // ── Vercel helpers ────────────────────────────────────────────────────────────
519
-
520
- /** Matches a path containing a dot-dot segment — literal (`/../`) or
521
- * percent-encoded (`%2e%2e`, `%2f` boundaries). `DOT_SEGMENT_PATH_RE2` is
522
- * the RE2 form handed to Vercel's matcher: wrapped in `.*` so it behaves
523
- * identically under search and full-match semantics (RE2 has no lookaround,
524
- * so the guard is a positive match on the traversal pattern, used as a
525
- * credential-stripping first rule). `DOT_SEGMENT_RE` is the local JS twin
526
- * used to validate DECLARED prefixes. */
527
- export const DOT_SEGMENT_PATH_RE2 = ".*(?:^|/|%2[fF])(?:\\.|%2[eE]){2}(?:/|%2[fF]|$).*";
528
- const DOT_SEGMENT_RE = /(?:^|\/|%2f)(?:\.|%2e){2}(?:\/|%2f|$)/i;
529
-
530
- /** A Tier-2 path gate decides where a brokered credential may ride, so a
531
- * malformed declared prefix is a config bug that must fail closed, not a
532
- * string we forward verbatim to a third-party matcher. */
533
- function assertSafePathPrefix(domain: string, prefix: string): void {
534
- if (!prefix.startsWith("/") || DOT_SEGMENT_RE.test(prefix)) {
535
- throw new Error(
536
- `Invalid Tier-2 pathPrefix ${JSON.stringify(prefix)} for "${domain}": prefixes must start with "/" and contain no dot-segments`,
537
- );
538
- }
539
- }
540
-
541
- /**
542
- * Translate our policy shape into Vercel's native `NetworkPolicy`.
543
- *
544
- * The shapes are identical except for Tier-2: our `requestRules`
545
- * (`{ methods, pathPrefixes }`) become Vercel `match` rules
546
- * (`{ method, path: { startsWith } }`) — one rule per path prefix, since a
547
- * Vercel matcher carries a single path.
548
- *
549
- * Dot-segment defense: prefix matching is raw — `/repos/acme/../../user`
550
- * starts with `/repos/acme/` but the origin normalizes it to `/user`, riding
551
- * the credential outside its declared scope. We don't get to assume the
552
- * enforcer RFC-3986-normalizes before matching, so every domain with a path
553
- * gate gets a transform-free FIRST rule matching dot-segment paths
554
- * (first-match-wins ⇒ such requests go out credential-free), and declared
555
- * prefixes themselves are validated (must start with "/", no dot-segments).
556
- *
557
- * Accepted divergence from iron-proxy (E2B): off-gate requests to an allowed
558
- * domain pass WITHOUT the transform (Vercel terminates TLS only for
559
- * transform-bearing domains and applies first-match-wins) — iron-proxy
560
- * REFUSES them instead. Reachability differs; the property that matters is
561
- * preserved on both substrates: the brokered credential rides only declared
562
- * method/path combinations. Pinned by vercel-network-policy.test.ts.
563
- */
564
- export function toVercelNetworkPolicy(policy: SandboxNetworkPolicy): VercelNetworkPolicy {
565
- if (typeof policy === "string" || !policy.allow || Array.isArray(policy.allow)) {
566
- return policy as VercelNetworkPolicy;
567
- }
568
- const allow: Record<string, VercelNetworkPolicyRule[]> = {};
569
- for (const [domain, rules] of Object.entries(policy.allow)) {
570
- const translated = rules.flatMap((rule): VercelNetworkPolicyRule[] => {
571
- const { requestRules, ...rest } = rule;
572
- const methods = requestRules?.methods ?? [];
573
- const prefixes = requestRules?.pathPrefixes ?? [];
574
- if (methods.length === 0 && prefixes.length === 0) return [rest];
575
- const methodMatch = methods.length > 0 ? { method: methods } : {};
576
- if (prefixes.length === 0) return [{ ...rest, match: methodMatch }];
577
- for (const prefix of prefixes) assertSafePathPrefix(domain, prefix);
578
- return prefixes.map((prefix) => ({ ...rest, match: { ...methodMatch, path: { startsWith: prefix } } }));
579
- });
580
- const hasPathGate = rules.some((rule) => (rule.requestRules?.pathPrefixes?.length ?? 0) > 0);
581
- allow[domain] = hasPathGate
582
- ? [{ match: { path: { regex: DOT_SEGMENT_PATH_RE2 } } }, ...translated]
583
- : translated;
584
- }
585
- return { ...policy, allow };
586
- }
587
-
588
- /**
589
- * Translate our policy shape into E2B's native `network` config
590
- * (`SandboxNetworkOpts`) — the E2B analogue of `toVercelNetworkPolicy`.
591
- *
592
- * - `allowOut`: the set of allowed egress targets — every host named in
593
- * `allow` PLUS every CIDR in `subnets.allow`. The Archil raw-TCP
594
- * data-plane CIDRs ride here as FIRST-CLASS allow entries (E2B has no
595
- * root-exemption to lean on, unlike the old in-VM iptables model).
596
- * - `denyOut`: the all-traffic sentinel (`0.0.0.0/0`) so the policy is
597
- * DEFAULT-DENY — only `allowOut` targets pass. E2B applies allow before
598
- * deny, so a host in both lists is allowed.
599
- * - `rules`: per-host header injection. For each host carrying transform(s),
600
- * emit one rule per transform with `{ transform: { headers } }`. A host
601
- * with a rule MUST also be in `allowOut` (registering a rule does not
602
- * grant egress on its own — E2B's API requires the host in `allowOut`).
603
- *
604
- * BEHAVIOURAL DIVERGENCE FROM iron-proxy / Vercel — path/method gating is NOT
605
- * available on E2B native rules. An `SandboxNetworkRule` carries only a
606
- * `transform` (header injection); there is no method/path matcher. So our
607
- * Tier-2 `requestRules` (`{ methods, pathPrefixes }`) cannot be enforced here:
608
- * a brokered credential rides EVERY request to an allowed host on E2B, whereas
609
- * Vercel (native `match`) and the old iron-proxy gated it to the declared
610
- * method+path. The host allowlist still confines WHICH hosts the credential
611
- * can reach; it just can't narrow to a method/path within an allowed host.
612
- *
613
- * String policies map straight through: "allow-all" → no restriction (omit
614
- * `allowOut`/`denyOut`), "deny-all" → block all egress.
615
- */
616
- export function toE2bNetwork(policy: SandboxNetworkPolicy): E2bNetworkOpts {
617
- if (policy === "allow-all") return {};
618
- if (policy === "deny-all") return { denyOut: ({ allTraffic }) => [allTraffic] };
619
-
620
- // List-form allow (no transforms): allowlist the hosts, default-deny the rest.
621
- const allowHosts: string[] = Array.isArray(policy.allow)
622
- ? [...policy.allow]
623
- : Object.keys(policy.allow ?? {});
624
- const subnetAllow = policy.subnets?.allow ?? [];
625
- const allowOut = [...new Set([...allowHosts, ...subnetAllow])];
626
-
627
- const network: E2bNetworkOpts = {
628
- // Default-deny: only `allowOut` passes (E2B applies allow before deny).
629
- denyOut: ({ allTraffic }) => [allTraffic],
630
- };
631
- if (allowOut.length > 0) network.allowOut = allowOut;
632
-
633
- // Per-host header injection. Each of our `transform` entries (a
634
- // `{ headers }` bag) becomes one E2B rule. `requestRules` is intentionally
635
- // dropped — see the divergence note above.
636
- if (!Array.isArray(policy.allow) && policy.allow) {
637
- const rules: Record<string, E2bNetworkRule[]> = {};
638
- for (const [host, hostRules] of Object.entries(policy.allow)) {
639
- const hostTransforms = hostRules.flatMap((rule) =>
640
- (rule.transform ?? [])
641
- .filter((t) => t.headers && Object.keys(t.headers).length > 0)
642
- .map((t): E2bNetworkRule => ({ transform: { headers: t.headers } })),
643
- );
644
- if (hostTransforms.length > 0) rules[host] = hostTransforms;
645
- }
646
- if (Object.keys(rules).length > 0) network.rules = rules;
647
- }
648
-
649
- return network;
650
- }
651
-
652
- /** Vercel caps sandbox tags at 5. Build the tag set with the fleet `executor`
653
- * tag always present and always winning over caller metadata — `listOwned` /
654
- * orphan reconciliation scope on it, so a clobbered or dropped executor tag
655
- * makes the sandbox invisible to cleanup. When metadata overflows the cap,
656
- * keep executor + the lexicographically-first 4 metadata keys, so what gets
657
- * dropped is deterministic rather than dependent on object insertion order. */
658
- export const VERCEL_MAX_TAGS = 5;
659
- export function buildVercelTags(metadata: Record<string, string>): Record<string, string> {
660
- const tags: Record<string, string> = { ...metadata, executor: AGENT_COMPOSE_TAG };
661
- const extras = Object.keys(tags).filter((k) => k !== "executor").sort();
662
- for (const key of extras.slice(VERCEL_MAX_TAGS - 1)) delete tags[key];
663
- return tags;
664
- }
665
-
666
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
667
- function makeVercelSandboxProvider(sb: any, globalEnvs?: Record<string, string>): SandboxProvider {
668
- // Vercel's Sandbox.create({ env }) does NOT flow to runCommand subprocesses — they start fresh shells.
669
- // Capture the sandbox-level envs and merge them into every runCommand call so that env vars like
670
- // ANTHROPIC_API_KEY (placeholder for network policy injection) are actually visible to subprocesses.
671
- const mergeEnvs = (cmdEnvs?: Record<string, string>) =>
672
- globalEnvs ? { ...globalEnvs, ...cmdEnvs } : cmdEnvs;
673
-
674
- // Wrap a sandbox-side failure as `SandboxUnavailableError`. `retryable` is
675
- // decided purely by *where* the error was caught, not by inspecting its
676
- // shape: true before the runner launched any user code (file write / command
677
- // launch / reconnect — safe to re-provision and retry), false once the
678
- // runner streamed (user code may have run). The original message is
679
- // preserved for server-side diagnostics.
680
- const asUnavailable = (err: unknown, retryable: boolean): never => {
681
- throw new SandboxUnavailableError(err instanceof Error ? err.message : String(err), { retryable, sandboxId: sb.name });
682
- };
683
-
684
- return {
685
- // @vercel/sandbox 2.x is name-keyed: `name` is the stable identifier
686
- // (`Sandbox.get({ name })`); the v1 `sandboxId` getter is gone. Our
687
- // provider-facing field keeps its name — it's "the provider-native id".
688
- sandboxId: sb.name,
689
- commands: {
690
- async run(cmd, opts) {
691
- const signal = opts?.timeoutMs ? AbortSignal.timeout(opts.timeoutMs) : undefined;
692
- let stdout = "";
693
- let stderr = "";
694
- // `sudo: true` is Vercel's native root flag — applied to the `sh`
695
- // invocation so the whole shell (and its children) runs as root.
696
- // A launch failure means the command never started — nothing
697
- // user-side ran, so any error here is safe to re-provision + retry.
698
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
699
- const handle: any = await sb.runCommand({ cmd: "sh", args: ["-c", cmd], cwd: opts?.cwd, env: mergeEnvs(opts?.envs), detached: true, signal, ...(opts?.sudo ? { sudo: true } : {}) })
700
- .catch((err: unknown) => asUnavailable(err, true));
701
- // Stream + wait. Reconnect to the already-running command on transient
702
- // stream failures (e.g. BrotliDecompressionError). `h.logs()` replays
703
- // from the start on reconnect, so reset accumulators per attempt to
704
- // avoid double-counting.
705
- const collect = async () => {
706
- await pRetry(async (attempt) => {
707
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
708
- const h: any = attempt === 1 ? handle : await sb.getCommand(handle.cmdId);
709
- if (attempt > 1) { stdout = ""; stderr = ""; }
710
- for await (const log of h.logs()) {
711
- if (log.stream === "stdout") { stdout += log.data; opts?.onStdout?.(log.data); }
712
- else { stderr += log.data; opts?.onStderr?.(log.data); }
713
- }
714
- }, { retries: 3, minTimeout: 1_000, factor: 2 });
715
- const finished = await handle.wait();
716
- return { exitCode: finished.exitCode, stdout, stderr };
717
- };
718
- // `timeoutMs` MUST be authoritative. The AbortSignal alone is not: a
719
- // command that RUNS but emits nothing (e.g. a wedged `archil checkout`
720
- // on a blocked data plane) leaves `logs()`/`wait()` pending and the
721
- // abort never interrupts the await — the call hangs for the activity's
722
- // whole multi-hour ceiling. Race a hard client-side deadline so a hung
723
- // command fails fast and the caller's degrade/retry policy takes over.
724
- let timer: ReturnType<typeof setTimeout> | undefined;
725
- const deadline = opts?.timeoutMs
726
- ? new Promise<never>((_, reject) => {
727
- timer = setTimeout(
728
- () => reject(new Error(`command timed out after ${opts.timeoutMs}ms: ${cmd.slice(0, 200)}`)),
729
- opts.timeoutMs);
730
- })
731
- : undefined;
732
- try {
733
- return await (deadline ? Promise.race([collect(), deadline]) : collect());
734
- } catch (err) {
735
- // A timeout (hard deadline or AbortSignal) is a COMMAND overrunning
736
- // its budget, not the sandbox dying — the VM is alive. Surface it as
737
- // a plain error so the caller's own retry/deadline policy decides
738
- // (classifying it as terminal sandbox-unavailable failed whole runs
739
- // over a single slow mount probe, observed live).
740
- if (err instanceof Error && err.message.startsWith("command timed out after")) throw err;
741
- if (signal?.aborted) throw new Error(`command timed out after ${opts?.timeoutMs}ms: ${cmd.slice(0, 200)}`);
742
- return asUnavailable(err, false);
743
- } finally {
744
- if (timer) clearTimeout(timer);
745
- }
746
- },
747
- },
748
- files: {
749
- async write(path: string, content: string) {
750
- // File writes happen before the runner launches (step input / pause
751
- // resolution files), so a failure here means nothing ran — retryable.
752
- await sb.writeFiles([{ path, content }]).catch((err: unknown) => asUnavailable(err, true));
753
- },
754
- },
755
- // Propagate errors — `killAllRunSandboxes` relies on kill failures being
756
- // observable so it can leave `sandbox_id` set for `findOrphanedSandboxes`
757
- // to retry on next boot. Swallowing here makes the orphan retry loop blind.
758
- //
759
- // kill must DESTROY: in @vercel/sandbox 2.x stop() only halts the VM — the
760
- // name-keyed sandbox record persists (reserving the name; commands against
761
- // a stale handle can transparently resume a stopped VM). delete() is the
762
- // destroying call ("after deletion the instance becomes inert"), so stop
763
- // then delete.
764
- async kill() { await sb.stop(); await sb.delete(); },
765
- // Vercel native snapshot — used by sandbox-environments to capture the
766
- // configured VM after the customer's `setup()` completes. `expiration: 0`
767
- // is Vercel's "never expires" value; env snapshots are long-lived by
768
- // design (they ARE the env) so we always pass it.
769
- async snapshot() {
770
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
771
- const res: any = await sb.snapshot({ expiration: 0 });
772
- const sizeBytes = typeof res.sizeBytes === "number" ? res.sizeBytes : undefined;
773
- return {
774
- snapshotId: res.snapshotId as string,
775
- ...(sizeBytes !== undefined ? { sizeBytes } : {}),
776
- };
777
- },
778
- // Push a freshly-resolved egress policy onto the live sandbox —
779
- // @vercel/sandbox 2.x `update({ networkPolicy })`. Lets the server
780
- // re-resolve the run policy (re-minting connector access tokens)
781
- // before each step instead of living with the policy baked at create.
782
- // E2B implements the same seam via `sb.updateNetwork(toE2bNetwork(...))`.
783
- async updateNetworkPolicy(policy) {
784
- await sb.update({ networkPolicy: toVercelNetworkPolicy(policy) });
785
- },
786
- };
787
- }
788
-
789
- // ── Local provider (runner's own VM) ──────────────────────────────────────────
790
-
791
- /** Minimal SandboxProvider that targets the current process's host VM.
792
- * `commands.run` → `child_process.spawn`; `files.write` → `fs.writeFile`;
793
- * `kill` is a no-op because the caller IS the VM. Used by
794
- * `defineSandboxEnvironment` so setup recipes read like imperative
795
- * provisioning scripts. Uses `node:child_process` — works under both Node
796
- * (the runner executes `node /tmp/runner.bundle.js`) and Bun. */
797
- export function makeLocalSandboxProvider(): SandboxProvider {
798
- return {
799
- sandboxId: "local",
800
- commands: {
801
- run(cmd, opts) {
802
- // Prepend `sudo` so the command runs as root. The local provider
803
- // targets the runner's own host VM (used by defineSandboxEnvironment
804
- // recipes); harmless when the host is already root or has passwordless
805
- // sudo, which is the only context it runs in.
806
- const finalCmd = opts?.sudo ? `sudo ${cmd}` : cmd;
807
- return new Promise((resolve, reject) => {
808
- const proc = spawn("sh", ["-c", finalCmd], {
809
- ...(opts?.cwd ? { cwd: opts.cwd } : {}),
810
- env: { ...process.env, ...(opts?.envs ?? {}) },
811
- stdio: ["ignore", "pipe", "pipe"],
812
- });
813
- let stdout = "";
814
- let stderr = "";
815
- proc.stdout?.setEncoding("utf8");
816
- proc.stderr?.setEncoding("utf8");
817
- proc.stdout?.on("data", (chunk: string) => { stdout += chunk; opts?.onStdout?.(chunk); });
818
- proc.stderr?.on("data", (chunk: string) => { stderr += chunk; opts?.onStderr?.(chunk); });
819
- proc.on("error", reject);
820
- proc.on("close", (code) => resolve({ exitCode: code ?? 0, stdout, stderr }));
821
- });
822
- },
823
- // Duplex spawn — the in-VM `child_process` pipe the ACP client needs.
824
- // `commands.run` buffers to completion and gives stdout only via a
825
- // callback; this keeps the process alive and exposes stdin AND stdout as
826
- // byte web-streams so `ndJsonStream(stdin, stdout)` can carry JSON-RPC
827
- // both ways. Only the local provider has this (the runner spawns CLIs
828
- // in-VM); vercel/e2b leave it undefined.
829
- spawnDuplex(cmd, opts) {
830
- // `detached: true` puts the `sh` and everything it spawns (the
831
- // `npx`-launched ACP binary) into a NEW process group whose pgid is the
832
- // `sh` pid. Without it `kill()` would SIGTERM only `sh`, orphaning the
833
- // grandchild ACP agent (still holding the provider's creds + CPU) until
834
- // the sandbox VM is torn down. Mirrors the Vercel `run()` detached
835
- // pattern. We don't `unref()` — the parent stays attached so `exited`
836
- // fires on close.
837
- const proc = spawn("sh", ["-c", cmd], {
838
- ...(opts?.cwd ? { cwd: opts.cwd } : {}),
839
- env: { ...process.env, ...(opts?.envs ?? {}) },
840
- stdio: ["pipe", "pipe", "pipe"],
841
- detached: true,
842
- });
843
- // Capture stderr for the exit result (diagnostics on a failed CLI),
844
- // but don't expose it as a stream — only stdin/stdout carry the ACP wire.
845
- let stderr = "";
846
- proc.stderr.setEncoding("utf8");
847
- proc.stderr.on("data", (chunk: string) => { stderr += chunk; });
848
-
849
- const exited = new Promise<{ exitCode: number; stderr: string }>((resolve, reject) => {
850
- proc.on("error", reject);
851
- proc.on("close", (code) => resolve({ exitCode: code ?? 0, stderr }));
852
- });
853
-
854
- return {
855
- stdin: Writable.toWeb(proc.stdin) as WritableStream<Uint8Array>,
856
- // `Readable.toWeb` is typed `ReadableStream<any>`; the double-cast
857
- // through `unknown` is the standard workaround for the generic
858
- // variance tsc flags on the direct cast.
859
- stdout: Readable.toWeb(proc.stdout) as unknown as ReadableStream<Uint8Array>,
860
- exited,
861
- // Process-GROUP kill: negate the pid to signal the whole group (`sh`
862
- // + the npx-spawned ACP binary), so the grandchild dies with its
863
- // parent instead of being orphaned. Guarded against ESRCH — the
864
- // group is already gone if the process exited first, which is benign.
865
- kill() {
866
- if (proc.pid === undefined) return;
867
- try { process.kill(-proc.pid, "SIGTERM"); }
868
- catch (err) {
869
- if ((err as NodeJS.ErrnoException).code !== "ESRCH") throw err;
870
- }
871
- },
872
- };
873
- },
874
- },
875
- files: {
876
- async write(path, content) {
877
- await fs.mkdir(dirname(path), { recursive: true });
878
- await fs.writeFile(path, content);
879
- },
880
- },
881
- async kill() { /* caller IS the sandbox — killing it is the server's job */ },
882
- };
883
- }
884
-
885
- // ── Provider registry ─────────────────────────────────────────────────────────
886
-
887
- const SANDBOX_PROVIDERS: Record<string, SandboxProviderDef> = {
888
- "vercel": {
889
- requiredEnv: {
890
- VERCEL_ACCESS_TOKEN: "Vercel access token — vercel.com/account/tokens",
891
- VERCEL_TEAM_ID: "Vercel team ID — vercel.com/account/settings",
892
- VERCEL_PROJECT_ID: "Vercel project ID — vercel.com/[team]/[project]/settings",
893
- },
894
- create: async (opts, env) => {
895
- // Dynamic import keeps @vercel/sandbox out of runner.bundle.js (runner never calls this path)
896
- const { Sandbox: VercelSandbox } = await import("@vercel/sandbox");
897
- const creds = { token: env.VERCEL_ACCESS_TOKEN, teamId: env.VERCEL_TEAM_ID, projectId: env.VERCEL_PROJECT_ID };
898
- // `template` is a snapshot id (runtime is baked in); absent → fresh node24.
899
- const np = opts.networkPolicy ? { networkPolicy: toVercelNetworkPolicy(opts.networkPolicy) } : {};
900
- // Caller metadata rides as Vercel tags — `listOwned` filters on the
901
- // `executor` key, mirroring E2B's metadata-tag scoping. buildVercelTags
902
- // guarantees executor wins over metadata and survives the 5-tag cap.
903
- const tags = buildVercelTags(opts.metadata);
904
- // No explicit template/bootFrom → VERCEL_DEFAULT_SNAPSHOT if set (the
905
- // platform agent-env base: claude, archil, rtk, bun, agentc CLI, the
906
- // SDK in /workspace/node_modules, /ac:* skills, AGENTS.md — built by
907
- // .agentc/environments/agent-env.ts), else raw node24. The E2B
908
- // analogue is E2B_DEFAULT_TEMPLATE below. Because `saveLatest`/
909
- // `reuse` captures snapshot the whole filesystem, everything a run
910
- // bakes on top of this base chains from it — the base tools are in
911
- // every derived snapshot for free.
912
- const tmpl = opts.template ?? process.env.VERCEL_DEFAULT_SNAPSHOT;
913
- // Machine size → vCPUs (RAM auto-follows at 2048 MB/vCPU). Always sent
914
- // explicitly so the spec is deterministic and self-documenting rather
915
- // than riding Vercel's implicit default; "small" maps to that default
916
- // anyway, so existing runs are unchanged.
917
- const resources = { vcpus: SANDBOX_VCPUS[opts.size ?? DEFAULT_SANDBOX_SIZE] };
918
- // `persistent: false` — @vercel/sandbox 2.x creates persistent-by-default
919
- // sandboxes: stop() auto-snapshots (and keeps billing storage), commands
920
- // transparently resume a stopped VM, and kill no longer destroys. Our
921
- // sandboxes are single-run and lifecycle-managed by the engine (explicit
922
- // snapshot() / kill()), so opt out in BOTH branches.
923
- const sb = await VercelSandbox.create(tmpl
924
- ? { source: { type: "snapshot" as const, snapshotId: tmpl }, timeout: opts.timeoutMs, env: opts.envs, tags, persistent: false, resources, ...np, ...creds }
925
- : { runtime: "node24" as const, timeout: opts.timeoutMs, env: opts.envs, tags, persistent: false, resources, ...np, ...creds },
926
- );
927
- // Pass envs as globalEnvs so they're injected into every runCommand subprocess.
928
- // (Vercel's Sandbox.create env parameter does not flow to runCommand subprocesses.)
929
- return makeVercelSandboxProvider(sb, opts.envs);
930
- },
931
- reconnect: async (sandboxId) => {
932
- const { Sandbox: VercelSandbox } = await import("@vercel/sandbox");
933
- const creds = {
934
- token: process.env.VERCEL_ACCESS_TOKEN ?? "",
935
- teamId: process.env.VERCEL_TEAM_ID ?? "",
936
- projectId: process.env.VERCEL_PROJECT_ID ?? "",
937
- };
938
- // Reconnecting happens before any user code runs, so any failure here
939
- // (sandbox already stopping, API blip) is a retryable sandbox-unavailable
940
- // — the workflow re-provisions a fresh one rather than failing the run.
941
- // 2.x is name-keyed: the id we persisted IS the sandbox name.
942
- const sb = await VercelSandbox.get({ name: sandboxId, ...creds }).catch((err: unknown) => {
943
- throw new SandboxUnavailableError(err instanceof Error ? err.message : String(err), { retryable: true, sandboxId });
944
- });
945
- return makeVercelSandboxProvider(sb);
946
- },
947
- getActiveCount: async (env) => (await SANDBOX_PROVIDERS.vercel.listOwned!(env)).length,
948
- listOwned: async (env) => {
949
- const { Sandbox: VercelSandbox } = await import("@vercel/sandbox");
950
- const creds = { token: env.VERCEL_ACCESS_TOKEN, teamId: env.VERCEL_TEAM_ID, projectId: env.VERCEL_PROJECT_ID };
951
- // Scoped to our fleet by the `executor` tag (stamped at create) and
952
- // bounded to `VERCEL_VM_LIFETIME_WINDOW_MS` — Pro/Enterprise caps VM
953
- // lifetime at 5h, +1h slack for clock skew and `extendTimeout()`. The
954
- // sort is pinned to createdAt-desc explicitly — the early-stop below is
955
- // only correct under that order, and the server's default sort is not a
956
- // contract we can rely on — so we stop at the first item older than the
957
- // window instead of paging through thousands of historical terminal
958
- // sandboxes (10k+ in v0.6.28). Truncating silently would hide orphans,
959
- // so we throw at the page cap instead — 200 pages × the API's 50-item
960
- // limit preserves the previous 10k scan bound (v1 paged 200 × 50). Each
961
- // page is wrapped in pRetry so a transient 5xx/429/network blip doesn't
962
- // cost a reconcile tick.
963
- //
964
- // 2.x cutover caveat: sandboxes created by the pre-2.x code carry no
965
- // tags, so for up to VERCEL_VM_LIFETIME_WINDOW_MS after a deploy they
966
- // are invisible here (and their persisted v1 sandbox ids don't resolve
967
- // via `Sandbox.get({ name })`). Self-limiting — the provider's 5h
968
- // lifetime cap expires them — but drain in-flight Vercel runs before
969
- // deploying if losing their sandbox state matters.
970
- const since = Date.now() - VERCEL_VM_LIFETIME_WINDOW_MS;
971
- const out: OwnedSandbox[] = [];
972
- let cursor: string | undefined;
973
- for (let page = 0; page < 200; page++) {
974
- const result = await pRetry(
975
- // limit is capped at 50 by the v2 list API — 51+ answers 400
976
- // (probed live 2026-06-10; not documented in the SDK types).
977
- () => VercelSandbox.list({ ...creds, limit: 50, sortBy: "createdAt", sortOrder: "desc", tags: { executor: AGENT_COMPOSE_TAG }, ...(cursor !== undefined ? { cursor } : {}) }),
978
- { retries: 3, minTimeout: 500, factor: 2 },
979
- );
980
- for (const sb of result.sandboxes) {
981
- if (sb.createdAt < since) return out;
982
- if (sb.status === "running") {
983
- out.push({ sandboxId: sb.name, createdAt: new Date(sb.createdAt), metadata: sb.tags ?? {} });
984
- }
985
- }
986
- if (result.pagination.next === null) return out;
987
- cursor = result.pagination.next;
988
- }
989
- throw new Error("Vercel listSandboxes exceeded 200 pages (10k sandboxes) within the lifetime window — refuse to silently truncate");
990
- },
991
- deleteSnapshot: async (snapshotId, env) => {
992
- const { Snapshot } = await import("@vercel/sandbox");
993
- const creds = { token: env.VERCEL_ACCESS_TOKEN, teamId: env.VERCEL_TEAM_ID, projectId: env.VERCEL_PROJECT_ID };
994
- const snap = await Snapshot.get({ snapshotId, ...creds });
995
- await snap.delete();
996
- },
997
- snapshotExists: async (snapshotId, env) => {
998
- const { Snapshot } = await import("@vercel/sandbox");
999
- const creds = { token: env.VERCEL_ACCESS_TOKEN, teamId: env.VERCEL_TEAM_ID, projectId: env.VERCEL_PROJECT_ID };
1000
- // Metadata-only lookup (no sandbox provisioned). A clean fetch ⇒ resolves.
1001
- // A 404 / not-found ⇒ definitively absent (false). Anything else (auth,
1002
- // 5xx, network) is INDETERMINATE and must propagate — never reported as
1003
- // "missing", which would falsely alert on a transient blip.
1004
- try {
1005
- await Snapshot.get({ snapshotId, ...creds });
1006
- return true;
1007
- } catch (err) {
1008
- const message = err instanceof Error ? err.message : String(err ?? "");
1009
- const status = (err as { status?: number; statusCode?: number })?.status
1010
- ?? (err as { statusCode?: number })?.statusCode;
1011
- if (status === 404 || /\b404\b|not[\s_-]?found|no such snapshot/i.test(message)) return false;
1012
- throw err;
1013
- }
1014
- },
1015
- },
1016
- "e2b": {
1017
- requiredEnv: { E2B_API_KEY: "E2B API key — e2b.dev/dashboard" },
1018
- // E2B has no create-time resource knob (specs are baked into the
1019
- // template/snapshot), so honouring `size` on E2B = picking a PRE-SIZED
1020
- // template, not passing the field through. We resolve the boot template
1021
- // from `size` below (`e2bBaseTemplate(size)`) when the caller gave no
1022
- // explicit template/bootFrom; the field itself is never forwarded to E2B.
1023
- create: async ({ template, timeoutMs, networkPolicy, size, ...rest }) => {
1024
- // `template` is an E2B template id/alias, a snapshot id (a valid create
1025
- // source that persists beyond its origin sandbox — bootFrom parity), or
1026
- // absent. When absent we pick the SIZE-MATCHED platform base alias
1027
- // (`agent-compose-base-<size>`) so `resources.size` gives the same
1028
- // machine spec on E2B as on Vercel — the cross-provider parity this whole
1029
- // change exists for. Only when no size is resolvable at all do we fall
1030
- // back to E2B_DEFAULT_TEMPLATE if set (a prebuilt base — e.g. one with the claude CLI
1031
- // + chromium baked in and more RAM than the stock 482MB base), the
1032
- // per-deployment analogue of Vercel's node24; else E2B's stock base.
1033
- // Self-provisioning runtimes (claude/codex/amp via bootFrom:"reuse") install
1034
- // their CLI on the base and cache it in the captured snapshot, so a
1035
- // template-less first run boots, installs, snapshots. Clamp the lifetime to
1036
- // E2B's per-plan cap (see e2bMaxSandboxMs) so a 4.5h AC_SANDBOX_DEADLINE
1037
- // doesn't 400 on smaller plans.
1038
- //
1039
- // The resolved policy — host allowlist + per-host header injection — is
1040
- // enforced natively by E2B's firewall, configured at create via the
1041
- // `network` field (toE2bNetwork). Connector tokens ride in the rule
1042
- // transforms and never enter the sandbox env. One policy shape, two
1043
- // native enforcement points (Vercel firewall, E2B firewall).
1044
- const maxMs = e2bMaxSandboxMs();
1045
- if (timeoutMs > maxMs) {
1046
- // The clamp turns E2B's explicit create-time 400 into a silent mid-run
1047
- // sandbox death at the cap — say so up front instead of letting a run
1048
- // budgeted past the cap discover it as a terminal infra failure.
1049
- console.warn(
1050
- `[sandbox] e2b create timeout clamped: requested ${timeoutMs}ms exceeds the plan cap ${maxMs}ms — ` +
1051
- `the sandbox dies at the cap, not the requested deadline. Raise E2B_MAX_SANDBOX_MS on plans that allow more.`,
1052
- );
1053
- }
1054
- const sandboxOpts = {
1055
- ...rest,
1056
- timeoutMs: Math.min(timeoutMs, maxMs),
1057
- ...(networkPolicy ? { network: toE2bNetwork(networkPolicy) } : {}),
1058
- };
1059
- // Boot template resolution, in priority order:
1060
- // 1. explicit `template`/bootFrom (a pinned snapshot or alias),
1061
- // 2. else the SIZE-MATCHED base alias `agent-compose-base-<size>`
1062
- // (`size` resolved to DEFAULT_SANDBOX_SIZE when unset) — this is the
1063
- // cross-provider parity path,
1064
- // 3. else E2B_DEFAULT_TEMPLATE as an ultimate per-deployment fallback,
1065
- // 4. else E2B's stock base.
1066
- // `32vcpu-64gb` has no base-<size> template (Pro caps ~8 vCPU); the
1067
- // register + invoke guards reject it before a run reaches here, so we
1068
- // never synthesize a non-existent `agent-compose-base-32vcpu-64gb` alias.
1069
- const resolvedSize = size ?? DEFAULT_SANDBOX_SIZE;
1070
- const tmpl =
1071
- template ??
1072
- (isE2bSupportedSize(resolvedSize) ? e2bBaseTemplate(resolvedSize) : undefined) ??
1073
- process.env.E2B_DEFAULT_TEMPLATE;
1074
- return makeE2bSandboxProvider(
1075
- await (tmpl ? Sandbox.create(tmpl, sandboxOpts) : Sandbox.create(sandboxOpts)),
1076
- );
1077
- },
1078
- reconnect: async (sandboxId) => makeE2bSandboxProvider(
1079
- await Sandbox.connect(sandboxId, { apiKey: process.env.E2B_API_KEY ?? "", timeoutMs: 60 * 60 * 1000 }),
1080
- ),
1081
- killAll: async () => {
1082
- const paginator = Sandbox.list({ query: { metadata: { executor: AGENT_COMPOSE_TAG } } });
1083
- const sandboxes = [];
1084
- while (paginator.hasNext) sandboxes.push(...await paginator.nextItems());
1085
- if (sandboxes.length === 0) return;
1086
- await Promise.all(sandboxes.map(s => Sandbox.kill(s.sandboxId)));
1087
- console.info(`[sandbox] killed ${sandboxes.length} stale E2B sandboxes (tag: ${AGENT_COMPOSE_TAG})`);
1088
- },
1089
- getActiveCount: async () => (await SANDBOX_PROVIDERS.e2b.listOwned!({})).length,
1090
- listOwned: async () => {
1091
- const paginator = Sandbox.list({ query: { metadata: { executor: AGENT_COMPOSE_TAG }, state: ["running"] } });
1092
- const out: OwnedSandbox[] = [];
1093
- while (paginator.hasNext) {
1094
- const items = await pRetry(() => paginator.nextItems(), { retries: 3, minTimeout: 500, factor: 2 });
1095
- for (const sb of items) {
1096
- out.push({
1097
- sandboxId: sb.sandboxId,
1098
- createdAt: new Date(sb.startedAt),
1099
- metadata: sb.metadata ?? {},
1100
- });
1101
- }
1102
- }
1103
- return out;
1104
- },
1105
- deleteSnapshot: async (snapshotId, env) => {
1106
- // E2B snapshots are team-scoped; delete by id. Best-effort like Vercel's.
1107
- await Sandbox.deleteSnapshot(snapshotId, { apiKey: env.E2B_API_KEY });
1108
- },
1109
- },
1110
- "e2b-desktop": {
1111
- requiredEnv: { E2B_API_KEY: "E2B API key — e2b.dev/dashboard" },
1112
- // `size` dropped here: E2B has no create-time resource knob (specs are
1113
- // baked into the template/snapshot), so sizing on E2B = a pre-sized
1114
- // template, not this field. Honoured only on Vercel.
1115
- //
1116
- // NO native firewall on this path: `@e2b/desktop` bundles its own (older)
1117
- // `e2b` whose `SandboxOpts` predates the `network` field, so there is no
1118
- // way to enforce an egress policy natively here — unlike the "e2b" and
1119
- // "vercel" providers. Rather than SILENTLY dropping a policy (fail-open —
1120
- // the exact leak the native-firewall migration closed), we refuse to create
1121
- // a desktop sandbox under any non-empty policy. In practice this never
1122
- // fires: e2b-desktop is registry-only, never a workflow's runtime (see
1123
- // `workflow-metadata.ts`), so it is only ever provisioned with no policy.
1124
- create: async ({ template, timeoutMs, networkPolicy, size: _size, ...rest }) => {
1125
- if (!template) throw new Error("E2B Desktop provider requires an explicit `template` (Dockerfile-based — no default base image)");
1126
- if (networkPolicy && networkPolicy !== "allow-all") {
1127
- throw new Error(
1128
- "E2B Desktop provider cannot enforce an egress network policy — `@e2b/desktop` bundles an e2b version that predates the native firewall. " +
1129
- "e2b-desktop is registry-only and must not run with a confined policy.",
1130
- );
1131
- }
1132
- return makeDesktopSandboxProvider(
1133
- await Desktop.create(template, {
1134
- ...rest,
1135
- timeoutMs: Math.min(timeoutMs, e2bMaxSandboxMs()),
1136
- }),
1137
- );
1138
- },
1139
- },
1140
- };
1141
-
1142
- /** Provision a sandbox for the named provider. */
1143
- export async function createSandbox(provider: SandboxProviderName, opts: SandboxCreateOpts): Promise<SandboxProvider> {
1144
- const def = SANDBOX_PROVIDERS[provider];
1145
- if (!def) throw new Error(`Unknown sandbox provider: "${provider}". Known: ${Object.keys(SANDBOX_PROVIDERS).join(", ")}`);
1146
- const missing = Object.entries(def.requiredEnv)
1147
- .filter(([key]) => !process.env[key])
1148
- .map(([key, desc]) => ` ${key} — ${desc}`);
1149
- if (missing.length > 0) throw new Error(`Sandbox provider "${provider}" requires env vars:\n${missing.join("\n")}`);
1150
- const env = Object.fromEntries(Object.keys(def.requiredEnv).map((k) => [k, process.env[k]!]));
1151
- // Provision once — do NOT wrap create in withSandboxRetry: a create whose RESPONSE
1152
- // is lost (client-side timeout after the provider already provisioned) would, on
1153
- // retry, mint a SECOND billed sandbox and orphan the first. Provision failures are
1154
- // re-attempted at the workflow layer. The result is still wrapped so snapshot()
1155
- // retries the transient pause/reclaim race (which is where the race actually lives).
1156
- return withSnapshotRetry(await def.create(opts, env));
1157
- }
1158
-
1159
- /** Reconnect to an existing sandbox by provider + provider-native sandbox ID. */
1160
- export async function reconnectSandbox(provider: SandboxProviderName, sandboxId: string): Promise<SandboxProvider> {
1161
- const def = SANDBOX_PROVIDERS[provider];
1162
- if (!def?.reconnect) throw new Error(`Provider "${provider}" does not support reconnect`);
1163
- // Retry the transient reconnect/resume race, then add snapshot-retry to the
1164
- // result. On RECONNECT, SandboxNotFoundError usually means the sandbox is
1165
- // genuinely gone (killed / lifetime-expired) — permanent — but it can also
1166
- // be the transient mid-pause race, so it gets exactly ONE retry instead of
1167
- // burning the full 4-attempt backoff before surfacing.
1168
- return withSnapshotRetry(await withSandboxRetry(
1169
- () => def.reconnect!(sandboxId),
1170
- (error) => error instanceof SandboxNotFoundError ? error.attemptNumber <= 1 : isTransientSandboxError(error),
1171
- ));
1172
- }
1173
-
1174
- /** Delete a snapshot by id on the named provider. No live sandbox needed. */
1175
- export async function deleteSandboxSnapshot(provider: SandboxProviderName, snapshotId: string): Promise<void> {
1176
- const def = SANDBOX_PROVIDERS[provider];
1177
- if (!def?.deleteSnapshot) throw new Error(`Provider "${provider}" does not support deleteSnapshot`);
1178
- const missing = Object.entries(def.requiredEnv)
1179
- .filter(([k]) => !process.env[k])
1180
- .map(([k, desc]) => ` ${k} — ${desc}`);
1181
- if (missing.length > 0) throw new Error(`Sandbox provider "${provider}" requires env vars:\n${missing.join("\n")}`);
1182
- return def.deleteSnapshot(snapshotId, Object.fromEntries(Object.keys(def.requiredEnv).map(k => [k, process.env[k]!])));
1183
- }
1184
-
1185
- /**
1186
- * Does `snapshotId` resolve on the named provider for this account? A cheap
1187
- * metadata lookup — never provisions a sandbox. `true` = resolves, `false` =
1188
- * provider says it does not exist. Transport/credential errors propagate (an
1189
- * indeterminate result is not "missing"). Used at server boot to validate the
1190
- * platform base snapshots the default templates boot from.
1191
- */
1192
- export async function snapshotResolves(provider: SandboxProviderName, snapshotId: string): Promise<boolean> {
1193
- const def = SANDBOX_PROVIDERS[provider];
1194
- if (!def?.snapshotExists) throw new Error(`Provider "${provider}" does not support snapshotExists`);
1195
- const missing = Object.entries(def.requiredEnv)
1196
- .filter(([k]) => !process.env[k])
1197
- .map(([k, desc]) => ` ${k} — ${desc}`);
1198
- if (missing.length > 0) throw new Error(`Sandbox provider "${provider}" requires env vars:\n${missing.join("\n")}`);
1199
- return def.snapshotExists(snapshotId, Object.fromEntries(Object.keys(def.requiredEnv).map(k => [k, process.env[k]!])));
1200
- }
1201
-
1202
- /**
1203
- * Current active-sandbox count per configured provider, for quota gauges.
1204
- * Skips providers whose required env isn't set or which don't implement
1205
- * `getActiveCount`. Errors are surfaced per-provider so one flaky provider
1206
- * doesn't silence the rest.
1207
- */
1208
- export type SandboxQuotaResult = Partial<Record<SandboxProviderName, number | Error>>;
1209
-
1210
- export async function getSandboxQuotas(): Promise<SandboxQuotaResult> {
1211
- const out: SandboxQuotaResult = {};
1212
- await Promise.all(
1213
- (Object.entries(SANDBOX_PROVIDERS) as Array<[SandboxProviderName, SandboxProviderDef]>).map(async ([name, def]) => {
1214
- if (!def.getActiveCount) return;
1215
- if (Object.keys(def.requiredEnv).some(k => !process.env[k])) return;
1216
- const env = Object.fromEntries(Object.keys(def.requiredEnv).map(k => [k, process.env[k]!]));
1217
- try { out[name] = await def.getActiveCount(env); }
1218
- catch (err) { out[name] = err instanceof Error ? err : new Error(String(err)); }
1219
- }),
1220
- );
1221
- return out;
1222
- }
1223
-
1224
- /**
1225
- * List every alive sandbox owned by this fleet, across all configured
1226
- * providers — the orphan-reconciler SoT. Providers without `listOwned`
1227
- * or missing env are skipped. Errors propagate per-provider so one flaky
1228
- * provider doesn't silence the rest.
1229
- */
1230
- export type OwnedSandboxResult = Partial<Record<SandboxProviderName, OwnedSandbox[] | Error>>;
1231
-
1232
- export async function listOwnedSandboxes(): Promise<OwnedSandboxResult> {
1233
- const out: OwnedSandboxResult = {};
1234
- await Promise.all(
1235
- (Object.entries(SANDBOX_PROVIDERS) as Array<[SandboxProviderName, SandboxProviderDef]>).map(async ([name, def]) => {
1236
- if (!def.listOwned) return;
1237
- if (Object.keys(def.requiredEnv).some(k => !process.env[k])) return;
1238
- const env = Object.fromEntries(Object.keys(def.requiredEnv).map(k => [k, process.env[k]!]));
1239
- try { out[name] = await def.listOwned(env); }
1240
- catch (err) { out[name] = err instanceof Error ? err : new Error(String(err)); }
1241
- }),
1242
- );
1243
- return out;
1244
- }
1245
-
1246
- /** Kill a sandbox by provider + native ID. Used by the orphan reconciler. */
1247
- export async function killSandboxById(provider: SandboxProviderName, sandboxId: string): Promise<void> {
1248
- const def = SANDBOX_PROVIDERS[provider];
1249
- if (!def?.reconnect) throw new Error(`Provider "${provider}" does not support reconnect (required for kill-by-id)`);
1250
- const sb = await def.reconnect(sandboxId);
1251
- await sb.kill();
1252
- }
1253
-
1254
- /** Kill all sandboxes across all registered providers. Call at startup to clean up after crashes. */
1255
- export async function killAllSandboxes(
1256
- onError?: (provider: SandboxProviderName, err: unknown) => void,
1257
- ): Promise<void> {
1258
- await Promise.allSettled(
1259
- (Object.entries(SANDBOX_PROVIDERS) as Array<[SandboxProviderName, SandboxProviderDef]>)
1260
- .filter(([, def]) => def.killAll)
1261
- .map(([name, def]) => {
1262
- const env = Object.fromEntries(Object.keys(def.requiredEnv).map(k => [k, process.env[k] ?? ""]));
1263
- if (Object.keys(def.requiredEnv).some(k => !process.env[k])) return Promise.resolve();
1264
- return def.killAll!(env).catch(err => onError?.(name, err));
1265
- }),
1266
- );
1267
- }
1268
-
1269
- export { SANDBOX_PROVIDERS };
16
+ export type {
17
+ SandboxNetworkHeaderTransform,
18
+ SandboxNetworkAllowRule,
19
+ SandboxNetworkSubnetPolicy,
20
+ SandboxNetworkPolicy,
21
+ } from "./sandbox/network-policy.js";
22
+ export { DOT_SEGMENT_PATH_RE2, toVercelNetworkPolicy, toE2bNetwork } from "./sandbox/network-policy.js";
23
+
24
+ export type { SandboxSize } from "./sandbox/sizes.js";
25
+ export {
26
+ SANDBOX_VCPUS,
27
+ DEFAULT_SANDBOX_SIZE,
28
+ E2B_TEMPLATE_SIZES,
29
+ isE2bSupportedSize,
30
+ e2bMachineSpec,
31
+ e2bBaseTemplate,
32
+ e2bAgentEnvTemplate,
33
+ isPlatformE2bTemplateAlias,
34
+ } from "./sandbox/sizes.js";
35
+
36
+ export {
37
+ E2B_DEVBOX_TEMPLATE,
38
+ E2B_DEVBOX_SPEC,
39
+ E2B_DEVBOX_RECIPE_VERSION,
40
+ e2bDevboxTemplateRef,
41
+ } from "./sandbox/devbox.js";
42
+
43
+ export { AGENT_COMPOSE_TAG } from "./sandbox/provider-def.js";
44
+ export type { SandboxCreateOpts, OwnedSandbox } from "./sandbox/provider-def.js";
45
+
46
+ export { parseSseExecStream } from "./sandbox/exec-stream.js";
47
+ export type { ParseSseExecStreamOptions } from "./sandbox/exec-stream.js";
48
+
49
+ export { makeSandboxProvider } from "./sandbox/providers/e2b.js";
50
+ export { makeDesktopSandboxProvider } from "./sandbox/providers/desktop.js";
51
+ export { VERCEL_MAX_TAGS, buildVercelTags } from "./sandbox/providers/vercel.js";
52
+ export { makeLocalSandboxProvider } from "./sandbox/providers/local.js";
53
+
54
+ export {
55
+ SANDBOX_PROVIDERS,
56
+ createSandbox,
57
+ reconnectSandbox,
58
+ deleteSandboxSnapshot,
59
+ snapshotResolves,
60
+ getSandboxQuotas,
61
+ listOwnedSandboxes,
62
+ killSandboxById,
63
+ killAllSandboxes,
64
+ } from "./sandbox/registry.js";
65
+ export type { SandboxProviderName, SandboxQuotaResult, OwnedSandboxResult } from "./sandbox/registry.js";