@tanstack/ai-sandbox-cloudflare 0.2.4 → 0.3.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.
Files changed (47) hide show
  1. package/dist/esm/agent.d.ts +4 -0
  2. package/dist/esm/agent.js +9 -21
  3. package/dist/esm/chat-coordinator.js +144 -132
  4. package/dist/esm/chat-coordinator.js.map +1 -1
  5. package/dist/esm/container-coordinator.js +251 -247
  6. package/dist/esm/container-coordinator.js.map +1 -1
  7. package/dist/esm/coordinator.d.ts +3 -2
  8. package/dist/esm/coordinator.js +204 -184
  9. package/dist/esm/coordinator.js.map +1 -1
  10. package/dist/esm/durability.d.ts +32 -0
  11. package/dist/esm/durability.js +104 -0
  12. package/dist/esm/durability.js.map +1 -0
  13. package/dist/esm/factory.js +98 -59
  14. package/dist/esm/factory.js.map +1 -1
  15. package/dist/esm/handle.js +205 -203
  16. package/dist/esm/handle.js.map +1 -1
  17. package/dist/esm/index.js +2 -8
  18. package/dist/esm/preview-tool.d.ts +7 -1
  19. package/dist/esm/preview-tool.js +75 -32
  20. package/dist/esm/preview-tool.js.map +1 -1
  21. package/dist/esm/protocol.js +61 -50
  22. package/dist/esm/protocol.js.map +1 -1
  23. package/dist/esm/provider.js +43 -62
  24. package/dist/esm/provider.js.map +1 -1
  25. package/dist/esm/public-host.js +84 -39
  26. package/dist/esm/public-host.js.map +1 -1
  27. package/dist/esm/run-log-do.d.ts +19 -5
  28. package/dist/esm/run-log-do.js +196 -121
  29. package/dist/esm/run-log-do.js.map +1 -1
  30. package/dist/esm/run-log.d.ts +127 -0
  31. package/dist/esm/run-log.js +198 -0
  32. package/dist/esm/run-log.js.map +1 -0
  33. package/dist/esm/runner.js +146 -95
  34. package/dist/esm/runner.js.map +1 -1
  35. package/dist/esm/web-crypto.js +27 -16
  36. package/dist/esm/web-crypto.js.map +1 -1
  37. package/dist/esm/worker.js +84 -72
  38. package/dist/esm/worker.js.map +1 -1
  39. package/package.json +9 -9
  40. package/src/agent.ts +26 -0
  41. package/src/coordinator.ts +36 -14
  42. package/src/durability.ts +164 -0
  43. package/src/handle.ts +5 -0
  44. package/src/run-log-do.ts +85 -20
  45. package/src/run-log.ts +352 -0
  46. package/dist/esm/agent.js.map +0 -1
  47. package/dist/esm/index.js.map +0 -1
@@ -1,256 +1,260 @@
1
- import { EventType } from "@tanstack/ai";
2
- import { toolDescriptors, isToolExecRequest, executeHostTool } from "@tanstack/ai-sandbox";
3
- import { getSandbox } from "@cloudflare/sandbox";
1
+ import { resolveBridgeOrigin } from "./public-host.js";
4
2
  import { SandboxCoordinator } from "./coordinator.js";
5
3
  import { timingSafeBearerEqualWeb } from "./web-crypto.js";
6
- import { resolveBridgeOrigin } from "./public-host.js";
7
- const RUNNER_PORT = 8080;
4
+ import { getSandbox } from "@cloudflare/sandbox";
5
+ import { executeHostTool, isToolExecRequest, toolDescriptors } from "@tanstack/ai-sandbox";
6
+ import { EventType } from "@tanstack/ai";
7
+ //#region src/container-coordinator.ts
8
+ /**
9
+ * `ContainerSandboxCoordinator` — the concrete {@link SandboxCoordinator} for
10
+ * the CO-LOCATED ("combined") model: the harness loop AND its MCP tool-bridge
11
+ * run INSIDE the container; this DO stays OUTSIDE as a thin durable coordinator.
12
+ *
13
+ * Worker (stateless trigger)
14
+ * → ContainerSandboxCoordinator (this DO: thin durable coordinator)
15
+ * → Container (runs the in-container harness runner that runs chat())
16
+ *
17
+ * The defining difference from {@link ChatSandboxCoordinator}: this DO does NOT
18
+ * call `chat()` / the adapter itself. It implements the one per-model seam,
19
+ * {@link buildRunStream}, by POSTing `/run` to the in-container runner over the
20
+ * sandbox binding and adapting its NDJSON `StreamChunk` stream — so the base's
21
+ * `RunController` / run-log / streaming tail all work unchanged.
22
+ *
23
+ * TWO channels cross the container ↔ DO boundary; everything else (the MCP
24
+ * transport, native stdin) is in-container localhost:
25
+ * • events OUT: runner → DO (NDJSON of StreamChunk, appended to the run-log)
26
+ * • host-tool EXECUTION: container → DO (`/tool-exec/:runId`, bearer-gated) —
27
+ * the REAL tool `execute()` (DB / secrets / app state) lives HERE.
28
+ *
29
+ * The per-run config — host tools, workspace, harness, model — is the subclass's
30
+ * {@link config} method.
31
+ *
32
+ * NOTE: Workers-runtime code — compiles against the real Cloudflare + TanStack
33
+ * AI types; not runtime-verified in this repo (no Workers runtime / container
34
+ * build here). It follows the proven run-log / remote-tool contracts.
35
+ */
36
+ /** Port the in-container runner listens on (matches RUNNER_PORT in the image). */
37
+ var RUNNER_PORT = 8080;
38
+ /** Narrow one NDJSON line into a StreamChunk (project rule: no `as`). */
8
39
  function isStreamChunk(value) {
9
- return value !== null && typeof value === "object" && "type" in value;
40
+ return value !== null && typeof value === "object" && "type" in value;
10
41
  }
42
+ /**
43
+ * Adapt the runner's NDJSON response body into an `AsyncIterable<StreamChunk>`
44
+ * so the DO can drive it through the SAME base `RunController` / `pipeToRunLog`
45
+ * the DO-drives coordinator uses — terminal-status handling, RUN_ERROR
46
+ * detection, and never-rejects semantics all come for free. A malformed line
47
+ * (unparseable JSON, or valid JSON that isn't a chunk) is surfaced as a terminal
48
+ * RUN_ERROR chunk, never silently dropped.
49
+ */
11
50
  async function* ndjsonToChunks(body) {
12
- const reader = body.getReader();
13
- const decoder = new TextDecoder();
14
- let buffer = "";
15
- let result = await reader.read();
16
- while (!result.done) {
17
- buffer += decoder.decode(result.value, { stream: true });
18
- let newline = buffer.indexOf("\n");
19
- while (newline !== -1) {
20
- const line = buffer.slice(0, newline).trim();
21
- buffer = buffer.slice(newline + 1);
22
- newline = buffer.indexOf("\n");
23
- if (line === "") continue;
24
- const chunk = parseChunkLine(line);
25
- yield chunk;
26
- if (chunk.type === EventType.RUN_ERROR) return;
27
- }
28
- result = await reader.read();
29
- }
30
- buffer += decoder.decode();
31
- const tail = buffer.trim();
32
- if (tail !== "") yield parseChunkLine(tail);
51
+ const reader = body.getReader();
52
+ const decoder = new TextDecoder();
53
+ let buffer = "";
54
+ let result = await reader.read();
55
+ while (!result.done) {
56
+ buffer += decoder.decode(result.value, { stream: true });
57
+ let newline = buffer.indexOf("\n");
58
+ while (newline !== -1) {
59
+ const line = buffer.slice(0, newline).trim();
60
+ buffer = buffer.slice(newline + 1);
61
+ newline = buffer.indexOf("\n");
62
+ if (line === "") continue;
63
+ const chunk = parseChunkLine(line);
64
+ yield chunk;
65
+ if (chunk.type === EventType.RUN_ERROR) return;
66
+ }
67
+ result = await reader.read();
68
+ }
69
+ buffer += decoder.decode();
70
+ const tail = buffer.trim();
71
+ if (tail !== "") yield parseChunkLine(tail);
33
72
  }
