@agent-compose/sdk 0.7.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 (116) 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 +24 -12
  10. package/dist/index.js +3545 -1667
  11. package/dist/pause/wrappers.d.ts +31 -9
  12. package/dist/runtimes/_acp-client.d.ts +46 -1
  13. package/dist/runtimes/_cli-agent.d.ts +49 -4
  14. package/dist/runtimes/_jsonl-guard.d.ts +103 -0
  15. package/dist/runtimes/amp.d.ts +2 -2
  16. package/dist/runtimes/claude-code.d.ts +59 -0
  17. package/dist/runtimes/claude-code.test.d.ts +14 -0
  18. package/dist/runtimes/claude.d.ts +16 -0
  19. package/dist/runtimes/claude.test.d.ts +8 -0
  20. package/dist/runtimes/codex.d.ts +9 -3
  21. package/dist/runtimes/cursor.d.ts +2 -2
  22. package/dist/runtimes/droid.d.ts +2 -2
  23. package/dist/runtimes/jsonl-guard.test.d.ts +19 -0
  24. package/dist/runtimes/openai-desktop.js +2691 -864
  25. package/dist/runtimes/opencode.d.ts +2 -2
  26. package/dist/runtimes/vercel.js +12 -1
  27. package/dist/sandbox/devbox.d.ts +42 -0
  28. package/dist/sandbox/exec-stream.d.ts +14 -0
  29. package/dist/sandbox/network-policy.d.ts +100 -0
  30. package/dist/sandbox/provider-def.d.ts +79 -0
  31. package/dist/sandbox/providers/desktop.d.ts +10 -0
  32. package/dist/sandbox/providers/e2b.d.ts +17 -0
  33. package/dist/sandbox/providers/local.d.ts +11 -0
  34. package/dist/sandbox/providers/vercel.d.ts +18 -0
  35. package/dist/sandbox/registry.d.ts +45 -0
  36. package/dist/sandbox/sizes.d.ts +68 -0
  37. package/dist/sandbox.d.ts +24 -299
  38. package/dist/step-invocation/__tests__/foreground-recovery.test.d.ts +1 -0
  39. package/dist/step-invocation/invoker.d.ts +10 -0
  40. package/dist/step-invocation/protocol.d.ts +5 -0
  41. package/dist/types/api-compliance.d.ts +71 -0
  42. package/dist/types/api-conversations.d.ts +492 -0
  43. package/dist/types/api-factory.d.ts +309 -0
  44. package/dist/types/api-projects.d.ts +131 -0
  45. package/dist/types/api-runs.d.ts +377 -0
  46. package/dist/types/api-scopes.d.ts +102 -0
  47. package/dist/types/conversation-stream.d.ts +191 -0
  48. package/dist/types/execution-context.d.ts +12 -2
  49. package/dist/types/protocol.d.ts +30 -1
  50. package/dist/types/sandbox-environment.d.ts +8 -5
  51. package/dist/types/sandbox.d.ts +74 -4
  52. package/dist/types/workflow-metadata.d.ts +33 -8
  53. package/dist/types/workflow-plan.d.ts +10 -0
  54. package/dist/types/workflow.d.ts +18 -205
  55. package/dist/utils/bundler.d.ts +12 -1
  56. package/dist/workflow-steps/index.d.ts +1 -1
  57. package/dist/workflow-steps/observability.d.ts +8 -1
  58. package/dist/workflow-steps/runner.d.ts +3 -3
  59. package/dist/workflow-steps/step.d.ts +15 -1
  60. package/dist/workflow-steps/types.d.ts +19 -5
  61. package/dist/workflow-steps/workflow.d.ts +22 -1
  62. package/dist/workflows/engine.d.ts +3 -2
  63. package/dist/workflows/invoke-child.d.ts +2 -2
  64. package/package.json +1 -1
  65. package/src/agent/agent-context.ts +186 -3
  66. package/src/agent/agent-loop.ts +31 -2
  67. package/src/client.ts +909 -621
  68. package/src/directives.ts +184 -0
  69. package/src/display.ts +788 -0
  70. package/src/errors.ts +39 -0
  71. package/src/index.ts +104 -10
  72. package/src/pause/wrappers.ts +44 -9
  73. package/src/runtimes/_acp-client.ts +72 -3
  74. package/src/runtimes/_cli-agent.ts +159 -36
  75. package/src/runtimes/_jsonl-guard.ts +219 -0
  76. package/src/runtimes/claude-code.ts +246 -0
  77. package/src/runtimes/claude.ts +32 -2
  78. package/src/runtimes/codex.ts +55 -3
  79. package/src/runtimes/openai-desktop.ts +59 -14
  80. package/src/sandbox/devbox.ts +48 -0
  81. package/src/sandbox/exec-stream.ts +48 -0
  82. package/src/sandbox/network-policy.ts +181 -0
  83. package/src/sandbox/provider-def.ts +94 -0
  84. package/src/sandbox/providers/desktop.ts +57 -0
  85. package/src/sandbox/providers/e2b.ts +354 -0
  86. package/src/sandbox/providers/local.ts +106 -0
  87. package/src/sandbox/providers/vercel.ts +331 -0
  88. package/src/sandbox/registry.ts +198 -0
  89. package/src/sandbox/sizes.ts +95 -0
  90. package/src/sandbox.ts +59 -1275
  91. package/src/step-invocation/invoker.ts +151 -28
  92. package/src/step-invocation/protocol.ts +8 -0
  93. package/src/types/api-compliance.ts +79 -0
  94. package/src/types/api-conversations.ts +522 -0
  95. package/src/types/api-factory.ts +336 -0
  96. package/src/types/api-projects.ts +140 -0
  97. package/src/types/api-runs.ts +412 -0
  98. package/src/types/api-scopes.ts +102 -0
  99. package/src/types/conversation-stream.ts +231 -0
  100. package/src/types/execution-context.ts +10 -2
  101. package/src/types/protocol.ts +33 -0
  102. package/src/types/sandbox-environment.ts +28 -9
  103. package/src/types/sandbox.ts +73 -4
  104. package/src/types/workflow-metadata.ts +35 -8
  105. package/src/types/workflow-plan.ts +11 -0
  106. package/src/types/workflow.ts +25 -292
  107. package/src/utils/bundler.ts +32 -5
  108. package/src/utils/errors.ts +16 -1
  109. package/src/workflow-steps/index.ts +1 -0
  110. package/src/workflow-steps/observability.ts +19 -8
  111. package/src/workflow-steps/runner.ts +4 -4
  112. package/src/workflow-steps/step.ts +49 -1
  113. package/src/workflow-steps/types.ts +20 -5
  114. package/src/workflow-steps/workflow.ts +22 -1
  115. package/src/workflows/engine.ts +3 -2
  116. package/src/workflows/invoke-child.ts +2 -2
