@agent-compose/sdk 0.8.4 → 0.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +213 -189
  2. package/dist/agent/agent-context.d.ts +9 -1
  3. package/dist/agent/agent-loop.d.ts +14 -6
  4. package/dist/agent/perf-sampler.d.ts +27 -2
  5. package/dist/agent/run-agent.d.ts +1 -1
  6. package/dist/client.d.ts +250 -59
  7. package/dist/directives.d.ts +14 -0
  8. package/dist/display.d.ts +7 -0
  9. package/dist/errors.d.ts +1 -1
  10. package/dist/generated/agentc-commands.d.ts +34 -0
  11. package/dist/index.d.ts +13 -11
  12. package/dist/index.js +1692 -194
  13. package/dist/request-context/request-context.d.ts +1 -1
  14. package/dist/runtimes/_cli-agent.d.ts +278 -58
  15. package/dist/runtimes/claude-code.d.ts +90 -1
  16. package/dist/runtimes/claude.d.ts +1 -1
  17. package/dist/runtimes/codex.d.ts +94 -6
  18. package/dist/runtimes/codex.mid-turn-hook.test.d.ts +10 -0
  19. package/dist/runtimes/openai-desktop.d.ts +50 -0
  20. package/dist/runtimes/openai-desktop.js +1689 -211
  21. package/dist/runtimes/openai-desktop.test.d.ts +20 -0
  22. package/dist/runtimes/opencode.d.ts +48 -11
  23. package/dist/runtimes/opencode.test.d.ts +14 -0
  24. package/dist/runtimes/tool-pulse.test.d.ts +17 -0
  25. package/dist/sandbox/baked-clis.d.ts +75 -0
  26. package/dist/sandbox/devbox.d.ts +5 -5
  27. package/dist/sandbox/exec-stream.d.ts +1 -2
  28. package/dist/sandbox/network-policy.d.ts +23 -5
  29. package/dist/sandbox/registry.d.ts +12 -0
  30. package/dist/sandbox/sizes.d.ts +11 -5
  31. package/dist/sandbox.d.ts +5 -3
  32. package/dist/step-invocation/protocol.d.ts +3 -4
  33. package/dist/step-invocation/server.d.ts +2 -2
  34. package/dist/step-invocation/types.d.ts +2 -2
  35. package/dist/types/api-conversations.d.ts +513 -27
  36. package/dist/types/api-factory.d.ts +183 -3
  37. package/dist/types/api-projects.d.ts +480 -0
  38. package/dist/types/api-runs.d.ts +8 -0
  39. package/dist/types/api-scopes.d.ts +32 -3
  40. package/dist/types/conversation-stream.d.ts +27 -1
  41. package/dist/types/execution-context.d.ts +1 -1
  42. package/dist/types/protocol.d.ts +182 -2
  43. package/dist/types/runtime.d.ts +80 -2
  44. package/dist/types/workflow-metadata.d.ts +2 -4
  45. package/dist/types/workflow-plan.d.ts +1 -3
  46. package/dist/utils/bundler.d.ts +23 -0
  47. package/dist/workflow-steps/observability.d.ts +2 -3
  48. package/dist/workflow-steps/runner.d.ts +5 -8
  49. package/dist/workflow-steps/types.d.ts +8 -10
  50. package/dist/workflow-steps/workflow.d.ts +2 -1
  51. package/dist/workflows/engine.d.ts +3 -5
  52. package/dist/workflows/invoke-child.d.ts +2 -2
  53. package/package.json +2 -2
  54. package/src/agent/agent-context.ts +193 -116
  55. package/src/agent/agent-loop.ts +16 -9
  56. package/src/agent/desktop-open.ts +13 -1
  57. package/src/agent/perf-sampler.ts +54 -3
  58. package/src/agent/run-agent.ts +1 -1
  59. package/src/client.ts +418 -80
  60. package/src/directives.ts +21 -1
  61. package/src/display.ts +12 -0
  62. package/src/errors.ts +1 -0
  63. package/src/generated/agentc-commands.ts +571 -0
  64. package/src/index.ts +65 -18
  65. package/src/pause/pause-core.ts +2 -1
  66. package/src/request-context/request-context.ts +1 -1
  67. package/src/runtimes/_cli-agent.ts +607 -132
  68. package/src/runtimes/claude-code.ts +427 -20
  69. package/src/runtimes/claude.ts +1 -1
  70. package/src/runtimes/codex.ts +188 -19
  71. package/src/runtimes/openai-desktop.ts +82 -19
  72. package/src/runtimes/opencode.ts +195 -26
  73. package/src/sandbox/baked-clis.ts +86 -0
  74. package/src/sandbox/devbox.ts +5 -5
  75. package/src/sandbox/exec-stream.ts +1 -2
  76. package/src/sandbox/network-policy.ts +51 -7
  77. package/src/sandbox/providers/e2b.ts +63 -19
  78. package/src/sandbox/providers/vercel.ts +6 -6
  79. package/src/sandbox/registry.ts +19 -1
  80. package/src/sandbox/sizes.ts +11 -5
  81. package/src/sandbox.ts +9 -2
  82. package/src/step-invocation/invoker.ts +2 -6
  83. package/src/step-invocation/protocol.ts +3 -4
  84. package/src/step-invocation/server.ts +2 -2
  85. package/src/types/api-conversations.ts +424 -29
  86. package/src/types/api-factory.ts +189 -3
  87. package/src/types/api-projects.ts +443 -0
  88. package/src/types/api-runs.ts +5 -0
  89. package/src/types/api-scopes.ts +32 -3
  90. package/src/types/conversation-stream.ts +29 -1
  91. package/src/types/execution-context.ts +1 -1
  92. package/src/types/protocol.ts +180 -2
  93. package/src/types/runtime.ts +71 -2
  94. package/src/types/sandbox-environment.ts +1 -2
  95. package/src/types/workflow-metadata.ts +2 -4
  96. package/src/types/workflow-plan.ts +1 -3
  97. package/src/utils/bundler.ts +88 -19
  98. package/src/workflow-steps/observability.ts +2 -3
  99. package/src/workflow-steps/runner.ts +5 -8
  100. package/src/workflow-steps/types.ts +8 -10
  101. package/src/workflow-steps/workflow.ts +2 -1
  102. package/src/workflows/engine.ts +3 -5
  103. package/src/workflows/invoke-child.ts +2 -2
  104. package/dist/pause/__tests__/errors.test.d.ts +0 -1
  105. package/dist/pause/__tests__/wrappers.test.d.ts +0 -1
  106. package/dist/step-invocation/__tests__/protocol.test.d.ts +0 -1
