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