@agent-compose/sdk 0.5.7 → 0.5.9
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/dist/agent/__tests__/run-agent-liveness.test.d.ts +17 -0
- package/dist/agent/agent-context.d.ts +67 -0
- package/dist/agent/agent-loop.d.ts +23 -12
- package/dist/agent/local-pause-request.d.ts +49 -0
- package/dist/agent/local-pause-request.test.d.ts +1 -0
- package/dist/agent/steer-control.d.ts +22 -6
- package/dist/client.d.ts +76 -2
- package/dist/index.d.ts +10 -5
- package/dist/index.js +2409 -1457
- package/dist/pause/checkpoint.d.ts +27 -10
- package/dist/pause/manager.d.ts +1 -0
- package/dist/pause/pause-core.d.ts +23 -0
- package/dist/pause/state-dir.d.ts +1 -1
- package/dist/pause/wrappers.d.ts +7 -11
- package/dist/processors/builtins.d.ts +20 -1
- package/dist/processors/index.d.ts +1 -1
- package/dist/processors/processor.d.ts +13 -0
- package/dist/runtimes/_acp-client.d.ts +140 -0
- package/dist/runtimes/_cli-agent.d.ts +155 -3
- package/dist/runtimes/amp.d.ts +2 -2
- package/dist/runtimes/cli-agent-acp-live.test.d.ts +30 -0
- package/dist/runtimes/cli-agent.test.d.ts +22 -6
- package/dist/runtimes/codex.d.ts +7 -2
- package/dist/runtimes/openai-desktop.js +2394 -1457
- package/dist/runtimes/vercel.js +389 -2
- package/dist/sandbox.d.ts +132 -14
- package/dist/step-invocation/types.d.ts +1 -1
- package/dist/types/__tests__/environment-build-flag.test.d.ts +1 -0
- package/dist/types/__tests__/workflow-metadata-provider.test.d.ts +1 -0
- package/dist/types/execution-context.d.ts +1 -11
- package/dist/types/protocol.d.ts +32 -1
- package/dist/types/runtime.d.ts +7 -0
- package/dist/types/sandbox-environment.d.ts +6 -1
- package/dist/types/sandbox.d.ts +41 -6
- package/dist/types/workflow-metadata.d.ts +47 -6
- package/dist/types/workflow.d.ts +27 -4
- package/dist/utils/bundler.d.ts +7 -1
- package/dist/workflow-steps/observability.d.ts +28 -2
- package/dist/workflow-steps/types.d.ts +11 -7
- package/dist/workflow-steps/workflow.d.ts +5 -1
- package/package.json +3 -2
- package/src/agent/agent-context.ts +220 -0
- package/src/agent/agent-loop.ts +90 -22
- package/src/agent/local-pause-request.ts +90 -0
- package/src/agent/run-agent.ts +43 -3
- package/src/agent/steer-control.ts +21 -7
- package/src/client.ts +123 -2
- package/src/index.ts +16 -4
- package/src/pause/checkpoint.ts +33 -14
- package/src/pause/manager.ts +2 -2
- package/src/pause/pause-core.ts +35 -0
- package/src/pause/state-dir.ts +2 -2
- package/src/pause/wrappers.ts +7 -21
- package/src/processors/builtins.ts +44 -1
- package/src/processors/index.ts +1 -0
- package/src/processors/processor.ts +13 -0
- package/src/runtimes/_acp-client.ts +516 -0
- package/src/runtimes/_cli-agent.ts +418 -3
- package/src/runtimes/claude.ts +27 -3
- package/src/runtimes/codex.ts +21 -1
- package/src/runtimes/vercel.ts +4 -1
- package/src/sandbox.ts +429 -67
- package/src/step-invocation/types.ts +1 -1
- package/src/types/execution-context.ts +1 -11
- package/src/types/protocol.ts +27 -1
- package/src/types/runtime.ts +7 -0
- package/src/types/sandbox-environment.ts +12 -1
- package/src/types/sandbox.ts +40 -6
- package/src/types/workflow-metadata.ts +51 -6
- package/src/types/workflow.ts +27 -6
- package/src/utils/bundler.ts +9 -1
- package/src/workflow-steps/observability.ts +51 -5
- package/src/workflow-steps/runner.ts +9 -5
- package/src/workflow-steps/types.ts +11 -7
- package/src/workflow-steps/workflow.ts +5 -1
- package/src/workflows/invoke-child.ts +7 -1
package/src/sandbox.ts
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
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 } 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";
|
|
@@ -17,7 +19,7 @@ import type { SandboxProvider, DesktopSandboxProvider, SandboxCommandResult } fr
|
|
|
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.
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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
|
|
|
@@ -71,6 +77,98 @@ export type SandboxNetworkPolicy =
|
|
|
71
77
|
subnets?: SandboxNetworkSubnetPolicy;
|
|
72
78
|
};
|
|
73
79
|
|
|
80
|
+
/** Sandbox machine size. A coarse small/medium/large knob that maps to
|
|
81
|
+
* provider machine specs at create time. Vercel honours it natively via
|
|
82
|
+
* `resources.vcpus` (2048 MB RAM per vCPU). E2B sizing is baked into the
|
|
83
|
+
* template, so E2B ignores this field. Default: "small". */
|
|
84
|
+
/** Sandbox hardware SKU. Named for the actual machine spec (vCPU + RAM) rather
|
|
85
|
+
* than abstract t-shirt sizes. Memory is always 2048 MB per vCPU:
|
|
86
|
+
* 2vcpu-4gb = 2 vCPU / 4 GiB (Vercel's own default machine)
|
|
87
|
+
* 4vcpu-8gb = 4 vCPU / 8 GiB
|
|
88
|
+
* 8vcpu-16gb = 8 vCPU / 16 GiB (per-sandbox ceiling on STANDARD accounts —
|
|
89
|
+
* probed live: 16 & 32 vCPU 400 on dev)
|
|
90
|
+
* 32vcpu-64gb = 32 vCPU / 64 GiB (ENTERPRISE ONLY — standard accounts reject >8 vCPU) */
|
|
91
|
+
export type SandboxSize = "2vcpu-4gb" | "4vcpu-8gb" | "8vcpu-16gb" | "32vcpu-64gb";
|
|
92
|
+
|
|
93
|
+
/** SKU → Vercel vCPU count (RAM follows at 2048 MB/vCPU). */
|
|
94
|
+
export const SANDBOX_VCPUS: Record<SandboxSize, number> = {
|
|
95
|
+
"2vcpu-4gb": 2,
|
|
96
|
+
"4vcpu-8gb": 4,
|
|
97
|
+
"8vcpu-16gb": 8,
|
|
98
|
+
"32vcpu-64gb": 32,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** SDK fallback size when neither the caller nor the deployment specifies one.
|
|
102
|
+
* Deliberately conservative — the OPERATIONAL default is the server's
|
|
103
|
+
* `SANDBOX_DEFAULT_SIZE` env var (now also `2vcpu-4gb`). Keeping the default
|
|
104
|
+
* small matters because Vercel rate-limits creation by vCPUs-per-window
|
|
105
|
+
* (`api-sandboxes-vcpus-creation`); a large default 429s bursty/simultaneous
|
|
106
|
+
* creates. Workloads that need more RAM/CPU declare `resources.size` on the
|
|
107
|
+
* workflow rather than inflating the default for everyone. */
|
|
108
|
+
export const DEFAULT_SANDBOX_SIZE: SandboxSize = "2vcpu-4gb";
|
|
109
|
+
|
|
110
|
+
/** The E2B sizes we pre-build a template for. E2B sizing is template-baked
|
|
111
|
+
* (no per-create cpu/mem knob), so honouring `resources.size` on E2B means
|
|
112
|
+
* ONE pre-built template per size. `32vcpu-64gb` is absent (E2B has no
|
|
113
|
+
* >8-vCPU equivalent). `8vcpu-16gb` is also absent: it needs 16 GiB RAM, but
|
|
114
|
+
* the E2B account caps memory at 8 GiB (`Template.build` 400s with
|
|
115
|
+
* "Memory can't be higher than 8192 MiB"). Add it back here (and rebuild the
|
|
116
|
+
* templates) only once the account's memory limit is raised. The register/
|
|
117
|
+
* invoke guards reject an unsupported E2B size before it can reach here. */
|
|
118
|
+
export const E2B_TEMPLATE_SIZES: readonly SandboxSize[] = [
|
|
119
|
+
"2vcpu-4gb",
|
|
120
|
+
"4vcpu-8gb",
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
/** Is `size` one E2B can be built/booted at? `32vcpu-64gb` (no >8-vCPU E2B
|
|
124
|
+
* equivalent) and `8vcpu-16gb` (exceeds the account's 8 GiB memory cap) are
|
|
125
|
+
* not — the guards lean on this so the "no E2B equivalent" decision lives in
|
|
126
|
+
* exactly one place. */
|
|
127
|
+
export function isE2bSupportedSize(size: SandboxSize): boolean {
|
|
128
|
+
return E2B_TEMPLATE_SIZES.includes(size);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Machine spec for a SandboxSize, in the shape `Template.build` wants. RAM is
|
|
132
|
+
* always 2048 MB/vCPU, matching the size name + Vercel parity
|
|
133
|
+
* (`SANDBOX_VCPUS` × 2048). Used by `infra/e2b-template/build.ts` to stamp the
|
|
134
|
+
* per-size base + agent-env templates. */
|
|
135
|
+
export function e2bMachineSpec(size: SandboxSize): { cpuCount: number; memoryMB: number } {
|
|
136
|
+
const cpuCount = SANDBOX_VCPUS[size];
|
|
137
|
+
return { cpuCount, memoryMB: cpuCount * 2048 };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Stable E2B template ALIAS for the platform base at a given size
|
|
141
|
+
* (`agent-compose-base-<size>`). Aliases — not snapshot ids — so the refs are
|
|
142
|
+
* multi-account-clean: the same string resolves in any E2B account that built
|
|
143
|
+
* the templates. Built by `infra/e2b-template/build.ts`; the E2B provider
|
|
144
|
+
* boots this when a run on E2B declares no explicit `bootFrom`/template. */
|
|
145
|
+
export function e2bBaseTemplate(size: SandboxSize): string {
|
|
146
|
+
return `agent-compose-base-${size}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Stable E2B template ALIAS for the agent runtime (base + claude binary) at a
|
|
150
|
+
* given size (`agent-env-<size>`). The agent default templates boot from this
|
|
151
|
+
* via `bootFrom: { snapshotId: e2bAgentEnvTemplate(size) }`. Multi-account-clean
|
|
152
|
+
* for the same reason as `e2bBaseTemplate`. */
|
|
153
|
+
export function e2bAgentEnvTemplate(size: SandboxSize): string {
|
|
154
|
+
return `agent-env-${size}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Is `id` one of the platform-managed E2B template aliases — a
|
|
158
|
+
* `agent-compose-base-<size>` or `agent-env-<size>` for a supported size?
|
|
159
|
+
*
|
|
160
|
+
* These are stable, platform-built, multi-account-clean strings (NOT tenant
|
|
161
|
+
* captures), so any workflow may boot from them: the same alias resolves to
|
|
162
|
+
* the same platform base in every account, and there is no tenant data behind
|
|
163
|
+
* it to leak. The dispatch snapshot gate uses this to vouch a platform E2B
|
|
164
|
+
* boot alias without it having to be a team-owned capture or a published
|
|
165
|
+
* template's bootFrom. */
|
|
166
|
+
export function isPlatformE2bTemplateAlias(id: string): boolean {
|
|
167
|
+
return E2B_TEMPLATE_SIZES.some(
|
|
168
|
+
(size) => id === e2bBaseTemplate(size) || id === e2bAgentEnvTemplate(size),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
74
172
|
export interface SandboxCreateOpts {
|
|
75
173
|
envs: Record<string, string>;
|
|
76
174
|
metadata: Record<string, string>;
|
|
@@ -78,13 +176,14 @@ export interface SandboxCreateOpts {
|
|
|
78
176
|
/** Provider-specific template/snapshot identifier. E2B: template id or
|
|
79
177
|
* snapshot id (omit → E2B's default base). Vercel: snapshot id (omit → node24). */
|
|
80
178
|
template?: string;
|
|
179
|
+
/** Machine size. Vercel maps it to `resources.vcpus`; E2B ignores it
|
|
180
|
+
* (size is template-defined). Omit → `DEFAULT_SANDBOX_SIZE`. */
|
|
181
|
+
size?: SandboxSize;
|
|
81
182
|
/** Outbound request policy with header transforms — ONE shape for every
|
|
82
|
-
* provider; only the enforcement point differs.
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* server's per-run egress-policy endpoint — so this field is not passed
|
|
87
|
-
* 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 }`). */
|
|
88
187
|
networkPolicy?: SandboxNetworkPolicy;
|
|
89
188
|
}
|
|
90
189
|
|
|
@@ -130,6 +229,16 @@ interface SandboxProviderDef {
|
|
|
130
229
|
* up eventually by the provider's own retention.
|
|
131
230
|
*/
|
|
132
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>;
|
|
133
242
|
}
|
|
134
243
|
|
|
135
244
|
// ── E2B helpers ───────────────────────────────────────────────────────────────
|
|
@@ -141,26 +250,61 @@ export function makeSandboxProvider(sb: Sandbox | Desktop): SandboxProvider {
|
|
|
141
250
|
commands: {
|
|
142
251
|
async run(cmd, opts) {
|
|
143
252
|
let stderr = "";
|
|
144
|
-
|
|
145
|
-
|
|
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" } : {}),
|
|
146
262
|
onStderr: (chunk: string) => {
|
|
147
263
|
stderr += chunk;
|
|
148
264
|
opts?.onStderr?.(chunk);
|
|
149
265
|
},
|
|
150
|
-
});
|
|
151
|
-
return {
|
|
152
|
-
exitCode: result.exitCode,
|
|
153
|
-
stdout: result.stdout,
|
|
154
|
-
stderr: typeof (result as { stderr?: unknown }).stderr === "string"
|
|
155
|
-
? (result as { stderr: string }).stderr
|
|
156
|
-
: stderr,
|
|
157
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
|
+
}
|
|
158
294
|
},
|
|
159
295
|
},
|
|
160
296
|
files: {
|
|
161
|
-
|
|
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
|
+
},
|
|
162
304
|
},
|
|
163
|
-
|
|
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(); },
|
|
164
308
|
};
|
|
165
309
|
}
|
|
166
310
|
|
|
@@ -235,6 +379,16 @@ function makeE2bSandboxProvider(sb: Sandbox): SandboxProvider {
|
|
|
235
379
|
const { snapshotId } = await sb.createSnapshot();
|
|
236
380
|
return { snapshotId };
|
|
237
381
|
},
|
|
382
|
+
// Push a freshly-resolved egress policy onto the live sandbox via E2B's
|
|
383
|
+
// native `updateNetwork` — the E2B analogue of Vercel's `update({
|
|
384
|
+
// networkPolicy })`. Lets the server re-resolve the run policy (re-minting
|
|
385
|
+
// connector access tokens) before each step instead of living with the
|
|
386
|
+
// policy baked at create. The update endpoint replaces egress rules
|
|
387
|
+
// atomically (omitted fields are cleared), so we always pass the full
|
|
388
|
+
// translated network config.
|
|
389
|
+
async updateNetworkPolicy(policy) {
|
|
390
|
+
await sb.updateNetwork(toE2bNetwork(policy));
|
|
391
|
+
},
|
|
238
392
|
};
|
|
239
393
|
}
|
|
240
394
|
|
|
@@ -368,6 +522,70 @@ export function toVercelNetworkPolicy(policy: SandboxNetworkPolicy): VercelNetwo
|
|
|
368
522
|
return { ...policy, allow };
|
|
369
523
|
}
|
|
370
524
|
|
|
525
|
+
/**
|
|
526
|
+
* Translate our policy shape into E2B's native `network` config
|
|
527
|
+
* (`SandboxNetworkOpts`) — the E2B analogue of `toVercelNetworkPolicy`.
|
|
528
|
+
*
|
|
529
|
+
* - `allowOut`: the set of allowed egress targets — every host named in
|
|
530
|
+
* `allow` PLUS every CIDR in `subnets.allow`. The Archil raw-TCP
|
|
531
|
+
* data-plane CIDRs ride here as FIRST-CLASS allow entries (E2B has no
|
|
532
|
+
* root-exemption to lean on, unlike the old in-VM iptables model).
|
|
533
|
+
* - `denyOut`: the all-traffic sentinel (`0.0.0.0/0`) so the policy is
|
|
534
|
+
* DEFAULT-DENY — only `allowOut` targets pass. E2B applies allow before
|
|
535
|
+
* deny, so a host in both lists is allowed.
|
|
536
|
+
* - `rules`: per-host header injection. For each host carrying transform(s),
|
|
537
|
+
* emit one rule per transform with `{ transform: { headers } }`. A host
|
|
538
|
+
* with a rule MUST also be in `allowOut` (registering a rule does not
|
|
539
|
+
* grant egress on its own — E2B's API requires the host in `allowOut`).
|
|
540
|
+
*
|
|
541
|
+
* BEHAVIOURAL DIVERGENCE FROM iron-proxy / Vercel — path/method gating is NOT
|
|
542
|
+
* available on E2B native rules. An `SandboxNetworkRule` carries only a
|
|
543
|
+
* `transform` (header injection); there is no method/path matcher. So our
|
|
544
|
+
* Tier-2 `requestRules` (`{ methods, pathPrefixes }`) cannot be enforced here:
|
|
545
|
+
* a brokered credential rides EVERY request to an allowed host on E2B, whereas
|
|
546
|
+
* Vercel (native `match`) and the old iron-proxy gated it to the declared
|
|
547
|
+
* method+path. The host allowlist still confines WHICH hosts the credential
|
|
548
|
+
* can reach; it just can't narrow to a method/path within an allowed host.
|
|
549
|
+
*
|
|
550
|
+
* String policies map straight through: "allow-all" → no restriction (omit
|
|
551
|
+
* `allowOut`/`denyOut`), "deny-all" → block all egress.
|
|
552
|
+
*/
|
|
553
|
+
export function toE2bNetwork(policy: SandboxNetworkPolicy): E2bNetworkOpts {
|
|
554
|
+
if (policy === "allow-all") return {};
|
|
555
|
+
if (policy === "deny-all") return { denyOut: ({ allTraffic }) => [allTraffic] };
|
|
556
|
+
|
|
557
|
+
// List-form allow (no transforms): allowlist the hosts, default-deny the rest.
|
|
558
|
+
const allowHosts: string[] = Array.isArray(policy.allow)
|
|
559
|
+
? [...policy.allow]
|
|
560
|
+
: Object.keys(policy.allow ?? {});
|
|
561
|
+
const subnetAllow = policy.subnets?.allow ?? [];
|
|
562
|
+
const allowOut = [...new Set([...allowHosts, ...subnetAllow])];
|
|
563
|
+
|
|
564
|
+
const network: E2bNetworkOpts = {
|
|
565
|
+
// Default-deny: only `allowOut` passes (E2B applies allow before deny).
|
|
566
|
+
denyOut: ({ allTraffic }) => [allTraffic],
|
|
567
|
+
};
|
|
568
|
+
if (allowOut.length > 0) network.allowOut = allowOut;
|
|
569
|
+
|
|
570
|
+
// Per-host header injection. Each of our `transform` entries (a
|
|
571
|
+
// `{ headers }` bag) becomes one E2B rule. `requestRules` is intentionally
|
|
572
|
+
// dropped — see the divergence note above.
|
|
573
|
+
if (!Array.isArray(policy.allow) && policy.allow) {
|
|
574
|
+
const rules: Record<string, E2bNetworkRule[]> = {};
|
|
575
|
+
for (const [host, hostRules] of Object.entries(policy.allow)) {
|
|
576
|
+
const hostTransforms = hostRules.flatMap((rule) =>
|
|
577
|
+
(rule.transform ?? [])
|
|
578
|
+
.filter((t) => t.headers && Object.keys(t.headers).length > 0)
|
|
579
|
+
.map((t): E2bNetworkRule => ({ transform: { headers: t.headers } })),
|
|
580
|
+
);
|
|
581
|
+
if (hostTransforms.length > 0) rules[host] = hostTransforms;
|
|
582
|
+
}
|
|
583
|
+
if (Object.keys(rules).length > 0) network.rules = rules;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
return network;
|
|
587
|
+
}
|
|
588
|
+
|
|
371
589
|
/** Vercel caps sandbox tags at 5. Build the tag set with the fleet `executor`
|
|
372
590
|
* tag always present and always winning over caller metadata — `listOwned` /
|
|
373
591
|
* orphan reconciliation scope on it, so a clobbered or dropped executor tag
|
|
@@ -417,16 +635,11 @@ function makeVercelSandboxProvider(sb: any, globalEnvs?: Record<string, string>)
|
|
|
417
635
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
418
636
|
const handle: any = await sb.runCommand({ cmd: "sh", args: ["-c", cmd], cwd: opts?.cwd, env: mergeEnvs(opts?.envs), detached: true, signal, ...(opts?.sudo ? { sudo: true } : {}) })
|
|
419
637
|
.catch((err: unknown) => asUnavailable(err, true));
|
|
420
|
-
// Reconnect to the already-running command on transient
|
|
421
|
-
// `h.logs()` replays
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
|
|
425
|
-
// A failure here (mid-stream / on `wait`) is NOT safe to retry: the
|
|
426
|
-
// runner already launched, so user code may have produced side
|
|
427
|
-
// effects. Surface it as a terminal `SandboxUnavailableError` so it's
|
|
428
|
-
// classified honestly but not auto-replayed.
|
|
429
|
-
try {
|
|
638
|
+
// Stream + wait. Reconnect to the already-running command on transient
|
|
639
|
+
// stream failures (e.g. BrotliDecompressionError). `h.logs()` replays
|
|
640
|
+
// from the start on reconnect, so reset accumulators per attempt to
|
|
641
|
+
// avoid double-counting.
|
|
642
|
+
const collect = async () => {
|
|
430
643
|
await pRetry(async (attempt) => {
|
|
431
644
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
432
645
|
const h: any = attempt === 1 ? handle : await sb.getCommand(handle.cmdId);
|
|
@@ -438,17 +651,34 @@ function makeVercelSandboxProvider(sb: any, globalEnvs?: Record<string, string>)
|
|
|
438
651
|
}, { retries: 3, minTimeout: 1_000, factor: 2 });
|
|
439
652
|
const finished = await handle.wait();
|
|
440
653
|
return { exitCode: finished.exitCode, stdout, stderr };
|
|
654
|
+
};
|
|
655
|
+
// `timeoutMs` MUST be authoritative. The AbortSignal alone is not: a
|
|
656
|
+
// command that RUNS but emits nothing (e.g. a wedged `archil checkout`
|
|
657
|
+
// on a blocked data plane) leaves `logs()`/`wait()` pending and the
|
|
658
|
+
// abort never interrupts the await — the call hangs for the activity's
|
|
659
|
+
// whole multi-hour ceiling. Race a hard client-side deadline so a hung
|
|
660
|
+
// command fails fast and the caller's degrade/retry policy takes over.
|
|
661
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
662
|
+
const deadline = opts?.timeoutMs
|
|
663
|
+
? new Promise<never>((_, reject) => {
|
|
664
|
+
timer = setTimeout(
|
|
665
|
+
() => reject(new Error(`command timed out after ${opts.timeoutMs}ms: ${cmd.slice(0, 200)}`)),
|
|
666
|
+
opts.timeoutMs);
|
|
667
|
+
})
|
|
668
|
+
: undefined;
|
|
669
|
+
try {
|
|
670
|
+
return await (deadline ? Promise.race([collect(), deadline]) : collect());
|
|
441
671
|
} catch (err) {
|
|
442
|
-
// A
|
|
443
|
-
//
|
|
444
|
-
//
|
|
445
|
-
// terminal sandbox-unavailable failed whole runs
|
|
446
|
-
// slow mount probe
|
|
447
|
-
|
|
448
|
-
if (signal?.aborted) {
|
|
449
|
-
throw new Error(`command timed out after ${opts?.timeoutMs}ms: ${cmd.slice(0, 200)}`);
|
|
450
|
-
}
|
|
672
|
+
// A timeout (hard deadline or AbortSignal) is a COMMAND overrunning
|
|
673
|
+
// its budget, not the sandbox dying — the VM is alive. Surface it as
|
|
674
|
+
// a plain error so the caller's own retry/deadline policy decides
|
|
675
|
+
// (classifying it as terminal sandbox-unavailable failed whole runs
|
|
676
|
+
// over a single slow mount probe, observed live).
|
|
677
|
+
if (err instanceof Error && err.message.startsWith("command timed out after")) throw err;
|
|
678
|
+
if (signal?.aborted) throw new Error(`command timed out after ${opts?.timeoutMs}ms: ${cmd.slice(0, 200)}`);
|
|
451
679
|
return asUnavailable(err, false);
|
|
680
|
+
} finally {
|
|
681
|
+
if (timer) clearTimeout(timer);
|
|
452
682
|
}
|
|
453
683
|
},
|
|
454
684
|
},
|
|
@@ -486,8 +716,7 @@ function makeVercelSandboxProvider(sb: any, globalEnvs?: Record<string, string>)
|
|
|
486
716
|
// @vercel/sandbox 2.x `update({ networkPolicy })`. Lets the server
|
|
487
717
|
// re-resolve the run policy (re-minting connector access tokens)
|
|
488
718
|
// before each step instead of living with the policy baked at create.
|
|
489
|
-
// E2B
|
|
490
|
-
// the VM and is configured at boot.
|
|
719
|
+
// E2B implements the same seam via `sb.updateNetwork(toE2bNetwork(...))`.
|
|
491
720
|
async updateNetworkPolicy(policy) {
|
|
492
721
|
await sb.update({ networkPolicy: toVercelNetworkPolicy(policy) });
|
|
493
722
|
},
|
|
@@ -528,6 +757,57 @@ export function makeLocalSandboxProvider(): SandboxProvider {
|
|
|
528
757
|
proc.on("close", (code) => resolve({ exitCode: code ?? 0, stdout, stderr }));
|
|
529
758
|
});
|
|
530
759
|
},
|
|
760
|
+
// Duplex spawn — the in-VM `child_process` pipe the ACP client needs.
|
|
761
|
+
// `commands.run` buffers to completion and gives stdout only via a
|
|
762
|
+
// callback; this keeps the process alive and exposes stdin AND stdout as
|
|
763
|
+
// byte web-streams so `ndJsonStream(stdin, stdout)` can carry JSON-RPC
|
|
764
|
+
// both ways. Only the local provider has this (the runner spawns CLIs
|
|
765
|
+
// in-VM); vercel/e2b leave it undefined.
|
|
766
|
+
spawnDuplex(cmd, opts) {
|
|
767
|
+
// `detached: true` puts the `sh` and everything it spawns (the
|
|
768
|
+
// `npx`-launched ACP binary) into a NEW process group whose pgid is the
|
|
769
|
+
// `sh` pid. Without it `kill()` would SIGTERM only `sh`, orphaning the
|
|
770
|
+
// grandchild ACP agent (still holding the provider's creds + CPU) until
|
|
771
|
+
// the sandbox VM is torn down. Mirrors the Vercel `run()` detached
|
|
772
|
+
// pattern. We don't `unref()` — the parent stays attached so `exited`
|
|
773
|
+
// fires on close.
|
|
774
|
+
const proc = spawn("sh", ["-c", cmd], {
|
|
775
|
+
...(opts?.cwd ? { cwd: opts.cwd } : {}),
|
|
776
|
+
env: { ...process.env, ...(opts?.envs ?? {}) },
|
|
777
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
778
|
+
detached: true,
|
|
779
|
+
});
|
|
780
|
+
// Capture stderr for the exit result (diagnostics on a failed CLI),
|
|
781
|
+
// but don't expose it as a stream — only stdin/stdout carry the ACP wire.
|
|
782
|
+
let stderr = "";
|
|
783
|
+
proc.stderr.setEncoding("utf8");
|
|
784
|
+
proc.stderr.on("data", (chunk: string) => { stderr += chunk; });
|
|
785
|
+
|
|
786
|
+
const exited = new Promise<{ exitCode: number; stderr: string }>((resolve, reject) => {
|
|
787
|
+
proc.on("error", reject);
|
|
788
|
+
proc.on("close", (code) => resolve({ exitCode: code ?? 0, stderr }));
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
return {
|
|
792
|
+
stdin: Writable.toWeb(proc.stdin) as WritableStream<Uint8Array>,
|
|
793
|
+
// `Readable.toWeb` is typed `ReadableStream<any>`; the double-cast
|
|
794
|
+
// through `unknown` is the standard workaround for the generic
|
|
795
|
+
// variance tsc flags on the direct cast.
|
|
796
|
+
stdout: Readable.toWeb(proc.stdout) as unknown as ReadableStream<Uint8Array>,
|
|
797
|
+
exited,
|
|
798
|
+
// Process-GROUP kill: negate the pid to signal the whole group (`sh`
|
|
799
|
+
// + the npx-spawned ACP binary), so the grandchild dies with its
|
|
800
|
+
// parent instead of being orphaned. Guarded against ESRCH — the
|
|
801
|
+
// group is already gone if the process exited first, which is benign.
|
|
802
|
+
kill() {
|
|
803
|
+
if (proc.pid === undefined) return;
|
|
804
|
+
try { process.kill(-proc.pid, "SIGTERM"); }
|
|
805
|
+
catch (err) {
|
|
806
|
+
if ((err as NodeJS.ErrnoException).code !== "ESRCH") throw err;
|
|
807
|
+
}
|
|
808
|
+
},
|
|
809
|
+
};
|
|
810
|
+
},
|
|
531
811
|
},
|
|
532
812
|
files: {
|
|
533
813
|
async write(path, content) {
|
|
@@ -567,14 +847,19 @@ const SANDBOX_PROVIDERS: Record<string, SandboxProviderDef> = {
|
|
|
567
847
|
// bakes on top of this base chains from it — the base tools are in
|
|
568
848
|
// every derived snapshot for free.
|
|
569
849
|
const tmpl = opts.template ?? process.env.VERCEL_DEFAULT_SNAPSHOT;
|
|
850
|
+
// Machine size → vCPUs (RAM auto-follows at 2048 MB/vCPU). Always sent
|
|
851
|
+
// explicitly so the spec is deterministic and self-documenting rather
|
|
852
|
+
// than riding Vercel's implicit default; "small" maps to that default
|
|
853
|
+
// anyway, so existing runs are unchanged.
|
|
854
|
+
const resources = { vcpus: SANDBOX_VCPUS[opts.size ?? DEFAULT_SANDBOX_SIZE] };
|
|
570
855
|
// `persistent: false` — @vercel/sandbox 2.x creates persistent-by-default
|
|
571
856
|
// sandboxes: stop() auto-snapshots (and keeps billing storage), commands
|
|
572
857
|
// transparently resume a stopped VM, and kill no longer destroys. Our
|
|
573
858
|
// sandboxes are single-run and lifecycle-managed by the engine (explicit
|
|
574
859
|
// snapshot() / kill()), so opt out in BOTH branches.
|
|
575
860
|
const sb = await VercelSandbox.create(tmpl
|
|
576
|
-
? { source: { type: "snapshot" as const, snapshotId: tmpl }, timeout: opts.timeoutMs, env: opts.envs, tags, persistent: false, ...np, ...creds }
|
|
577
|
-
: { runtime: "node24" as const, timeout: opts.timeoutMs, env: opts.envs, tags, persistent: false, ...np, ...creds },
|
|
861
|
+
? { source: { type: "snapshot" as const, snapshotId: tmpl }, timeout: opts.timeoutMs, env: opts.envs, tags, persistent: false, resources, ...np, ...creds }
|
|
862
|
+
: { runtime: "node24" as const, timeout: opts.timeoutMs, env: opts.envs, tags, persistent: false, resources, ...np, ...creds },
|
|
578
863
|
);
|
|
579
864
|
// Pass envs as globalEnvs so they're injected into every runCommand subprocess.
|
|
580
865
|
// (Vercel's Sandbox.create env parameter does not flow to runCommand subprocesses.)
|
|
@@ -646,15 +931,40 @@ const SANDBOX_PROVIDERS: Record<string, SandboxProviderDef> = {
|
|
|
646
931
|
const snap = await Snapshot.get({ snapshotId, ...creds });
|
|
647
932
|
await snap.delete();
|
|
648
933
|
},
|
|
934
|
+
snapshotExists: async (snapshotId, env) => {
|
|
935
|
+
const { Snapshot } = await import("@vercel/sandbox");
|
|
936
|
+
const creds = { token: env.VERCEL_ACCESS_TOKEN, teamId: env.VERCEL_TEAM_ID, projectId: env.VERCEL_PROJECT_ID };
|
|
937
|
+
// Metadata-only lookup (no sandbox provisioned). A clean fetch ⇒ resolves.
|
|
938
|
+
// A 404 / not-found ⇒ definitively absent (false). Anything else (auth,
|
|
939
|
+
// 5xx, network) is INDETERMINATE and must propagate — never reported as
|
|
940
|
+
// "missing", which would falsely alert on a transient blip.
|
|
941
|
+
try {
|
|
942
|
+
await Snapshot.get({ snapshotId, ...creds });
|
|
943
|
+
return true;
|
|
944
|
+
} catch (err) {
|
|
945
|
+
const message = err instanceof Error ? err.message : String(err ?? "");
|
|
946
|
+
const status = (err as { status?: number; statusCode?: number })?.status
|
|
947
|
+
?? (err as { statusCode?: number })?.statusCode;
|
|
948
|
+
if (status === 404 || /\b404\b|not[\s_-]?found|no such snapshot/i.test(message)) return false;
|
|
949
|
+
throw err;
|
|
950
|
+
}
|
|
951
|
+
},
|
|
649
952
|
},
|
|
650
953
|
"e2b": {
|
|
651
954
|
requiredEnv: { E2B_API_KEY: "E2B API key — e2b.dev/dashboard" },
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
955
|
+
// E2B has no create-time resource knob (specs are baked into the
|
|
956
|
+
// template/snapshot), so honouring `size` on E2B = picking a PRE-SIZED
|
|
957
|
+
// template, not passing the field through. We resolve the boot template
|
|
958
|
+
// from `size` below (`e2bBaseTemplate(size)`) when the caller gave no
|
|
959
|
+
// explicit template/bootFrom; the field itself is never forwarded to E2B.
|
|
960
|
+
create: async ({ template, timeoutMs, networkPolicy, size, ...rest }) => {
|
|
961
|
+
// `template` is an E2B template id/alias, a snapshot id (a valid create
|
|
962
|
+
// source that persists beyond its origin sandbox — bootFrom parity), or
|
|
963
|
+
// absent. When absent we pick the SIZE-MATCHED platform base alias
|
|
964
|
+
// (`agent-compose-base-<size>`) so `resources.size` gives the same
|
|
965
|
+
// machine spec on E2B as on Vercel — the cross-provider parity this whole
|
|
966
|
+
// change exists for. Only when no size is resolvable at all do we fall
|
|
967
|
+
// back to E2B_DEFAULT_TEMPLATE if set (a prebuilt base — e.g. one with the claude CLI
|
|
658
968
|
// + chromium baked in and more RAM than the stock 482MB base), the
|
|
659
969
|
// per-deployment analogue of Vercel's node24; else E2B's stock base.
|
|
660
970
|
// Self-provisioning runtimes (claude/codex/amp via bootFrom:"reuse") install
|
|
@@ -663,11 +973,11 @@ const SANDBOX_PROVIDERS: Record<string, SandboxProviderDef> = {
|
|
|
663
973
|
// E2B's per-plan cap (see e2bMaxSandboxMs) so a 4.5h AC_SANDBOX_DEADLINE
|
|
664
974
|
// doesn't 400 on smaller plans.
|
|
665
975
|
//
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
//
|
|
669
|
-
//
|
|
670
|
-
// enforcement points
|
|
976
|
+
// The resolved policy — host allowlist + per-host header injection — is
|
|
977
|
+
// enforced natively by E2B's firewall, configured at create via the
|
|
978
|
+
// `network` field (toE2bNetwork). Connector tokens ride in the rule
|
|
979
|
+
// transforms and never enter the sandbox env. One policy shape, two
|
|
980
|
+
// native enforcement points (Vercel firewall, E2B firewall).
|
|
671
981
|
const maxMs = e2bMaxSandboxMs();
|
|
672
982
|
if (timeoutMs > maxMs) {
|
|
673
983
|
// The clamp turns E2B's explicit create-time 400 into a silent mid-run
|
|
@@ -678,8 +988,26 @@ const SANDBOX_PROVIDERS: Record<string, SandboxProviderDef> = {
|
|
|
678
988
|
`the sandbox dies at the cap, not the requested deadline. Raise E2B_MAX_SANDBOX_MS on plans that allow more.`,
|
|
679
989
|
);
|
|
680
990
|
}
|
|
681
|
-
const sandboxOpts = {
|
|
682
|
-
|
|
991
|
+
const sandboxOpts = {
|
|
992
|
+
...rest,
|
|
993
|
+
timeoutMs: Math.min(timeoutMs, maxMs),
|
|
994
|
+
...(networkPolicy ? { network: toE2bNetwork(networkPolicy) } : {}),
|
|
995
|
+
};
|
|
996
|
+
// Boot template resolution, in priority order:
|
|
997
|
+
// 1. explicit `template`/bootFrom (a pinned snapshot or alias),
|
|
998
|
+
// 2. else the SIZE-MATCHED base alias `agent-compose-base-<size>`
|
|
999
|
+
// (`size` resolved to DEFAULT_SANDBOX_SIZE when unset) — this is the
|
|
1000
|
+
// cross-provider parity path,
|
|
1001
|
+
// 3. else E2B_DEFAULT_TEMPLATE as an ultimate per-deployment fallback,
|
|
1002
|
+
// 4. else E2B's stock base.
|
|
1003
|
+
// `32vcpu-64gb` has no base-<size> template (Pro caps ~8 vCPU); the
|
|
1004
|
+
// register + invoke guards reject it before a run reaches here, so we
|
|
1005
|
+
// never synthesize a non-existent `agent-compose-base-32vcpu-64gb` alias.
|
|
1006
|
+
const resolvedSize = size ?? DEFAULT_SANDBOX_SIZE;
|
|
1007
|
+
const tmpl =
|
|
1008
|
+
template ??
|
|
1009
|
+
(isE2bSupportedSize(resolvedSize) ? e2bBaseTemplate(resolvedSize) : undefined) ??
|
|
1010
|
+
process.env.E2B_DEFAULT_TEMPLATE;
|
|
683
1011
|
return makeE2bSandboxProvider(
|
|
684
1012
|
await (tmpl ? Sandbox.create(tmpl, sandboxOpts) : Sandbox.create(sandboxOpts)),
|
|
685
1013
|
);
|
|
@@ -718,14 +1046,31 @@ const SANDBOX_PROVIDERS: Record<string, SandboxProviderDef> = {
|
|
|
718
1046
|
},
|
|
719
1047
|
"e2b-desktop": {
|
|
720
1048
|
requiredEnv: { E2B_API_KEY: "E2B API key — e2b.dev/dashboard" },
|
|
721
|
-
//
|
|
722
|
-
//
|
|
723
|
-
//
|
|
724
|
-
//
|
|
725
|
-
|
|
1049
|
+
// `size` dropped here: E2B has no create-time resource knob (specs are
|
|
1050
|
+
// baked into the template/snapshot), so sizing on E2B = a pre-sized
|
|
1051
|
+
// template, not this field. Honoured only on Vercel.
|
|
1052
|
+
//
|
|
1053
|
+
// NO native firewall on this path: `@e2b/desktop` bundles its own (older)
|
|
1054
|
+
// `e2b` whose `SandboxOpts` predates the `network` field, so there is no
|
|
1055
|
+
// way to enforce an egress policy natively here — unlike the "e2b" and
|
|
1056
|
+
// "vercel" providers. Rather than SILENTLY dropping a policy (fail-open —
|
|
1057
|
+
// the exact leak the native-firewall migration closed), we refuse to create
|
|
1058
|
+
// a desktop sandbox under any non-empty policy. In practice this never
|
|
1059
|
+
// fires: e2b-desktop is registry-only, never a workflow's runtime (see
|
|
1060
|
+
// `workflow-metadata.ts`), so it is only ever provisioned with no policy.
|
|
1061
|
+
create: async ({ template, timeoutMs, networkPolicy, size: _size, ...rest }) => {
|
|
726
1062
|
if (!template) throw new Error("E2B Desktop provider requires an explicit `template` (Dockerfile-based — no default base image)");
|
|
1063
|
+
if (networkPolicy && networkPolicy !== "allow-all") {
|
|
1064
|
+
throw new Error(
|
|
1065
|
+
"E2B Desktop provider cannot enforce an egress network policy — `@e2b/desktop` bundles an e2b version that predates the native firewall. " +
|
|
1066
|
+
"e2b-desktop is registry-only and must not run with a confined policy.",
|
|
1067
|
+
);
|
|
1068
|
+
}
|
|
727
1069
|
return makeDesktopSandboxProvider(
|
|
728
|
-
await Desktop.create(template, {
|
|
1070
|
+
await Desktop.create(template, {
|
|
1071
|
+
...rest,
|
|
1072
|
+
timeoutMs: Math.min(timeoutMs, e2bMaxSandboxMs()),
|
|
1073
|
+
}),
|
|
729
1074
|
);
|
|
730
1075
|
},
|
|
731
1076
|
},
|
|
@@ -774,6 +1119,23 @@ export async function deleteSandboxSnapshot(provider: SandboxProviderName, snaps
|
|
|
774
1119
|
return def.deleteSnapshot(snapshotId, Object.fromEntries(Object.keys(def.requiredEnv).map(k => [k, process.env[k]!])));
|
|
775
1120
|
}
|
|
776
1121
|
|
|
1122
|
+
/**
|
|
1123
|
+
* Does `snapshotId` resolve on the named provider for this account? A cheap
|
|
1124
|
+
* metadata lookup — never provisions a sandbox. `true` = resolves, `false` =
|
|
1125
|
+
* provider says it does not exist. Transport/credential errors propagate (an
|
|
1126
|
+
* indeterminate result is not "missing"). Used at server boot to validate the
|
|
1127
|
+
* platform base snapshots the default templates boot from.
|
|
1128
|
+
*/
|
|
1129
|
+
export async function snapshotResolves(provider: SandboxProviderName, snapshotId: string): Promise<boolean> {
|
|
1130
|
+
const def = SANDBOX_PROVIDERS[provider];
|
|
1131
|
+
if (!def?.snapshotExists) throw new Error(`Provider "${provider}" does not support snapshotExists`);
|
|
1132
|
+
const missing = Object.entries(def.requiredEnv)
|
|
1133
|
+
.filter(([k]) => !process.env[k])
|
|
1134
|
+
.map(([k, desc]) => ` ${k} — ${desc}`);
|
|
1135
|
+
if (missing.length > 0) throw new Error(`Sandbox provider "${provider}" requires env vars:\n${missing.join("\n")}`);
|
|
1136
|
+
return def.snapshotExists(snapshotId, Object.fromEntries(Object.keys(def.requiredEnv).map(k => [k, process.env[k]!])));
|
|
1137
|
+
}
|
|
1138
|
+
|
|
777
1139
|
/**
|
|
778
1140
|
* Current active-sandbox count per configured provider, for quota gauges.
|
|
779
1141
|
* Skips providers whose required env isn't set or which don't implement
|