@@ -1,25 +1,143 @@
1
1
  /**
2
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
3
  *
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.
4
+ * Two transports share this spec:
5
+ * - the SDK runner (createCliAgentRuntime) speaks ACP: `opencode acp` is a
6
+ * protocolVersion-1 ACP server (verified live on E2B, 2026-06-30) on a
7
+ * provider with a duplex-stdin command primitive, and the JSONL members
8
+ * below are its version-mismatch fallback;
9
+ * - the platform's cloud lane (server turn-workflow/tailer.ts) never speaks
10
+ * ACP: it launches `buildCommand` detached, tails the CLI's stdout file
11
+ * line by line, JSON-parses every line and maps it through `mapEvent`.
12
+ * The dispatch constitution's `fast` lane (google/gemini-3.7-flash on
13
+ * opencode) runs there, so the JSONL members are the LIVE path for it.
11
14
  *
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.
15
+ * The 2026-10-02 fast-lane failure (prod: 5 of 5 opencode turns settled
16
+ * `harness_no_output` over ten days) — `opencode run` without `--format json`
17
+ * prints the reply as human-formatted prose and its tool narration on stderr,
18
+ * so the tailer parsed zero JSON lines and the turn settled empty while the
19
+ * work itself had completed (reproduced locally, 2026-10-03). `--format json`
20
+ * makes `run` print one JSON event per line — `{type, timestamp, sessionID,
21
+ * part}` for step_start / step_finish / text / reasoning / tool_use, and
22
+ * `{type: "error", timestamp, sessionID, error}` for a session error
23
+ * (packages/opencode/src/cli/cmd/run.ts, v1.18.34: text and reasoning parts
24
+ * fire once, when the part's `time.end` lands; a tool part fires once, at
25
+ * `state.status` completed or error). Captured real streams:
26
+ * `__fixtures__/jsonl/opencode`.
27
+ *
28
+ * Auth + model: the platform routes opencode through its gateway as the
29
+ * custom provider `gw` — server session-runtime-config.ts writes
30
+ * ~/.config/opencode/opencode.json with the gateway base URL and
31
+ * `{env:OPENROUTER_API_KEY}`, and the model flag is `gw/openrouter/<provider>/
32
+ * <model>`. Stand-alone SDK use sets OPENROUTER_API_KEY (a factory/workflow
33
+ * secret) and an `openrouter/<provider>/<model>` id.
34
+ *
35
+ * Resume (owner, 2026-10-03: "resume like other workers"): every event
36
+ * carries the CLI's `sessionID`; `extractSessionId` reads it, the tailer
37
+ * records it, and a follow-up turn on the same machine launches `run
38
+ * --session <id>` with only the new messages (renderCloudPrompt). The store
39
+ * is SQLite under the guest's ~/.local/share/opencode, so a resume target
40
+ * can be gone (a stale binding, a wiped store): `run --session` then exits 1
41
+ * with `Error: Session not found` on stderr and nothing on stdout — the
42
+ * classifier's `resume_target_missing` (turn-failure-class.ts), which the
43
+ * cloud turn workflow heals by dropping the binding and relaunching the turn
44
+ * fresh. Verified live on 1.18.34 (fixtures `resume-turn-1/2.jsonl`,
45
+ * `session-not-found.stderr`).
16
46
  */
17
47
 
18
48
  import type { AgentMessage } from "../index.js";
49
+ import type { AgentMessagePlan } from "../types/protocol.js";
50
+ import { OPENCODE_CLI_VERSION } from "../sandbox/baked-clis.js";
51
+ import { formatError } from "../utils/errors.js";
19
52
  import { createCliAgentRuntime, shellQuote, type CliAgentSpec } from "./_cli-agent.js";
20
53
 
21
54
  function now(): string { return new Date().toISOString(); }
22
55
 