73
+ /**
74
+ * Parse one NDJSON line into a {@link StreamChunk}, turning a truncated/garbled
75
+ * line (a crashed container's last write) or a non-chunk object into a terminal
76
+ * RUN_ERROR chunk rather than throwing or silently dropping it.
77
+ */
34
78
  function parseChunkLine(line) {
35
- let parsed;
36
- try {
37
- parsed = JSON.parse(line);
38
- } catch {
39
- return {
40
- type: EventType.RUN_ERROR,
41
- message: `runner sent unparseable NDJSON: ${line.slice(0, 200)}`
42
- };
43
- }
44
- if (!isStreamChunk(parsed)) {
45
- return {
46
- type: EventType.RUN_ERROR,
47
- message: "runner sent a non-chunk line"
48
- };
49
- }
50
- return parsed;
51
- }
52
- class ContainerSandboxCoordinator extends SandboxCoordinator {
53
- /**
54
- * Live per-run tool-exec tokens, keyed by runId. In-memory by design: a run's
55
- * tool-exec endpoint is only reachable while the run is in flight, and
56
- * `ctx.waitUntil(done)` keeps THIS instance alive for the run's lifetime, so
57
- * the container's callbacks always hit the instance that minted the token.
58
- */
59
- toolExec = /* @__PURE__ */ new Map();
60
- /**
61
- * In-flight runner boot, memoized so two runs starting near-simultaneously on
62
- * this instance don't both spawn `container-runner` (the second would hit
63
- * EADDRINUSE on RUNNER_PORT). Cleared once boot settles.
64
- */
65
- runnerBoot;
66
- /** Last `/health` probe error, surfaced if the runner never comes up. */
67
- lastProbeError;
68
- // ===========================================================================
69
- // The one per-model seam: drive the in-container runner
70
- // ===========================================================================
71
- /**
72
- * Mint the per-run tool-exec token, POST `/run` to the in-container runner, and
73
- * yield its NDJSON chunks. The token is registered BEFORE the container is told
74
- * to run, so a tool callback can never arrive before the token exists.
75
- */
76
- buildRunStream(input) {
77
- const runConfig = this.config(input);
78
- const token = crypto.randomUUID() + crypto.randomUUID().replace(/-/g, "");
79
- this.toolExec.set(input.runId, {
80
- token,
81
- hostTools: runConfig.hostTools,
82
- ...runConfig.context !== void 0 ? { context: runConfig.context } : {},
83
- abort: new AbortController()
84
- });
85
- return this.driveContainer(input, runConfig, token);
86
- }
87
- /**
88
- * Once the run is terminal, abort any host tool still running on its behalf
89
- * (so a tool that outlived the run doesn't leak), then drop the per-run state.
90
- */
91
- onRunSettled(runId) {
92
- const state = this.toolExec.get(runId);
93
- if (state) state.abort.abort();
94
- this.toolExec.delete(runId);
95
- }
96
- /**
97
- * POST `/run` to the in-container runner and yield its NDJSON chunks. The DO
98
- * reaches the runner DIRECTLY over the sandbox binding (`containerFetch` to
99
- * RUNNER_PORT) — this internal channel needs no public hostname. The runner
100
- * gets the host-tool descriptors plus the `/tool-exec` URL + token it calls
101
- * back on.
102
- */
103
- async *driveContainer(input, runConfig, token) {
104
- const sandbox = getSandbox(this.env.Sandbox, input.threadId);
105
- await this.ensureRunner(sandbox, runConfig.workspace);
106
- const origin = resolveBridgeOrigin(this.env, input);
107
- const body = {
108
- runId: input.runId,
109
- threadId: input.threadId,
110
- messages: input.messages,
111
- harness: runConfig.harness,
112
- model: runConfig.model,
113
- workspace: runConfig.workspace,
114
- // Serialize the DO's real tools to wire descriptors for the container.
115
- toolDescriptors: toolDescriptors(runConfig.hostTools),
116
- // The container calls back here for host-tool EXECUTION. It must be a URL
117
- // the CONTAINER can reach, so it goes via the Worker's public hostname.
118
- toolExecUrl: `${origin}/tool-exec/${input.runId}?threadId=${encodeURIComponent(input.threadId)}`,
119
- toolExecToken: token
120
- };
121
- const response = await sandbox.containerFetch(
122
- "http://runner/run",
123
- {
124
- method: "POST",
125
- headers: { "content-type": "application/json" },
126
- body: JSON.stringify(body)
127
- },
128
- RUNNER_PORT
129
- );
130
- if (!response.ok || !response.body) {
131
- const text = await response.text();
132
- yield {
133
- type: EventType.RUN_ERROR,
134
- message: `container runner failed: ${response.status} ${text.slice(0, 200)}`
135
- };
136
- return;
137
- }
138
- yield* ndjsonToChunks(response.body);
139
- }
140
- /**
141
- * Ensure the in-container runner is listening on RUNNER_PORT. The base image's
142
- * ENTRYPOINT is the sandbox CONTROL server, not our runner — so we start the
143
- * bundled runner as a background process via that control server. Idempotent
144
- * for a thread-reused container: if `/health` already answers, we skip spawn.
145
- */
146
- ensureRunner(sandbox, workspace) {
147
- if (this.runnerBoot) return this.runnerBoot;
148
- const boot = this.bootRunner(sandbox, workspace).finally(() => {
149
- this.runnerBoot = void 0;
150
- });
151
- this.runnerBoot = boot;
152
- return boot;
153
- }
154
- /**
155
- * Copy the run's DECLARED secret names out of the Worker `env` into a plain
156
- * record for the container env. The workspace's `createSecrets` carries only the
157
- * names across the `/run` boundary; the VALUES come from `env` by that name —
158
- * which is how `ANTHROPIC_API_KEY` / `CODEX_API_KEY` / any harness key reach the
159
- * CLI without the package hardcoding which one. A declared name missing from
160
- * `env` is skipped here and fails loudly later in the runner's
161
- * `reconstituteWorkspace` (never a silent keyless run).
162
- */
163
- secretEnvFromWorkspace(workspace) {
164
- const env = this.env;
165
- const out = {};
166
- for (const name of Object.keys(workspace.secrets ?? {})) {
167
- const value = env[name];
168
- if (typeof value === "string" && value !== "") out[name] = value;
169
- }
170
- return out;
171
- }
172
- async bootRunner(sandbox, workspace) {
173
- if (await this.runnerHealthy(sandbox)) return;
174
- const secretEnv = this.secretEnvFromWorkspace(workspace);
175
- if (Object.keys(secretEnv).length > 0) {
176
- await sandbox.setEnvVars(secretEnv);
177
- }
178
- await sandbox.startProcess(`node /app/container-runner.mjs`, {
179
- env: { RUNNER_PORT: String(RUNNER_PORT) }
180
- });
181
- for (let attempt = 0; attempt < 20; attempt += 1) {
182
- if (await this.runnerHealthy(sandbox)) return;
183
- await new Promise((resolve) => setTimeout(resolve, 250));
184
- }
185
- const detail = this.lastProbeError instanceof Error ? `: ${this.lastProbeError.message}` : this.lastProbeError !== void 0 ? `: ${String(this.lastProbeError)}` : "";
186
- throw new Error(
187
- `in-container runner did not become healthy in time${detail}`
188
- );
189
- }
190
- async runnerHealthy(sandbox) {
191
- try {
192
- const res = await sandbox.containerFetch(
193
- "http://runner/health",
194
- { method: "GET" },
195
- RUNNER_PORT
196
- );
197
- return res.ok;
198
- } catch (error) {
199
- this.lastProbeError = error;
200
- return false;
201
- }
202
- }
203
- // ===========================================================================
204
- // The host-tool-exec callback (`/tool-exec/:runId`), from the base fetch
205
- // ===========================================================================
206
- handleRoute(request, parts) {
207
- if (parts[0] === "tool-exec" && typeof parts[1] === "string") {
208
- return this.serveToolExec(parts[1], request);
209
- }
210
- return super.handleRoute(request, parts);
211
- }
212
- /**
213
- * Execute a host tool the in-container agent called back for. The token gates
214
- * it (constant-time Web Crypto compare); the REAL tool's `execute()` runs here
215
- * via {@link executeHostTool} and its raw result returns as `{ result }`. An
216
- * unknown tool or a thrown `execute()` is surfaced as a 4xx/5xx, never masked.
217
- */
218
- async serveToolExec(runId, request) {
219
- const state = this.toolExec.get(runId);
220
- if (!state) return new Response("no active run", { status: 404 });
221
- if (!timingSafeBearerEqualWeb(
222
- request.headers.get("authorization") ?? void 0,
223
- state.token
224
- )) {
225
- return new Response("unauthorized", { status: 401 });
226
- }
227
- let payload;
228
- try {
229
- payload = await request.json();
230
- } catch {
231
- return this.jsonResponse({ error: "body must be valid JSON" }, 400);
232
- }
233
- if (!isToolExecRequest(payload)) {
234
- return this.jsonResponse({ error: "body must be { name, args }" }, 400);
235
- }
236
- try {
237
- const result = await executeHostTool(
238
- state.hostTools,
239
- payload.name,
240
- payload.args,
241
- {
242
- ...state.context !== void 0 ? { context: state.context } : {},
243
- signal: state.abort.signal
244
- }
245
- );
246
- return this.jsonResponse({ result });
247
- } catch (error) {
248
- const message = error instanceof Error ? error.message : String(error);
249
- return this.jsonResponse({ error: message }, 500);
250
- }
251
- }
79
+ let parsed;
80
+ try {
81
+ parsed = JSON.parse(line);
82
+ } catch {
83
+ return {
84
+ type: EventType.RUN_ERROR,
85
+ message: `runner sent unparseable NDJSON: ${line.slice(0, 200)}`
86
+ };
87
+ }
88
+ if (!isStreamChunk(parsed)) return {
89
+ type: EventType.RUN_ERROR,
90
+ message: "runner sent a non-chunk line"
91
+ };
92
+ return parsed;
252
93
  }
