@agent-compose/sdk 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -39
- package/dist/agent/__tests__/runtime-json-schema.test.d.ts +10 -0
- package/dist/agent/agent-context.d.ts +21 -1
- package/dist/agent/agent-loop.d.ts +24 -1
- package/dist/client.d.ts +338 -534
- package/dist/directives.d.ts +112 -0
- package/dist/display.d.ts +242 -0
- package/dist/errors.d.ts +24 -1
- package/dist/index.d.ts +34 -13
- package/dist/index.js +2984 -861
- package/dist/pause/wrappers.d.ts +31 -9
- package/dist/processors/ask-human.d.ts +30 -0
- package/dist/processors/ask-human.test.d.ts +1 -0
- package/dist/processors/index.d.ts +1 -0
- package/dist/runtimes/_acp-client.d.ts +46 -1
- package/dist/runtimes/_cli-agent.d.ts +58 -4
- package/dist/runtimes/_jsonl-guard.d.ts +103 -0
- package/dist/runtimes/amp.d.ts +2 -2
- package/dist/runtimes/claude-code.d.ts +59 -0
- package/dist/runtimes/claude-code.test.d.ts +14 -0
- package/dist/runtimes/claude.d.ts +16 -0
- package/dist/runtimes/claude.test.d.ts +8 -0
- package/dist/runtimes/codex.d.ts +9 -3
- package/dist/runtimes/cursor.d.ts +9 -0
- package/dist/runtimes/droid.d.ts +9 -0
- package/dist/runtimes/jsonl-guard.test.d.ts +19 -0
- package/dist/runtimes/openai-desktop.js +2922 -861
- package/dist/runtimes/opencode.d.ts +25 -0
- package/dist/runtimes/vercel.js +22 -1
- package/dist/sandbox/devbox.d.ts +42 -0
- package/dist/sandbox/exec-stream.d.ts +14 -0
- package/dist/sandbox/network-policy.d.ts +100 -0
- package/dist/sandbox/provider-def.d.ts +79 -0
- package/dist/sandbox/providers/desktop.d.ts +10 -0
- package/dist/sandbox/providers/e2b.d.ts +17 -0
- package/dist/sandbox/providers/local.d.ts +11 -0
- package/dist/sandbox/providers/vercel.d.ts +18 -0
- package/dist/sandbox/registry.d.ts +45 -0
- package/dist/sandbox/sizes.d.ts +68 -0
- package/dist/sandbox.d.ts +24 -299
- package/dist/step-invocation/__tests__/foreground-recovery.test.d.ts +1 -0
- package/dist/step-invocation/invoker.d.ts +24 -1
- package/dist/step-invocation/protocol.d.ts +13 -0
- package/dist/types/api-compliance.d.ts +71 -0
- package/dist/types/api-conversations.d.ts +492 -0
- package/dist/types/api-factory.d.ts +309 -0
- package/dist/types/api-projects.d.ts +131 -0
- package/dist/types/api-runs.d.ts +377 -0
- package/dist/types/api-scopes.d.ts +102 -0
- package/dist/types/conversation-stream.d.ts +191 -0
- package/dist/types/execution-context.d.ts +12 -2
- package/dist/types/protocol.d.ts +30 -1
- package/dist/types/sandbox-environment.d.ts +8 -5
- package/dist/types/sandbox.d.ts +79 -0
- package/dist/types/workflow-metadata.d.ts +33 -8
- package/dist/types/workflow-plan.d.ts +10 -0
- package/dist/types/workflow.d.ts +18 -193
- package/dist/utils/bundler.d.ts +12 -1
- package/dist/utils/errors.d.ts +9 -1
- package/dist/workflow-steps/index.d.ts +1 -1
- package/dist/workflow-steps/observability.d.ts +8 -1
- package/dist/workflow-steps/runner.d.ts +3 -3
- package/dist/workflow-steps/step.d.ts +15 -1
- package/dist/workflow-steps/types.d.ts +19 -5
- package/dist/workflow-steps/workflow.d.ts +22 -1
- package/dist/workflows/engine.d.ts +3 -2
- package/dist/workflows/invoke-child.d.ts +2 -2
- package/package.json +1 -1
- package/src/agent/agent-context.ts +206 -16
- package/src/agent/agent-loop.ts +40 -4
- package/src/agent/run-agent.ts +9 -1
- package/src/client.ts +909 -621
- package/src/directives.ts +184 -0
- package/src/display.ts +788 -0
- package/src/errors.ts +39 -0
- package/src/index.ts +117 -10
- package/src/pause/wrappers.ts +44 -9
- package/src/processors/ask-human.ts +136 -0
- package/src/processors/index.ts +5 -0
- package/src/runtimes/_acp-client.ts +72 -3
- package/src/runtimes/_cli-agent.ts +171 -38
- package/src/runtimes/_jsonl-guard.ts +219 -0
- package/src/runtimes/claude-code.ts +246 -0
- package/src/runtimes/claude.ts +32 -2
- package/src/runtimes/codex.ts +55 -3
- package/src/runtimes/cursor.ts +59 -0
- package/src/runtimes/droid.ts +63 -0
- package/src/runtimes/openai-desktop.ts +59 -14
- package/src/runtimes/opencode.ts +61 -0
- package/src/sandbox/devbox.ts +48 -0
- package/src/sandbox/exec-stream.ts +48 -0
- package/src/sandbox/network-policy.ts +181 -0
- package/src/sandbox/provider-def.ts +94 -0
- package/src/sandbox/providers/desktop.ts +57 -0
- package/src/sandbox/providers/e2b.ts +354 -0
- package/src/sandbox/providers/local.ts +106 -0
- package/src/sandbox/providers/vercel.ts +331 -0
- package/src/sandbox/registry.ts +198 -0
- package/src/sandbox/sizes.ts +95 -0
- package/src/sandbox.ts +59 -1263
- package/src/step-invocation/invoker.ts +319 -34
- package/src/step-invocation/protocol.ts +19 -0
- package/src/types/api-compliance.ts +79 -0
- package/src/types/api-conversations.ts +522 -0
- package/src/types/api-factory.ts +336 -0
- package/src/types/api-projects.ts +140 -0
- package/src/types/api-runs.ts +412 -0
- package/src/types/api-scopes.ts +102 -0
- package/src/types/conversation-stream.ts +231 -0
- package/src/types/execution-context.ts +10 -2
- package/src/types/protocol.ts +33 -0
- package/src/types/sandbox-environment.ts +28 -9
- package/src/types/sandbox.ts +78 -0
- package/src/types/workflow-metadata.ts +35 -8
- package/src/types/workflow-plan.ts +11 -0
- package/src/types/workflow.ts +25 -280
- package/src/utils/bundler.ts +32 -5
- package/src/utils/errors.ts +34 -2
- package/src/workflow-steps/index.ts +1 -0
- package/src/workflow-steps/observability.ts +19 -8
- package/src/workflow-steps/runner.ts +4 -4
- package/src/workflow-steps/step.ts +49 -1
- package/src/workflow-steps/types.ts +20 -5
- package/src/workflow-steps/workflow.ts +22 -1
- package/src/workflows/engine.ts +3 -2
- package/src/workflows/invoke-child.ts +2 -2
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode CLI runtime — drives sst's `opencode` agentic CLI inside the sandbox.
|
|
3
|
+
* OpenCode speaks ACP natively (`opencode acp`, protocolVersion 1 — verified
|
|
4
|
+
* live on E2B), so the runner drives it over ACP; the JSONL members below are
|
|
5
|
+
* only the version-mismatch fallback (vestigial for a v1 agent).
|
|
6
|
+
*
|
|
7
|
+
* Auth + model via OpenRouter: set OPENROUTER_API_KEY (a factory/workflow
|
|
8
|
+
* secret) and use a model id like `openrouter/z-ai/glm-5.2`. The runtime
|
|
9
|
+
* installs the `opencode-ai` CLI on demand; pair with
|
|
10
|
+
* `snapshots: { bootFrom: "reuse" }` to install once and boot from the capture.
|
|
11
|
+
*
|
|
12
|
+
* Verified live on E2B (2026-06-30): `npm i -g opencode-ai` (v1.17.12);
|
|
13
|
+
* `opencode run --model openrouter/z-ai/glm-5.2` drove a GLM-5.2 turn through
|
|
14
|
+
* OpenRouter; `opencode acp` answered the ACP `initialize` handshake with
|
|
15
|
+
* protocolVersion 1.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { AgentMessage } from "../index.js";
|
|
19
|
+
import { createCliAgentRuntime, shellQuote, type CliAgentSpec } from "./_cli-agent.js";
|
|
20
|
+
|
|
21
|
+
function now(): string { return new Date().toISOString(); }
|
|
22
|
+
|
|
23
|
+
export const opencodeSpec: CliAgentSpec = {
|
|
24
|
+
kind: "opencode",
|
|
25
|
+
// OpenRouter is the gateway: opencode reads OPENROUTER_API_KEY from the env
|
|
26
|
+
// and serves any `openrouter/<provider>/<model>` id (e.g. z-ai/glm-5.2).
|
|
27
|
+
authEnv: "OPENROUTER_API_KEY",
|
|
28
|
+
bin: "opencode",
|
|
29
|
+
defaultModel: "openrouter/z-ai/glm-5.2",
|
|
30
|
+
// ACP-mode invocation — `opencode acp` is a protocolVersion-1 ACP server, so
|
|
31
|
+
// the runner delegates the whole wire protocol to AcpClientPeer. The model is
|
|
32
|
+
// resolved from opencode's config / the `--model` it was started with; the
|
|
33
|
+
// run provisioning writes the OpenRouter default so ACP turns use GLM-5.2.
|
|
34
|
+
acp: { command: "opencode", args: ["acp"] },
|
|
35
|
+
// Global npm install; symlink onto PATH only if the global bin dir isn't
|
|
36
|
+
// already there (so a non-login `sh -c` can find it).
|
|
37
|
+
install: 'sudo npm install -g opencode-ai && (command -v opencode >/dev/null 2>&1 || sudo ln -sf "$(npm prefix -g)/bin/opencode" /usr/local/bin/opencode)',
|
|
38
|
+
// ── JSONL fallback (only reached if the ACP handshake negotiates a non-1
|
|
39
|
+
// version; opencode is v1, so this is vestigial). `opencode run` prints
|
|
40
|
+
// human-formatted text, so we capture the prompt round-trip as one message.
|
|
41
|
+
promptPayload: (prompt) => prompt,
|
|
42
|
+
buildCommand: ({ promptPath, model, cwd }) =>
|
|
43
|
+
`${cwd ? `cd ${shellQuote(cwd)} && ` : ""}opencode run ${model ? `--model ${shellQuote(model)} ` : ""}"$(cat ${shellQuote(promptPath)})"`,
|
|
44
|
+
extractSessionId: () => undefined,
|
|
45
|
+
mapEvent: (p): AgentMessage[] => {
|
|
46
|
+
const ts = now();
|
|
47
|
+
const text = typeof p.text === "string" ? p.text : typeof p.content === "string" ? p.content : "";
|
|
48
|
+
return text ? [{ type: "text", text, timestamp: ts }] : [];
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export interface OpencodeRuntimeConfig {
|
|
53
|
+
/** OpenRouter-prefixed model id (default `openrouter/z-ai/glm-5.2`). */
|
|
54
|
+
model?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createOpencodeRuntime(config: OpencodeRuntimeConfig = {}) {
|
|
58
|
+
return createCliAgentRuntime(opencodeSpec, config.model ?? opencodeSpec.defaultModel);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export default createOpencodeRuntime();
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The devbox — ADR-0038's "Computer": one persistent E2B **desktop** machine per
|
|
3
|
+
* member, paused by default, resumed on attach (~100ms).
|
|
4
|
+
*
|
|
5
|
+
* This module is the SINGLE SOURCE for the machine's template identity: the
|
|
6
|
+
* recipe lives in `infra/e2b-template/devbox.ts`, `infra/e2b-template/build.ts`
|
|
7
|
+
* bakes it under the alias below, and the server imports the alias from here to
|
|
8
|
+
* provision a machine (exactly how `e2bBaseTemplate` reaches the E2B provider —
|
|
9
|
+
* a stable ALIAS, never a snapshot id, so the same string resolves in whichever
|
|
10
|
+
* E2B account a deployment uses).
|
|
11
|
+
*
|
|
12
|
+
* Interactive only. Workflow RUNS never execute on a devbox (ADR-0038 §2.6):
|
|
13
|
+
* runs boot the clean per-size `agent-compose-base-<size>` / `agent-env-<size>`
|
|
14
|
+
* templates so reproducibility never inherits hand-configured drift.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Stable E2B template ALIAS for the per-member desktop machine. Baked by
|
|
18
|
+
* `infra/e2b-template/build.ts` from `infra/e2b-template/devbox.ts`; CI rebakes
|
|
19
|
+
* it on any master push that touches the recipe (.github/workflows/sandbox-images.yml).
|
|
20
|
+
* Boot it with `@e2b/desktop`'s `Sandbox.create(E2B_DEVBOX_TEMPLATE, …)`. */
|
|
21
|
+
export const E2B_DEVBOX_TEMPLATE = "agent-compose-devbox";
|
|
22
|
+
|
|
23
|
+
/** Machine spec the devbox alias is stamped with. E2B sizing is TEMPLATE-baked
|
|
24
|
+
* (no create-time cpu/mem knob), so this is the machine every member gets.
|
|
25
|
+
*
|
|
26
|
+
* 8 vCPU / 8192 MB matches E2B's stock `desktop` template — the exact shape the
|
|
27
|
+
* ADR-0038 spike measured (pause 456ms / resume 99ms, RAM + desktop + dockerd
|
|
28
|
+
* preserved). 8192 MB is also the E2B account's hard memory ceiling
|
|
29
|
+
* (`Template.build` 400s above it), so this is the largest machine we can bake
|
|
30
|
+
* today; raising it needs a plan change first. */
|
|
31
|
+
export const E2B_DEVBOX_SPEC: { readonly cpuCount: number; readonly memoryMB: number } = {
|
|
32
|
+
cpuCount: 8,
|
|
33
|
+
memoryMB: 8192,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Recipe version of the baked devbox image. The alias is stable across
|
|
37
|
+
* rebuilds (it re-points at the new build), so the alias alone can't tell you
|
|
38
|
+
* WHICH recipe a machine was provisioned from — the server stamps
|
|
39
|
+
* `dev_machines.template` with `e2bDevboxTemplateRef()` for that lineage.
|
|
40
|
+
* BUMP whenever `infra/e2b-template/devbox.ts` changes what it bakes. */
|
|
41
|
+
export const E2B_DEVBOX_RECIPE_VERSION = "1";
|
|
42
|
+
|
|
43
|
+
/** Versioned, human-readable ref for the image a machine was provisioned from
|
|
44
|
+
* (`agent-compose-devbox@1`) — what ADR-0038 §6's `dev_machines.template`
|
|
45
|
+
* stores. NOT a boot target: boot from `E2B_DEVBOX_TEMPLATE`. */
|
|
46
|
+
export function e2bDevboxTemplateRef(): string {
|
|
47
|
+
return `${E2B_DEVBOX_TEMPLATE}@${E2B_DEVBOX_RECIPE_VERSION}`;
|
|
48
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE exec-stream parser — used by the agent sandbox broker clients in
|
|
3
|
+
* runner.ts to consume a streamed command execution.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { SandboxCommandResult } from "../types/sandbox.js";
|
|
7
|
+
|
|
8
|
+
type SseEvent = { type: string; data?: string; exitCode?: number };
|
|
9
|
+
|
|
10
|
+
export interface ParseSseExecStreamOptions {
|
|
11
|
+
onStdout?: (data: string) => void;
|
|
12
|
+
onStderr?: (data: string) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Parse an SSE exec stream from a ReadableStream.
|
|
17
|
+
* Used by the agent sandbox broker in runner.ts.
|
|
18
|
+
*/
|
|
19
|
+
export async function parseSseExecStream(
|
|
20
|
+
body: ReadableStream<Uint8Array>,
|
|
21
|
+
opts?: ParseSseExecStreamOptions,
|
|
22
|
+
): Promise<SandboxCommandResult> {
|
|
23
|
+
let stdout = "", stderr = "", exitCode = 0, exited = false;
|
|
24
|
+
const reader = body.getReader(), decoder = new TextDecoder();
|
|
25
|
+
let buf = "";
|
|
26
|
+
while (true) {
|
|
27
|
+
const { done, value } = await reader.read();
|
|
28
|
+
if (done) break;
|
|
29
|
+
buf += decoder.decode(value, { stream: true });
|
|
30
|
+
const lines = buf.replace(/\r\n/g, "\n").split("\n");
|
|
31
|
+
buf = lines.pop() ?? "";
|
|
32
|
+
for (const line of lines) {
|
|
33
|
+
if (!line.startsWith("data: ")) continue;
|
|
34
|
+
let event: SseEvent;
|
|
35
|
+
try { event = JSON.parse(line.slice(6)) as SseEvent; }
|
|
36
|
+
catch (err) { throw new Error(`Malformed sandbox exec event: ${err instanceof Error ? err.message : String(err)}`); }
|
|
37
|
+
if (event.type === "stdout") { stdout += event.data ?? ""; opts?.onStdout?.(event.data ?? ""); }
|
|
38
|
+
else if (event.type === "stderr") { stderr += event.data ?? ""; opts?.onStderr?.(event.data ?? ""); }
|
|
39
|
+
else if (event.type === "exit") { exitCode = event.exitCode ?? 0; exited = true; }
|
|
40
|
+
else if (event.type === "error") { throw new Error(`Sandbox exec error: ${event.data}`); }
|
|
41
|
+
}
|
|
42
|
+
// Stop reading as soon as the exit event is received — don't wait for stream close.
|
|
43
|
+
// QUIC tunnels can delay the stream-end signal even after all data has arrived.
|
|
44
|
+
if (exited) break;
|
|
45
|
+
}
|
|
46
|
+
if (!exited) throw new Error("Sandbox exec stream ended without an exit event");
|
|
47
|
+
return { exitCode, stdout, stderr };
|
|
48
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandbox egress network policy — ONE shape for every provider, plus the
|
|
3
|
+
* translators into each provider's native enforcement config
|
|
4
|
+
* (`toVercelNetworkPolicy` / `toE2bNetwork`) and the dot-segment path guards.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { SandboxNetworkOpts as E2bNetworkOpts, SandboxNetworkRule as E2bNetworkRule } from "e2b";
|
|
8
|
+
import type { ConnectorRequestRules } from "../types/workflow-metadata.js";
|
|
9
|
+
// Type-only — erased at compile time, so @vercel/sandbox stays out of
|
|
10
|
+
// runner.bundle.js (the provider's value imports are dynamic for the same reason).
|
|
11
|
+
import type { NetworkPolicy as VercelNetworkPolicy, NetworkPolicyRule as VercelNetworkPolicyRule } from "@vercel/sandbox";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Network policy for outbound HTTPS requests — ONE shape for every provider;
|
|
15
|
+
* only the enforcement point differs. When a sandbox makes a request matching
|
|
16
|
+
* a domain in `allow`, the egress layer injects the specified headers before
|
|
17
|
+
* forwarding — credentials never exist inside the VM. Both providers enforce
|
|
18
|
+
* this natively at the platform edge from the resolved policy passed at
|
|
19
|
+
* create: Vercel via its firewall (`requestRules` translate to native `match`
|
|
20
|
+
* rules), E2B via its native network firewall (`toE2bNetwork` →
|
|
21
|
+
* `allowOut`/`denyOut`/`rules`). See `toVercelNetworkPolicy` / `toE2bNetwork`.
|
|
22
|
+
*/
|
|
23
|
+
export interface SandboxNetworkHeaderTransform {
|
|
24
|
+
headers?: Record<string, string>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SandboxNetworkAllowRule {
|
|
28
|
+
transform?: SandboxNetworkHeaderTransform[];
|
|
29
|
+
/** Tier-2 request gate (method/path) — the transform only applies when the
|
|
30
|
+
* request matches. Honoured on Vercel (translated to native `match` rules).
|
|
31
|
+
* NOT honoured on E2B: its native rules carry only a `transform`, no
|
|
32
|
+
* method/path matcher, so a brokered credential rides ALL requests to an
|
|
33
|
+
* allowed host there (see `toE2bNetwork`). Present on connector-auth rules;
|
|
34
|
+
* see `ConnectorRequestRules`. */
|
|
35
|
+
requestRules?: ConnectorRequestRules;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SandboxNetworkSubnetPolicy {
|
|
39
|
+
allow?: string[];
|
|
40
|
+
deny?: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type SandboxNetworkPolicy =
|
|
44
|
+
| "allow-all"
|
|
45
|
+
| "deny-all"
|
|
46
|
+
| {
|
|
47
|
+
allow?: string[] | Record<string, SandboxNetworkAllowRule[]>;
|
|
48
|
+
subnets?: SandboxNetworkSubnetPolicy;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Matches a path containing a dot-dot segment — literal (`/../`) or
|
|
52
|
+
* percent-encoded (`%2e%2e`, `%2f` boundaries). `DOT_SEGMENT_PATH_RE2` is
|
|
53
|
+
* the RE2 form handed to Vercel's matcher: wrapped in `.*` so it behaves
|
|
54
|
+
* identically under search and full-match semantics (RE2 has no lookaround,
|
|
55
|
+
* so the guard is a positive match on the traversal pattern, used as a
|
|
56
|
+
* credential-stripping first rule). `DOT_SEGMENT_RE` is the local JS twin
|
|
57
|
+
* used to validate DECLARED prefixes. */
|
|
58
|
+
export const DOT_SEGMENT_PATH_RE2 = ".*(?:^|/|%2[fF])(?:\\.|%2[eE]){2}(?:/|%2[fF]|$).*";
|
|
59
|
+
const DOT_SEGMENT_RE = /(?:^|\/|%2f)(?:\.|%2e){2}(?:\/|%2f|$)/i;
|
|
60
|
+
|
|
61
|
+
/** A Tier-2 path gate decides where a brokered credential may ride, so a
|
|
62
|
+
* malformed declared prefix is a config bug that must fail closed, not a
|
|
63
|
+
* string we forward verbatim to a third-party matcher. */
|
|
64
|
+
function assertSafePathPrefix(domain: string, prefix: string): void {
|
|
65
|
+
if (!prefix.startsWith("/") || DOT_SEGMENT_RE.test(prefix)) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`Invalid Tier-2 pathPrefix ${JSON.stringify(prefix)} for "${domain}": prefixes must start with "/" and contain no dot-segments`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Translate our policy shape into Vercel's native `NetworkPolicy`.
|
|
74
|
+
*
|
|
75
|
+
* The shapes are identical except for Tier-2: our `requestRules`
|
|
76
|
+
* (`{ methods, pathPrefixes }`) become Vercel `match` rules
|
|
77
|
+
* (`{ method, path: { startsWith } }`) — one rule per path prefix, since a
|
|
78
|
+
* Vercel matcher carries a single path.
|
|
79
|
+
*
|
|
80
|
+
* Dot-segment defense: prefix matching is raw — `/repos/acme/../../user`
|
|
81
|
+
* starts with `/repos/acme/` but the origin normalizes it to `/user`, riding
|
|
82
|
+
* the credential outside its declared scope. We don't get to assume the
|
|
83
|
+
* enforcer RFC-3986-normalizes before matching, so every domain with a path
|
|
84
|
+
* gate gets a transform-free FIRST rule matching dot-segment paths
|
|
85
|
+
* (first-match-wins ⇒ such requests go out credential-free), and declared
|
|
86
|
+
* prefixes themselves are validated (must start with "/", no dot-segments).
|
|
87
|
+
*
|
|
88
|
+
* Accepted divergence from iron-proxy (E2B): off-gate requests to an allowed
|
|
89
|
+
* domain pass WITHOUT the transform (Vercel terminates TLS only for
|
|
90
|
+
* transform-bearing domains and applies first-match-wins) — iron-proxy
|
|
91
|
+
* REFUSES them instead. Reachability differs; the property that matters is
|
|
92
|
+
* preserved on both substrates: the brokered credential rides only declared
|
|
93
|
+
* method/path combinations. Pinned by vercel-network-policy.test.ts.
|
|
94
|
+
*/
|
|
95
|
+
export function toVercelNetworkPolicy(policy: SandboxNetworkPolicy): VercelNetworkPolicy {
|
|
96
|
+
if (typeof policy === "string" || !policy.allow || Array.isArray(policy.allow)) {
|
|
97
|
+
return policy as VercelNetworkPolicy;
|
|
98
|
+
}
|
|
99
|
+
const allow: Record<string, VercelNetworkPolicyRule[]> = {};
|
|
100
|
+
for (const [domain, rules] of Object.entries(policy.allow)) {
|
|
101
|
+
const translated = rules.flatMap((rule): VercelNetworkPolicyRule[] => {
|
|
102
|
+
const { requestRules, ...rest } = rule;
|
|
103
|
+
const methods = requestRules?.methods ?? [];
|
|
104
|
+
const prefixes = requestRules?.pathPrefixes ?? [];
|
|
105
|
+
if (methods.length === 0 && prefixes.length === 0) return [rest];
|
|
106
|
+
const methodMatch = methods.length > 0 ? { method: methods } : {};
|
|
107
|
+
if (prefixes.length === 0) return [{ ...rest, match: methodMatch }];
|
|
108
|
+
for (const prefix of prefixes) assertSafePathPrefix(domain, prefix);
|
|
109
|
+
return prefixes.map((prefix) => ({ ...rest, match: { ...methodMatch, path: { startsWith: prefix } } }));
|
|
110
|
+
});
|
|
111
|
+
const hasPathGate = rules.some((rule) => (rule.requestRules?.pathPrefixes?.length ?? 0) > 0);
|
|
112
|
+
allow[domain] = hasPathGate
|
|
113
|
+
? [{ match: { path: { regex: DOT_SEGMENT_PATH_RE2 } } }, ...translated]
|
|
114
|
+
: translated;
|
|
115
|
+
}
|
|
116
|
+
return { ...policy, allow };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Translate our policy shape into E2B's native `network` config
|
|
121
|
+
* (`SandboxNetworkOpts`) — the E2B analogue of `toVercelNetworkPolicy`.
|
|
122
|
+
*
|
|
123
|
+
* - `allowOut`: the set of allowed egress targets — every host named in
|
|
124
|
+
* `allow` PLUS every CIDR in `subnets.allow`. The Archil raw-TCP
|
|
125
|
+
* data-plane CIDRs ride here as FIRST-CLASS allow entries (E2B has no
|
|
126
|
+
* root-exemption to lean on, unlike the old in-VM iptables model).
|
|
127
|
+
* - `denyOut`: the all-traffic sentinel (`0.0.0.0/0`) so the policy is
|
|
128
|
+
* DEFAULT-DENY — only `allowOut` targets pass. E2B applies allow before
|
|
129
|
+
* deny, so a host in both lists is allowed.
|
|
130
|
+
* - `rules`: per-host header injection. For each host carrying transform(s),
|
|
131
|
+
* emit one rule per transform with `{ transform: { headers } }`. A host
|
|
132
|
+
* with a rule MUST also be in `allowOut` (registering a rule does not
|
|
133
|
+
* grant egress on its own — E2B's API requires the host in `allowOut`).
|
|
134
|
+
*
|
|
135
|
+
* BEHAVIOURAL DIVERGENCE FROM iron-proxy / Vercel — path/method gating is NOT
|
|
136
|
+
* available on E2B native rules. An `SandboxNetworkRule` carries only a
|
|
137
|
+
* `transform` (header injection); there is no method/path matcher. So our
|
|
138
|
+
* Tier-2 `requestRules` (`{ methods, pathPrefixes }`) cannot be enforced here:
|
|
139
|
+
* a brokered credential rides EVERY request to an allowed host on E2B, whereas
|
|
140
|
+
* Vercel (native `match`) and the old iron-proxy gated it to the declared
|
|
141
|
+
* method+path. The host allowlist still confines WHICH hosts the credential
|
|
142
|
+
* can reach; it just can't narrow to a method/path within an allowed host.
|
|
143
|
+
*
|
|
144
|
+
* String policies map straight through: "allow-all" → no restriction (omit
|
|
145
|
+
* `allowOut`/`denyOut`), "deny-all" → block all egress.
|
|
146
|
+
*/
|
|
147
|
+
export function toE2bNetwork(policy: SandboxNetworkPolicy): E2bNetworkOpts {
|
|
148
|
+
if (policy === "allow-all") return {};
|
|
149
|
+
if (policy === "deny-all") return { denyOut: ({ allTraffic }) => [allTraffic] };
|
|
150
|
+
|
|
151
|
+
// List-form allow (no transforms): allowlist the hosts, default-deny the rest.
|
|
152
|
+
const allowHosts: string[] = Array.isArray(policy.allow)
|
|
153
|
+
? [...policy.allow]
|
|
154
|
+
: Object.keys(policy.allow ?? {});
|
|
155
|
+
const subnetAllow = policy.subnets?.allow ?? [];
|
|
156
|
+
const allowOut = [...new Set([...allowHosts, ...subnetAllow])];
|
|
157
|
+
|
|
158
|
+
const network: E2bNetworkOpts = {
|
|
159
|
+
// Default-deny: only `allowOut` passes (E2B applies allow before deny).
|
|
160
|
+
denyOut: ({ allTraffic }) => [allTraffic],
|
|
161
|
+
};
|
|
162
|
+
if (allowOut.length > 0) network.allowOut = allowOut;
|
|
163
|
+
|
|
164
|
+
// Per-host header injection. Each of our `transform` entries (a
|
|
165
|
+
// `{ headers }` bag) becomes one E2B rule. `requestRules` is intentionally
|
|
166
|
+
// dropped — see the divergence note above.
|
|
167
|
+
if (!Array.isArray(policy.allow) && policy.allow) {
|
|
168
|
+
const rules: Record<string, E2bNetworkRule[]> = {};
|
|
169
|
+
for (const [host, hostRules] of Object.entries(policy.allow)) {
|
|
170
|
+
const hostTransforms = hostRules.flatMap((rule) =>
|
|
171
|
+
(rule.transform ?? [])
|
|
172
|
+
.filter((t) => t.headers && Object.keys(t.headers).length > 0)
|
|
173
|
+
.map((t): E2bNetworkRule => ({ transform: { headers: t.headers } })),
|
|
174
|
+
);
|
|
175
|
+
if (hostTransforms.length > 0) rules[host] = hostTransforms;
|
|
176
|
+
}
|
|
177
|
+
if (Object.keys(rules).length > 0) network.rules = rules;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return network;
|
|
181
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The contract a sandbox provider implements (`SandboxProviderDef`), the
|
|
3
|
+
* create-time options every provider receives, and the fleet tag that scopes
|
|
4
|
+
* a deployment's sandboxes on the provider side. Providers implement this;
|
|
5
|
+
* the registry consumes it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { SandboxProvider } from "../types/sandbox.js";
|
|
9
|
+
import type { SandboxNetworkPolicy } from "./network-policy.js";
|
|
10
|
+
import type { SandboxSize } from "./sizes.js";
|
|
11
|
+
|
|
12
|
+
// Fleet-wide tag, NOT machine-scoped. Previously we embedded FLY_MACHINE_ID
|
|
13
|
+
// so each replica scoped its own E2B sandboxes at boot, but that made
|
|
14
|
+
// cross-replica orphan reconciliation impossible: when a replica hard-died,
|
|
15
|
+
// its E2B sandboxes became invisible to every surviving replica and stayed
|
|
16
|
+
// running until E2B's own lifetime cap. With a fleet-wide tag the provider
|
|
17
|
+
// reconciler (`listOwned` + DB cross-ref) handles cleanup correctly across
|
|
18
|
+
// all replicas. Boot-time `killAllSandboxes` was deliberately removed when
|
|
19
|
+
// this changed — killing at boot with a shared tag would nuke live sandboxes
|
|
20
|
+
// owned by other replicas.
|
|
21
|
+
export const AGENT_COMPOSE_TAG = process.env.AGENT_COMPOSE_TAG ??
|
|
22
|
+
`agent-compose-${process.env.AGENT_COMPOSE_ENV ?? "dev"}`;
|
|
23
|
+
|
|
24
|
+
export interface SandboxCreateOpts {
|
|
25
|
+
envs: Record<string, string>;
|
|
26
|
+
metadata: Record<string, string>;
|
|
27
|
+
timeoutMs: number;
|
|
28
|
+
/** Provider-specific template/snapshot identifier. E2B: template id or
|
|
29
|
+
* snapshot id (omit → E2B's default base). Vercel: snapshot id (omit → node24). */
|
|
30
|
+
template?: string;
|
|
31
|
+
/** Machine size. Vercel maps it to `resources.vcpus`; E2B ignores it
|
|
32
|
+
* (size is template-defined). Omit → `DEFAULT_SANDBOX_SIZE`. */
|
|
33
|
+
size?: SandboxSize;
|
|
34
|
+
/** Outbound request policy with header transforms — ONE shape for every
|
|
35
|
+
* provider; only the enforcement point differs. Both providers enforce +
|
|
36
|
+
* inject natively at create from this value: Vercel via its firewall
|
|
37
|
+
* (`toVercelNetworkPolicy`), E2B via its native network firewall
|
|
38
|
+
* (`toE2bNetwork` → `network: { allowOut, denyOut, rules }`). */
|
|
39
|
+
networkPolicy?: SandboxNetworkPolicy;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* What the provider reports as "currently alive" — the single input to orphan
|
|
44
|
+
* reconciliation. `metadata` is best-effort: E2B populates it from sandbox
|
|
45
|
+
* labels, Vercel from sandbox tags (max 5, stamped at create). Callers
|
|
46
|
+
* that need runId correlation cross-reference `sandboxId` against their own
|
|
47
|
+
* state (server: `workflow_runs.sandbox_id` + `run_agent_sandboxes.provider_sandbox_id`).
|
|
48
|
+
*/
|
|
49
|
+
export interface OwnedSandbox {
|
|
50
|
+
sandboxId: string;
|
|
51
|
+
createdAt: Date;
|
|
52
|
+
metadata: Record<string, string>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface SandboxProviderDef {
|
|
56
|
+
requiredEnv: Record<string, string>;
|
|
57
|
+
create: (opts: SandboxCreateOpts, env: Record<string, string>) => Promise<SandboxProvider>;
|
|
58
|
+
reconnect?: (sandboxId: string) => Promise<SandboxProvider>;
|
|
59
|
+
killAll?: (env: Record<string, string>) => Promise<void>;
|
|
60
|
+
/**
|
|
61
|
+
* Returns the number of sandboxes currently running against this provider.
|
|
62
|
+
* Used for quota observability — account-wide, not per-instance.
|
|
63
|
+
*
|
|
64
|
+
* Both providers scope by our AGENT_COMPOSE_TAG (E2B metadata labels,
|
|
65
|
+
* Vercel tags) so each fleet only reports its own sandboxes; DD aggregates
|
|
66
|
+
* across instances with `sum by {provider}`.
|
|
67
|
+
*/
|
|
68
|
+
getActiveCount?: (env: Record<string, string>) => Promise<number>;
|
|
69
|
+
/**
|
|
70
|
+
* Provider's view of what's currently alive for our account/fleet.
|
|
71
|
+
* The reconciliation SoT — a sandbox missing from this list IS dead,
|
|
72
|
+
* regardless of what our DB says. Implemented by listing the provider's
|
|
73
|
+
* running sandboxes; E2B filters by metadata tag, Vercel by the `executor`
|
|
74
|
+
* sandbox tag.
|
|
75
|
+
*/
|
|
76
|
+
listOwned?: (env: Record<string, string>) => Promise<OwnedSandbox[]>;
|
|
77
|
+
/**
|
|
78
|
+
* Delete a snapshot by id. Called from the customer-initiated
|
|
79
|
+
* `DELETE /workflows/:runId/snapshot` route. Best-effort — the DB is
|
|
80
|
+
* source of truth; an orphan on the provider side is benign and cleaned
|
|
81
|
+
* up eventually by the provider's own retention.
|
|
82
|
+
*/
|
|
83
|
+
deleteSnapshot?: (snapshotId: string, env: Record<string, string>) => Promise<void>;
|
|
84
|
+
/**
|
|
85
|
+
* Does this snapshot id resolve on the provider for this account? A cheap
|
|
86
|
+
* metadata lookup — NEVER provisions a sandbox. Used at server boot to
|
|
87
|
+
* validate that the platform base snapshots the default templates boot from
|
|
88
|
+
* actually exist for this deployment (catching e.g. a dev-account snapshot
|
|
89
|
+
* baked into prod). Returns true if it resolves, false if the provider
|
|
90
|
+
* reports it does not exist. Transport/credential errors PROPAGATE — an
|
|
91
|
+
* indeterminate result must not be reported as "missing".
|
|
92
|
+
*/
|
|
93
|
+
snapshotExists?: (snapshotId: string, env: Record<string, string>) => Promise<boolean>;
|
|
94
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2B Desktop sandbox provider — the base E2B wrapper plus mouse/keyboard/
|
|
3
|
+
* screenshot control, and the "e2b-desktop" registry entry. Registry-only:
|
|
4
|
+
* never a workflow's runtime (see `workflow-metadata.ts`).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Sandbox as Desktop } from "@e2b/desktop";
|
|
8
|
+
import type { DesktopSandboxProvider } from "../../types/sandbox.js";
|
|
9
|
+
import type { SandboxProviderDef } from "../provider-def.js";
|
|
10
|
+
import { makeSandboxProvider, e2bMaxSandboxMs } from "./e2b.js";
|
|
11
|
+
|
|
12
|
+
export function makeDesktopSandboxProvider(sb: Desktop): DesktopSandboxProvider {
|
|
13
|
+
return {
|
|
14
|
+
...makeSandboxProvider(sb),
|
|
15
|
+
screenshot: () => sb.screenshot() as Promise<Buffer>,
|
|
16
|
+
leftClick: (x, y) => sb.leftClick(x, y),
|
|
17
|
+
doubleClick: (x, y) => sb.doubleClick(x, y),
|
|
18
|
+
rightClick: (x, y) => sb.rightClick(x, y),
|
|
19
|
+
middleClick: (x, y) => sb.middleClick(x, y),
|
|
20
|
+
moveMouse: (x, y) => sb.moveMouse(x, y),
|
|
21
|
+
write: (text) => sb.write(text),
|
|
22
|
+
press: (key) => sb.press(key),
|
|
23
|
+
scroll: (dir, ticks) => sb.scroll(dir, ticks),
|
|
24
|
+
drag: (from, to) => sb.drag(from, to),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const e2bDesktopProviderDef: SandboxProviderDef = {
|
|
29
|
+
requiredEnv: { E2B_API_KEY: "E2B API key — e2b.dev/dashboard" },
|
|
30
|
+
// `size` dropped here: E2B has no create-time resource knob (specs are
|
|
31
|
+
// baked into the template/snapshot), so sizing on E2B = a pre-sized
|
|
32
|
+
// template, not this field. Honoured only on Vercel.
|
|
33
|
+
//
|
|
34
|
+
// NO native firewall on this path: `@e2b/desktop` bundles its own (older)
|
|
35
|
+
// `e2b` whose `SandboxOpts` predates the `network` field, so there is no
|
|
36
|
+
// way to enforce an egress policy natively here — unlike the "e2b" and
|
|
37
|
+
// "vercel" providers. Rather than SILENTLY dropping a policy (fail-open —
|
|
38
|
+
// the exact leak the native-firewall migration closed), we refuse to create
|
|
39
|
+
// a desktop sandbox under any non-empty policy. In practice this never
|
|
40
|
+
// fires: e2b-desktop is registry-only, never a workflow's runtime (see
|
|
41
|
+
// `workflow-metadata.ts`), so it is only ever provisioned with no policy.
|
|
42
|
+
create: async ({ template, timeoutMs, networkPolicy, size: _size, ...rest }) => {
|
|
43
|
+
if (!template) throw new Error("E2B Desktop provider requires an explicit `template` (Dockerfile-based — no default base image)");
|
|
44
|
+
if (networkPolicy && networkPolicy !== "allow-all") {
|
|
45
|
+
throw new Error(
|
|
46
|
+
"E2B Desktop provider cannot enforce an egress network policy — `@e2b/desktop` bundles an e2b version that predates the native firewall. " +
|
|
47
|
+
"e2b-desktop is registry-only and must not run with a confined policy.",
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return makeDesktopSandboxProvider(
|
|
51
|
+
await Desktop.create(template, {
|
|
52
|
+
...rest,
|
|
53
|
+
timeoutMs: Math.min(timeoutMs, e2bMaxSandboxMs()),
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
},
|
|
57
|
+
};
|