56
+ const asRecord = (v: unknown): Record<string, unknown> | null =>
57
+ typeof v === "object" && v !== null && !Array.isArray(v) ? v as Record<string, unknown> : null;
58
+
59
+ const asCount = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0);
60
+
61
+ /** The text of an opencode session error — `{name, data: {message, statusCode?}}`
62
+ * (ProviderAuthError / APIError / UnknownError / … in the opencode SDK
63
+ * types): name and HTTP status first so the settle's classifier reads the
64
+ * cause, then the provider's own words. Captured shapes: a 401 from the
65
+ * gateway is `APIError` with statusCode 401; an unknown model id is
66
+ * `UnknownError` with the gateway's "Unexpected server error". */
67
+ export function opencodeErrorText(error: unknown): string {
68
+ const e = asRecord(error);
69
+ if (!e) return formatError(error);
70
+ const data = asRecord(e.data);
71
+ const name = typeof e.name === "string" && e.name.trim().length > 0 ? e.name.trim() : "";
72
+ const message = typeof data?.message === "string" ? data.message.trim() : "";
73
+ const status = typeof data?.statusCode === "number" ? ` (HTTP ${data.statusCode})` : "";
74
+ if (message) return name ? `${name}${status}: ${message}` : `${message}${status}`;
75
+ if (name) return `${name}${status}`;
76
+ return formatError(error);
77
+ }
78
+
79
+ /** opencode's `todowrite` input (`{todos: [{content, status, priority}]}`),
80
+ * as the platform's whole-plan message — the codex `todo_list` idiom. A
81
+ * cancelled todo is off the plan; the ACP plan has no such state. */
82
+ function opencodePlanMessages(input: Record<string, unknown>, timestamp: string): AgentMessage[] {
83
+ const todos = Array.isArray(input.todos) ? input.todos : [];
84
+ const entries: AgentMessagePlan["entries"] = [];
85
+ for (const todo of todos) {
86
+ const t = asRecord(todo);
87
+ if (!t || typeof t.content !== "string" || t.content.trim().length === 0) continue;
88
+ const status = t.status === "completed" || t.status === "in_progress" || t.status === "pending" ? t.status : null;
89
+ if (status === null) continue;
90
+ const priority = t.priority === "high" || t.priority === "medium" || t.priority === "low" ? t.priority : undefined;
91
+ entries.push({ content: t.content.trim(), status, ...(priority ? { priority } : {}) });
92
+ }
93
+ return entries.length > 0 ? [{ type: "plan", entries, timestamp }] : [];
94
+ }
95
+
96
+ /** One `tool_use` event: the part's `state` is the whole call — input, and
97
+ * the output (completed) or the error text (error). Both land in one
98
+ * event, so the tool_use/tool_result pair is emitted together; a pending or
99
+ * running state (not emitted by 1.18.34's `run`, kept for a later CLI) is
100
+ * skipped so the pair is never duplicated. */
101
+ function opencodeToolMessages(part: Record<string, unknown>, timestamp: string): AgentMessage[] {
102
+ const state = asRecord(part.state);
103
+ if (!state || (state.status !== "completed" && state.status !== "error")) return [];
104
+ const tool = typeof part.tool === "string" && part.tool.length > 0 ? part.tool : "tool";
105
+ const id = typeof part.callID === "string" && part.callID.length > 0 ? part.callID : String(part.id ?? "");
106
+ const input = asRecord(state.input) ?? {};
107
+ if (tool === "todowrite") return opencodePlanMessages(input, timestamp);
108
+ const result: AgentMessage = state.status === "error"
109
+ ? { type: "tool_result", toolUseId: id, output: typeof state.error === "string" ? state.error : "tool error", isError: true, timestamp }
110
+ : { type: "tool_result", toolUseId: id, output: typeof state.output === "string" ? state.output : "", isError: false, timestamp };
111
+ return [{ type: "tool_use", toolName: tool, toolInput: input, toolUseId: id, timestamp }, result];
112
+ }
113
+
114
+ /** One `step_finish` event: opencode reports tokens per MODEL CALL
115
+ * (`tokens: {input, output, reasoning, cache: {read, write}}`; `input`
116
+ * excludes the cached prefix, `reasoning` is separate from `output`) and
117
+ * never a turn total, so each step rides the live counter as one call —
118
+ * call_start with the input-side classes, call_delta with the call's
119
+ * output (reasoning folded in, the platform's "inside outputTokens"
120
+ * convention). The counter's running totals are the turn's durable stamp. */
121
+ function opencodeStepUsage(part: Record<string, unknown>, timestamp: string): AgentMessage[] {
122
+ const tokens = asRecord(part.tokens);
123
+ if (!tokens) return [];
124
+ const cache = asRecord(tokens.cache);
125
+ return [
126
+ {
127
+ type: "usage_delta", boundary: "call_start",
128
+ inputTokens: asCount(tokens.input), outputTokens: 0,
129
+ cacheReadTokens: asCount(cache?.read), cacheCreationTokens: asCount(cache?.write),
130
+ timestamp,
131
+ },
132
+ {
133
+ type: "usage_delta", boundary: "call_delta",
134
+ inputTokens: 0, outputTokens: asCount(tokens.output) + asCount(tokens.reasoning),
135
+ cacheReadTokens: 0, cacheCreationTokens: 0,
136
+ timestamp,
137
+ },
138
+ ];
139
+ }
140
+
23
141
  export const opencodeSpec: CliAgentSpec = {
24
142
  kind: "opencode",
25
143
  // OpenRouter is the gateway: opencode reads OPENROUTER_API_KEY from the env
@@ -27,25 +145,76 @@ export const opencodeSpec: CliAgentSpec = {
27
145
  authEnv: "OPENROUTER_API_KEY",
28
146
  bin: "opencode",
29
147
  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.
148
+ // ACP-mode invocation (the SDK runner's path — see the header).
34
149
  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.
150
+ // One live writer per session: the store is one SQLite database, and a
151
+ // superseded turn's process left alive beside its successor's `--session`
152
+ // resume would interleave two writers in one thread (codex's flock makes
153
+ // the same rule explicit). A supersede kills the predecessor first
154
+ // (server runner-kill.ts teardownDisposition).
155
+ exclusiveSessionWriter: true,
156
+ // Pinned to the SDK's OPENCODE_CLI_VERSION: the version the image bakes
157
+ // (infra/e2b-template parts.ts OPENCODE_INSTALL) and the one every flag
158
+ // and event shape in this file was verified against. Global npm install;
159
+ // symlink onto PATH only if the global bin dir isn't already there (so a
160
+ // non-login `sh -c` can find it).
161
+ install: `sudo npm install -g opencode-ai@${OPENCODE_CLI_VERSION} && (command -v opencode >/dev/null 2>&1 || sudo ln -sf "$(npm prefix -g)/bin/opencode" /usr/local/bin/opencode)`,
41
162
  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,
163
+ // One turn: `opencode run --format json --auto --thinking [--model m] < prompt`.
164
+ // --format json one JSON event per stdout line (the header's whole point).
165
+ // --auto approve the permission asks no rule denies. `run` is
166
+ // non-interactive: without it every ask — opencode's
167
+ // defaults ask for a path outside the project directory
168
+ // and for a tool repeating itself — is auto-REJECTED and
169
+ // the model works on blind. The platform's sandbox is the
170
+ // isolation boundary, the same reasoning as codex's
171
+ // --dangerously-bypass-approvals-and-sandbox; explicit
172
+ // `deny` rules still hold.
173
+ // --thinking emit reasoning parts (the json format emits them only
174
+ // under this flag); they arrive whole, as thinking blocks.
175
+ // --session id continue the recorded session (a follow-up turn on the
176
+ // same machine); absent on a fresh thread.
177
+ // < prompt the message on stdin — `run` reads piped stdin as the
178
+ // message when no positional text is given (verified
179
+ // 1.18.34, with and without --session), the idiom codex
180
+ // uses. A `"$(cat prompt)"` argument would meet Linux's
181
+ // 128 KiB single-argument limit on a long rendered prompt.
182
+ buildCommand: ({ promptPath, sessionId, model, cwd }) => {
183
+ const cd = cwd ? `cd ${shellQuote(cwd)} && ` : "";
184
+ const flags = [
185
+ "--format json", "--auto", "--thinking",
186
+ ...(sessionId ? [`--session ${shellQuote(sessionId)}`] : []),
187
+ ...(model ? [`--model ${shellQuote(model)}`] : []),
188
+ ].join(" ");
189
+ return `${cd}opencode run ${flags} < ${shellQuote(promptPath)}`;
190
+ },
191
+ // The resume identity: every `run --format json` event carries the CLI's
192
+ // `sessionID` (the same id on every line of a turn, and on every line of
193
+ // a `--session` continuation of it).
194
+ extractSessionId: (p) =>
195
+ typeof p.sessionID === "string" && p.sessionID.length > 0 ? p.sessionID : undefined,
45
196
  mapEvent: (p): AgentMessage[] => {
46
197
  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 }] : [];
198
+ const part = asRecord(p.part);
199
+ switch (p.type) {
200
+ case "text": {
201
+ const text = typeof part?.text === "string" ? part.text : "";
202
+ return text.trim().length > 0 ? [{ type: "text", text, timestamp: ts }] : [];
203
+ }
204
+ case "reasoning": {
205
+ const text = typeof part?.text === "string" ? part.text : "";
206
+ return text.trim().length > 0 ? [{ type: "thinking", text, timestamp: ts }] : [];
207
+ }
208
+ case "tool_use":
209
+ return part ? opencodeToolMessages(part, ts) : [];
210
+ case "step_finish":
211
+ return part ? opencodeStepUsage(part, ts) : [];
212
+ case "error":
213
+ return [{ type: "error", text: opencodeErrorText(p.error), timestamp: ts }];
214
+ default:
215
+ // step_start, and whatever a later opencode adds: lifecycle, no content.
216
+ return [];
217
+ }
49
218
  },
