@agent-compose/sdk 0.5.8 → 0.6.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 (78) hide show
  1. package/dist/agent/agent-context.d.ts +1 -1
  2. package/dist/agent/agent-loop.d.ts +14 -12
  3. package/dist/agent/pause-client.d.ts +50 -0
  4. package/dist/agent/pause-client.test.d.ts +1 -0
  5. package/dist/agent/steer-control.d.ts +22 -6
  6. package/dist/client.d.ts +12 -1
  7. package/dist/index.d.ts +7 -5
  8. package/dist/index.js +2379 -1463
  9. package/dist/pause/checkpoint.d.ts +27 -10
  10. package/dist/pause/manager.d.ts +1 -0
  11. package/dist/pause/pause-core.d.ts +23 -0
  12. package/dist/pause/state-dir.d.ts +1 -1
  13. package/dist/processors/builtins.d.ts +20 -1
  14. package/dist/processors/gate-pause.d.ts +46 -0
  15. package/dist/processors/gate-pause.test.d.ts +1 -0
  16. package/dist/processors/index.d.ts +3 -1
  17. package/dist/processors/processor.d.ts +13 -0
  18. package/dist/runtimes/_acp-client.d.ts +140 -0
  19. package/dist/runtimes/_cli-agent.d.ts +155 -3
  20. package/dist/runtimes/amp.d.ts +2 -2
  21. package/dist/runtimes/cli-agent-acp-live.test.d.ts +30 -0
  22. package/dist/runtimes/cli-agent.test.d.ts +22 -6
  23. package/dist/runtimes/codex.d.ts +7 -2
  24. package/dist/runtimes/openai-desktop.js +2365 -1463
  25. package/dist/runtimes/vercel.js +389 -2
  26. package/dist/sandbox.d.ts +113 -19
  27. package/dist/step-invocation/__tests__/background-invoker.test.d.ts +1 -0
  28. package/dist/step-invocation/index.d.ts +2 -1
  29. package/dist/step-invocation/invoker.d.ts +36 -0
  30. package/dist/types/__tests__/environment-build-flag.test.d.ts +1 -0
  31. package/dist/types/__tests__/workflow-metadata-provider.test.d.ts +1 -0
  32. package/dist/types/execution-context.d.ts +0 -8
  33. package/dist/types/protocol.d.ts +32 -1
  34. package/dist/types/runtime.d.ts +14 -0
  35. package/dist/types/sandbox-environment.d.ts +6 -1
  36. package/dist/types/sandbox.d.ts +86 -6
  37. package/dist/types/workflow-metadata.d.ts +40 -10
  38. package/dist/types/workflow.d.ts +22 -6
  39. package/dist/utils/bundler.d.ts +5 -1
  40. package/dist/workflow-steps/observability.d.ts +28 -2
  41. package/dist/workflow-steps/types.d.ts +11 -7
  42. package/dist/workflow-steps/workflow.d.ts +3 -2
  43. package/package.json +3 -2
  44. package/src/agent/agent-context.ts +14 -6
  45. package/src/agent/agent-loop.ts +32 -10
  46. package/src/agent/pause-client.ts +108 -0
  47. package/src/agent/run-agent.ts +9 -4
  48. package/src/agent/steer-control.ts +21 -7
  49. package/src/client.ts +35 -1
  50. package/src/index.ts +20 -2
  51. package/src/pause/checkpoint.ts +33 -14
  52. package/src/pause/manager.ts +2 -2
  53. package/src/pause/pause-core.ts +35 -0
  54. package/src/pause/state-dir.ts +2 -2
  55. package/src/processors/builtins.ts +44 -1
  56. package/src/processors/gate-pause.ts +94 -0
  57. package/src/processors/index.ts +7 -0
  58. package/src/processors/processor.ts +13 -0
  59. package/src/runtimes/_acp-client.ts +516 -0
  60. package/src/runtimes/_cli-agent.ts +416 -3
  61. package/src/runtimes/claude.ts +31 -3
  62. package/src/runtimes/codex.ts +21 -1
  63. package/src/runtimes/vercel.ts +4 -1
  64. package/src/sandbox.ts +426 -56
  65. package/src/step-invocation/index.ts +2 -1
  66. package/src/step-invocation/invoker.ts +195 -84
  67. package/src/types/execution-context.ts +0 -8
  68. package/src/types/protocol.ts +27 -1
  69. package/src/types/runtime.ts +14 -0
  70. package/src/types/sandbox-environment.ts +12 -1
  71. package/src/types/sandbox.ts +84 -6
  72. package/src/types/workflow-metadata.ts +42 -10
  73. package/src/types/workflow.ts +22 -7
  74. package/src/utils/bundler.ts +6 -1
  75. package/src/workflow-steps/observability.ts +51 -5
  76. package/src/workflow-steps/runner.ts +9 -5
  77. package/src/workflow-steps/types.ts +11 -7
  78. package/src/workflow-steps/workflow.ts +3 -2
