@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.
- package/README.md +66 -39
- package/dist/agent/__tests__/runtime-json-schema.test.d.ts +10 -0
- package/dist/agent/agent-context.d.ts +21 -1
- package/dist/agent/agent-loop.d.ts +24 -1
- package/dist/client.d.ts +338 -534
- package/dist/directives.d.ts +112 -0
- package/dist/display.d.ts +242 -0
- package/dist/errors.d.ts +24 -1
- package/dist/index.d.ts +34 -13
- package/dist/index.js +2984 -861
- package/dist/pause/wrappers.d.ts +31 -9
- package/dist/processors/ask-human.d.ts +30 -0
- package/dist/processors/ask-human.test.d.ts +1 -0
- package/dist/processors/index.d.ts +1 -0
- package/dist/runtimes/_acp-client.d.ts +46 -1
- package/dist/runtimes/_cli-agent.d.ts +58 -4
- package/dist/runtimes/_jsonl-guard.d.ts +103 -0
- package/dist/runtimes/amp.d.ts +2 -2
- package/dist/runtimes/claude-code.d.ts +59 -0
- package/dist/runtimes/claude-code.test.d.ts +14 -0
- package/dist/runtimes/claude.d.ts +16 -0
- package/dist/runtimes/claude.test.d.ts +8 -0
- package/dist/runtimes/codex.d.ts +9 -3
- package/dist/runtimes/cursor.d.ts +9 -0
- package/dist/runtimes/droid.d.ts +9 -0
- package/dist/runtimes/jsonl-guard.test.d.ts +19 -0
- package/dist/runtimes/openai-desktop.js +2922 -861
- package/dist/runtimes/opencode.d.ts +25 -0
- package/dist/runtimes/vercel.js +22 -1
- package/dist/sandbox/devbox.d.ts +42 -0
- package/dist/sandbox/exec-stream.d.ts +14 -0
- package/dist/sandbox/network-policy.d.ts +100 -0
- package/dist/sandbox/provider-def.d.ts +79 -0
- package/dist/sandbox/providers/desktop.d.ts +10 -0
- package/dist/sandbox/providers/e2b.d.ts +17 -0
- package/dist/sandbox/providers/local.d.ts +11 -0
- package/dist/sandbox/providers/vercel.d.ts +18 -0
- package/dist/sandbox/registry.d.ts +45 -0
- package/dist/sandbox/sizes.d.ts +68 -0
- package/dist/sandbox.d.ts +24 -299
- package/dist/step-invocation/__tests__/foreground-recovery.test.d.ts +1 -0
- package/dist/step-invocation/invoker.d.ts +24 -1
- package/dist/step-invocation/protocol.d.ts +13 -0
- package/dist/types/api-compliance.d.ts +71 -0
- package/dist/types/api-conversations.d.ts +492 -0
- package/dist/types/api-factory.d.ts +309 -0
- package/dist/types/api-projects.d.ts +131 -0
- package/dist/types/api-runs.d.ts +377 -0
- package/dist/types/api-scopes.d.ts +102 -0
- package/dist/types/conversation-stream.d.ts +191 -0
- package/dist/types/execution-context.d.ts +12 -2
- package/dist/types/protocol.d.ts +30 -1
- package/dist/types/sandbox-environment.d.ts +8 -5
- package/dist/types/sandbox.d.ts +79 -0
- package/dist/types/workflow-metadata.d.ts +33 -8
- package/dist/types/workflow-plan.d.ts +10 -0
- package/dist/types/workflow.d.ts +18 -193
- package/dist/utils/bundler.d.ts +12 -1
- package/dist/utils/errors.d.ts +9 -1
- package/dist/workflow-steps/index.d.ts +1 -1
- package/dist/workflow-steps/observability.d.ts +8 -1
- package/dist/workflow-steps/runner.d.ts +3 -3
- package/dist/workflow-steps/step.d.ts +15 -1
- package/dist/workflow-steps/types.d.ts +19 -5
- package/dist/workflow-steps/workflow.d.ts +22 -1
- package/dist/workflows/engine.d.ts +3 -2
- package/dist/workflows/invoke-child.d.ts +2 -2
- package/package.json +1 -1
- package/src/agent/agent-context.ts +206 -16
- package/src/agent/agent-loop.ts +40 -4
- package/src/agent/run-agent.ts +9 -1
- package/src/client.ts +909 -621
- package/src/directives.ts +184 -0
- package/src/display.ts +788 -0
- package/src/errors.ts +39 -0
- package/src/index.ts +117 -10
- package/src/pause/wrappers.ts +44 -9
- package/src/processors/ask-human.ts +136 -0
- package/src/processors/index.ts +5 -0
- package/src/runtimes/_acp-client.ts +72 -3
- package/src/runtimes/_cli-agent.ts +171 -38
- package/src/runtimes/_jsonl-guard.ts +219 -0
- package/src/runtimes/claude-code.ts +246 -0
- package/src/runtimes/claude.ts +32 -2
- package/src/runtimes/codex.ts +55 -3
- package/src/runtimes/cursor.ts +59 -0
- package/src/runtimes/droid.ts +63 -0
- package/src/runtimes/openai-desktop.ts +59 -14
- package/src/runtimes/opencode.ts +61 -0
- package/src/sandbox/devbox.ts +48 -0
- package/src/sandbox/exec-stream.ts +48 -0
- package/src/sandbox/network-policy.ts +181 -0
- package/src/sandbox/provider-def.ts +94 -0
- package/src/sandbox/providers/desktop.ts +57 -0
- package/src/sandbox/providers/e2b.ts +354 -0
- package/src/sandbox/providers/local.ts +106 -0
- package/src/sandbox/providers/vercel.ts +331 -0
- package/src/sandbox/registry.ts +198 -0
- package/src/sandbox/sizes.ts +95 -0
- package/src/sandbox.ts +59 -1263
- package/src/step-invocation/invoker.ts +319 -34
- package/src/step-invocation/protocol.ts +19 -0
- package/src/types/api-compliance.ts +79 -0
- package/src/types/api-conversations.ts +522 -0
- package/src/types/api-factory.ts +336 -0
- package/src/types/api-projects.ts +140 -0
- package/src/types/api-runs.ts +412 -0
- package/src/types/api-scopes.ts +102 -0
- package/src/types/conversation-stream.ts +231 -0
- package/src/types/execution-context.ts +10 -2
- package/src/types/protocol.ts +33 -0
- package/src/types/sandbox-environment.ts +28 -9
- package/src/types/sandbox.ts +78 -0
- package/src/types/workflow-metadata.ts +35 -8
- package/src/types/workflow-plan.ts +11 -0
- package/src/types/workflow.ts +25 -280
- package/src/utils/bundler.ts +32 -5
- package/src/utils/errors.ts +34 -2
- package/src/workflow-steps/index.ts +1 -0
- package/src/workflow-steps/observability.ts +19 -8
- package/src/workflow-steps/runner.ts +4 -4
- package/src/workflow-steps/step.ts +49 -1
- package/src/workflow-steps/types.ts +20 -5
- package/src/workflow-steps/workflow.ts +22 -1
- package/src/workflows/engine.ts +3 -2
- 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
|
+
}
|