50
219
  };
51
220
 
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Harness CLIs the E2B session image bakes, pinned: the SINGLE SOURCE for
3
+ * each version. `infra/e2b-template/parts.ts` installs exactly this version
4
+ * into every `agent-env-<size>`, and the runtime spec's on-demand install
5
+ * fetches the same one on a machine whose image predates the bake. It lives
6
+ * under `sdk/src/sandbox/` so a bump rebuilds the images: sandbox-images.yml
7
+ * watches this directory.
8
+ *
9
+ * The ruling (owner, 2026-10-02): workers run the NEWEST CLIs, baked and
10
+ * pinned so every machine runs the same tested version, with no per-machine
11
+ * self-update — prod workers were running claude 2.1.287 on an image that
12
+ * baked 2.1.283, because the CLI updated itself inside the machines. Each
13
+ * release bumps the pins here; the two constants below turn the CLIs' own
14
+ * updaters off wherever the platform runs them.
15
+ */
16
+
17
+ /** Anthropic's claude CLI (the native installer's versioned binary, copied
18
+ * to /usr/local/bin/claude). The floor is the worker model: Opus 5.5 needs
19
+ * 2.1.280 or newer. 2.1.287 is current on npm (2026-10-02) and is the build
20
+ * the rtk `--settings` hook, the `[1m]` window behind a gateway base URL and
21
+ * the prompt-cache TTL env were verified against. */
22
+ export const CLAUDE_CODE_VERSION = "2.1.287";
23
+
24
+ /** OpenAI's codex CLI (`@openai/codex`). 0.160.0 is current on npm
25
+ * (2026-10-02); codex.ts's event mapping (command_execution / agent_message
26
+ * / turn.completed usage) and the hook trust-hash recipe were re-verified
27
+ * live against it, and its plan-tool and hook sources are byte-identical to
28
+ * rust-v0.159.2, where the `todo_list` shape was captured. Unbaked, every
29
+ * session's first codex turn waited ~13 s in prod for the npm install
30
+ * before its launch (2026-09-30), and each machine got whichever version
31
+ * npm served that minute. Bump deliberately. */
32
+ export const CODEX_CLI_VERSION = "0.160.0";
33
+
34
+ /** sst's opencode CLI (`opencode-ai`) — the harness behind the dispatch
35
+ * constitution's `fast` lane. 1.18.34 is current on npm (2026-09-30) and
36
+ * is the build the cloud lane (runtimes/opencode.ts) was verified against,
37
+ * live through the gateway on google/gemini-3.7-flash (2026-10-03): the
38
+ * `run --format json` event shapes (runtimes/__fixtures__/jsonl/opencode),
39
+ * the prompt on stdin, and the `--auto` permission flag — which 1.15.13
40
+ * still spelled `--dangerously-skip-permissions`, the kind of flag drift
41
+ * an unpinned install ships straight into prod. Until this pin the spec
42
+ * installed whatever npm served that minute, on each machine's first
43
+ * opencode turn. Bump deliberately. */
44
+ export const OPENCODE_CLI_VERSION = "1.18.34";
45
+
46
+ /** rtk (rtk-ai/rtk), the shell-output compressor behind the Bash PreToolUse
47
+ * hook every claude and codex session carries (runtimes/claude-code.ts
48
+ * RTK_BASH_HOOK_COMMAND, runtimes/codex.ts RTK_CODEX_HOOK_COMMAND). The
49
+ * floor is `rtk hook codex`, added in 0.50.0 (2026-09-24); 0.51.0 is the
50
+ * current release (2026-10-02) and the build both hooks' exact smoke
51
+ * payloads were run against (2026-10-04). Until this pin the image ran
52
+ * rtk's installer unpinned, so it baked whatever release the installer
53
+ * served on the day its layer was last built — and E2B served that layer
54
+ * from cache for six weeks: every 2026-10 image carried rtk 0.45.0, whose
55
+ * `rtk hook` had no `codex`, so the codex hook exited 2 with clap's usage
56
+ * on stderr and Codex blocked every shell command of every codex session
57
+ * from the hook's 2026-10-03 deploy on. The image recipe downloads exactly
58
+ * this release (infra parts.ts RTK_INSTALL, sha256-verified); its verify
59
+ * layer and the smoke gate assert the baked version. Bump deliberately,
60
+ * re-running both hooks' payloads against the new build. */
61
+ export const RTK_VERSION = "0.51.0";
62
+
63
+ /** Env that turns the claude CLI's updater off — the admin-grade switch
64
+ * (verified in the 2.1.287 binary): it disables the background self-update
65
+ * AND `claude update`, so a machine runs the baked version until the image
66
+ * is rebuilt; `DISABLE_AUTOUPDATER` would stop only the background updater.
67
+ * The `autoUpdates: false` config does nothing for the native installer.
68
+ * Sessions get it from the session env file, runs from the runner env. */
69
+ export const CLAUDE_CODE_NO_SELF_UPDATE_ENV: Readonly<Record<string, string>> = {
70
+ DISABLE_UPDATES: "1",
71
+ };
72
+
73
+ /** The config.toml line that turns codex's startup update check off
74
+ * (`check_for_update_on_startup`, codex-rs config: "set to false only if
75
+ * your Codex updates are centrally managed" — they are: the image). A
76
+ * top-level key, so it must come before any `[table]` in the file.
77
+ * Accepted by 0.160.0 under `--strict-config` (live, 2026-10-02). */
78
+ export const CODEX_NO_SELF_UPDATE_CONFIG_LINE = "check_for_update_on_startup = false\n";
79
+
80
+ /** The opencode.json keys that turn opencode's updater off (`autoupdate`
81
+ * takes true | false | "notify" in 1.18.34's config schema). Spread into
82
+ * the session config the server writes (session-runtime-config.ts), so a
83
+ * machine runs the baked version until the image is rebuilt. */
84
+ export const OPENCODE_NO_SELF_UPDATE_CONFIG: Readonly<Record<string, boolean>> = {
85
+ autoupdate: false,
86
+ };
@@ -5,13 +5,13 @@
5
5
  * This module is the SINGLE SOURCE for the machine's template identity: the