package/src/sandbox.ts CHANGED
@@ -8,16 +8,18 @@
8
8
  import { promises as fs } from "node:fs";
9
9
  import { dirname } from "node:path";
10
10
  import { spawn } from "node:child_process";
11
+ import { Readable, Writable } from "node:stream";
11
12
  import { Sandbox, SandboxNotFoundError, RateLimitError } from "e2b";
13
+ import type { SandboxNetworkOpts as E2bNetworkOpts, SandboxNetworkRule as E2bNetworkRule, CommandHandle } from "e2b";
12
14
  import { Sandbox as Desktop } from "@e2b/desktop";
13
15
  import pRetry from "p-retry";
14
16
  import type { FailedAttemptError } from "p-retry";
15
17
  import { SandboxUnavailableError } from "./sandbox-errors.js";
16
- import type { SandboxProvider, DesktopSandboxProvider, SandboxCommandResult } from "./types/sandbox.js";
18
+ import type { SandboxProvider, DesktopSandboxProvider, SandboxCommandResult, SandboxCommandRunOptions, SandboxBackgroundProcess } from "./types/sandbox.js";
17
19
  import type { ConnectorRequestRules } from "./types/workflow-metadata.js";
18
20
  import type { NetworkPolicy as VercelNetworkPolicy, NetworkPolicyRule as VercelNetworkPolicyRule } from "@vercel/sandbox";
19
21
 
20
- export type { SandboxProvider, DesktopSandboxProvider, SandboxCommandRunOptions, SandboxCommandResult } from "./types/sandbox.js";
22
+ export type { SandboxProvider, DesktopSandboxProvider, SandboxCommandRunOptions, SandboxCommandResult, SandboxDuplexProcess, SandboxSpawnDuplexOptions } from "./types/sandbox.js";
21
23
 
22
24
  export type SandboxProviderName = "vercel" | "e2b" | "e2b-desktop";
23
25
 
@@ -41,10 +43,11 @@ const VERCEL_VM_LIFETIME_WINDOW_MS = 6 * 60 * 60 * 1000;
41
43
  * Network policy for outbound HTTPS requests — ONE shape for every provider;
42
44
  * only the enforcement point differs. When a sandbox makes a request matching
43
45
  * a domain in `allow`, the egress layer injects the specified headers before
44
- * forwarding — credentials never exist inside the VM. Vercel's firewall
45
- * enforces this natively (`requestRules` translate to native `match` rules);
46
- * E2B enforces it via the embedded iron-proxy, which pulls the identical
47
- * resolved policy from the server.
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`.
48
51
  */
49
52
  export interface SandboxNetworkHeaderTransform {
50
53
  headers?: Record<string, string>;
@@ -52,9 +55,12 @@ export interface SandboxNetworkHeaderTransform {
52
55
 
53
56
  export interface SandboxNetworkAllowRule {
54
57
  transform?: SandboxNetworkHeaderTransform[];
55
- /** Tier-2 request gate (method/path) — the transform (and, on iron-proxy,
56
- * the request itself) only applies when the request matches. Present on
57
- * connector-auth rules; see `ConnectorRequestRules`. */
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`. */
58
64
  requestRules?: ConnectorRequestRules;
