@agent-compose/sdk 0.8.5 → 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 (97) hide show
  1. package/README.md +213 -189
  2. package/dist/agent/agent-context.d.ts +3 -3
  3. package/dist/agent/agent-loop.d.ts +4 -5
  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 +105 -52
  7. package/dist/directives.d.ts +3 -3
  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 +12 -12
  12. package/dist/index.js +716 -203
  13. package/dist/request-context/request-context.d.ts +1 -1
  14. package/dist/runtimes/_cli-agent.d.ts +182 -68
  15. package/dist/runtimes/claude-code.d.ts +60 -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.js +686 -199
  20. package/dist/runtimes/opencode.d.ts +48 -11
  21. package/dist/runtimes/opencode.test.d.ts +14 -0
  22. package/dist/sandbox/baked-clis.d.ts +75 -0
  23. package/dist/sandbox/exec-stream.d.ts +1 -2
  24. package/dist/sandbox/network-policy.d.ts +23 -5
  25. package/dist/sandbox.d.ts +4 -2
  26. package/dist/step-invocation/protocol.d.ts +3 -4
  27. package/dist/step-invocation/server.d.ts +2 -2
  28. package/dist/step-invocation/types.d.ts +1 -1
  29. package/dist/types/api-conversations.d.ts +442 -29
  30. package/dist/types/api-factory.d.ts +78 -8
  31. package/dist/types/api-projects.d.ts +480 -0
  32. package/dist/types/api-runs.d.ts +8 -0
  33. package/dist/types/api-scopes.d.ts +32 -3
  34. package/dist/types/conversation-stream.d.ts +5 -0
  35. package/dist/types/execution-context.d.ts +1 -1
  36. package/dist/types/protocol.d.ts +65 -2
  37. package/dist/types/runtime.d.ts +9 -2
  38. package/dist/types/workflow-metadata.d.ts +2 -4
  39. package/dist/types/workflow-plan.d.ts +1 -3
  40. package/dist/utils/bundler.d.ts +23 -0
  41. package/dist/workflow-steps/observability.d.ts +2 -3
  42. package/dist/workflow-steps/runner.d.ts +5 -8
  43. package/dist/workflow-steps/types.d.ts +8 -10
  44. package/dist/workflow-steps/workflow.d.ts +2 -1
  45. package/dist/workflows/engine.d.ts +3 -5
  46. package/dist/workflows/invoke-child.d.ts +2 -2
  47. package/package.json +2 -2
  48. package/src/agent/agent-context.ts +168 -125
  49. package/src/agent/agent-loop.ts +5 -4
  50. package/src/agent/perf-sampler.ts +54 -3
  51. package/src/agent/run-agent.ts +1 -1
  52. package/src/client.ts +191 -71
  53. package/src/directives.ts +3 -3
  54. package/src/display.ts +12 -0
  55. package/src/errors.ts +1 -0
  56. package/src/generated/agentc-commands.ts +571 -0
  57. package/src/index.ts +54 -21
  58. package/src/pause/pause-core.ts +2 -1
  59. package/src/request-context/request-context.ts +1 -1
  60. package/src/runtimes/_cli-agent.ts +306 -122
  61. package/src/runtimes/claude-code.ts +179 -9
  62. package/src/runtimes/claude.ts +1 -1
  63. package/src/runtimes/codex.ts +188 -19
  64. package/src/runtimes/opencode.ts +195 -26
  65. package/src/sandbox/baked-clis.ts +86 -0
  66. package/src/sandbox/exec-stream.ts +1 -2
  67. package/src/sandbox/network-policy.ts +51 -7
  68. package/src/sandbox/providers/e2b.ts +3 -3
  69. package/src/sandbox/providers/vercel.ts +6 -6
  70. package/src/sandbox.ts +8 -2
  71. package/src/step-invocation/invoker.ts +2 -6
  72. package/src/step-invocation/protocol.ts +3 -4
  73. package/src/step-invocation/server.ts +2 -2
  74. package/src/types/api-conversations.ts +366 -23
  75. package/src/types/api-factory.ts +74 -8
  76. package/src/types/api-projects.ts +443 -0
  77. package/src/types/api-runs.ts +5 -0
  78. package/src/types/api-scopes.ts +32 -3
  79. package/src/types/conversation-stream.ts +5 -0
  80. package/src/types/execution-context.ts +1 -1
  81. package/src/types/protocol.ts +67 -1
  82. package/src/types/runtime.ts +8 -2
  83. package/src/types/sandbox-environment.ts +1 -2
  84. package/src/types/workflow-metadata.ts +2 -4
  85. package/src/types/workflow-plan.ts +1 -3
  86. package/src/utils/bundler.ts +88 -19
  87. package/src/workflow-steps/observability.ts +2 -3
  88. package/src/workflow-steps/runner.ts +5 -8
  89. package/src/workflow-steps/types.ts +8 -10
  90. package/src/workflow-steps/workflow.ts +2 -1
  91. package/src/workflows/engine.ts +3 -5
  92. package/src/workflows/invoke-child.ts +2 -2
  93. package/dist/generated/verb-synopsis.d.ts +0 -34
  94. package/dist/pause/__tests__/errors.test.d.ts +0 -1
  95. package/dist/pause/__tests__/wrappers.test.d.ts +0 -1
  96. package/dist/step-invocation/__tests__/protocol.test.d.ts +0 -1
  97. package/src/generated/verb-synopsis.ts +0 -544