253
- export {
254
- ContainerSandboxCoordinator
94
+ var ContainerSandboxCoordinator = class extends SandboxCoordinator {
95
+ /**
96
+ * Live per-run tool-exec tokens, keyed by runId. In-memory by design: a run's
97
+ * tool-exec endpoint is only reachable while the run is in flight, and
98
+ * `ctx.waitUntil(done)` keeps THIS instance alive for the run's lifetime, so
99
+ * the container's callbacks always hit the instance that minted the token.
100
+ */
101
+ toolExec = /* @__PURE__ */ new Map();
102
+ /**
103
+ * In-flight runner boot, memoized so two runs starting near-simultaneously on
104
+ * this instance don't both spawn `container-runner` (the second would hit
105
+ * EADDRINUSE on RUNNER_PORT). Cleared once boot settles.
106
+ */
107
+ runnerBoot;
108
+ /** Last `/health` probe error, surfaced if the runner never comes up. */
109
+ lastProbeError;
110
+ /**
111
+ * Mint the per-run tool-exec token, POST `/run` to the in-container runner, and
112
+ * yield its NDJSON chunks. The token is registered BEFORE the container is told
113
+ * to run, so a tool callback can never arrive before the token exists.
114
+ */
115
+ buildRunStream(input) {
116
+ const runConfig = this.config(input);
117
+ const token = crypto.randomUUID() + crypto.randomUUID().replace(/-/g, "");
118
+ this.toolExec.set(input.runId, {
119
+ token,
120
+ hostTools: runConfig.hostTools,
121
+ ...runConfig.context !== void 0 ? { context: runConfig.context } : {},
122
+ abort: new AbortController()
123
+ });
124
+ return this.driveContainer(input, runConfig, token);
125
+ }
126
+ /**
127
+ * Once the run is terminal, abort any host tool still running on its behalf
128
+ * (so a tool that outlived the run doesn't leak), then drop the per-run state.
129
+ */
130
+ onRunSettled(runId) {
131
+ const state = this.toolExec.get(runId);
132
+ if (state) state.abort.abort();
133
+ this.toolExec.delete(runId);
134
+ }
135
+ /**
136
+ * POST `/run` to the in-container runner and yield its NDJSON chunks. The DO
137
+ * reaches the runner DIRECTLY over the sandbox binding (`containerFetch` to
138
+ * RUNNER_PORT) — this internal channel needs no public hostname. The runner
139
+ * gets the host-tool descriptors plus the `/tool-exec` URL + token it calls
140
+ * back on.
141
+ */
142
+ async *driveContainer(input, runConfig, token) {
143
+ const sandbox = getSandbox(this.env.Sandbox, input.threadId);
144
+ await this.ensureRunner(sandbox, runConfig.workspace);
145
+ const origin = resolveBridgeOrigin(this.env, input);
146
+ const body = {
147
+ runId: input.runId,
148
+ threadId: input.threadId,
149
+ messages: input.messages,
150
+ harness: runConfig.harness,
151
+ model: runConfig.model,
152
+ workspace: runConfig.workspace,
153
+ toolDescriptors: toolDescriptors(runConfig.hostTools),
154
+ toolExecUrl: `${origin}/tool-exec/${input.runId}?threadId=${encodeURIComponent(input.threadId)}`,
155
+ toolExecToken: token
156
+ };
157
+ const response = await sandbox.containerFetch("http://runner/run", {
158
+ method: "POST",
159
+ headers: { "content-type": "application/json" },
160
+ body: JSON.stringify(body)
161
+ }, RUNNER_PORT);
162
+ if (!response.ok || !response.body) {
163
+ const text = await response.text();
164
+ yield {
165
+ type: EventType.RUN_ERROR,
166
+ message: `container runner failed: ${response.status} ${text.slice(0, 200)}`
167
+ };
168
+ return;
169
+ }
170
+ yield* ndjsonToChunks(response.body);
171
+ }
172
+ /**
173
+ * Ensure the in-container runner is listening on RUNNER_PORT. The base image's
174
+ * ENTRYPOINT is the sandbox CONTROL server, not our runner — so we start the
175
+ * bundled runner as a background process via that control server. Idempotent
176
+ * for a thread-reused container: if `/health` already answers, we skip spawn.
177
+ */
178
+ ensureRunner(sandbox, workspace) {
179
+ if (this.runnerBoot) return this.runnerBoot;
180
+ const boot = this.bootRunner(sandbox, workspace).finally(() => {
181
+ this.runnerBoot = void 0;
182
+ });
183
+ this.runnerBoot = boot;
184
+ return boot;
185
+ }
186
+ /**
187
+ * Copy the run's DECLARED secret names out of the Worker `env` into a plain
188
+ * record for the container env. The workspace's `createSecrets` carries only the
189
+ * names across the `/run` boundary; the VALUES come from `env` by that name —
190
+ * which is how `ANTHROPIC_API_KEY` / `CODEX_API_KEY` / any harness key reach the
191
+ * CLI without the package hardcoding which one. A declared name missing from
192
+ * `env` is skipped here and fails loudly later in the runner's
193
+ * `reconstituteWorkspace` (never a silent keyless run).
194
+ */
195
+ secretEnvFromWorkspace(workspace) {
196
+ const env = this.env;
197
+ const out = {};
198
+ for (const name of Object.keys(workspace.secrets ?? {})) {
199
+ const value = env[name];
200
+ if (typeof value === "string" && value !== "") out[name] = value;
201
+ }
202
+ return out;
203
+ }
204
+ async bootRunner(sandbox, workspace) {
205
+ if (await this.runnerHealthy(sandbox)) return;
206
+ const secretEnv = this.secretEnvFromWorkspace(workspace);
207
+ if (Object.keys(secretEnv).length > 0) await sandbox.setEnvVars(secretEnv);
208
+ await sandbox.startProcess(`node /app/container-runner.mjs`, { env: { RUNNER_PORT: String(RUNNER_PORT) } });
209
+ for (let attempt = 0; attempt < 20; attempt += 1) {
210
+ if (await this.runnerHealthy(sandbox)) return;
211
+ await new Promise((resolve) => setTimeout(resolve, 250));
212
+ }
213
+ const detail = this.lastProbeError instanceof Error ? `: ${this.lastProbeError.message}` : this.lastProbeError !== void 0 ? `: ${String(this.lastProbeError)}` : "";
214
+ throw new Error(`in-container runner did not become healthy in time${detail}`);
215
+ }
216
+ async runnerHealthy(sandbox) {
217
+ try {
218
+ return (await sandbox.containerFetch("http://runner/health", { method: "GET" }, RUNNER_PORT)).ok;
219
+ } catch (error) {
220
+ this.lastProbeError = error;
221
+ return false;
222
+ }
223
+ }
224
+ handleRoute(request, parts) {
225
+ if (parts[0] === "tool-exec" && typeof parts[1] === "string") return this.serveToolExec(parts[1], request);
226
+ return super.handleRoute(request, parts);
227
+ }
228
+ /**
229
+ * Execute a host tool the in-container agent called back for. The token gates
230
+ * it (constant-time Web Crypto compare); the REAL tool's `execute()` runs here
231
+ * via {@link executeHostTool} and its raw result returns as `{ result }`. An
232
+ * unknown tool or a thrown `execute()` is surfaced as a 4xx/5xx, never masked.
233
+ */
234
+ async serveToolExec(runId, request) {
235
+ const state = this.toolExec.get(runId);
236
+ if (!state) return new Response("no active run", { status: 404 });
237
+ if (!timingSafeBearerEqualWeb(request.headers.get("authorization") ?? void 0, state.token)) return new Response("unauthorized", { status: 401 });
238
+ let payload;
239
+ try {
240
+ payload = await request.json();
241
+ } catch {
242
+ return this.jsonResponse({ error: "body must be valid JSON" }, 400);
243
+ }
244
+ if (!isToolExecRequest(payload)) return this.jsonResponse({ error: "body must be { name, args }" }, 400);
245
+ try {
246
+ const result = await executeHostTool(state.hostTools, payload.name, payload.args, {
247
+ ...state.context !== void 0 ? { context: state.context } : {},
248
+ signal: state.abort.signal
249
+ });
250
+ return this.jsonResponse({ result });
251
+ } catch (error) {
252
+ const message = error instanceof Error ? error.message : String(error);
253
+ return this.jsonResponse({ error: message }, 500);
254
+ }
255
+ }
255
256
  };
256
- //# sourceMappingURL=container-coordinator.js.map
257
+ //#endregion
258
+ export { ContainerSandboxCoordinator };
259
+
260
+ //# sourceMappingURL=container-coordinator.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"container-coordinator.js","sources":["../../src/container-coordinator.ts"],"sourcesContent":["/**\n * `ContainerSandboxCoordinator` — the concrete {@link SandboxCoordinator} for\n * the CO-LOCATED (\"combined\") model: the harness loop AND its MCP tool-bridge\n * run INSIDE the container; this DO stays OUTSIDE as a thin durable coordinator.\n *\n * Worker (stateless trigger)\n * → ContainerSandboxCoordinator (this DO: thin durable coordinator)\n * → Container (runs the in-container harness runner that runs chat())\n *\n * The defining difference from {@link ChatSandboxCoordinator}: this DO does NOT\n * call `chat()` / the adapter itself. It implements the one per-model seam,\n * {@link buildRunStream}, by POSTing `/run` to the in-container runner over the\n * sandbox binding and adapting its NDJSON `StreamChunk` stream — so the base's\n * `RunController` / run-log / streaming tail all work unchanged.\n *\n * TWO channels cross the container ↔ DO boundary; everything else (the MCP\n * transport, native stdin) is in-container localhost:\n * • events OUT: runner → DO (NDJSON of StreamChunk, appended to the run-log)\n * • host-tool EXECUTION: container → DO (`/tool-exec/:runId`, bearer-gated) —\n * the REAL tool `execute()` (DB / secrets / app state) lives HERE.\n *\n * The per-run config — host tools, workspace, harness, model — is the subclass's\n * {@link config} method.\n *\n * NOTE: Workers-runtime code — compiles against the real Cloudflare + TanStack\n * AI types; not runtime-verified in this repo (no Workers runtime / container\n * build here). It follows the proven run-log / remote-tool contracts.\n */\nimport { EventType } from '@tanstack/ai'\nimport {\n executeHostTool,\n isToolExecRequest,\n toolDescriptors,\n} from '@tanstack/ai-sandbox'\nimport { getSandbox } from '@cloudflare/sandbox'\nimport { SandboxCoordinator, resolveBridgeOrigin } from './coordinator'\nimport { timingSafeBearerEqualWeb } from './web-crypto'\nimport type { StartRunInput } from './coordinator'\nimport type { ContainerRunRequest, HarnessId } from './protocol'\nimport type { AnyTool, StreamChunk } from '@tanstack/ai'\nimport type { WorkspaceDefinition } from '@tanstack/ai-sandbox'\nimport type { Sandbox } from '@cloudflare/sandbox'\n\n/** Port the in-container runner listens on (matches RUNNER_PORT in the image). */\nconst RUNNER_PORT = 8080\n\n/**\n * The Env bindings a {@link ContainerSandboxCoordinator} requires. The\n * `tool-exec` URL the CONTAINER calls back on needs a hostname; `PUBLIC_HOSTNAME`\n * is OPTIONAL (request-derived when unset; locally → `host.docker.internal` — see\n * {@link resolveBridgeOrigin}).\n *\n * Auth is HARNESS-AGNOSTIC: the in-container CLI's API key is NOT a fixed field on\n * this env. Instead each run's workspace DECLARES the secret names it needs (via\n * `createSecrets`), and the coordinator copies those names out of the Worker `env`\n * into the container env at boot. So a Claude run declares `ANTHROPIC_API_KEY`, a\n * codex run declares `CODEX_API_KEY`, and neither name is baked into the package —\n * the concrete key binding lives on the APP's env type, not here.\n */\nexport interface ContainerCoordinatorEnv {\n /** The `@cloudflare/sandbox` Sandbox DO namespace (the container hosts). */\n Sandbox: DurableObjectNamespace<Sandbox>\n /**\n * Hostname the container uses to reach the DO's `/tool-exec` endpoint. Optional:\n * unset → derived from the trigger request (deployed: request host; local dev:\n * `host.docker.internal`). Set it only to override. See {@link resolveBridgeOrigin}.\n */\n PUBLIC_HOSTNAME?: string\n}\n\n/** What {@link ContainerSandboxCoordinator.config} returns for one run. */\nexport interface ContainerRunConfig {\n /**\n * The REAL host tools. Their `execute()` runs HERE, in the DO — the\n * in-container agent only ever reaches them via `/tool-exec/:runId`. Only the\n * serialized descriptors cross to the container.\n */\n hostTools: Array<AnyTool>\n /** Workspace the in-container runner bootstraps for the agent. */\n workspace: WorkspaceDefinition\n /** Which in-sandbox harness the runner spawns. */\n harness: HarnessId\n /** Model id passed to that harness. */\n model: string\n /** Runtime context forwarded to each host tool's `execute()` (DB / app state). */\n context?: unknown\n}\n\n/** Per-run tool-exec state; gates `/tool-exec/:runId` and runs the host tools. */\ninterface ToolExecState {\n token: string\n hostTools: Array<AnyTool>\n context?: unknown\n /** Aborted once the run is terminal so a still-running host tool is cancelled. */\n abort: AbortController\n}\n\n/** Narrow one NDJSON line into a StreamChunk (project rule: no `as`). */\nfunction isStreamChunk(value: unknown): value is StreamChunk {\n return value !== null && typeof value === 'object' && 'type' in value\n}\n\n/**\n * Adapt the runner's NDJSON response body into an `AsyncIterable<StreamChunk>`\n * so the DO can drive it through the SAME base `RunController` / `pipeToRunLog`\n * the DO-drives coordinator uses — terminal-status handling, RUN_ERROR\n * detection, and never-rejects semantics all come for free. A malformed line\n * (unparseable JSON, or valid JSON that isn't a chunk) is surfaced as a terminal\n * RUN_ERROR chunk, never silently dropped.\n */\nasync function* ndjsonToChunks(\n body: ReadableStream<Uint8Array>,\n): AsyncIterable<StreamChunk> {\n const reader = body.getReader()\n // Decode incrementally with `stream: true` so a multi-byte char split across\n // two reads is reassembled correctly (TextDecoderStream's DOM/Workers typings\n // disagree across versions; a plain TextDecoder is version-robust and no-cast).\n const decoder = new TextDecoder()\n let buffer = ''\n let result = await reader.read()\n while (!result.done) {\n buffer += decoder.decode(result.value, { stream: true })\n let newline = buffer.indexOf('\\n')\n while (newline !== -1) {\n const line = buffer.slice(0, newline).trim()\n buffer = buffer.slice(newline + 1)\n newline = buffer.indexOf('\\n')\n if (line === '') continue\n const chunk = parseChunkLine(line)\n yield chunk\n if (chunk.type === EventType.RUN_ERROR) return\n }\n result = await reader.read()\n }\n buffer += decoder.decode()\n const tail = buffer.trim()\n if (tail !== '') yield parseChunkLine(tail)\n}\n\n/**\n * Parse one NDJSON line into a {@link StreamChunk}, turning a truncated/garbled\n * line (a crashed container's last write) or a non-chunk object into a terminal\n * RUN_ERROR chunk rather than throwing or silently dropping it.\n */\nfunction parseChunkLine(line: string): StreamChunk {\n let parsed: unknown\n try {\n parsed = JSON.parse(line)\n } catch {\n return {\n type: EventType.RUN_ERROR,\n message: `runner sent unparseable NDJSON: ${line.slice(0, 200)}`,\n }\n }\n if (!isStreamChunk(parsed)) {\n return {\n type: EventType.RUN_ERROR,\n message: 'runner sent a non-chunk line',\n }\n }\n return parsed\n}\n\nexport abstract class ContainerSandboxCoordinator<\n TEnv extends ContainerCoordinatorEnv = ContainerCoordinatorEnv,\n> extends SandboxCoordinator<TEnv> {\n /**\n * Live per-run tool-exec tokens, keyed by runId. In-memory by design: a run's\n * tool-exec endpoint is only reachable while the run is in flight, and\n * `ctx.waitUntil(done)` keeps THIS instance alive for the run's lifetime, so\n * the container's callbacks always hit the instance that minted the token.\n */\n private readonly toolExec = new Map<string, ToolExecState>()\n\n /**\n * In-flight runner boot, memoized so two runs starting near-simultaneously on\n * this instance don't both spawn `container-runner` (the second would hit\n * EADDRINUSE on RUNNER_PORT). Cleared once boot settles.\n */\n private runnerBoot?: Promise<void>\n\n /** Last `/health` probe error, surfaced if the runner never comes up. */\n private lastProbeError?: unknown\n\n // ===========================================================================\n // Subclass seam: the per-run configuration\n // ===========================================================================\n\n /**\n * Resolve the host tools, workspace, harness, and model for one run.\n * Implemented by the app subclass (or supplied by\n * {@link createCloudflareSandboxAgent}).\n */\n protected abstract config(input: StartRunInput): ContainerRunConfig\n\n // ===========================================================================\n // The one per-model seam: drive the in-container runner\n // ===========================================================================\n\n /**\n * Mint the per-run tool-exec token, POST `/run` to the in-container runner, and\n * yield its NDJSON chunks. The token is registered BEFORE the container is told\n * to run, so a tool callback can never arrive before the token exists.\n */\n protected override buildRunStream(\n input: StartRunInput,\n ): AsyncIterable<StreamChunk> {\n const runConfig = this.config(input)\n // Mint the token BEFORE driving the container, registering the real tools so\n // `/tool-exec/:runId` can execute them.\n const token = crypto.randomUUID() + crypto.randomUUID().replace(/-/g, '')\n this.toolExec.set(input.runId, {\n token,\n hostTools: runConfig.hostTools,\n ...(runConfig.context !== undefined\n ? { context: runConfig.context }\n : {}),\n abort: new AbortController(),\n })\n return this.driveContainer(input, runConfig, token)\n }\n\n /**\n * Once the run is terminal, abort any host tool still running on its behalf\n * (so a tool that outlived the run doesn't leak), then drop the per-run state.\n */\n protected override onRunSettled(runId: string): void {\n const state = this.toolExec.get(runId)\n if (state) state.abort.abort()\n this.toolExec.delete(runId)\n }\n\n /**\n * POST `/run` to the in-container runner and yield its NDJSON chunks. The DO\n * reaches the runner DIRECTLY over the sandbox binding (`containerFetch` to\n * RUNNER_PORT) — this internal channel needs no public hostname. The runner\n * gets the host-tool descriptors plus the `/tool-exec` URL + token it calls\n * back on.\n */\n private async *driveContainer(\n input: StartRunInput,\n runConfig: ContainerRunConfig,\n token: string,\n ): AsyncIterable<StreamChunk> {\n const sandbox = getSandbox(this.env.Sandbox, input.threadId)\n await this.ensureRunner(sandbox, runConfig.workspace)\n // Container→Worker origin: `PUBLIC_HOSTNAME` if set, else derived from the\n // trigger request (locally → host.docker.internal). The tool-exec token rides\n // this URL. See `resolveBridgeOrigin`.\n const origin = resolveBridgeOrigin(this.env, input)\n const body: ContainerRunRequest = {\n runId: input.runId,\n threadId: input.threadId,\n messages: input.messages,\n harness: runConfig.harness,\n model: runConfig.model,\n workspace: runConfig.workspace,\n // Serialize the DO's real tools to wire descriptors for the container.\n toolDescriptors: toolDescriptors(runConfig.hostTools),\n // The container calls back here for host-tool EXECUTION. It must be a URL\n // the CONTAINER can reach, so it goes via the Worker's public hostname.\n toolExecUrl: `${origin}/tool-exec/${input.runId}?threadId=${encodeURIComponent(input.threadId)}`,\n toolExecToken: token,\n }\n const response = await sandbox.containerFetch(\n 'http://runner/run',\n {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(body),\n },\n RUNNER_PORT,\n )\n if (!response.ok || !response.body) {\n const text = await response.text()\n // Surface as a terminal RUN_ERROR chunk; the base run driver finishes the\n // run as `error` and tailing clients observe it.\n yield {\n type: EventType.RUN_ERROR,\n message: `container runner failed: ${response.status} ${text.slice(0, 200)}`,\n }\n return\n }\n yield* ndjsonToChunks(response.body)\n }\n\n /**\n * Ensure the in-container runner is listening on RUNNER_PORT. The base image's\n * ENTRYPOINT is the sandbox CONTROL server, not our runner — so we start the\n * bundled runner as a background process via that control server. Idempotent\n * for a thread-reused container: if `/health` already answers, we skip spawn.\n */\n private ensureRunner(\n sandbox: Sandbox,\n workspace: WorkspaceDefinition,\n ): Promise<void> {\n // Memoize so concurrent runs on this instance share ONE boot.\n if (this.runnerBoot) return this.runnerBoot\n const boot = this.bootRunner(sandbox, workspace).finally(() => {\n this.runnerBoot = undefined\n })\n this.runnerBoot = boot\n return boot\n }\n\n /**\n * Copy the run's DECLARED secret names out of the Worker `env` into a plain\n * record for the container env. The workspace's `createSecrets` carries only the\n * names across the `/run` boundary; the VALUES come from `env` by that name —\n * which is how `ANTHROPIC_API_KEY` / `CODEX_API_KEY` / any harness key reach the\n * CLI without the package hardcoding which one. A declared name missing from\n * `env` is skipped here and fails loudly later in the runner's\n * `reconstituteWorkspace` (never a silent keyless run).\n */\n private secretEnvFromWorkspace(\n workspace: WorkspaceDefinition,\n ): Record<string, string> {\n const env = this.env as Record<string, unknown>\n const out: Record<string, string> = {}\n for (const name of Object.keys(workspace.secrets ?? {})) {\n const value = env[name]\n if (typeof value === 'string' && value !== '') out[name] = value\n }\n return out\n }\n\n private async bootRunner(\n sandbox: Sandbox,\n workspace: WorkspaceDefinition,\n ): Promise<void> {\n if (await this.runnerHealthy(sandbox)) return\n // Inject the run's declared secrets into the container env so the in-container\n // CLI can authenticate. Values never land in argv or the run-log. Harness-\n // agnostic: whichever secret names the workspace declared (ANTHROPIC_API_KEY,\n // CODEX_API_KEY, …) are read from `env` by name. The runner process inherits\n // this env at boot, so secrets must be set BEFORE startProcess.\n const secretEnv = this.secretEnvFromWorkspace(workspace)\n if (Object.keys(secretEnv).length > 0) {\n await sandbox.setEnvVars(secretEnv)\n }\n // The Dockerfile copies the bundled runner to /app/container-runner.mjs.\n await sandbox.startProcess(`node /app/container-runner.mjs`, {\n env: { RUNNER_PORT: String(RUNNER_PORT) },\n })\n // Poll until it answers /health (container cold-start + node boot). A run\n // that never comes up surfaces as a failed containerFetch above — not a hang.\n for (let attempt = 0; attempt < 20; attempt += 1) {\n if (await this.runnerHealthy(sandbox)) return\n await new Promise((resolve) => setTimeout(resolve, 250))\n }\n // Include the last probe error so a real misconfig (missing binding, image\n // without the runner) is distinguishable from a plain slow cold-start.\n const detail =\n this.lastProbeError instanceof Error\n ? `: ${this.lastProbeError.message}`\n : this.lastProbeError !== undefined\n ? `: ${String(this.lastProbeError)}`\n : ''\n throw new Error(\n `in-container runner did not become healthy in time${detail}`,\n )\n }\n\n private async runnerHealthy(sandbox: Sandbox): Promise<boolean> {\n try {\n const res = await sandbox.containerFetch(\n 'http://runner/health',\n { method: 'GET' },\n RUNNER_PORT,\n )\n return res.ok\n } catch (error) {\n this.lastProbeError = error\n return false\n }\n }\n\n // ===========================================================================\n // The host-tool-exec callback (`/tool-exec/:runId`), from the base fetch\n // ===========================================================================\n\n protected override handleRoute(\n request: Request,\n parts: Array<string>,\n ): Promise<Response> | Response {\n if (parts[0] === 'tool-exec' && typeof parts[1] === 'string') {\n return this.serveToolExec(parts[1], request)\n }\n return super.handleRoute(request, parts)\n }\n\n /**\n * Execute a host tool the in-container agent called back for. The token gates\n * it (constant-time Web Crypto compare); the REAL tool's `execute()` runs here\n * via {@link executeHostTool} and its raw result returns as `{ result }`. An\n * unknown tool or a thrown `execute()` is surfaced as a 4xx/5xx, never masked.\n */\n private async serveToolExec(\n runId: string,\n request: Request,\n ): Promise<Response> {\n const state = this.toolExec.get(runId)\n if (!state) return new Response('no active run', { status: 404 })\n if (\n !timingSafeBearerEqualWeb(\n request.headers.get('authorization') ?? undefined,\n state.token,\n )\n ) {\n return new Response('unauthorized', { status: 401 })\n }\n let payload: unknown\n try {\n payload = await request.json()\n } catch {\n return this.jsonResponse({ error: 'body must be valid JSON' }, 400)\n }\n if (!isToolExecRequest(payload)) {\n return this.jsonResponse({ error: 'body must be { name, args }' }, 400)\n }\n try {\n const result = await executeHostTool(\n state.hostTools,\n payload.name,\n payload.args,\n {\n ...(state.context !== undefined ? { context: state.context } : {}),\n signal: state.abort.signal,\n },\n )\n return this.jsonResponse({ result })\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n return this.jsonResponse({ error: message }, 500)\n }\n }\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"container-coordinator.js","names":[],"sources":["../../src/container-coordinator.ts"],"sourcesContent":["/**\n * `ContainerSandboxCoordinator` — the concrete {@link SandboxCoordinator} for\n * the CO-LOCATED (\"combined\") model: the harness loop AND its MCP tool-bridge\n * run INSIDE the container; this DO stays OUTSIDE as a thin durable coordinator.\n *\n * Worker (stateless trigger)\n * → ContainerSandboxCoordinator (this DO: thin durable coordinator)\n * → Container (runs the in-container harness runner that runs chat())\n *\n * The defining difference from {@link ChatSandboxCoordinator}: this DO does NOT\n * call `chat()` / the adapter itself. It implements the one per-model seam,\n * {@link buildRunStream}, by POSTing `/run` to the in-container runner over the\n * sandbox binding and adapting its NDJSON `StreamChunk` stream — so the base's\n * `RunController` / run-log / streaming tail all work unchanged.\n *\n * TWO channels cross the container ↔ DO boundary; everything else (the MCP\n * transport, native stdin) is in-container localhost:\n * • events OUT: runner → DO (NDJSON of StreamChunk, appended to the run-log)\n * • host-tool EXECUTION: container → DO (`/tool-exec/:runId`, bearer-gated) —\n * the REAL tool `execute()` (DB / secrets / app state) lives HERE.\n *\n * The per-run config — host tools, workspace, harness, model — is the subclass's\n * {@link config} method.\n *\n * NOTE: Workers-runtime code — compiles against the real Cloudflare + TanStack\n * AI types; not runtime-verified in this repo (no Workers runtime / container\n * build here). It follows the proven run-log / remote-tool contracts.\n */\nimport { EventType } from '@tanstack/ai'\nimport {\n executeHostTool,\n isToolExecRequest,\n toolDescriptors,\n} from '@tanstack/ai-sandbox'\nimport { getSandbox } from '@cloudflare/sandbox'\nimport { SandboxCoordinator, resolveBridgeOrigin } from './coordinator'\nimport { timingSafeBearerEqualWeb } from './web-crypto'\nimport type { StartRunInput } from './coordinator'\nimport type { ContainerRunRequest, HarnessId } from './protocol'\nimport type { AnyTool, StreamChunk } from '@tanstack/ai'\nimport type { WorkspaceDefinition } from '@tanstack/ai-sandbox'\nimport type { Sandbox } from '@cloudflare/sandbox'\n\n/** Port the in-container runner listens on (matches RUNNER_PORT in the image). */\nconst RUNNER_PORT = 8080\n\n/**\n * The Env bindings a {@link ContainerSandboxCoordinator} requires. The\n * `tool-exec` URL the CONTAINER calls back on needs a hostname; `PUBLIC_HOSTNAME`\n * is OPTIONAL (request-derived when unset; locally → `host.docker.internal` — see\n * {@link resolveBridgeOrigin}).\n *\n * Auth is HARNESS-AGNOSTIC: the in-container CLI's API key is NOT a fixed field on\n * this env. Instead each run's workspace DECLARES the secret names it needs (via\n * `createSecrets`), and the coordinator copies those names out of the Worker `env`\n * into the container env at boot. So a Claude run declares `ANTHROPIC_API_KEY`, a\n * codex run declares `CODEX_API_KEY`, and neither name is baked into the package —\n * the concrete key binding lives on the APP's env type, not here.\n */\nexport interface ContainerCoordinatorEnv {\n /** The `@cloudflare/sandbox` Sandbox DO namespace (the container hosts). */\n Sandbox: DurableObjectNamespace<Sandbox>\n /**\n * Hostname the container uses to reach the DO's `/tool-exec` endpoint. Optional:\n * unset → derived from the trigger request (deployed: request host; local dev:\n * `host.docker.internal`). Set it only to override. See {@link resolveBridgeOrigin}.\n */\n PUBLIC_HOSTNAME?: string\n}\n\n/** What {@link ContainerSandboxCoordinator.config} returns for one run. */\nexport interface ContainerRunConfig {\n /**\n * The REAL host tools. Their `execute()` runs HERE, in the DO — the\n * in-container agent only ever reaches them via `/tool-exec/:runId`. Only the\n * serialized descriptors cross to the container.\n */\n hostTools: Array<AnyTool>\n /** Workspace the in-container runner bootstraps for the agent. */\n workspace: WorkspaceDefinition\n /** Which in-sandbox harness the runner spawns. */\n harness: HarnessId\n /** Model id passed to that harness. */\n model: string\n /** Runtime context forwarded to each host tool's `execute()` (DB / app state). */\n context?: unknown\n}\n\n/** Per-run tool-exec state; gates `/tool-exec/:runId` and runs the host tools. */\ninterface ToolExecState {\n token: string\n hostTools: Array<AnyTool>\n context?: unknown\n /** Aborted once the run is terminal so a still-running host tool is cancelled. */\n abort: AbortController\n}\n\n/** Narrow one NDJSON line into a StreamChunk (project rule: no `as`). */\nfunction isStreamChunk(value: unknown): value is StreamChunk {\n return value !== null && typeof value === 'object' && 'type' in value\n}\n\n/**\n * Adapt the runner's NDJSON response body into an `AsyncIterable<StreamChunk>`\n * so the DO can drive it through the SAME base `RunController` / `pipeToRunLog`\n * the DO-drives coordinator uses — terminal-status handling, RUN_ERROR\n * detection, and never-rejects semantics all come for free. A malformed line\n * (unparseable JSON, or valid JSON that isn't a chunk) is surfaced as a terminal\n * RUN_ERROR chunk, never silently dropped.\n */\nasync function* ndjsonToChunks(\n body: ReadableStream<Uint8Array>,\n): AsyncIterable<StreamChunk> {\n const reader = body.getReader()\n // Decode incrementally with `stream: true` so a multi-byte char split across\n // two reads is reassembled correctly (TextDecoderStream's DOM/Workers typings\n // disagree across versions; a plain TextDecoder is version-robust and no-cast).\n const decoder = new TextDecoder()\n let buffer = ''\n let result = await reader.read()\n while (!result.done) {\n buffer += decoder.decode(result.value, { stream: true })\n let newline = buffer.indexOf('\\n')\n while (newline !== -1) {\n const line = buffer.slice(0, newline).trim()\n buffer = buffer.slice(newline + 1)\n newline = buffer.indexOf('\\n')\n if (line === '') continue\n const chunk = parseChunkLine(line)\n yield chunk\n if (chunk.type === EventType.RUN_ERROR) return\n }\n result = await reader.read()\n }\n buffer += decoder.decode()\n const tail = buffer.trim()\n if (tail !== '') yield parseChunkLine(tail)\n}\n\n/**\n * Parse one NDJSON line into a {@link StreamChunk}, turning a truncated/garbled\n * line (a crashed container's last write) or a non-chunk object into a terminal\n * RUN_ERROR chunk rather than throwing or silently dropping it.\n */\nfunction parseChunkLine(line: string): StreamChunk {\n let parsed: unknown\n try {\n parsed = JSON.parse(line)\n } catch {\n return {\n type: EventType.RUN_ERROR,\n message: `runner sent unparseable NDJSON: ${line.slice(0, 200)}`,\n }\n }\n if (!isStreamChunk(parsed)) {\n return {\n type: EventType.RUN_ERROR,\n message: 'runner sent a non-chunk line',\n }\n }\n return parsed\n}\n\nexport abstract class ContainerSandboxCoordinator<\n TEnv extends ContainerCoordinatorEnv = ContainerCoordinatorEnv,\n> extends SandboxCoordinator<TEnv> {\n /**\n * Live per-run tool-exec tokens, keyed by runId. In-memory by design: a run's\n * tool-exec endpoint is only reachable while the run is in flight, and\n * `ctx.waitUntil(done)` keeps THIS instance alive for the run's lifetime, so\n * the container's callbacks always hit the instance that minted the token.\n */\n private readonly toolExec = new Map<string, ToolExecState>()\n\n /**\n * In-flight runner boot, memoized so two runs starting near-simultaneously on\n * this instance don't both spawn `container-runner` (the second would hit\n * EADDRINUSE on RUNNER_PORT). Cleared once boot settles.\n */\n private runnerBoot?: Promise<void>\n\n /** Last `/health` probe error, surfaced if the runner never comes up. */\n private lastProbeError?: unknown\n\n // ===========================================================================\n // Subclass seam: the per-run configuration\n // ===========================================================================\n\n /**\n * Resolve the host tools, workspace, harness, and model for one run.\n * Implemented by the app subclass (or supplied by\n * {@link createCloudflareSandboxAgent}).\n */\n protected abstract config(input: StartRunInput): ContainerRunConfig\n\n // ===========================================================================\n // The one per-model seam: drive the in-container runner\n // ===========================================================================\n\n /**\n * Mint the per-run tool-exec token, POST `/run` to the in-container runner, and\n * yield its NDJSON chunks. The token is registered BEFORE the container is told\n * to run, so a tool callback can never arrive before the token exists.\n */\n protected override buildRunStream(\n input: StartRunInput,\n ): AsyncIterable<StreamChunk> {\n const runConfig = this.config(input)\n // Mint the token BEFORE driving the container, registering the real tools so\n // `/tool-exec/:runId` can execute them.\n const token = crypto.randomUUID() + crypto.randomUUID().replace(/-/g, '')\n this.toolExec.set(input.runId, {\n token,\n hostTools: runConfig.hostTools,\n ...(runConfig.context !== undefined\n ? { context: runConfig.context }\n : {}),\n abort: new AbortController(),\n })\n return this.driveContainer(input, runConfig, token)\n }\n\n /**\n * Once the run is terminal, abort any host tool still running on its behalf\n * (so a tool that outlived the run doesn't leak), then drop the per-run state.\n */\n protected override onRunSettled(runId: string): void {\n const state = this.toolExec.get(runId)\n if (state) state.abort.abort()\n this.toolExec.delete(runId)\n }\n\n /**\n * POST `/run` to the in-container runner and yield its NDJSON chunks. The DO\n * reaches the runner DIRECTLY over the sandbox binding (`containerFetch` to\n * RUNNER_PORT) — this internal channel needs no public hostname. The runner\n * gets the host-tool descriptors plus the `/tool-exec` URL + token it calls\n * back on.\n */\n private async *driveContainer(\n input: StartRunInput,\n runConfig: ContainerRunConfig,\n token: string,\n ): AsyncIterable<StreamChunk> {\n const sandbox = getSandbox(this.env.Sandbox, input.threadId)\n await this.ensureRunner(sandbox, runConfig.workspace)\n // Container→Worker origin: `PUBLIC_HOSTNAME` if set, else derived from the\n // trigger request (locally → host.docker.internal). The tool-exec token rides\n // this URL. See `resolveBridgeOrigin`.\n const origin = resolveBridgeOrigin(this.env, input)\n const body: ContainerRunRequest = {\n runId: input.runId,\n threadId: input.threadId,\n messages: input.messages,\n harness: runConfig.harness,\n model: runConfig.model,\n workspace: runConfig.workspace,\n // Serialize the DO's real tools to wire descriptors for the container.\n toolDescriptors: toolDescriptors(runConfig.hostTools),\n // The container calls back here for host-tool EXECUTION. It must be a URL\n // the CONTAINER can reach, so it goes via the Worker's public hostname.\n toolExecUrl: `${origin}/tool-exec/${input.runId}?threadId=${encodeURIComponent(input.threadId)}`,\n toolExecToken: token,\n }\n const response = await sandbox.containerFetch(\n 'http://runner/run',\n {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(body),\n },\n RUNNER_PORT,\n )\n if (!response.ok || !response.body) {\n const text = await response.text()\n // Surface as a terminal RUN_ERROR chunk; the base run driver finishes the\n // run as `error` and tailing clients observe it.\n yield {\n type: EventType.RUN_ERROR,\n message: `container runner failed: ${response.status} ${text.slice(0, 200)}`,\n }\n return\n }\n yield* ndjsonToChunks(response.body)\n }\n\n /**\n * Ensure the in-container runner is listening on RUNNER_PORT. The base image's\n * ENTRYPOINT is the sandbox CONTROL server, not our runner — so we start the\n * bundled runner as a background process via that control server. Idempotent\n * for a thread-reused container: if `/health` already answers, we skip spawn.\n */\n private ensureRunner(\n sandbox: Sandbox,\n workspace: WorkspaceDefinition,\n ): Promise<void> {\n // Memoize so concurrent runs on this instance share ONE boot.\n if (this.runnerBoot) return this.runnerBoot\n const boot = this.bootRunner(sandbox, workspace).finally(() => {\n this.runnerBoot = undefined\n })\n this.runnerBoot = boot\n return boot\n }\n\n /**\n * Copy the run's DECLARED secret names out of the Worker `env` into a plain\n * record for the container env. The workspace's `createSecrets` carries only the\n * names across the `/run` boundary; the VALUES come from `env` by that name —\n * which is how `ANTHROPIC_API_KEY` / `CODEX_API_KEY` / any harness key reach the\n * CLI without the package hardcoding which one. A declared name missing from\n * `env` is skipped here and fails loudly later in the runner's\n * `reconstituteWorkspace` (never a silent keyless run).\n */\n private secretEnvFromWorkspace(\n workspace: WorkspaceDefinition,\n ): Record<string, string> {\n const env = this.env as Record<string, unknown>\n const out: Record<string, string> = {}\n for (const name of Object.keys(workspace.secrets ?? {})) {\n const value = env[name]\n if (typeof value === 'string' && value !== '') out[name] = value\n }\n return out\n }\n\n private async bootRunner(\n sandbox: Sandbox,\n workspace: WorkspaceDefinition,\n ): Promise<void> {\n if (await this.runnerHealthy(sandbox)) return\n // Inject the run's declared secrets into the container env so the in-container\n // CLI can authenticate. Values never land in argv or the run-log. Harness-\n // agnostic: whichever secret names the workspace declared (ANTHROPIC_API_KEY,\n // CODEX_API_KEY, …) are read from `env` by name. The runner process inherits\n // this env at boot, so secrets must be set BEFORE startProcess.\n const secretEnv = this.secretEnvFromWorkspace(workspace)\n if (Object.keys(secretEnv).length > 0) {\n await sandbox.setEnvVars(secretEnv)\n }\n // The Dockerfile copies the bundled runner to /app/container-runner.mjs.\n await sandbox.startProcess(`node /app/container-runner.mjs`, {\n env: { RUNNER_PORT: String(RUNNER_PORT) },\n })\n // Poll until it answers /health (container cold-start + node boot). A run\n // that never comes up surfaces as a failed containerFetch above — not a hang.\n for (let attempt = 0; attempt < 20; attempt += 1) {\n if (await this.runnerHealthy(sandbox)) return\n await new Promise((resolve) => setTimeout(resolve, 250))\n }\n // Include the last probe error so a real misconfig (missing binding, image\n // without the runner) is distinguishable from a plain slow cold-start.\n const detail =\n this.lastProbeError instanceof Error\n ? `: ${this.lastProbeError.message}`\n : this.lastProbeError !== undefined\n ? `: ${String(this.lastProbeError)}`\n : ''\n throw new Error(\n `in-container runner did not become healthy in time${detail}`,\n )\n }\n\n private async runnerHealthy(sandbox: Sandbox): Promise<boolean> {\n try {\n const res = await sandbox.containerFetch(\n 'http://runner/health',\n { method: 'GET' },\n RUNNER_PORT,\n )\n return res.ok\n } catch (error) {\n this.lastProbeError = error\n return false\n }\n }\n\n // ===========================================================================\n // The host-tool-exec callback (`/tool-exec/:runId`), from the base fetch\n // ===========================================================================\n\n protected override handleRoute(\n request: Request,\n parts: Array<string>,\n ): Promise<Response> | Response {\n if (parts[0] === 'tool-exec' && typeof parts[1] === 'string') {\n return this.serveToolExec(parts[1], request)\n }\n return super.handleRoute(request, parts)\n }\n\n /**\n * Execute a host tool the in-container agent called back for. The token gates\n * it (constant-time Web Crypto compare); the REAL tool's `execute()` runs here\n * via {@link executeHostTool} and its raw result returns as `{ result }`. An\n * unknown tool or a thrown `execute()` is surfaced as a 4xx/5xx, never masked.\n */\n private async serveToolExec(\n runId: string,\n request: Request,\n ): Promise<Response> {\n const state = this.toolExec.get(runId)\n if (!state) return new Response('no active run', { status: 404 })\n if (\n !timingSafeBearerEqualWeb(\n request.headers.get('authorization') ?? undefined,\n state.token,\n )\n ) {\n return new Response('unauthorized', { status: 401 })\n }\n let payload: unknown\n try {\n payload = await request.json()\n } catch {\n return this.jsonResponse({ error: 'body must be valid JSON' }, 400)\n }\n if (!isToolExecRequest(payload)) {\n return this.jsonResponse({ error: 'body must be { name, args }' }, 400)\n }\n try {\n const result = await executeHostTool(\n state.hostTools,\n payload.name,\n payload.args,\n {\n ...(state.context !== undefined ? { context: state.context } : {}),\n signal: state.abort.signal,\n },\n )\n return this.jsonResponse({ result })\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n return this.jsonResponse({ error: message }, 500)\n }\n }\n}\n"],"mappings":"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"}
@@ -1,7 +1,8 @@
1
1
  import { DurableObject } from 'cloudflare:workers';
2
- import { RunController, RunRecord } from '@tanstack/ai-sandbox';
2
+ import { RunController } from '@tanstack/ai-sandbox';
3
3
  import { DurableObjectRunEventLog } from './run-log-do.js';
4
4
  import { ModelMessage, StreamChunk } from '@tanstack/ai';
5
+ import { RunLogRecord } from './run-log.js';
5
6
  /** What the Worker hands the coordinator to start a run. */
6
7
  export interface StartRunInput {
7
8
  runId: string;
@@ -51,7 +52,7 @@ export declare abstract class SandboxCoordinator<TEnv = unknown> extends Durable
51
52
  startRun(input: StartRunInput): Promise<{
52
53
  runId: string;
53
54
  }>;
54
- status(runId: string): Promise<RunRecord | null>;
55
+ status(runId: string): Promise<RunLogRecord | null>;
55
56
  fetch(request: Request): Promise<Response>;
56
57
  private acceptStream;
57
58
  /**