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