59
65
  }
60
66
 
@@ -93,13 +99,76 @@ export const SANDBOX_VCPUS: Record<SandboxSize, number> = {
93
99
  };
94
100
 
95
101
  /** SDK fallback size when neither the caller nor the deployment specifies one.
96
- * Deliberately conservative — the OPERATIONAL default is chosen per-environment
97
- * by the server via the `SANDBOX_DEFAULT_SIZE` env var (prod = Enterprise →
98
- * `32vcpu-64gb`; dev = `8vcpu-16gb`, since the dev account caps at 8 vCPU).
99
- * TODO(sandbox-size): the prod default is temporarily `32vcpu-64gb` for a
100
- * memory-hungry workload — lower it back when no longer needed. */
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. */
101
108
  export const DEFAULT_SANDBOX_SIZE: SandboxSize = "2vcpu-4gb";
102
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
+
103
172
  export interface SandboxCreateOpts {
104
173
  envs: Record<string, string>;
105
174
  metadata: Record<string, string>;
@@ -111,12 +180,10 @@ export interface SandboxCreateOpts {
111
180
  * (size is template-defined). Omit → `DEFAULT_SANDBOX_SIZE`. */
112
181
  size?: SandboxSize;
113
182
  /** Outbound request policy with header transforms — ONE shape for every
114
- * provider; only the enforcement point differs. Vercel's firewall
115
- * enforces + injects natively from this value. E2B enforces it via an
116
- * EMBEDDED iron-proxy inside the VM (loopback DNS + iptables, started
117
- * at boot), which fetches the identical resolved policy from the
118
- * server's per-run egress-policy endpoint — so this field is not passed
119
- * to E2B's API. See server/src/sandbox/iron-proxy.ts. */
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 }`). */
120
187
  networkPolicy?: SandboxNetworkPolicy;
121
188
  }
122
189
 
@@ -162,6 +229,16 @@ interface SandboxProviderDef {
162
229
  * up eventually by the provider's own retention.
163
230
  */
164
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>;
165
242
  }
166
243
 
167
244
  // ── E2B helpers ───────────────────────────────────────────────────────────────
@@ -173,26 +250,61 @@ export function makeSandboxProvider(sb: Sandbox | Desktop): SandboxProvider {
173
250
  commands: {
174
251
  async run(cmd, opts) {
175
252
  let stderr = "";
176
- const result = await commands.run(cmd, {
177
- ...opts,
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" } : {}),
178
262
  onStderr: (chunk: string) => {
179
263
  stderr += chunk;
180
264
  opts?.onStderr?.(chunk);
181
265
  },
182
- });
183
- return {
184
- exitCode: result.exitCode,
185
- stdout: result.stdout,
186
- stderr: typeof (result as { stderr?: unknown }).stderr === "string"
187
- ? (result as { stderr: string }).stderr
188
- : stderr,
189
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
+ }
190
294
  },
191
295
  },
192
296
  files: {
193
- async write(path, content) { await sb.files.write(path, content); },
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
+ },
194
304
  },
195
- kill: () => sb.kill(),
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(); },
196
308
  };
197
309
  }
198
310
 
@@ -251,14 +363,69 @@ function withSnapshotRetry(p: SandboxProvider): SandboxProvider {
251
363
  return { ...p, snapshot: () => withSandboxRetry(snapshot) };
252
364
  }
253
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
+
254
393
  /** E2B base provider + live-filesystem snapshot. E2B's `createSnapshot()` captures
255
394
  * the running sandbox as a persistent snapshot whose id is usable as a
256
395
  * `Sandbox.create()` source and outlives the origin sandbox — so E2B reaches
257
396
  * snapshot / `bootFrom` parity with Vercel, and snapshot-backed `ctx.pause` works
258
397
  * on E2B. (Desktop intentionally omits this — it is registry-only, not selectable.) */
259
398
  function makeE2bSandboxProvider(sb: Sandbox): SandboxProvider {
399
+ const base = makeSandboxProvider(sb);
260
400
  return {
261
- ...makeSandboxProvider(sb),
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
+ },
262
429
  async snapshot() {
263
430
  // Raw capture — the transient pause/reclaim race is retried centrally:
264
431
  // createSandbox/reconnectSandbox wrap every provider's snapshot() in
@@ -267,6 +434,24 @@ function makeE2bSandboxProvider(sb: Sandbox): SandboxProvider {
267
434
  const { snapshotId } = await sb.createSnapshot();
268
435
  return { snapshotId };
269
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
+ },
270
455
  };
271
456
  }
272
457
 
@@ -400,6 +585,70 @@ export function toVercelNetworkPolicy(policy: SandboxNetworkPolicy): VercelNetwo
400
585
  return { ...policy, allow };
401
586
  }
402
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
+
403
652
  /** Vercel caps sandbox tags at 5. Build the tag set with the fleet `executor`
404
653
  * tag always present and always winning over caller metadata — `listOwned` /
405
654
  * orphan reconciliation scope on it, so a clobbered or dropped executor tag
@@ -530,8 +779,7 @@ function makeVercelSandboxProvider(sb: any, globalEnvs?: Record<string, string>)
530
779
  // @vercel/sandbox 2.x `update({ networkPolicy })`. Lets the server
531
780
  // re-resolve the run policy (re-minting connector access tokens)
532
781
  // before each step instead of living with the policy baked at create.
533
- // E2B leaves this undefined: its enforcement (iron-proxy) lives inside
534
- // the VM and is configured at boot.
782
+ // E2B implements the same seam via `sb.updateNetwork(toE2bNetwork(...))`.
535
783
  async updateNetworkPolicy(policy) {
536
784
  await sb.update({ networkPolicy: toVercelNetworkPolicy(policy) });
537
785
  },
@@ -572,6 +820,57 @@ export function makeLocalSandboxProvider(): SandboxProvider {
572
820
  proc.on("close", (code) => resolve({ exitCode: code ?? 0, stdout, stderr }));
573
821
  });
574
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
+ },
575
874
  },
576
875
  files: {
577
876
  async write(path, content) {
@@ -695,18 +994,40 @@ const SANDBOX_PROVIDERS: Record<string, SandboxProviderDef> = {
695
994
  const snap = await Snapshot.get({ snapshotId, ...creds });
696
995
  await snap.delete();
697
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
+ },
698
1015
  },
699
1016
  "e2b": {
700
1017
  requiredEnv: { E2B_API_KEY: "E2B API key — e2b.dev/dashboard" },
701
- // `size` dropped here: E2B has no create-time resource knob (specs are
702
- // baked into the template/snapshot), so sizing on E2B = a pre-sized
703
- // template, not this field. Honoured only on Vercel.
704
- create: async ({ template, timeoutMs, networkPolicy: _np, size: _size, ...rest }) => {
705
- // `template` is an E2B template id, a snapshot id (a valid create source
706
- // that persists beyond its origin sandbox — bootFrom parity), or absent →
707
- // E2B's default base (Debian + node + npm/python/git), mirroring Vercel's
708
- // node24 default. With no explicit template/bootFrom, fall back to
709
- // E2B_DEFAULT_TEMPLATE if set (a prebuilt base — e.g. one with the claude CLI
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
710
1031
  // + chromium baked in and more RAM than the stock 482MB base), the
711
1032
  // per-deployment analogue of Vercel's node24; else E2B's stock base.
712
1033
  // Self-provisioning runtimes (claude/codex/amp via bootFrom:"reuse") install
@@ -715,11 +1036,11 @@ const SANDBOX_PROVIDERS: Record<string, SandboxProviderDef> = {
715
1036
  // E2B's per-plan cap (see e2bMaxSandboxMs) so a 4.5h AC_SANDBOX_DEADLINE
716
1037
  // doesn't 400 on smaller plans.
717
1038
  //
718
- // `networkPolicy` is deliberately NOT passed to E2B's API. E2B has no
719
- // header-injecting firewall; enforcement happens INSIDE the VM via
720
- // the embedded iron-proxy started at sandbox boot, which pulls the
721
- // identical resolved policy from the server. One policy shape, two
722
- // enforcement points. See server/src/sandbox/iron-proxy.ts.
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).
723
1044
  const maxMs = e2bMaxSandboxMs();
724
1045
  if (timeoutMs > maxMs) {
725
1046
  // The clamp turns E2B's explicit create-time 400 into a silent mid-run
@@ -730,8 +1051,26 @@ const SANDBOX_PROVIDERS: Record<string, SandboxProviderDef> = {
730
1051
  `the sandbox dies at the cap, not the requested deadline. Raise E2B_MAX_SANDBOX_MS on plans that allow more.`,
731
1052
  );
732
1053
  }
733
- const sandboxOpts = { ...rest, timeoutMs: Math.min(timeoutMs, maxMs) };
734
- const tmpl = template ?? process.env.E2B_DEFAULT_TEMPLATE;
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;
735
1074
  return makeE2bSandboxProvider(
736
1075
  await (tmpl ? Sandbox.create(tmpl, sandboxOpts) : Sandbox.create(sandboxOpts)),
737
1076
  );
@@ -770,17 +1109,31 @@ const SANDBOX_PROVIDERS: Record<string, SandboxProviderDef> = {
770
1109
  },
771
1110
  "e2b-desktop": {
772
1111
  requiredEnv: { E2B_API_KEY: "E2B API key — e2b.dev/dashboard" },
773
- // Mirror "e2b": `networkPolicy` is deliberately NOT passed to the SDK —
774
- // its transforms carry live connector tokens, and a credential-bearing
775
- // object must never cross into a third-party library's option bag.
776
- // Lifetime is clamped to E2B's per-plan cap the same way.
777
1112
  // `size` dropped here: E2B has no create-time resource knob (specs are
778
1113
  // baked into the template/snapshot), so sizing on E2B = a pre-sized
779
1114
  // template, not this field. Honoured only on Vercel.
780
- create: async ({ template, timeoutMs, networkPolicy: _np, size: _size, ...rest }) => {
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 }) => {
781
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
+ }
782
1132
  return makeDesktopSandboxProvider(
783
- await Desktop.create(template, { ...rest, timeoutMs: Math.min(timeoutMs, e2bMaxSandboxMs()) }),
1133
+ await Desktop.create(template, {
1134
+ ...rest,
1135
+ timeoutMs: Math.min(timeoutMs, e2bMaxSandboxMs()),
1136
+ }),
784
1137
  );
785
1138
  },
786
1139
  },
@@ -829,6 +1182,23 @@ export async function deleteSandboxSnapshot(provider: SandboxProviderName, snaps
829
1182
  return def.deleteSnapshot(snapshotId, Object.fromEntries(Object.keys(def.requiredEnv).map(k => [k, process.env[k]!])));
830
1183
  }
831
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
+
832
1202
  /**
833
1203
  * Current active-sandbox count per configured provider, for quota gauges.
834
1204
  * Skips providers whose required env isn't set or which don't implement
@@ -27,7 +27,8 @@ export {
27
27
  stepInputPath,
28
28
  requestContextPath,
29
29
  } from "./protocol.js";
30
- export { invokeStep, parseStepResult, buildStepEnvs } from "./invoker.js";
30
+ export { invokeStep, launchStep, reconnectStep, parseStepResult, buildStepEnvs } from "./invoker.js";
31
+ export type { RunningStep, InvokeStepOptions } from "./invoker.js";
31
32
  export { serveStep } from "./server.js";
32
33
  export type { StepHandler, ServeStepRequest, StepHandlerResult } from "./server.js";
33
34
  export { StepExecutionError } from "./types.js";