@@ -0,0 +1,354 @@
1
+ /**
2
+ * E2B sandbox provider — the generic E2B command/file wrapper
3
+ * (`makeSandboxProvider`, shared with the desktop provider), the E2B-specific
4
+ * provider with background launch + native VM-suspend, and the "e2b"
5
+ * registry entry.
6
+ */
7
+
8
+ import { Sandbox } from "e2b";
9
+ import type { CommandHandle } from "e2b";
10
+ import type { Sandbox as Desktop } from "@e2b/desktop";
11
+ import pRetry from "p-retry";
12
+ import type {
13
+ SandboxProvider, SandboxCommandRunOptions, SandboxBackgroundProcess, SandboxPtyHandle,
14
+ } from "../../types/sandbox.js";
15
+ import { toE2bNetwork } from "../network-policy.js";
16
+ import { DEFAULT_SANDBOX_SIZE, e2bBaseTemplate, isE2bSupportedSize } from "../sizes.js";
17
+ import { AGENT_COMPOSE_TAG } from "../provider-def.js";
18
+ import type { OwnedSandbox, SandboxProviderDef } from "../provider-def.js";
19
+
20
+ export function makeSandboxProvider(sb: Sandbox | Desktop): SandboxProvider {
21
+ const commands = sb.commands as { run: (cmd: string, opts?: unknown) => Promise<{ exitCode: number; stdout: string; stderr?: string }> };
22
+ return {
23
+ sandboxId: sb.sandboxId,
24
+ commands: {
25
+ async run(cmd, opts) {
26
+ let stderr = "";
27
+ // SandboxProvider exposes `sudo: true`; E2B has no `sudo` flag — it runs
28
+ // a command as root via `user: "root"` (Vercel/local map sudo to their own
29
+ // mechanism). Passing `sudo` straight through means E2B ignores it and runs
30
+ // as the non-root default user, so a root-only command (e.g. `archil mount`,
31
+ // which REQUIRES root) silently fails. Translate sudo → user:"root".
32
+ const { sudo, ...rest } = (opts ?? {}) as { sudo?: boolean } & Record<string, unknown>;
33
+ const runOpts = {
34
+ ...rest,
35
+ ...(sudo ? { user: "root" } : {}),
36
+ onStderr: (chunk: string) => {
37
+ stderr += chunk;
38
+ opts?.onStderr?.(chunk);
39
+ },
40
+ };
41
+ // E2B's commands.run THROWS CommandExitError on a non-zero exit, but the
42
+ // SandboxProvider contract (and the Vercel provider) RETURNS a result with
43
+ // `exitCode` so callers can branch on it (e.g. the archil-mount degrade
44
+ // path inspects res.exitCode/res.stderr; a thrown bare "exit status N"
45
+ // loses the command's stderr). Normalize the throw back into a result;
46
+ // only a genuine failure (spawn error, timeout, dead sandbox — no numeric
47
+ // exitCode on the error) propagates.
48
+ try {
49
+ const result = await commands.run(cmd, runOpts);
50
+ return {
51
+ exitCode: result.exitCode,
52
+ stdout: result.stdout,
53
+ stderr: typeof (result as { stderr?: unknown }).stderr === "string"
54
+ ? (result as { stderr: string }).stderr
55
+ : stderr,
56
+ };
57
+ } catch (e) {
58
+ const ce = e as { exitCode?: unknown; stdout?: unknown; stderr?: unknown };
59
+ if (typeof ce.exitCode === "number") {
60
+ return {
61
+ exitCode: ce.exitCode,
62
+ stdout: typeof ce.stdout === "string" ? ce.stdout : "",
63
+ stderr: typeof ce.stderr === "string" ? ce.stderr : stderr,
64
+ };
65
+ }
66
+ throw e;
67
+ }
68
+ },
69
+ },
70
+ files: {
71
+ // e2b 2.30 overloads `files.write` (single `(path, data)` and batch
72
+ // `(WriteEntry[])`); TS can't resolve the union from a bare two-arg call,
73
+ // so bind the single-file overload explicitly. The `WriteInfo` return is
74
+ // discarded — our provider contract is `Promise<void>`.
75
+ async write(path, content) {
76
+ await (sb.files.write as (p: string, d: string) => Promise<unknown>)(path, content);
77
+ },
78
+ // Read over the envd HTTP API (`Sandbox.files.read` → `GET /files`), a
79
+ // DIFFERENT transport from `commands` (the connect-web gRPC stream). A large
80
+ // readback over HTTP is decoded by `fetch`'s native `Content-Encoding`
81
+ // handling, so it is immune to the connect-web "received unsupported
82
+ // compressed output" failure that can abort `commands.run` output on a big
83
+ // frame — the property `launchStep` relies on to recover full logs.
84
+ async read(path) {
85
+ return await (sb.files.read as (p: string) => Promise<string>)(path);
86
+ },
87
+ },
88
+ // e2b 2.30 `kill()` returns Promise<boolean>; our provider contract is
89
+ // Promise<void>, so discard the result.
90
+ async kill() { await sb.kill(); },
91
+ // ADR-0052 §4.1: E2B natively forwards a port to a public `*.e2b.app`
92
+ // host. Pure string op (no network round-trip, no retry). This host is
93
+ // the RAW capability — the server's member-gated preview proxy resolves
94
+ // it server-side and never hands it to a browser.
95
+ getHost(port) { return sb.getHost(port); },
96
+ };
97
+ }
98
+
99
+ /** E2B caps a sandbox's lifetime per plan (Hobby 1h, Pro 24h) and 400s when the
100
+ * create timeout exceeds it — and our `AC_SANDBOX_DEADLINE` (4.5h) tops Hobby.
101
+ * Clamp the E2B create timeout to this max (raise on Pro via `E2B_MAX_SANDBOX_MS`).
102
+ * Read at call time so the workflow bundle stays env-free. */
103
+ export function e2bMaxSandboxMs(): number {
104
+ return Number(process.env.E2B_MAX_SANDBOX_MS) || 60 * 60 * 1000;
105
+ }
106
+
107
+ /** Wrap an E2B `CommandHandle` as a provider-agnostic background process.
108
+ * `wait()` is normalised NOT to throw on a non-zero exit (mirroring the
109
+ * `commands.run` contract in `makeSandboxProvider`) so callers branch on
110
+ * `exitCode` instead of catching. */
111
+ function wrapE2bBackgroundProcess(handle: CommandHandle): SandboxBackgroundProcess {
112
+ return {
113
+ pid: handle.pid,
114
+ async wait() {
115
+ try {
116
+ const r = await handle.wait();
117
+ return { exitCode: r.exitCode ?? 0, stdout: r.stdout, stderr: r.stderr };
118
+ } catch (e) {
119
+ const ce = e as { exitCode?: unknown; stdout?: unknown; stderr?: unknown };
120
+ if (typeof ce.exitCode === "number") {
121
+ return {
122
+ exitCode: ce.exitCode,
123
+ stdout: typeof ce.stdout === "string" ? ce.stdout : "",
124
+ stderr: typeof ce.stderr === "string" ? ce.stderr : "",
125
+ };
126
+ }
127
+ throw e;
128
+ }
129
+ },
130
+ async kill() { await handle.kill(); },
131
+ };
132
+ }
133
+
134
+ /** Wrap an E2B PTY `CommandHandle` as a provider-agnostic PTY handle, wiring
135
+ * `onExit` off `wait()` — which settles when the PTY process ends, however
136
+ * it ends (resolve and reject both mean "gone"). A `disconnect()`ed handle's
137
+ * `wait()` also settles, but by then the broker's socket is closed and its
138
+ * `onExit` is a guarded no-op. Shared by `createPty` and `connectPty` so the
139
+ * two attach paths return identical handles. */
140
+ function wrapE2bPtyHandle(
141
+ sb: Sandbox, handle: CommandHandle, onExit: (() => void) | undefined,
142
+ ): SandboxPtyHandle {
143
+ void handle.wait().catch(() => undefined).finally(() => onExit?.());
144
+ return {
145
+ pid: handle.pid,
146
+ sendInput: (data) => sb.pty.sendInput(handle.pid, data),
147
+ resize: (size) => sb.pty.resize(handle.pid, size),
148
+ kill: async () => { await sb.pty.kill(handle.pid); },
149
+ // Stream detach WITHOUT killing: the guest PTY keeps running (and
150
+ // survives a VM pause); `sb.pty.connect(pid)` re-attaches later.
151
+ disconnect: async () => { await handle.disconnect(); },
152
+ };
153
+ }
154
+
155
+ /** E2B base provider + live-filesystem snapshot. E2B's `createSnapshot()` captures
156
+ * the running sandbox as a persistent snapshot whose id is usable as a
157
+ * `Sandbox.create()` source and outlives the origin sandbox — so E2B reaches
158
+ * snapshot / `bootFrom` parity with Vercel, and snapshot-backed `ctx.pause` works
159
+ * on E2B. (Desktop intentionally omits this — it is registry-only, not selectable.) */
160
+ function makeE2bSandboxProvider(sb: Sandbox): SandboxProvider {
161
+ const base = makeSandboxProvider(sb);
162
+ return {
163
+ ...base,
164
+ commands: {
165
+ ...base.commands,
166
+ // ADR-0028: background launch + reconnect-by-pid. The step runner runs as
167
+ // a background command so a server-driven pause can freeze it mid-turn
168
+ // (pauseProcess) and the resume activity can re-attach by pid and await
169
+ // its exit — continuing the SAME process, no re-run. E2B-only; Vercel's
170
+ // provider omits these and pauses via snapshot + re-run.
171
+ async runBackground(cmd, opts) {
172
+ const { sudo, onStdout, onStderr, ...rest } = (opts ?? {}) as SandboxCommandRunOptions;
173
+ const handle = await sb.commands.run(cmd, {
174
+ ...rest,
175
+ background: true,
176
+ ...(sudo ? { user: "root" } : {}),
177
+ ...(onStdout ? { onStdout } : {}),
178
+ ...(onStderr ? { onStderr } : {}),
179
+ });
180
+ return wrapE2bBackgroundProcess(handle);
181
+ },
182
+ async connectProcess(pid, opts) {
183
+ const handle = await sb.commands.connect(pid, {
184
+ ...(opts?.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
185
+ ...(opts?.onStdout ? { onStdout: opts.onStdout } : {}),
186
+ ...(opts?.onStderr ? { onStderr: opts.onStderr } : {}),
187
+ });
188
+ return wrapE2bBackgroundProcess(handle);
189
+ },
190
+ },
191
+ // ADR-0055: the brokered session terminal. E2B's native `sb.pty` opens a
192
+ // real PTY in the guest; the server's pty broker splices it to a browser
193
+ // WebSocket. E2B-only — presence is the capability flag (like
194
+ // runBackground); Vercel/local leave it undefined.
195
+ async createPty(opts) {
196
+ const handle = await sb.pty.create({
197
+ cols: opts.cols,
198
+ rows: opts.rows,
199
+ ...(opts.cwd ? { cwd: opts.cwd } : {}),
200
+ envs: { TERM: "xterm-256color", ...(opts.envs ?? {}) },
201
+ onData: opts.onData,
202
+ // No provider-side lifetime cap — the PTY lives until an explicit
203
+ // kill (it survives handle disconnects), or freezes/thaws with the
204
+ // VM's pause.
205
+ timeoutMs: 0,
206
+ });
207
+ return wrapE2bPtyHandle(sb, handle, opts.onExit);
208
+ },
209
+ // ADR-0055 §7 rework: silent terminal reattach. `sb.pty.connect(pid)`
210
+ // resumes the output stream of a PTY an earlier handle disconnected
211
+ // from. PtyConnectOpts accepts only onData/timeoutMs (+ request
212
+ // plumbing) — the process already exists, so cols/rows/cwd/envs from
213
+ // SandboxPtyOpts don't apply here; the server re-asserts geometry via
214
+ // `resize` after connect (the repaint jiggle). Throws when no PTY with
215
+ // that pid is running — the caller's alive-probe races are its problem.
216
+ async connectPty(pid, opts) {
217
+ const handle = await sb.pty.connect(pid, {
218
+ onData: opts.onData,
219
+ timeoutMs: 0,
220
+ });
221
+ return wrapE2bPtyHandle(sb, handle, opts.onExit);
222
+ },
223
+ async snapshot() {
224
+ // Raw capture — the transient pause/reclaim race is retried centrally:
225
+ // createSandbox/reconnectSandbox wrap every provider's snapshot() in
226
+ // withSandboxRetry (via withSnapshotRetry). create itself is deliberately
227
+ // NOT retried — see createSandbox.
228
+ const { snapshotId } = await sb.createSnapshot();
229
+ return { snapshotId };
230
+ },
231
+ // ADR-0027: native VM-suspend. Freezes the live process in place (zero
232
+ // compute) and returns the sandbox id as the resume handle — resume is
233
+ // `reconnectSandbox(...)` (Sandbox.connect), which auto-resumes a paused VM.
234
+ // Distinct from snapshot(): no FS image, no kill, no re-run from the top.
235
+ async pauseProcess() {
236
+ await sb.pause();
237
+ return { resumeHandle: sb.sandboxId };
238
+ },
239
+ // The provider kill-clock seam: e2b's `setTimeout` REPLACES the deadline
240
+ // (extend or reduce) relative to now. The server's session lifecycle
241
+ // keeps this behind its own suspend clock so the deferred pause always
242
+ // wins the race against the create-time timeout.
243
+ async extendLifetime(ms) {
244
+ await sb.setTimeout(ms);
245
+ },
246
+ // Push a freshly-resolved egress policy onto the live sandbox via E2B's
247
+ // native `updateNetwork` — the E2B analogue of Vercel's `update({
248
+ // networkPolicy })`. Lets the server re-resolve the run policy (re-minting
249
+ // connector access tokens) before each step instead of living with the
250
+ // policy baked at create. The update endpoint replaces egress rules
251
+ // atomically (omitted fields are cleared), so we always pass the full
252
+ // translated network config.
253
+ async updateNetworkPolicy(policy) {
254
+ await sb.updateNetwork(toE2bNetwork(policy));
255
+ },
256
+ };
257
+ }
258
+
259
+ async function listOwned(): Promise<OwnedSandbox[]> {
260
+ const paginator = Sandbox.list({ query: { metadata: { executor: AGENT_COMPOSE_TAG }, state: ["running"] } });
261
+ const out: OwnedSandbox[] = [];
262
+ while (paginator.hasNext) {
263
+ const items = await pRetry(() => paginator.nextItems(), { retries: 3, minTimeout: 500, factor: 2 });
264
+ for (const sb of items) {
265
+ out.push({
266
+ sandboxId: sb.sandboxId,
267
+ createdAt: new Date(sb.startedAt),
268
+ metadata: sb.metadata ?? {},
269
+ });
270
+ }
271
+ }
272
+ return out;
273
+ }
274
+
275
+ export const e2bProviderDef: SandboxProviderDef = {
276
+ requiredEnv: { E2B_API_KEY: "E2B API key — e2b.dev/dashboard" },
277
+ // E2B has no create-time resource knob (specs are baked into the
278
+ // template/snapshot), so honouring `size` on E2B = picking a PRE-SIZED
279
+ // template, not passing the field through. We resolve the boot template
280
+ // from `size` below (`e2bBaseTemplate(size)`) when the caller gave no
281
+ // explicit template/bootFrom; the field itself is never forwarded to E2B.
282
+ create: async ({ template, timeoutMs, networkPolicy, size, ...rest }) => {
283
+ // `template` is an E2B template id/alias, a snapshot id (a valid create
284
+ // source that persists beyond its origin sandbox — bootFrom parity), or
285
+ // absent. When absent we pick the SIZE-MATCHED platform base alias
286
+ // (`agent-compose-base-<size>`) so `resources.size` gives the same
287
+ // machine spec on E2B as on Vercel — the cross-provider parity this whole
288
+ // change exists for. Only when no size is resolvable at all do we fall
289
+ // back to E2B_DEFAULT_TEMPLATE if set (a prebuilt base — e.g. one with the claude CLI
290
+ // + chromium baked in and more RAM than the stock 482MB base), the
291
+ // per-deployment analogue of Vercel's node24; else E2B's stock base.
292
+ // Self-provisioning runtimes (claude/codex/amp via bootFrom:"reuse") install
293
+ // their CLI on the base and cache it in the captured snapshot, so a
294
+ // template-less first run boots, installs, snapshots. Clamp the lifetime to
295
+ // E2B's per-plan cap (see e2bMaxSandboxMs) so a 4.5h AC_SANDBOX_DEADLINE
296
+ // doesn't 400 on smaller plans.
297
+ //
298
+ // The resolved policy — host allowlist + per-host header injection — is
299
+ // enforced natively by E2B's firewall, configured at create via the
300
+ // `network` field (toE2bNetwork). Connector tokens ride in the rule
301
+ // transforms and never enter the sandbox env. One policy shape, two
302
+ // native enforcement points (Vercel firewall, E2B firewall).
303
+ const maxMs = e2bMaxSandboxMs();
304
+ if (timeoutMs > maxMs) {
305
+ // The clamp turns E2B's explicit create-time 400 into a silent mid-run
306
+ // sandbox death at the cap — say so up front instead of letting a run
307
+ // budgeted past the cap discover it as a terminal infra failure.
308
+ console.warn(
309
+ `[sandbox] e2b create timeout clamped: requested ${timeoutMs}ms exceeds the plan cap ${maxMs}ms — ` +
310
+ `the sandbox dies at the cap, not the requested deadline. Raise E2B_MAX_SANDBOX_MS on plans that allow more.`,
311
+ );
312
+ }
313
+ const sandboxOpts = {
314
+ ...rest,
315
+ timeoutMs: Math.min(timeoutMs, maxMs),
316
+ ...(networkPolicy ? { network: toE2bNetwork(networkPolicy) } : {}),
317
+ };
318
+ // Boot template resolution, in priority order:
319
+ // 1. explicit `template`/bootFrom (a pinned snapshot or alias),
320
+ // 2. else the SIZE-MATCHED base alias `agent-compose-base-<size>`
321
+ // (`size` resolved to DEFAULT_SANDBOX_SIZE when unset) — this is the
322
+ // cross-provider parity path,
323
+ // 3. else E2B_DEFAULT_TEMPLATE as an ultimate per-deployment fallback,
324
+ // 4. else E2B's stock base.
325
+ // `32vcpu-64gb` has no base-<size> template (Pro caps ~8 vCPU); the
326
+ // register + invoke guards reject it before a run reaches here, so we
327
+ // never synthesize a non-existent `agent-compose-base-32vcpu-64gb` alias.
328
+ const resolvedSize = size ?? DEFAULT_SANDBOX_SIZE;
329
+ const tmpl =
330
+ template ??
331
+ (isE2bSupportedSize(resolvedSize) ? e2bBaseTemplate(resolvedSize) : undefined) ??
332
+ process.env.E2B_DEFAULT_TEMPLATE;
333
+ return makeE2bSandboxProvider(
334
+ await (tmpl ? Sandbox.create(tmpl, sandboxOpts) : Sandbox.create(sandboxOpts)),
335
+ );
336
+ },
337
+ reconnect: async (sandboxId) => makeE2bSandboxProvider(
338
+ await Sandbox.connect(sandboxId, { apiKey: process.env.E2B_API_KEY ?? "", timeoutMs: 60 * 60 * 1000 }),
339
+ ),
340
+ killAll: async () => {
341
+ const paginator = Sandbox.list({ query: { metadata: { executor: AGENT_COMPOSE_TAG } } });
342
+ const sandboxes = [];
343
+ while (paginator.hasNext) sandboxes.push(...await paginator.nextItems());
344
+ if (sandboxes.length === 0) return;
345
+ await Promise.all(sandboxes.map(s => Sandbox.kill(s.sandboxId)));
346
+ console.info(`[sandbox] killed ${sandboxes.length} stale E2B sandboxes (tag: ${AGENT_COMPOSE_TAG})`);
347
+ },
348
+ getActiveCount: async () => (await listOwned()).length,
349
+ listOwned,
350
+ deleteSnapshot: async (snapshotId, env) => {
351
+ // E2B snapshots are team-scoped; delete by id. Best-effort like Vercel's.
352
+ await Sandbox.deleteSnapshot(snapshotId, { apiKey: env.E2B_API_KEY });
353
+ },
354
+ };
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Local provider (runner's own VM).
3
+ */
4
+
5
+ import { promises as fs } from "node:fs";
6
+ import { dirname } from "node:path";
7
+ import { spawn } from "node:child_process";
8
+ import { Readable, Writable } from "node:stream";
9
+ import type { SandboxProvider } from "../../types/sandbox.js";
10
+
11
+ /** Minimal SandboxProvider that targets the current process's host VM.
12
+ * `commands.run` → `child_process.spawn`; `files.write` → `fs.writeFile`;
13
+ * `kill` is a no-op because the caller IS the VM. Used by
14
+ * `defineSandboxEnvironment` so setup recipes read like imperative
15
+ * provisioning scripts. Uses `node:child_process` — works under both Node
16
+ * (the runner executes `node /tmp/runner.bundle.js`) and Bun. */
17
+ export function makeLocalSandboxProvider(): SandboxProvider {
18
+ return {
19
+ sandboxId: "local",
20
+ commands: {
21
+ run(cmd, opts) {
22
+ // Prepend `sudo` so the command runs as root. The local provider
23
+ // targets the runner's own host VM (used by defineSandboxEnvironment
24
+ // recipes); harmless when the host is already root or has passwordless
25
+ // sudo, which is the only context it runs in.
26
+ const finalCmd = opts?.sudo ? `sudo ${cmd}` : cmd;
27
+ return new Promise((resolve, reject) => {
28
+ const proc = spawn("sh", ["-c", finalCmd], {
29
+ ...(opts?.cwd ? { cwd: opts.cwd } : {}),
30
+ env: { ...process.env, ...(opts?.envs ?? {}) },
31
+ stdio: ["ignore", "pipe", "pipe"],
32
+ });
33
+ let stdout = "";
34
+ let stderr = "";
35
+ proc.stdout?.setEncoding("utf8");
36
+ proc.stderr?.setEncoding("utf8");
37
+ proc.stdout?.on("data", (chunk: string) => { stdout += chunk; opts?.onStdout?.(chunk); });
38
+ proc.stderr?.on("data", (chunk: string) => { stderr += chunk; opts?.onStderr?.(chunk); });
39
+ proc.on("error", reject);
40
+ proc.on("close", (code) => resolve({ exitCode: code ?? 0, stdout, stderr }));
41
+ });
42
+ },
43
+ // Duplex spawn — the in-VM `child_process` pipe the ACP client needs.
44
+ // `commands.run` buffers to completion and gives stdout only via a
45
+ // callback; this keeps the process alive and exposes stdin AND stdout as
46
+ // byte web-streams so `ndJsonStream(stdin, stdout)` can carry JSON-RPC
47
+ // both ways. Only the local provider has this (the runner spawns CLIs
48
+ // in-VM); vercel/e2b leave it undefined.
49
+ spawnDuplex(cmd, opts) {
50
+ // `detached: true` puts the `sh` and everything it spawns (the
51
+ // `npx`-launched ACP binary) into a NEW process group whose pgid is the
52
+ // `sh` pid. Without it `kill()` would SIGTERM only `sh`, orphaning the
53
+ // grandchild ACP agent (still holding the provider's creds + CPU) until
54
+ // the sandbox VM is torn down. Mirrors the Vercel `run()` detached
55
+ // pattern. We don't `unref()` — the parent stays attached so `exited`
56
+ // fires on close.
57
+ const proc = spawn("sh", ["-c", cmd], {
58
+ ...(opts?.cwd ? { cwd: opts.cwd } : {}),
59
+ env: { ...process.env, ...(opts?.envs ?? {}) },
60
+ stdio: ["pipe", "pipe", "pipe"],
61
+ detached: true,
62
+ });
63
+ // Capture stderr for the exit result (diagnostics on a failed CLI),
64
+ // but don't expose it as a stream — only stdin/stdout carry the ACP wire.
65
+ let stderr = "";
66
+ proc.stderr.setEncoding("utf8");
67
+ proc.stderr.on("data", (chunk: string) => { stderr += chunk; });
68
+
69
+ const exited = new Promise<{ exitCode: number; stderr: string }>((resolve, reject) => {
70
+ proc.on("error", reject);
71
+ proc.on("close", (code) => resolve({ exitCode: code ?? 0, stderr }));
72
+ });
73
+
74
+ return {
75
+ stdin: Writable.toWeb(proc.stdin) as WritableStream<Uint8Array>,
76
+ // `Readable.toWeb` is typed `ReadableStream<any>`; the double-cast
77
+ // through `unknown` is the standard workaround for the generic
78
+ // variance tsc flags on the direct cast.
79
+ stdout: Readable.toWeb(proc.stdout) as unknown as ReadableStream<Uint8Array>,
80
+ exited,
81
+ // Process-GROUP kill: negate the pid to signal the whole group (`sh`
82
+ // + the npx-spawned ACP binary), so the grandchild dies with its
83
+ // parent instead of being orphaned. Guarded against ESRCH — the
84
+ // group is already gone if the process exited first, which is benign.
85
+ kill() {
86
+ if (proc.pid === undefined) return;
87
+ try { process.kill(-proc.pid, "SIGTERM"); }
88
+ catch (err) {
89
+ if ((err as NodeJS.ErrnoException).code !== "ESRCH") throw err;
90
+ }
91
+ },
92
+ };
93
+ },
94
+ },
95
+ files: {
96
+ async write(path, content) {
97
+ await fs.mkdir(dirname(path), { recursive: true });
98
+ await fs.writeFile(path, content);
99
+ },
100
+ async read(path) {
101
+ return await fs.readFile(path, "utf8");
102
+ },
103
+ },
104
+ async kill() { /* caller IS the sandbox — killing it is the server's job */ },
105
+ };
106
+ }