@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 @@
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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"}
@@ -0,0 +1,68 @@
1
+ import { DurableObject } from 'cloudflare:workers';
2
+ import { RunController, RunRecord } from '@tanstack/ai-sandbox';
3
+ import { DurableObjectRunEventLog } from './run-log-do.js';
4
+ import { ModelMessage, StreamChunk } from '@tanstack/ai';
5
+ /** What the Worker hands the coordinator to start a run. */
6
+ export interface StartRunInput {
7
+ runId: string;
8
+ threadId: string;
9
+ messages: Array<ModelMessage>;
10
+ /**
11
+ * The host the `POST /runs` trigger request arrived on, captured by the Worker
12
+ * (`new URL(request.url).host`). Used to derive the container's callback hosts
13
+ * when `PUBLIC_HOSTNAME` / `PREVIEW_HOSTNAME` are not set — see
14
+ * {@link resolveBridgeOrigin} / {@link resolvePreviewHost} for the rules (and the
15
+ * Cloudflare-specific reason request-derivation is safe to trust).
16
+ */
17
+ publicHost?: string;
18
+ /**
19
+ * Free-form per-run input forwarded verbatim from the trigger to the app's
20
+ * `adapter` / `sandbox` / `tools` resolvers (it reaches them through `config`
21
+ * unchanged; it is NOT persisted to the run-log). Use it to carry browser-chosen
22
+ * run options the base trigger has no field for — e.g. which harness to run, or a
23
+ * model id. The package never inspects it; the app validates whatever it reads.
24
+ */
25
+ metadata?: Record<string, unknown>;
26
+ }
27
+ export { resolveBridgeOrigin, resolvePreviewHost } from './public-host.js';
28
+ export declare abstract class SandboxCoordinator<TEnv = unknown> extends DurableObject<TEnv> {
29
+ protected readonly log: DurableObjectRunEventLog;
30
+ protected readonly controller: RunController;
31
+ /**
32
+ * Sockets with a live {@link pump} loop. Guards against a second concurrent
33
+ * pump on the same socket: `acceptStream` starts one, and `webSocketMessage`
34
+ * would start another on any inbound client message while the first is still
35
+ * running — double-delivering events and racing the persisted cursor.
36
+ */
37
+ private readonly pumping;
38
+ constructor(ctx: DurableObjectState, env: TEnv);
39
+ /**
40
+ * Produce the run's `StreamChunk` stream. The ONE model-specific method:
41
+ * `ChatSandboxCoordinator` runs `chat()` here; `ContainerSandboxCoordinator`
42
+ * drives the in-container runner. Lazily consumed by the run driver, so any
43
+ * setup (mint a token, start a container) can happen at the top.
44
+ */
45
+ protected abstract buildRunStream(input: StartRunInput): AsyncIterable<StreamChunk> | Promise<AsyncIterable<StreamChunk>>;
46
+ /** Extra fetch routes a subclass serves (e.g. `/_bridge`, `/tool-exec`). */
47
+ protected handleRoute(_request: Request, _parts: Array<string>): Promise<Response> | Response;
48
+ /** Called once a run reaches a terminal status (override to clean up state). */
49
+ protected onRunSettled(_runId: string): void;
50
+ protected jsonResponse(body: unknown, status?: number): Response;
51
+ startRun(input: StartRunInput): Promise<{
52
+ runId: string;
53
+ }>;
54
+ status(runId: string): Promise<RunRecord | null>;
55
+ fetch(request: Request): Promise<Response>;
56
+ private acceptStream;
57
+ /**
58
+ * Replay-then-tail loop for one socket. Each delivered event advances the
59
+ * socket's persisted cursor so a mid-stream reconnect resumes exactly once.
60
+ * No-ops if a pump is already running for this socket (see {@link pumping}).
61
+ */
62
+ private pump;
63
+ webSocketMessage(ws: WebSocket, _message: string | ArrayBuffer): void;
64
+ webSocketClose(_ws: WebSocket, _code: number, _reason: string): void;
65
+ alarm(): Promise<void>;
66
+ /** Mark a stalled (orchestrator-presumed-dead) run as a terminal error. */
67
+ private failStalledRun;
68
+ }
@@ -0,0 +1,188 @@
1
+ import { DurableObject } from "cloudflare:workers";
2
+ import { EventType } from "@tanstack/ai";
3
+ import { RunController, isTerminalRunStatus } from "@tanstack/ai-sandbox";
4
+ import { DurableObjectRunEventLog } from "./run-log-do.js";
5
+ const WATCHDOG_MS = 3e4;
6
+ const WATCHDOG_STALL_MS = 5 * 6e4;
7
+ function isSocketAttachment(value) {
8
+ return value !== null && typeof value === "object" && "runId" in value && typeof value.runId === "string" && "lastSeq" in value && typeof value.lastSeq === "number";
9
+ }
10
+ class SandboxCoordinator extends DurableObject {
11
+ log;
12
+ controller;
13
+ /**
14
+ * Sockets with a live {@link pump} loop. Guards against a second concurrent
15
+ * pump on the same socket: `acceptStream` starts one, and `webSocketMessage`
16
+ * would start another on any inbound client message while the first is still
17
+ * running — double-delivering events and racing the persisted cursor.
18
+ */
19
+ pumping = /* @__PURE__ */ new WeakSet();
20
+ constructor(ctx, env) {
21
+ super(ctx, env);
22
+ this.log = new DurableObjectRunEventLog(ctx.storage);
23
+ this.controller = new RunController(this.log);
24
+ }
25
+ /** Extra fetch routes a subclass serves (e.g. `/_bridge`, `/tool-exec`). */
26
+ handleRoute(_request, _parts) {
27
+ return new Response("not found", { status: 404 });
28
+ }
29
+ /** Called once a run reaches a terminal status (override to clean up state). */
30
+ onRunSettled(_runId) {
31
+ }
32
+ jsonResponse(body, status = 200) {
33
+ return new Response(JSON.stringify(body), {
34
+ status,
35
+ headers: { "content-type": "application/json" }
36
+ });
37
+ }
38
+ // ===========================================================================
39
+ // Trigger (called by the Worker; returns immediately)
40
+ // ===========================================================================
41
+ async startRun(input) {
42
+ const existing = await this.log.get(input.runId);
43
+ if (existing) return { runId: input.runId };
44
+ await this.log.open({ runId: input.runId, threadId: input.threadId });
45
+ let stream;
46
+ try {
47
+ stream = await this.buildRunStream(input);
48
+ } catch (error) {
49
+ const message = error instanceof Error ? error.message : String(error);
50
+ await this.log.append(input.runId, {
51
+ type: EventType.RUN_ERROR,
52
+ message
53
+ });
54
+ await this.log.finish(input.runId, "error", { message });
55
+ this.onRunSettled(input.runId);
56
+ return { runId: input.runId };
57
+ }
58
+ const { done } = this.controller.start({
59
+ runId: input.runId,
60
+ threadId: input.threadId,
61
+ stream
62
+ });
63
+ this.ctx.waitUntil(done.finally(() => this.onRunSettled(input.runId)));
64
+ await this.ctx.storage.setAlarm(Date.now() + WATCHDOG_MS);
65
+ return { runId: input.runId };
66
+ }
67
+ async status(runId) {
68
+ return this.controller.status(runId);
69
+ }
70
+ // ===========================================================================
71
+ // HTTP surface
72
+ // ===========================================================================
73
+ async fetch(request) {
74
+ const url = new URL(request.url);
75
+ const parts = url.pathname.split("/").filter(Boolean);
76
+ if (parts[0] === "runs" && typeof parts[1] === "string") {
77
+ if (parts[2] === "stream") return this.acceptStream(parts[1], request);
78
+ if (parts.length === 2 && request.method === "GET") {
79
+ const record = await this.status(parts[1]);
80
+ return record ? this.jsonResponse(record) : this.jsonResponse({ error: "unknown run" }, 404);
81
+ }
82
+ }
83
+ return this.handleRoute(request, parts);
84
+ }
85
+ // ===========================================================================
86
+ // WebSocket streaming with hibernation + resumable cursor
87
+ // ===========================================================================
88
+ async acceptStream(runId, request) {
89
+ if (request.headers.get("upgrade") !== "websocket") {
90
+ return new Response("expected websocket upgrade", { status: 426 });
91
+ }
92
+ const record = await this.log.get(runId);
93
+ if (!record) return new Response("unknown run", { status: 404 });
94
+ const url = new URL(request.url);
95
+ const lastSeqParam = url.searchParams.get("lastSeq");
96
+ const lastSeq = lastSeqParam !== null ? Number.parseInt(lastSeqParam, 10) : -1;
97
+ if (Number.isNaN(lastSeq)) {
98
+ return new Response("lastSeq must be an integer", { status: 400 });
99
+ }
100
+ const pair = new WebSocketPair();
101
+ const [client, server] = [pair[0], pair[1]];
102
+ server.serializeAttachment({ runId, lastSeq });
103
+ this.ctx.acceptWebSocket(server);
104
+ this.pump(server, runId, lastSeq);
105
+ return new Response(null, { status: 101, webSocket: client });
106
+ }
107
+ /**
108
+ * Replay-then-tail loop for one socket. Each delivered event advances the
109
+ * socket's persisted cursor so a mid-stream reconnect resumes exactly once.
110
+ * No-ops if a pump is already running for this socket (see {@link pumping}).
111
+ */
112
+ pump(socket, runId, fromSeq) {
113
+ if (this.pumping.has(socket)) return;
114
+ this.pumping.add(socket);
115
+ const done = (async () => {
116
+ try {
117
+ for await (const event of this.controller.attach(runId, { fromSeq })) {
118
+ socket.send(JSON.stringify(event));
119
+ socket.serializeAttachment({
120
+ runId,
121
+ lastSeq: event.seq
122
+ });
123
+ }
124
+ const record = await this.log.get(runId);
125
+ if (socket.readyState === WebSocket.OPEN) {
126
+ socket.send(JSON.stringify({ type: "status", record }));
127
+ socket.close(1e3, "run complete");
128
+ }
129
+ } catch (error) {
130
+ const message = error instanceof Error ? error.message : String(error);
131
+ console.error(
132
+ `[sandbox-coordinator] tail failed for run ${runId}:`,
133
+ error
134
+ );
135
+ if (socket.readyState === WebSocket.OPEN) {
136
+ socket.close(1011, message.slice(0, 120));
137
+ }
138
+ } finally {
139
+ this.pumping.delete(socket);
140
+ }
141
+ })();
142
+ this.ctx.waitUntil(done);
143
+ }
144
+ webSocketMessage(ws, _message) {
145
+ const attachment = ws.deserializeAttachment();
146
+ if (isSocketAttachment(attachment)) {
147
+ this.pump(ws, attachment.runId, attachment.lastSeq);
148
+ }
149
+ }
150
+ webSocketClose(_ws, _code, _reason) {
151
+ }
152
+ // ===========================================================================
153
+ // Watchdog alarm — keeps a run observable across hibernation
154
+ // ===========================================================================
155
+ async alarm() {
156
+ try {
157
+ const runs = await this.ctx.storage.list({ prefix: "rec:" });
158
+ const now = Date.now();
159
+ let active = false;
160
+ for (const record of runs.values()) {
161
+ if (isTerminalRunStatus(record.status)) continue;
162
+ if (now - record.updatedAt > WATCHDOG_STALL_MS) {
163
+ await this.failStalledRun(record.runId);
164
+ } else {
165
+ active = true;
166
+ }
167
+ }
168
+ if (active) await this.ctx.storage.setAlarm(Date.now() + WATCHDOG_MS);
169
+ } catch (error) {
170
+ console.error("[sandbox-coordinator] watchdog alarm failed:", error);
171
+ await this.ctx.storage.setAlarm(Date.now() + WATCHDOG_MS);
172
+ }
173
+ }
174
+ /** Mark a stalled (orchestrator-presumed-dead) run as a terminal error. */
175
+ async failStalledRun(runId) {
176
+ const message = "run watchdog: no progress; orchestrator presumed dead";
177
+ try {
178
+ await this.log.append(runId, { type: EventType.RUN_ERROR, message });
179
+ } catch {
180
+ }
181
+ await this.log.finish(runId, "error", { message });
182
+ this.onRunSettled(runId);
183
+ }
184
+ }
185
+ export {
186
+ SandboxCoordinator
187
+ };
188
+ //# sourceMappingURL=coordinator.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"coordinator.js","sources":["../../src/coordinator.ts"],"sourcesContent":["/**\n * `SandboxCoordinator` — the abstract Durable Object base for the serverless/\n * edge agent run model. It owns everything the two concrete models share:\n *\n * - a durable, resumable run-log ({@link DurableObjectRunEventLog});\n * - `startRun`: open the run, kick off the model's chunk stream WITHOUT blocking\n * the trigger, start piping it into the log via {@link RunController}, register\n * the resulting `done` promise with `ctx.waitUntil` (keeping the instance alive\n * until the run is terminal rather than letting it hibernate mid-run), and arm\n * a watchdog alarm;\n * - `status` (poll fallback) + a hibernatable WebSocket tail with a resumable\n * cursor (replay after `lastSeq`, then live-tail, reconnect-safe);\n * - routing for `GET /runs/:id` and `GET /runs/:id/stream`, delegating any other\n * path to {@link handleRoute} (which a subclass overrides for e.g. `/_bridge`\n * or `/tool-exec`).\n *\n * Subclasses implement {@link buildRunStream} — the ONE difference between the\n * models: run `chat()` in the DO ({@link ChatSandboxCoordinator}) or drive an\n * in-container runner ({@link ContainerSandboxCoordinator}).\n *\n * NOTE: Workers-runtime code — compiles against `@cloudflare/workers-types`; not\n * runtime-verified in this repo.\n */\nimport { DurableObject } from 'cloudflare:workers'\nimport { EventType } from '@tanstack/ai'\nimport { RunController, isTerminalRunStatus } from '@tanstack/ai-sandbox'\nimport { DurableObjectRunEventLog } from './run-log-do'\nimport type { ModelMessage, StreamChunk } from '@tanstack/ai'\nimport type { RunRecord } from '@tanstack/ai-sandbox'\n\n/** Re-arm window for the liveness watchdog while a run is in flight (ms). */\nconst WATCHDOG_MS = 30_000\n\n/**\n * How long a non-terminal run may go without ANY new event before the watchdog\n * presumes the orchestrator driving it is dead (eviction that lost the\n * `waitUntil` promise, an uncaught fault, a hung container) and fails the run so\n * tailing clients stop waiting forever. Generous so a legitimately slow agent\n * step (a long tool call that emits no chunks) is not killed prematurely.\n */\nconst WATCHDOG_STALL_MS = 5 * 60_000\n\n/** What the Worker hands the coordinator to start a run. */\nexport interface StartRunInput {\n runId: string\n threadId: string\n messages: Array<ModelMessage>\n /**\n * The host the `POST /runs` trigger request arrived on, captured by the Worker\n * (`new URL(request.url).host`). Used to derive the container's callback hosts\n * when `PUBLIC_HOSTNAME` / `PREVIEW_HOSTNAME` are not set — see\n * {@link resolveBridgeOrigin} / {@link resolvePreviewHost} for the rules (and the\n * Cloudflare-specific reason request-derivation is safe to trust).\n */\n publicHost?: string\n /**\n * Free-form per-run input forwarded verbatim from the trigger to the app's\n * `adapter` / `sandbox` / `tools` resolvers (it reaches them through `config`\n * unchanged; it is NOT persisted to the run-log). Use it to carry browser-chosen\n * run options the base trigger has no field for — e.g. which harness to run, or a\n * model id. The package never inspects it; the app validates whatever it reads.\n */\n metadata?: Record<string, unknown>\n}\n\n// Host resolvers live in their own (Workers-free) module so they stay pure and\n// unit-testable; re-exported here because the coordinators build their callback\n// URLs with them. `resolveBridgeOrigin` = container→Worker (/_bridge, /tool-exec);\n// `resolvePreviewHost` = browser→container previews. See their docstrings.\nexport { resolveBridgeOrigin, resolvePreviewHost } from './public-host'\n\n/** Cursor stashed on each hibernatable WebSocket so it survives eviction. */\ninterface SocketAttachment {\n runId: string\n lastSeq: number\n}\n\nfunction isSocketAttachment(value: unknown): value is SocketAttachment {\n return (\n value !== null &&\n typeof value === 'object' &&\n 'runId' in value &&\n typeof value.runId === 'string' &&\n 'lastSeq' in value &&\n typeof value.lastSeq === 'number'\n )\n}\n\nexport abstract class SandboxCoordinator<\n TEnv = unknown,\n> extends DurableObject<TEnv> {\n protected readonly log: DurableObjectRunEventLog\n protected readonly controller: RunController\n\n /**\n * Sockets with a live {@link pump} loop. Guards against a second concurrent\n * pump on the same socket: `acceptStream` starts one, and `webSocketMessage`\n * would start another on any inbound client message while the first is still\n * running — double-delivering events and racing the persisted cursor.\n */\n private readonly pumping = new WeakSet<WebSocket>()\n\n constructor(ctx: DurableObjectState, env: TEnv) {\n super(ctx, env)\n this.log = new DurableObjectRunEventLog(ctx.storage)\n this.controller = new RunController(this.log)\n }\n\n // ===========================================================================\n // Subclass seam\n // ===========================================================================\n\n /**\n * Produce the run's `StreamChunk` stream. The ONE model-specific method:\n * `ChatSandboxCoordinator` runs `chat()` here; `ContainerSandboxCoordinator`\n * drives the in-container runner. Lazily consumed by the run driver, so any\n * setup (mint a token, start a container) can happen at the top.\n */\n protected abstract buildRunStream(\n input: StartRunInput,\n ): AsyncIterable<StreamChunk> | Promise<AsyncIterable<StreamChunk>>\n\n /** Extra fetch routes a subclass serves (e.g. `/_bridge`, `/tool-exec`). */\n protected handleRoute(\n _request: Request,\n _parts: Array<string>,\n ): Promise<Response> | Response {\n return new Response('not found', { status: 404 })\n }\n\n /** Called once a run reaches a terminal status (override to clean up state). */\n protected onRunSettled(_runId: string): void {}\n\n protected jsonResponse(body: unknown, status = 200): Response {\n return new Response(JSON.stringify(body), {\n status,\n headers: { 'content-type': 'application/json' },\n })\n }\n\n // ===========================================================================\n // Trigger (called by the Worker; returns immediately)\n // ===========================================================================\n\n async startRun(input: StartRunInput): Promise<{ runId: string }> {\n const existing = await this.log.get(input.runId)\n if (existing) return { runId: input.runId } // idempotent re-trigger\n\n // Open the run BEFORE building the stream. `pipeToRunLog`'s never-rejects\n // guarantee only covers failures AFTER the stream is handed to it — a throw\n // while BUILDING the stream (config(), chat() validation, mint a token)\n // would otherwise leave no record and no terminal event, so a tailing client\n // would never see the failure. Opening here (idempotent with pipeToRunLog's\n // own open) lets us record it.\n await this.log.open({ runId: input.runId, threadId: input.threadId })\n let stream: AsyncIterable<StreamChunk>\n try {\n stream = await this.buildRunStream(input)\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n await this.log.append(input.runId, {\n type: EventType.RUN_ERROR,\n message,\n })\n await this.log.finish(input.runId, 'error', { message })\n this.onRunSettled(input.runId)\n return { runId: input.runId }\n }\n\n const { done } = this.controller.start({\n runId: input.runId,\n threadId: input.threadId,\n stream,\n })\n // Keep the instance alive until the run is terminal; `pipeToRunLog` never\n // rejects (failures land in the log), so no `.catch` is needed.\n this.ctx.waitUntil(done.finally(() => this.onRunSettled(input.runId)))\n await this.ctx.storage.setAlarm(Date.now() + WATCHDOG_MS)\n return { runId: input.runId }\n }\n\n async status(runId: string): Promise<RunRecord | null> {\n return this.controller.status(runId)\n }\n\n // ===========================================================================\n // HTTP surface\n // ===========================================================================\n\n override async fetch(request: Request): Promise<Response> {\n const url = new URL(request.url)\n const parts = url.pathname.split('/').filter(Boolean)\n\n if (parts[0] === 'runs' && typeof parts[1] === 'string') {\n if (parts[2] === 'stream') return this.acceptStream(parts[1], request)\n if (parts.length === 2 && request.method === 'GET') {\n const record = await this.status(parts[1])\n return record\n ? this.jsonResponse(record)\n : this.jsonResponse({ error: 'unknown run' }, 404)\n }\n }\n return this.handleRoute(request, parts)\n }\n\n // ===========================================================================\n // WebSocket streaming with hibernation + resumable cursor\n // ===========================================================================\n\n private async acceptStream(\n runId: string,\n request: Request,\n ): Promise<Response> {\n if (request.headers.get('upgrade') !== 'websocket') {\n return new Response('expected websocket upgrade', { status: 426 })\n }\n const record = await this.log.get(runId)\n if (!record) return new Response('unknown run', { status: 404 })\n\n const url = new URL(request.url)\n const lastSeqParam = url.searchParams.get('lastSeq')\n const lastSeq =\n lastSeqParam !== null ? Number.parseInt(lastSeqParam, 10) : -1\n if (Number.isNaN(lastSeq)) {\n return new Response('lastSeq must be an integer', { status: 400 })\n }\n\n const pair = new WebSocketPair()\n const [client, server] = [pair[0], pair[1]]\n server.serializeAttachment({ runId, lastSeq } satisfies SocketAttachment)\n this.ctx.acceptWebSocket(server)\n this.pump(server, runId, lastSeq)\n\n return new Response(null, { status: 101, webSocket: client })\n }\n\n /**\n * Replay-then-tail loop for one socket. Each delivered event advances the\n * socket's persisted cursor so a mid-stream reconnect resumes exactly once.\n * No-ops if a pump is already running for this socket (see {@link pumping}).\n */\n private pump(socket: WebSocket, runId: string, fromSeq: number): void {\n if (this.pumping.has(socket)) return\n this.pumping.add(socket)\n const done = (async () => {\n try {\n for await (const event of this.controller.attach(runId, { fromSeq })) {\n socket.send(JSON.stringify(event))\n socket.serializeAttachment({\n runId,\n lastSeq: event.seq,\n } satisfies SocketAttachment)\n }\n const record = await this.log.get(runId)\n if (socket.readyState === WebSocket.OPEN) {\n socket.send(JSON.stringify({ type: 'status', record }))\n socket.close(1000, 'run complete')\n }\n } catch (error) {\n // A tail loop throwing means a run-log read failed — an operator needs\n // the full error, but the client only gets a truncated close reason.\n const message = error instanceof Error ? error.message : String(error)\n console.error(\n `[sandbox-coordinator] tail failed for run ${runId}:`,\n error,\n )\n if (socket.readyState === WebSocket.OPEN) {\n socket.close(1011, message.slice(0, 120))\n }\n } finally {\n this.pumping.delete(socket)\n }\n })()\n this.ctx.waitUntil(done)\n }\n\n override webSocketMessage(\n ws: WebSocket,\n _message: string | ArrayBuffer,\n ): void {\n // Only meaningful as a post-hibernation resume nudge: restart the tail from\n // the persisted cursor IF no pump is live (the guard in `pump` enforces the\n // \"resume exactly once\" invariant when the original pump is still running).\n const attachment: unknown = ws.deserializeAttachment()\n if (isSocketAttachment(attachment)) {\n this.pump(ws, attachment.runId, attachment.lastSeq)\n }\n }\n\n override webSocketClose(\n _ws: WebSocket,\n _code: number,\n _reason: string,\n ): void {\n // Nothing to clean up: the run-log is durable and independent of any socket.\n }\n\n // ===========================================================================\n // Watchdog alarm — keeps a run observable across hibernation\n // ===========================================================================\n\n override async alarm(): Promise<void> {\n try {\n const runs = await this.ctx.storage.list<RunRecord>({ prefix: 'rec:' })\n const now = Date.now()\n let active = false\n for (const record of runs.values()) {\n if (isTerminalRunStatus(record.status)) continue\n if (now - record.updatedAt > WATCHDOG_STALL_MS) {\n // No progress for too long — the driver is presumed dead. Fail the run\n // so tailing clients stop waiting forever (the whole point of the\n // watchdog; without this a stuck run sits at `running` indefinitely).\n await this.failStalledRun(record.runId)\n } else {\n active = true\n }\n }\n if (active) await this.ctx.storage.setAlarm(Date.now() + WATCHDOG_MS)\n } catch (error) {\n // Never let the watchdog die silently: a transient storage error must not\n // permanently disable liveness detection. Re-arm and try again next tick.\n console.error('[sandbox-coordinator] watchdog alarm failed:', error)\n await this.ctx.storage.setAlarm(Date.now() + WATCHDOG_MS)\n }\n }\n\n /** Mark a stalled (orchestrator-presumed-dead) run as a terminal error. */\n private async failStalledRun(runId: string): Promise<void> {\n const message = 'run watchdog: no progress; orchestrator presumed dead'\n try {\n await this.log.append(runId, { type: EventType.RUN_ERROR, message })\n } catch {\n // The run may have just reached terminal concurrently; finish is idempotent.\n }\n await this.log.finish(runId, 'error', { message })\n this.onRunSettled(runId)\n }\n}\n"],"names":[],"mappings":"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"}
@@ -0,0 +1,80 @@
1
+ import { Sandbox } from '@cloudflare/sandbox';
2
+ import { ChatCoordinatorEnv } from './chat-coordinator.js';
3
+ import { ContainerCoordinatorEnv } from './container-coordinator.js';
4
+ import { HarnessId } from './protocol.js';
5
+ import { SandboxCoordinator, StartRunInput } from './coordinator.js';
6
+ import { AnyTextAdapter, AnyTool, SystemPrompt } from '@tanstack/ai';
7
+ import { SandboxDefinition, WorkspaceDefinition } from '@tanstack/ai-sandbox';
8
+ /**
9
+ * The base Env every generated app binds: the coordinator's own namespace, the
10
+ * Sandbox namespace, the OPTIONAL bridge/preview hostnames (request-derived when
11
+ * unset), and the Anthropic key. The two modes extend this with exactly the
12
+ * coordinator base each one requires.
13
+ */
14
+ export interface SandboxAgentEnv extends ChatCoordinatorEnv, ContainerCoordinatorEnv {
15
+ /** This coordinator DO's own namespace (so the Worker can address it). */
16
+ RUN_COORDINATOR: DurableObjectNamespace<SandboxCoordinator<SandboxAgentEnv>>;
17
+ /**
18
+ * Custom domain (with a `*.<domain>` route) for browser-facing `exposePort`
19
+ * preview URLs. Optional: unset → request-derived (local dev → `localhost`).
20
+ * REQUIRED on a `*.workers.dev` deploy (no wildcard subdomains). Distinct from
21
+ * `PUBLIC_HOSTNAME`, which is the CONTAINER→Worker bridge host. See
22
+ * {@link resolvePreviewHost}.
23
+ */
24
+ PREVIEW_HOSTNAME?: string;
25
+ }
26
+ /** Shared config across both modes. */
27
+ interface BaseAgentConfig<TEnv extends SandboxAgentEnv> {
28
+ /** chat()-provided server tools, resolved per run (DO-drives: bridged over MCP). */
29
+ tools?: (input: StartRunInput, env: TEnv) => Array<AnyTool>;
30
+ }
31
+ /** DO-drives config: the DO runs `chat()` with the given adapter. */
32
+ export interface DoDrivesAgentConfig<TEnv extends SandboxAgentEnv> extends BaseAgentConfig<TEnv> {
33
+ mode?: 'do-drives';
34
+ /** The harness/text adapter `chat()` runs, resolved per run. */
35
+ adapter: (input: StartRunInput, env: TEnv) => AnyTextAdapter;
36
+ /**
37
+ * Base system prompts prepended to every run's `chat()` (DO-drives only — the DO
38
+ * runs `chat()` itself). The natural home for transport-level guidance the agent
39
+ * needs regardless of what it builds — e.g. `systemPrompts: [PREVIEW_GUIDANCE]`
40
+ * so previews don't reload-loop. See {@link PREVIEW_GUIDANCE}.
41
+ */
42
+ systemPrompts?: Array<SystemPrompt>;
43
+ /**
44
+ * The sandbox the agent runs in, resolved per run. When omitted, a default
45
+ * Cloudflare sandbox (one per thread, no source clone, NO auth secrets) is built
46
+ * from the `Sandbox` binding and the resolved preview host, optionally
47
+ * bootstrapping `workspace`. Supply the harness's API key either here (a custom
48
+ * `sandbox` resolver whose workspace declares the secret) or via `workspace`
49
+ * below — the package binds no key of its own.
50
+ */
51
+ sandbox?: (input: StartRunInput, env: TEnv) => SandboxDefinition;
52
+ /**
53
+ * Workspace for the default sandbox (ignored when `sandbox` is provided). This is
54
+ * where a default-sandbox app declares its harness auth, e.g.
55
+ * `defineWorkspace({ source: { type: 'none' }, secrets: createSecrets({ ANTHROPIC_API_KEY: env.ANTHROPIC_API_KEY }) })`.
56
+ */
57
+ workspace?: WorkspaceDefinition;
58
+ }
59
+ /** Co-located config: an in-container runner runs `chat()`. */
60
+ export interface ColocatedAgentConfig<TEnv extends SandboxAgentEnv> extends BaseAgentConfig<TEnv> {
61
+ mode: 'colocated';
62
+ /** Which in-sandbox harness the runner spawns. */
63
+ harness: HarnessId;
64
+ /** Model id passed to that harness. */
65
+ model: string;
66
+ /** Workspace the in-container runner bootstraps for the agent. */
67
+ workspace: WorkspaceDefinition;
68
+ }
69
+ export type CloudflareSandboxAgentConfig<TEnv extends SandboxAgentEnv> = DoDrivesAgentConfig<TEnv> | ColocatedAgentConfig<TEnv>;
70
+ /** What {@link createCloudflareSandboxAgent} returns: the app's whole worker. */
71
+ export interface CloudflareSandboxAgent<TEnv extends SandboxAgentEnv> {
72
+ /** The coordinator Durable Object class — export as your `RUN_COORDINATOR` binding. */
73
+ Coordinator: new (ctx: DurableObjectState, env: TEnv) => SandboxCoordinator<TEnv>;
74
+ /** The `@cloudflare/sandbox` Sandbox DO class — export for the `Sandbox` binding. */
75
+ Sandbox: typeof Sandbox;
76
+ /** The Worker fetch handler — `export default` it. */
77
+ worker: ExportedHandler<TEnv>;
78
+ }
79
+ export declare function createCloudflareSandboxAgent<TEnv extends SandboxAgentEnv = SandboxAgentEnv>(config: CloudflareSandboxAgentConfig<TEnv>): CloudflareSandboxAgent<TEnv>;
80
+ export {};
@@ -0,0 +1,69 @@
1
+ import { defineSandbox, defineWorkspace } from "@tanstack/ai-sandbox";
2
+ import { Sandbox } from "@cloudflare/sandbox";
3
+ import { cloudflareSandbox } from "./provider.js";
4
+ import { ChatSandboxCoordinator } from "./chat-coordinator.js";
5
+ import { ContainerSandboxCoordinator } from "./container-coordinator.js";
6
+ import { createSandboxAgentWorker } from "./worker.js";
7
+ import "cloudflare:workers";
8
+ import "@tanstack/ai";
9
+ import { resolvePreviewHost } from "./public-host.js";
10
+ function defaultSandbox(env, input, workspace) {
11
+ return defineSandbox({
12
+ id: "cf-edge-agent",
13
+ provider: cloudflareSandbox({
14
+ binding: env.Sandbox,
15
+ // Browser-facing preview host: `PREVIEW_HOSTNAME` if set, else derived from
16
+ // the trigger request (local dev → `localhost`; deployed → a custom domain,
17
+ // since `*.workers.dev` has no wildcard). See `resolvePreviewHost`.
18
+ previewHostname: resolvePreviewHost(env, input)
19
+ }),
20
+ workspace: workspace ?? // The container image ships the harness CLI; no source to clone, and NO auth
21
+ // secrets — the package is harness-agnostic, so it can't know which key the
22
+ // CLI needs. Supply the harness's API key via a `workspace` with `secrets`
23
+ // (or a custom `sandbox` resolver), e.g.:
24
+ // workspace: defineWorkspace({
25
+ // source: { type: 'none' },
26
+ // secrets: createSecrets({ ANTHROPIC_API_KEY: env.ANTHROPIC_API_KEY }),
27
+ // })
28
+ defineWorkspace({ source: { type: "none" } }),
29
+ // One sandbox per thread, so a follow-up run resumes the same workspace.
30
+ lifecycle: { reuse: "thread" }
31
+ });
32
+ }
33
+ function resolveCoordinator(env, threadId) {
34
+ return env.RUN_COORDINATOR.get(env.RUN_COORDINATOR.idFromName(threadId));
35
+ }
36
+ function createCloudflareSandboxAgent(config) {
37
+ const worker = createSandboxAgentWorker(resolveCoordinator);
38
+ if (config.mode === "colocated") {
39
+ const colocated = config;
40
+ class ConfiguredContainerCoordinator extends ContainerSandboxCoordinator {
41
+ config(input) {
42
+ return {
43
+ hostTools: colocated.tools?.(input, this.env) ?? [],
44
+ workspace: colocated.workspace,
45
+ harness: colocated.harness,
46
+ model: colocated.model
47
+ };
48
+ }
49
+ }
50
+ return { Coordinator: ConfiguredContainerCoordinator, Sandbox, worker };
51
+ }
52
+ const doDrives = config;
53
+ class ConfiguredChatCoordinator extends ChatSandboxCoordinator {
54
+ config(input) {
55
+ const tools = doDrives.tools?.(input, this.env);
56
+ return {
57
+ adapter: doDrives.adapter(input, this.env),
58
+ sandbox: doDrives.sandbox?.(input, this.env) ?? defaultSandbox(this.env, input, doDrives.workspace),
59
+ ...tools !== void 0 ? { tools } : {},
60
+ ...doDrives.systemPrompts !== void 0 ? { systemPrompts: doDrives.systemPrompts } : {}
61
+ };
62
+ }
63
+ }
64
+ return { Coordinator: ConfiguredChatCoordinator, Sandbox, worker };
65
+ }
66
+ export {
67
+ createCloudflareSandboxAgent
68
+ };
69
+ //# sourceMappingURL=factory.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"factory.js","sources":["../../src/factory.ts"],"sourcesContent":["/**\n * `createCloudflareSandboxAgent` — the headline DX: one configured function call\n * returns the Durable Object coordinator, the Sandbox DO, and the Worker fetch\n * handler, so a Cloudflare app's whole `worker.ts` is just export wiring:\n *\n * ```ts\n * const agent = createCloudflareSandboxAgent({\n * adapter: () => claudeCodeText('sonnet'),\n * })\n * export const RunCoordinator = agent.Coordinator\n * export const Sandbox = agent.Sandbox\n * export default agent.worker\n * ```\n *\n * Two modes, switched by `config.mode`:\n * - `'do-drives'` (default) → a {@link ChatSandboxCoordinator}: the DO runs\n * `chat()` itself and hosts the MCP tool-bridge.\n * - `'colocated'` → a {@link ContainerSandboxCoordinator}: an in-container\n * runner runs `chat()`; the DO is a thin coordinator that executes host tools.\n *\n * Env bindings (set in `wrangler.jsonc`):\n * - `RUN_COORDINATOR` — this coordinator DO's own namespace (so the Worker can\n * address it by `threadId`). Class name: whatever you export `Coordinator` as.\n * - `Sandbox` — the `@cloudflare/sandbox` Sandbox DO namespace (the container\n * hosts). Bind the exported `Sandbox` class.\n * - `PUBLIC_HOSTNAME` — OPTIONAL. Hostname the CONTAINER uses to reach the Worker's\n * tool-bridge / tool-exec endpoint. Unset → request-derived (local dev →\n * `host.docker.internal`). See `resolveBridgeOrigin`.\n * - `PREVIEW_HOSTNAME` — OPTIONAL. Custom domain (with a `*.<domain>` route) for\n * browser-facing `exposePort` preview URLs. Unset → request-derived (local dev →\n * `localhost`); REQUIRED on a `*.workers.dev` deploy, which has no wildcard\n * subdomains. See `resolvePreviewHost`.\n * - The harness's API key (`ANTHROPIC_API_KEY` for Claude Code, `CODEX_API_KEY` for\n * codex, …) — supplied by YOUR app, never by the package. Declare it as a secret\n * on the run's workspace (via a `sandbox`/`workspace` resolver) and add the field\n * to your own env type; the coordinator injects each declared secret into the\n * sandbox env by name. The package itself is harness-agnostic and binds no key.\n *\n * NOTE: Workers-runtime code — compiles against the real Cloudflare + TanStack\n * AI types; not runtime-verified in this repo (no Workers runtime here).\n */\nimport { defineSandbox, defineWorkspace } from '@tanstack/ai-sandbox'\nimport { Sandbox } from '@cloudflare/sandbox'\nimport { cloudflareSandbox } from './provider'\nimport { ChatSandboxCoordinator } from './chat-coordinator'\nimport { ContainerSandboxCoordinator } from './container-coordinator'\nimport { createSandboxAgentWorker } from './worker'\nimport { resolvePreviewHost } from './coordinator'\nimport type { ChatCoordinatorEnv, ChatRunConfig } from './chat-coordinator'\nimport type {\n ContainerCoordinatorEnv,\n ContainerRunConfig,\n} from './container-coordinator'\nimport type { HarnessId } from './protocol'\nimport type { SandboxCoordinator, StartRunInput } from './coordinator'\nimport type { AnyTextAdapter, AnyTool, SystemPrompt } from '@tanstack/ai'\nimport type {\n SandboxDefinition,\n WorkspaceDefinition,\n} from '@tanstack/ai-sandbox'\n\n/**\n * The base Env every generated app binds: the coordinator's own namespace, the\n * Sandbox namespace, the OPTIONAL bridge/preview hostnames (request-derived when\n * unset), and the Anthropic key. The two modes extend this with exactly the\n * coordinator base each one requires.\n */\nexport interface SandboxAgentEnv\n extends ChatCoordinatorEnv, ContainerCoordinatorEnv {\n /** This coordinator DO's own namespace (so the Worker can address it). */\n RUN_COORDINATOR: DurableObjectNamespace<SandboxCoordinator<SandboxAgentEnv>>\n /**\n * Custom domain (with a `*.<domain>` route) for browser-facing `exposePort`\n * preview URLs. Optional: unset → request-derived (local dev → `localhost`).\n * REQUIRED on a `*.workers.dev` deploy (no wildcard subdomains). Distinct from\n * `PUBLIC_HOSTNAME`, which is the CONTAINER→Worker bridge host. See\n * {@link resolvePreviewHost}.\n */\n PREVIEW_HOSTNAME?: string\n}\n\n/** Shared config across both modes. */\ninterface BaseAgentConfig<TEnv extends SandboxAgentEnv> {\n /** chat()-provided server tools, resolved per run (DO-drives: bridged over MCP). */\n tools?: (input: StartRunInput, env: TEnv) => Array<AnyTool>\n}\n\n/** DO-drives config: the DO runs `chat()` with the given adapter. */\nexport interface DoDrivesAgentConfig<\n TEnv extends SandboxAgentEnv,\n> extends BaseAgentConfig<TEnv> {\n mode?: 'do-drives'\n /** The harness/text adapter `chat()` runs, resolved per run. */\n adapter: (input: StartRunInput, env: TEnv) => AnyTextAdapter\n /**\n * Base system prompts prepended to every run's `chat()` (DO-drives only — the DO\n * runs `chat()` itself). The natural home for transport-level guidance the agent\n * needs regardless of what it builds — e.g. `systemPrompts: [PREVIEW_GUIDANCE]`\n * so previews don't reload-loop. See {@link PREVIEW_GUIDANCE}.\n */\n systemPrompts?: Array<SystemPrompt>\n /**\n * The sandbox the agent runs in, resolved per run. When omitted, a default\n * Cloudflare sandbox (one per thread, no source clone, NO auth secrets) is built\n * from the `Sandbox` binding and the resolved preview host, optionally\n * bootstrapping `workspace`. Supply the harness's API key either here (a custom\n * `sandbox` resolver whose workspace declares the secret) or via `workspace`\n * below — the package binds no key of its own.\n */\n sandbox?: (input: StartRunInput, env: TEnv) => SandboxDefinition\n /**\n * Workspace for the default sandbox (ignored when `sandbox` is provided). This is\n * where a default-sandbox app declares its harness auth, e.g.\n * `defineWorkspace({ source: { type: 'none' }, secrets: createSecrets({ ANTHROPIC_API_KEY: env.ANTHROPIC_API_KEY }) })`.\n */\n workspace?: WorkspaceDefinition\n}\n\n/** Co-located config: an in-container runner runs `chat()`. */\nexport interface ColocatedAgentConfig<\n TEnv extends SandboxAgentEnv,\n> extends BaseAgentConfig<TEnv> {\n mode: 'colocated'\n /** Which in-sandbox harness the runner spawns. */\n harness: HarnessId\n /** Model id passed to that harness. */\n model: string\n /** Workspace the in-container runner bootstraps for the agent. */\n workspace: WorkspaceDefinition\n}\n\nexport type CloudflareSandboxAgentConfig<TEnv extends SandboxAgentEnv> =\n | DoDrivesAgentConfig<TEnv>\n | ColocatedAgentConfig<TEnv>\n\n/** What {@link createCloudflareSandboxAgent} returns: the app's whole worker. */\nexport interface CloudflareSandboxAgent<TEnv extends SandboxAgentEnv> {\n /** The coordinator Durable Object class — export as your `RUN_COORDINATOR` binding. */\n Coordinator: new (\n ctx: DurableObjectState,\n env: TEnv,\n ) => SandboxCoordinator<TEnv>\n /** The `@cloudflare/sandbox` Sandbox DO class — export for the `Sandbox` binding. */\n Sandbox: typeof Sandbox\n /** The Worker fetch handler — `export default` it. */\n worker: ExportedHandler<TEnv>\n}\n\n/** Build the default per-thread Cloudflare sandbox for the DO-drives mode. */\nfunction defaultSandbox<TEnv extends SandboxAgentEnv>(\n env: TEnv,\n input: StartRunInput,\n workspace: WorkspaceDefinition | undefined,\n): SandboxDefinition {\n return defineSandbox({\n id: 'cf-edge-agent',\n provider: cloudflareSandbox({\n binding: env.Sandbox,\n // Browser-facing preview host: `PREVIEW_HOSTNAME` if set, else derived from\n // the trigger request (local dev → `localhost`; deployed → a custom domain,\n // since `*.workers.dev` has no wildcard). See `resolvePreviewHost`.\n previewHostname: resolvePreviewHost(env, input),\n }),\n workspace:\n workspace ??\n // The container image ships the harness CLI; no source to clone, and NO auth\n // secrets — the package is harness-agnostic, so it can't know which key the\n // CLI needs. Supply the harness's API key via a `workspace` with `secrets`\n // (or a custom `sandbox` resolver), e.g.:\n // workspace: defineWorkspace({\n // source: { type: 'none' },\n // secrets: createSecrets({ ANTHROPIC_API_KEY: env.ANTHROPIC_API_KEY }),\n // })\n defineWorkspace({ source: { type: 'none' } }),\n // One sandbox per thread, so a follow-up run resumes the same workspace.\n lifecycle: { reuse: 'thread' },\n })\n}\n\n/** Resolve the coordinator DO that owns a thread's runs (`RUN_COORDINATOR`). */\nfunction resolveCoordinator<TEnv extends SandboxAgentEnv>(\n env: TEnv,\n threadId: string,\n): DurableObjectStub<SandboxCoordinator<TEnv>> {\n return env.RUN_COORDINATOR.get(env.RUN_COORDINATOR.idFromName(threadId))\n}\n\nexport function createCloudflareSandboxAgent<\n TEnv extends SandboxAgentEnv = SandboxAgentEnv,\n>(config: CloudflareSandboxAgentConfig<TEnv>): CloudflareSandboxAgent<TEnv> {\n const worker = createSandboxAgentWorker<TEnv>(resolveCoordinator)\n\n if (config.mode === 'colocated') {\n const colocated = config\n class ConfiguredContainerCoordinator extends ContainerSandboxCoordinator<TEnv> {\n protected override config(input: StartRunInput): ContainerRunConfig {\n return {\n hostTools: colocated.tools?.(input, this.env) ?? [],\n workspace: colocated.workspace,\n harness: colocated.harness,\n model: colocated.model,\n }\n }\n }\n return { Coordinator: ConfiguredContainerCoordinator, Sandbox, worker }\n }\n\n const doDrives = config\n class ConfiguredChatCoordinator extends ChatSandboxCoordinator<TEnv> {\n protected override config(input: StartRunInput): ChatRunConfig {\n const tools = doDrives.tools?.(input, this.env)\n return {\n adapter: doDrives.adapter(input, this.env),\n sandbox:\n doDrives.sandbox?.(input, this.env) ??\n defaultSandbox(this.env, input, doDrives.workspace),\n ...(tools !== undefined ? { tools } : {}),\n ...(doDrives.systemPrompts !== undefined\n ? { systemPrompts: doDrives.systemPrompts }\n : {}),\n }\n }\n }\n return { Coordinator: ConfiguredChatCoordinator, Sandbox, worker }\n}\n"],"names":[],"mappings":";;;;;;;;;AAqJA,SAAS,eACP,KACA,OACA,WACmB;AACnB,SAAO,cAAc;AAAA,IACnB,IAAI;AAAA,IACJ,UAAU,kBAAkB;AAAA,MAC1B,SAAS,IAAI;AAAA;AAAA;AAAA;AAAA,MAIb,iBAAiB,mBAAmB,KAAK,KAAK;AAAA,IAAA,CAC/C;AAAA,IACD,WACE;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IASA,gBAAgB,EAAE,QAAQ,EAAE,MAAM,OAAA,GAAU;AAAA;AAAA,IAE9C,WAAW,EAAE,OAAO,SAAA;AAAA,EAAS,CAC9B;AACH;AAGA,SAAS,mBACP,KACA,UAC6C;AAC7C,SAAO,IAAI,gBAAgB,IAAI,IAAI,gBAAgB,WAAW,QAAQ,CAAC;AACzE;AAEO,SAAS,6BAEd,QAA0E;AAC1E,QAAM,SAAS,yBAA+B,kBAAkB;AAEhE,MAAI,OAAO,SAAS,aAAa;AAC/B,UAAM,YAAY;AAAA,IAClB,MAAM,uCAAuC,4BAAkC;AAAA,MAC1D,OAAO,OAA0C;AAClE,eAAO;AAAA,UACL,WAAW,UAAU,QAAQ,OAAO,KAAK,GAAG,KAAK,CAAA;AAAA,UACjD,WAAW,UAAU;AAAA,UACrB,SAAS,UAAU;AAAA,UACnB,OAAO,UAAU;AAAA,QAAA;AAAA,MAErB;AAAA,IAAA;AAEF,WAAO,EAAE,aAAa,gCAAgC,SAAS,OAAA;AAAA,EACjE;AAEA,QAAM,WAAW;AAAA,EACjB,MAAM,kCAAkC,uBAA6B;AAAA,IAChD,OAAO,OAAqC;AAC7D,YAAM,QAAQ,SAAS,QAAQ,OAAO,KAAK,GAAG;AAC9C,aAAO;AAAA,QACL,SAAS,SAAS,QAAQ,OAAO,KAAK,GAAG;AAAA,QACzC,SACE,SAAS,UAAU,OAAO,KAAK,GAAG,KAClC,eAAe,KAAK,KAAK,OAAO,SAAS,SAAS;AAAA,QACpD,GAAI,UAAU,SAAY,EAAE,MAAA,IAAU,CAAA;AAAA,QACtC,GAAI,SAAS,kBAAkB,SAC3B,EAAE,eAAe,SAAS,kBAC1B,CAAA;AAAA,MAAC;AAAA,IAET;AAAA,EAAA;AAEF,SAAO,EAAE,aAAa,2BAA2B,SAAS,OAAA;AAC5D;"}
@@ -0,0 +1,23 @@
1
+ import { Sandbox } from '@cloudflare/sandbox';
2
+ import { SandboxCapabilities, SandboxHandle } from '@tanstack/ai-sandbox';
3
+ export declare const CLOUDFLARE_CAPS: SandboxCapabilities;
4
+ export declare class CloudflareHandle implements SandboxHandle {
5
+ readonly id: string;
6
+ readonly provider = "cloudflare";
7
+ readonly workspaceRoot: string;
8
+ readonly capabilities: SandboxCapabilities;
9
+ readonly fs: SandboxHandle['fs'];
10
+ readonly git: SandboxHandle['git'];
11
+ readonly process: SandboxHandle['process'];
12
+ readonly ports: SandboxHandle['ports'];
13
+ readonly env: SandboxHandle['env'];
14
+ private readonly sandbox;
15
+ private readonly workdir;
16
+ private readonly previewHostname;
17
+ constructor(id: string, sandbox: Sandbox, workdir: string, previewHostname?: string);
18
+ private abs;
19
+ private exec;
20
+ private spawnProcess;
21
+ private connectPort;
22
+ destroy(): Promise<void>;
23
+ }