6
6
  * recipe lives in `infra/e2b-template/devbox.ts`, `infra/e2b-template/build.ts`
7
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).
8
+ * provision a machine (exactly how `e2bAgentEnvTemplate` reaches the E2B
9
+ * provider — a stable ALIAS, never a snapshot id, so the same string resolves
10
+ * in whichever E2B account a deployment uses).
11
11
  *
12
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.
13
+ * runs boot the clean per-size `agent-env-<size>` template (the same image
14
+ * sessions boot) so reproducibility never inherits hand-configured drift.
15
15
  */
16
16
 
17
17
  /** Stable E2B template ALIAS for the per-member desktop machine. Baked by
@@ -1,6 +1,5 @@
1
1
  /**
2
- * SSE exec-stream parser — used by the agent sandbox broker clients in
3
- * runner.ts to consume a streamed command execution.
2
+ * SSE exec-stream parser — consumes a streamed command execution.
4
3
  */
5
4
 
6
5
  import type { SandboxCommandResult } from "../types/sandbox.js";
@@ -48,6 +48,37 @@ export type SandboxNetworkPolicy =
48
48
  subnets?: SandboxNetworkSubnetPolicy;
49
49
  };
50
50
 
51
+ /** Every IPv4 destination, as a `subnets.allow` entry — the lever that
52
+ * admits raw TCP. A domain allow (`"*"` included) is matched by TLS SNI /
53
+ * HTTP Host, so it cannot carry an ssh, database or other non-HTTP
54
+ * connection. */
55
+ const ALL_IPV4 = "0.0.0.0/0";
56
+
57
+ /**
58
+ * OPEN egress save the given CIDRs: every domain and every IPv4 destination,
59
+ * with `deny` applied ahead of both — "allow-all" spelled out so it can carry
60
+ * a deny (the server stamps its metadata-endpoint deny on every open policy).
61
+ * Vercel enforces this shape natively (its subnet denies take precedence
62
+ * over allowed domains and CIDRs). E2B lets an allow entry win over a deny,
63
+ * so `toE2bNetwork` recognizes the shape and emits a bare deny list instead.
64
+ */
65
+ export function openEgressExcept(deny: readonly string[]): SandboxNetworkPolicy {
66
+ return { allow: { "*": [] }, subnets: { allow: [ALL_IPV4], deny: [...deny] } };
67
+ }
68
+
69
+ /** The deny list of an `openEgressExcept` policy — the `"*"` wildcard alone,
70
+ * with no per-host rules, plus every IPv4 destination — or null for any
71
+ * other policy. */
72
+ function openEgressDeny(policy: SandboxNetworkPolicy): string[] | null {
73
+ if (typeof policy === "string" || !policy.allow) return null;
74
+ const hosts = Array.isArray(policy.allow) ? policy.allow : Object.keys(policy.allow);
75
+ const ruled = !Array.isArray(policy.allow) && Object.values(policy.allow).some((rules) => rules.length > 0);
76
+ const subnetAllow = policy.subnets?.allow ?? [];
77
+ if (ruled || hosts.length !== 1 || hosts[0] !== "*") return null;
78
+ if (subnetAllow.length !== 1 || subnetAllow[0] !== ALL_IPV4) return null;
79
+ return [...(policy.subnets?.deny ?? [])];
80
+ }
81
+
51
82
  /** Matches a path containing a dot-dot segment — literal (`/../`) or
52
83
  * percent-encoded (`%2e%2e`, `%2f` boundaries). `DOT_SEGMENT_PATH_RE2` is
53
84
  * the RE2 form handed to Vercel's matcher: wrapped in `.*` so it behaves
@@ -121,17 +152,26 @@ export function toVercelNetworkPolicy(policy: SandboxNetworkPolicy): VercelNetwo
121
152
  * (`SandboxNetworkOpts`) — the E2B analogue of `toVercelNetworkPolicy`.
122
153
  *
123
154
  * - `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).
155
+ * `allow` PLUS every CIDR in `subnets.allow`.
127
156
  * - `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.
157
+ * DEFAULT-DENY — only `allowOut` targets pass — plus every CIDR in
158
+ * `subnets.deny`. E2B applies allow before deny, so a host in both lists
159
+ * is allowed: a `subnets.deny` CIDR bites on E2B only where no allow
160
+ * entry covers the destination (E2B's documented precedence; Vercel
161
+ * applies its `subnets.deny` ahead of every allow). Whether E2B's `"*"`
162
+ * domain wildcard also covers a request addressed to a bare IP is E2B's
163
+ * call, so a deny under a `"*"`-plus-rules policy is not relied on here.
130
164
  * - `rules`: per-host header injection. For each host carrying transform(s),
131
165
  * emit one rule per transform with `{ transform: { headers } }`. A host
132
166
  * with a rule MUST also be in `allowOut` (registering a rule does not
133
167
  * grant egress on its own — E2B's API requires the host in `allowOut`).
134
168
  *
169
+ * OPEN egress save some CIDRs (`openEgressExcept`) is the one shape E2B's
170
+ * allow precedence cannot express as allow + deny lists — its "every IPv4
171
+ * destination" allow would win over the deny. It becomes a bare `denyOut`
172
+ * of those CIDRs: no allow list and no domain filtering (so raw TCP keeps
173
+ * flowing, as under "allow-all"), only the denied destinations refused.
174
+ *
135
175
  * BEHAVIOURAL DIVERGENCE FROM iron-proxy / Vercel — path/method gating is NOT
136
176
  * available on E2B native rules. An `SandboxNetworkRule` carries only a
137
177
  * `transform` (header injection); there is no method/path matcher. So our
@@ -147,6 +187,8 @@ export function toVercelNetworkPolicy(policy: SandboxNetworkPolicy): VercelNetwo
147
187
  export function toE2bNetwork(policy: SandboxNetworkPolicy): E2bNetworkOpts {
148
188
  if (policy === "allow-all") return {};
149
189
  if (policy === "deny-all") return { denyOut: ({ allTraffic }) => [allTraffic] };
190
+ const openDeny = openEgressDeny(policy);
191
+ if (openDeny) return openDeny.length > 0 ? { denyOut: openDeny } : {};
150
192
 
151
193
  // List-form allow (no transforms): allowlist the hosts, default-deny the rest.
152
194
  const allowHosts: string[] = Array.isArray(policy.allow)
@@ -155,9 +197,11 @@ export function toE2bNetwork(policy: SandboxNetworkPolicy): E2bNetworkOpts {
155
197
  const subnetAllow = policy.subnets?.allow ?? [];
156
198
  const allowOut = [...new Set([...allowHosts, ...subnetAllow])];
157
199
 
200
+ const subnetDeny = policy.subnets?.deny ?? [];
158
201
  const network: E2bNetworkOpts = {
159
- // Default-deny: only `allowOut` passes (E2B applies allow before deny).
160
- denyOut: ({ allTraffic }) => [allTraffic],
202
+ // Default-deny: only `allowOut` passes (E2B applies allow before deny),
203
+ // with the policy's own denied CIDRs stated alongside.
204
+ denyOut: ({ allTraffic }) => [allTraffic, ...subnetDeny],
161
205
  };
162
206
  if (allowOut.length > 0) network.allowOut = allowOut;
163
207
 
@@ -5,7 +5,7 @@
5
5
  * registry entry.
6
6
  */