@@ -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
+ };
@@ -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
 
@@ -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
@@ -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
package/src/sandbox.ts CHANGED
@@ -5,8 +5,9 @@
5
5
  * - `sandbox/network-policy.ts` — egress policy shape + per-provider translators
6
6
  * - `sandbox/sizes.ts` — machine sizes + E2B template aliases
7
7
  * - `sandbox/devbox.ts` — the ADR-0038 per-member desktop machine alias
8
+ * - `sandbox/baked-clis.ts` — harness CLI versions the session image bakes
8
9
  * - `sandbox/provider-def.ts` — provider contract, create opts, fleet tag
9
- * - `sandbox/exec-stream.ts` — SSE exec-stream parser (broker clients)
10
+ * - `sandbox/exec-stream.ts` — SSE exec-stream parser
10
11
  * - `sandbox/providers/*` — vercel / e2b / e2b-desktop / local providers
11
12
  * - `sandbox/registry.ts` — provider registry + fleet management
12
13
  */
@@ -19,7 +20,7 @@ export type {
19
20
  SandboxNetworkSubnetPolicy,
20
21
  SandboxNetworkPolicy,
21
22
  } from "./sandbox/network-policy.js";
22
- export { DOT_SEGMENT_PATH_RE2, toVercelNetworkPolicy, toE2bNetwork } from "./sandbox/network-policy.js";
23
+ export { DOT_SEGMENT_PATH_RE2, openEgressExcept, toVercelNetworkPolicy, toE2bNetwork } from "./sandbox/network-policy.js";
23
24
 
24
25
  export type { SandboxSize } from "./sandbox/sizes.js";
25
26
  export {
@@ -48,6 +49,11 @@ export {
48
49
  e2bDevboxTemplateRef,
49
50
  } from "./sandbox/devbox.js";
50
51
 
52
+ export {
53
+ CLAUDE_CODE_VERSION, CODEX_CLI_VERSION, OPENCODE_CLI_VERSION, RTK_VERSION,
54
+ CLAUDE_CODE_NO_SELF_UPDATE_ENV, CODEX_NO_SELF_UPDATE_CONFIG_LINE, OPENCODE_NO_SELF_UPDATE_CONFIG,
55
+ } from "./sandbox/baked-clis.js";
56
+
51
57
  export { AGENT_COMPOSE_TAG } from "./sandbox/provider-def.js";
52
58
  export type { SandboxCreateOpts, OwnedSandbox } from "./sandbox/provider-def.js";
53
59
 
@@ -526,12 +526,8 @@ export async function invokeStep<TOutput = unknown>(
526
526
 
527
527
  let result: { stdout: string; stderr: string; exitCode: number };
528
528
  try {
529
- // Run the agent as ROOT (ADR-0019 follow-up). The factory drive (Archil)
530
- // presents its S3-synced files root-owned and exposes no uid-mapped mount,
531
- // so a non-root agent EACCESes on every shared file — the reason for the
532
- // expensive boot-time `chmod -R` walk. As root the agent writes them
533
- // directly: the walk disappears entirely. Egress is edge-enforced with NO
534
- // root exemption (see sandbox.ts), so root is confined exactly like the
529
+ // Run the agent as ROOT (ADR-0019 follow-up). Egress is edge-enforced with
530
+ // NO root exemption (see sandbox.ts), so root is confined exactly like the
535
531
  // non-root user. `HOME=/root` so the spawned `claude` finds root's skills.
536
532
  //
537
533
  // Same pipeline pattern as `launchStep`: tee the runner's stdout to the
@@ -38,12 +38,11 @@ export function stepPauseLinePrefix(token: string): string {
38
38
  return `${STEP_PAUSE_PREFIX}${token}:`;
39
39
  }
40
40
 
41
- /** Sandbox-side path where dispatch writes the compiled runner bundle.
42
- * Both modes (full-mode `dispatch.ts` and step-mode `invokeStep`) spawn
43
- * the runner from this path; single source of truth. */
41
+ /** Sandbox-side path where the sandbox bootstrap writes the compiled runner
42
+ * bundle; `invokeStep` spawns the runner from this path. */
44
43
  export const RUNNER_BUNDLE_PATH = "/tmp/runner.bundle.js";
45
44
 
46
- /** Command the invoker (and dispatch) spawns inside the sandbox. */
45
+ /** Command the invoker spawns inside the sandbox. */
47
46
  export const RUNNER_COMMAND = `node ${RUNNER_BUNDLE_PATH}`;
48
47
 
49
48
  /** Names of the env vars the invoker stamps onto the runner subprocess.
@@ -153,8 +153,8 @@ function scrubProtocolEnvs(): void {
153
153
  * from process.env so user code (including any workflow module imported
154
154
  * inside the handler) cannot read AC_STEP_RESULT_TOKEN — that keeps the
155
155
  * result channel uncorruptible by stdout output from anywhere in the
156
- * workflow's dep tree. RUN_ID remains available to match full-mode
157
- * runner behaviour.
156
+ * workflow's dep tree. RUN_ID remains available: SDK code inside the step
157
+ * (`agent()`, the client's parent-run link) reads it.
158
158
  */
159
159
  export async function serveStep<TInput, TOutput>(
160
160
  handler: StepHandler<TInput, TOutput>,