7
7
 
8
- import { Sandbox } from "e2b";
8
+ import { Sandbox, Template } from "e2b";
9
9
  import type { CommandHandle } from "e2b";
10
10
  import type { Sandbox as Desktop } from "@e2b/desktop";
11
11
  import pRetry from "p-retry";
@@ -13,7 +13,7 @@ import type {
13
13
  SandboxProvider, SandboxCommandRunOptions, SandboxBackgroundProcess, SandboxPtyHandle,
14
14
  } from "../../types/sandbox.js";
15
15
  import { toE2bNetwork } from "../network-policy.js";
16
- import { DEFAULT_SANDBOX_SIZE, e2bBaseTemplate, isE2bSupportedSize } from "../sizes.js";
16
+ import { DEFAULT_SANDBOX_SIZE, e2bAgentEnvTemplate, isE2bSupportedSize } from "../sizes.js";
17
17
  import { AGENT_COMPOSE_TAG } from "../provider-def.js";
18
18
  import type { OwnedSandbox, SandboxProviderDef } from "../provider-def.js";
19
19
 
@@ -27,8 +27,8 @@ export function makeSandboxProvider(sb: Sandbox | Desktop): SandboxProvider {
27
27
  // SandboxProvider exposes `sudo: true`; E2B has no `sudo` flag — it runs
28
28
  // a command as root via `user: "root"` (Vercel/local map sudo to their own
29
29
  // mechanism). Passing `sudo` straight through means E2B ignores it and runs
30
- // as the non-root default user, so a root-only command (e.g. `archil mount`,
31
- // which REQUIRES root) silently fails. Translate sudo → user:"root".
30
+ // as the non-root default user, so a root-only command (e.g. `agentc files
31
+ // mount`, which REQUIRES root) silently fails. Translate sudo → user:"root".
32
32
  const { sudo, ...rest } = (opts ?? {}) as { sudo?: boolean } & Record<string, unknown>;
33
33
  const runOpts = {
34
34
  ...rest,
@@ -40,7 +40,7 @@ export function makeSandboxProvider(sb: Sandbox | Desktop): SandboxProvider {
40
40
  };
41
41
  // E2B's commands.run THROWS CommandExitError on a non-zero exit, but the
42
42
  // SandboxProvider contract (and the Vercel provider) RETURNS a result with
43
- // `exitCode` so callers can branch on it (e.g. the archil-mount degrade
43
+ // `exitCode` so callers can branch on it (e.g. the factory-drive mount
44
44
  // path inspects res.exitCode/res.stderr; a thrown bare "exit status N"
45
45
  // loses the command's stderr). Normalize the throw back into a result;
46
46
  // only a genuine failure (spawn error, timeout, dead sandbox — no numeric
@@ -297,18 +297,24 @@ export const e2bProviderDef: SandboxProviderDef = {
297
297
  // E2B has no create-time resource knob (specs are baked into the
298
298
  // template/snapshot), so honouring `size` on E2B = picking a PRE-SIZED
299
299
  // template, not passing the field through. We resolve the boot template
300
- // from `size` below (`e2bBaseTemplate(size)`) when the caller gave no
300
+ // from `size` below (`e2bAgentEnvTemplate(size)`) when the caller gave no
301
301
  // explicit template/bootFrom; the field itself is never forwarded to E2B.
302
302
  create: async ({ template, timeoutMs, networkPolicy, size, ...rest }) => {
303
303
  // `template` is an E2B template id/alias, a snapshot id (a valid create
304
304
  // source that persists beyond its origin sandbox — bootFrom parity), or
305
- // absent. When absent we pick the SIZE-MATCHED platform base alias
306
- // (`agent-compose-base-<size>`) so `resources.size` gives the same
307
- // machine spec on E2B as on Vercel — the cross-provider parity this whole
308
- // change exists for. Only when no size is resolvable at all do we fall
309
- // back to E2B_DEFAULT_TEMPLATE if set (a prebuilt base — e.g. one with the claude CLI
310
- // + chromium baked in and more RAM than the stock 482MB base), the
311
- // per-deployment analogue of Vercel's node24; else E2B's stock base.
305
+ // absent. When absent we pick the SIZE-MATCHED platform agent-env alias
306
+ // (`agent-env-<size>`) — the SAME image every cloud session boots
307
+ // (server/src/sandbox/persistent.ts resolves the identical
308
+ // `e2bAgentEnvTemplate(size)`), so a template-less workflow run gets the
309
+ // full session toolchain (claude runtime, dev toolbelt, agentc, fsgw
310
+ // client) instead of a thinner image. One seam, both lanes: the
311
+ // session/run divergence that caused fleet-wide `exit 127`s (missing
312
+ // harness CLIs, fixed by on-demand install in v0.10.73) is structurally
313
+ // gone — there is no separate run-lane template to drift. The per-size
314
+ // aliasing also keeps `resources.size` giving the same machine spec on
315
+ // E2B as on Vercel (cross-provider parity). Only when no size is
316
+ // resolvable at all do we fall back to E2B_DEFAULT_TEMPLATE if set (the
317
+ // per-deployment analogue of Vercel's node24); else E2B's stock base.
312
318
  // Self-provisioning runtimes (claude/codex/amp via bootFrom:"reuse") install
313
319
  // their CLI on the base and cache it in the captured snapshot, so a
314
320
  // template-less first run boots, installs, snapshots. Clamp the lifetime to
@@ -346,18 +352,19 @@ export const e2bProviderDef: SandboxProviderDef = {
346
352
  };
347
353
  // Boot template resolution, in priority order:
348
354
  // 1. explicit `template`/bootFrom (a pinned snapshot or alias),
349
- // 2. else the SIZE-MATCHED base alias `agent-compose-base-<size>`
350
- // (`size` resolved to DEFAULT_SANDBOX_SIZE when unset) — this is the
351
- // cross-provider parity path,
355
+ // 2. else the SIZE-MATCHED agent-env alias `agent-env-<size>` — the
356
+ // session-identical image (`size` resolved to DEFAULT_SANDBOX_SIZE
357
+ // when unset). NEVER the legacy `agent-compose-base-<size>` — that
358
+ // family remains a valid EXPLICIT bootFrom target only,
352
359
  // 3. else E2B_DEFAULT_TEMPLATE as an ultimate per-deployment fallback,
353
360
  // 4. else E2B's stock base.
354
- // `32vcpu-64gb` has no base-<size> template (Pro caps ~8 vCPU); the
361
+ // `32vcpu-64gb` has no per-size template (Pro caps ~8 vCPU); the
355
362
  // register + invoke guards reject it before a run reaches here, so we
356
- // never synthesize a non-existent `agent-compose-base-32vcpu-64gb` alias.
363
+ // never synthesize a non-existent `agent-env-32vcpu-64gb` alias.
357
364
  const resolvedSize = size ?? DEFAULT_SANDBOX_SIZE;
358
365
  const tmpl =
359
366
  template ??
360
- (isE2bSupportedSize(resolvedSize) ? e2bBaseTemplate(resolvedSize) : undefined) ??
367
+ (isE2bSupportedSize(resolvedSize) ? e2bAgentEnvTemplate(resolvedSize) : undefined) ??
361
368
  process.env.E2B_DEFAULT_TEMPLATE;
362
369
  return makeE2bSandboxProvider(
363
370
  await (tmpl ? Sandbox.create(tmpl, sandboxOpts) : Sandbox.create(sandboxOpts)),
@@ -383,4 +390,41 @@ export const e2bProviderDef: SandboxProviderDef = {
383
390
  // E2B snapshots are team-scoped; delete by id. Best-effort like Vercel's.
384
391
  await Sandbox.deleteSnapshot(snapshotId, { apiKey: env.E2B_API_KEY });
385
392
  },
393
+ snapshotExists: async (snapshotId, env) => {
394
+ // Existence probe for a CREATE SOURCE (the `snapshotResolves` contract:
395
+ // `true` = resolves, `false` = the provider definitively says it does not
396
+ // exist, anything indeterminate PROPAGATES — never reported as missing).
397
+ //
398
+ // e2b 2.30.5 has no GET-snapshot-by-id, so this composes the two
399
+ // documented lookups, cheapest first:
400
+ // 1. `Template.exists` — ONE round-trip to the template-existence
401
+ // endpoint (`GET /templates/aliases/{alias}`), with DOCUMENTED
402
+ // not-found semantics: 404 → false, 403 → exists but owned by
403
+ // another team → true (the SDK's own mapping). Snapshots are
404
+ // templates provider-side, and this probe also resolves the
405
+ // `agent-env-*` template ALIASES that E2B-pinned default templates
406
+ // register as their bootFrom — boot-time platform validation
407
+ // (validatePlatformSnapshots) probes those through this same seam,
408
+ // so a snapshots-only lookup would falsely alert "unresolvable" on
409
+ // every alias.
410
+ // 2. A paged scan of the team's snapshot list (`Sandbox.listSnapshots`)
411
+ // — authoritative for `createSnapshot` artifacts whatever their id
412
+ // shape, reached only when the template probe answered not-found.
413
+ // Bounded by the team's snapshot count (session rings are GC'd to a
414
+ // fixed retain depth), and this is rare-path code: machine-loss
415
+ // recovery and boot validation, never a hot loop.
416
+ // `false` therefore means BOTH documented lookups answered not-found.
417
+ // (`Sandbox.listSnapshots({ sandboxId })` — source-filtered — was
418
+ // rejected as the primary: callers hold only the snapshot id, and a
419
+ // filtered miss would still need the full scan before "absent" is
420
+ // honest.) Transport/auth faults from either call throw — indeterminate.
421
+ const opts = { apiKey: env.E2B_API_KEY };
422
+ if (await Template.exists(snapshotId, opts)) return true;
423
+ const paginator = Sandbox.listSnapshots(opts);
424
+ while (paginator.hasNext) {
425
+ const items = await pRetry(() => paginator.nextItems(), { retries: 3, minTimeout: 500, factor: 2 });
426
+ if (items.some((s) => s.snapshotId === snapshotId || s.names.includes(snapshotId))) return true;
427
+ }
428
+ return false;
429
+ },
386
430
  };
@@ -117,8 +117,8 @@ function makeVercelSandboxProvider(sb: VercelSandbox, globalEnvs?: Record<string
117
117
  return { exitCode: finished.exitCode, stdout, stderr };
118
118
  };
119
119
  // `timeoutMs` MUST be authoritative. The AbortSignal alone is not: a
120
- // command that RUNS but emits nothing (e.g. a wedged `archil checkout`
121
- // on a blocked data plane) leaves `logs()`/`wait()` pending and the
120
+ // command that RUNS but emits nothing (e.g. a mount wedged on a
121
+ // blocked data plane) leaves `logs()`/`wait()` pending and the
122
122
  // abort never interrupts the await — the call hangs for the activity's
123
123
  // whole multi-hour ceiling. Race a hard client-side deadline so a hung
124
124
  // command fails fast and the caller's degrade/retry policy takes over.
@@ -164,9 +164,9 @@ function makeVercelSandboxProvider(sb: VercelSandbox, globalEnvs?: Record<string
164
164
  return buf.toString("utf8");
165
165
  },
166
166
  },
167
- // Propagate errors — `killAllRunSandboxes` relies on kill failures being
168
- // observable so it can leave `sandbox_id` set for `findOrphanedSandboxes`
169
- // to retry on next boot. Swallowing here makes the orphan retry loop blind.
167
+ // Propagate errors — a failed kill must stay observable to its caller;
168
+ // the server's provider reconciler (`listOwned` + DB cross-ref) sweeps
169
+ // any sandbox that survives.
170
170
  //
171
171
  // kill must DESTROY: in @vercel/sandbox 2.x stop() only halts the VM — the
172
172
  // name-keyed sandbox record persists (reserving the name; commands against
@@ -260,7 +260,7 @@ export const vercelProviderDef: SandboxProviderDef = {
260
260
  // guarantees executor wins over metadata and survives the 5-tag cap.
261
261
  const tags = buildVercelTags(opts.metadata);
262
262
  // No explicit template/bootFrom → VERCEL_DEFAULT_SNAPSHOT if set (the
263
- // platform agent-env base: claude, archil, rtk, bun, agentc CLI, the
263
+ // platform agent-env base: claude, rtk, bun, agentc CLI, the
264
264
  // SDK in /workspace/node_modules, /ac:* skills, AGENTS.md — built by
265
265
  // .agentc/environments/agent-env.ts), else raw node24. The E2B
266
266
  // analogue is E2B_DEFAULT_TEMPLATE (see providers/e2b.ts). Because