@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,30 @@
1
+ /**
2
+ * `@tanstack/ai-sandbox-cloudflare/agent` — the Workers-runtime building blocks
3
+ * for running a TanStack AI sandbox agent on Cloudflare with minimal app code.
4
+ *
5
+ * The headline API is {@link createCloudflareSandboxAgent}: one configured
6
+ * function call returns the Durable Object coordinator + the Sandbox DO + the
7
+ * Worker fetch handler, so an app's `worker.ts` is just export wiring. The
8
+ * coordinator base classes, the concrete coordinators, the Worker factory, and
9
+ * the durable run-log are exported too for apps that want to compose them
10
+ * directly.
11
+ *
12
+ * This entry imports `cloudflare:workers` and is Workers-only — keep it out of
13
+ * the node-importable main entry (`@tanstack/ai-sandbox-cloudflare`).
14
+ */
15
+ export { createCloudflareSandboxAgent } from './factory.js';
16
+ export type { CloudflareSandboxAgent, CloudflareSandboxAgentConfig, DoDrivesAgentConfig, ColocatedAgentConfig, SandboxAgentEnv, } from './factory.js';
17
+ export { SandboxCoordinator, resolveBridgeOrigin, resolvePreviewHost, } from './coordinator.js';
18
+ export type { StartRunInput } from './coordinator.js';
19
+ export { exposePreviewTool, PREVIEW_GUIDANCE } from './preview-tool.js';
20
+ export type { PreviewToolEnv } from './preview-tool.js';
21
+ export { ChatSandboxCoordinator } from './chat-coordinator.js';
22
+ export type { ChatCoordinatorEnv, ChatRunConfig } from './chat-coordinator.js';
23
+ export { ContainerSandboxCoordinator } from './container-coordinator.js';
24
+ export type { ContainerCoordinatorEnv, ContainerRunConfig, } from './container-coordinator.js';
25
+ export { parseContainerRunRequest } from './protocol.js';
26
+ export type { ContainerRunRequest, HarnessId } from './protocol.js';
27
+ export { createSandboxAgentWorker } from './worker.js';
28
+ export type { ResolveCoordinator } from './worker.js';
29
+ export { DurableObjectRunEventLog } from './run-log-do.js';
30
+ export { timingSafeBearerEqualWeb } from './web-crypto.js';
@@ -0,0 +1,25 @@
1
+ import { createCloudflareSandboxAgent } from "./factory.js";
2
+ import { SandboxCoordinator } from "./coordinator.js";
3
+ import { PREVIEW_GUIDANCE, exposePreviewTool } from "./preview-tool.js";
4
+ import { ChatSandboxCoordinator } from "./chat-coordinator.js";
5
+ import { ContainerSandboxCoordinator } from "./container-coordinator.js";
6
+ import { parseContainerRunRequest } from "./protocol.js";
7
+ import { createSandboxAgentWorker } from "./worker.js";
8
+ import { DurableObjectRunEventLog } from "./run-log-do.js";
9
+ import { timingSafeBearerEqualWeb } from "./web-crypto.js";
10
+ import { resolveBridgeOrigin, resolvePreviewHost } from "./public-host.js";
11
+ export {
12
+ ChatSandboxCoordinator,
13
+ ContainerSandboxCoordinator,
14
+ DurableObjectRunEventLog,
15
+ PREVIEW_GUIDANCE,
16
+ SandboxCoordinator,
17
+ createCloudflareSandboxAgent,
18
+ createSandboxAgentWorker,
19
+ exposePreviewTool,
20
+ parseContainerRunRequest,
21
+ resolveBridgeOrigin,
22
+ resolvePreviewHost,
23
+ timingSafeBearerEqualWeb
24
+ };
25
+ //# sourceMappingURL=agent.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agent.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;"}
@@ -0,0 +1,75 @@
1
+ import { SandboxCoordinator, StartRunInput } from './coordinator.js';
2
+ import { AnyTextAdapter, AnyTool, StreamChunk, SystemPrompt } from '@tanstack/ai';
3
+ import { SandboxDefinition } from '@tanstack/ai-sandbox';
4
+ /**
5
+ * The Env bindings a {@link ChatSandboxCoordinator} requires. The bridge origin the
6
+ * SANDBOX calls back on needs a hostname; `PUBLIC_HOSTNAME` is OPTIONAL — when
7
+ * unset, the coordinator derives it from the trigger request (locally →
8
+ * `host.docker.internal`; safe on Cloudflare). See {@link resolveBridgeOrigin}.
9
+ */
10
+ export interface ChatCoordinatorEnv {
11
+ /**
12
+ * Hostname the CONTAINER uses to reach the Worker's tool-bridge (`/_bridge`).
13
+ * Optional: unset → derived from each trigger request (deployed: the request
14
+ * host; local dev: `host.docker.internal`). Set it only to override — e.g. a
15
+ * stable named-tunnel host. See {@link resolveBridgeOrigin}. (Browser-facing
16
+ * preview URLs use a separate `PREVIEW_HOSTNAME`; see {@link resolvePreviewHost}.)
17
+ */
18
+ PUBLIC_HOSTNAME?: string;
19
+ }
20
+ /** What {@link ChatSandboxCoordinator.config} returns for one run. */
21
+ export interface ChatRunConfig {
22
+ /** The harness/text adapter `chat()` runs (e.g. `claudeCodeText('sonnet')`). */
23
+ adapter: AnyTextAdapter;
24
+ /** The sandbox the agent executes in, projected by `withSandbox`. */
25
+ sandbox: SandboxDefinition;
26
+ /** chat()-provided server tools bridged into the harness over MCP. */
27
+ tools?: Array<AnyTool>;
28
+ /** Base system prompts prepended to the run's `chat()` (e.g. `[PREVIEW_GUIDANCE]`). */
29
+ systemPrompts?: Array<SystemPrompt>;
30
+ }
31
+ export declare abstract class ChatSandboxCoordinator<TEnv extends ChatCoordinatorEnv = ChatCoordinatorEnv> extends SandboxCoordinator<TEnv> {
32
+ /**
33
+ * Live per-run bridges, keyed by runId. In-memory by design: a bridge is only
34
+ * reachable while its run is in flight, and `ctx.waitUntil(done)` keeps THIS
35
+ * instance alive (un-hibernated) for the run's whole lifetime — so the agent's
36
+ * MCP calls always hit the instance that provisioned the bridge. A request for
37
+ * a run with no live bridge (finished, or never started here) is a hard 404,
38
+ * not a silent re-provision.
39
+ */
40
+ private readonly bridges;
41
+ /**
42
+ * Resolve the adapter, sandbox, and chat()-tools for one run. Implemented by
43
+ * the app subclass (or supplied by {@link createCloudflareSandboxAgent}); this
44
+ * is the only model-specific input the DO-drives coordinator needs.
45
+ */
46
+ protected abstract config(input: StartRunInput): ChatRunConfig;
47
+ /**
48
+ * Run `chat()` IN the DO, streaming its `StreamChunk`s. `stream: true` (with no
49
+ * outputSchema) makes chat() return an `AsyncIterable<StreamChunk>` directly —
50
+ * no cast needed for the run driver. Both middlewares run `setup` before
51
+ * streaming begins: our middleware provides the DO-backed bridge provisioner,
52
+ * and `withSandbox` provides the sandbox handle the harness adapter needs.
53
+ */
54
+ protected buildRunStream(input: StartRunInput): AsyncIterable<StreamChunk>;
55
+ /** Drop the per-run bridge once the run is terminal (override from base). */
56
+ protected onRunSettled(runId: string): void;
57
+ /**
58
+ * A tiny middleware that PROVIDES our DO-backed {@link ToolBridgeProvisioner}.
59
+ * The harness adapter reads it via `getOptional` and falls back to the
60
+ * `node:http` host transport when absent — here we override that so the bridge
61
+ * is served from this DO's `fetch` handler instead of a TCP listener.
62
+ */
63
+ private bridgeProvisionerMiddleware;
64
+ /**
65
+ * Stand up the per-run bridge: register the tool core + a fresh bearer token
66
+ * on this DO, and hand back a URL the SANDBOX can reach — the Worker's public
67
+ * hostname routed to `/_bridge/:runId`. The `threadId` query lets the Worker
68
+ * route the agent's MCP calls back to THIS coordinator. No raw socket is opened.
69
+ */
70
+ private makeBridgeProvisioner;
71
+ /** Serve `/_bridge/:runId` (the in-sandbox agent's MCP calls) from the base fetch. */
72
+ protected handleRoute(request: Request, parts: Array<string>): Promise<Response> | Response;
73
+ /** Serve one MCP JSON-RPC request for a run after a constant-time token check. */
74
+ private serveBridge;
75
+ }
@@ -0,0 +1,135 @@
1
+ import { chat, defineChatMiddleware } from "@tanstack/ai";
2
+ import { withSandbox, ToolBridgeProvisionerCapability, createToolBridgeCore, handleBridgeJsonRpc } from "@tanstack/ai-sandbox";
3
+ import { SandboxCoordinator } from "./coordinator.js";
4
+ import { timingSafeBearerEqualWeb } from "./web-crypto.js";
5
+ import { resolveBridgeOrigin } from "./public-host.js";
6
+ class ChatSandboxCoordinator extends SandboxCoordinator {
7
+ /**
8
+ * Live per-run bridges, keyed by runId. In-memory by design: a bridge is only
9
+ * reachable while its run is in flight, and `ctx.waitUntil(done)` keeps THIS
10
+ * instance alive (un-hibernated) for the run's whole lifetime — so the agent's
11
+ * MCP calls always hit the instance that provisioned the bridge. A request for
12
+ * a run with no live bridge (finished, or never started here) is a hard 404,
13
+ * not a silent re-provision.
14
+ */
15
+ bridges = /* @__PURE__ */ new Map();
16
+ // ===========================================================================
17
+ // The one per-model seam: run chat() in the DO
18
+ // ===========================================================================
19
+ /**
20
+ * Run `chat()` IN the DO, streaming its `StreamChunk`s. `stream: true` (with no
21
+ * outputSchema) makes chat() return an `AsyncIterable<StreamChunk>` directly —
22
+ * no cast needed for the run driver. Both middlewares run `setup` before
23
+ * streaming begins: our middleware provides the DO-backed bridge provisioner,
24
+ * and `withSandbox` provides the sandbox handle the harness adapter needs.
25
+ */
26
+ buildRunStream(input) {
27
+ const { adapter, sandbox, tools, systemPrompts } = this.config(input);
28
+ const sessionId = input.metadata?.sessionId;
29
+ const modelOptions = typeof sessionId === "string" && sessionId !== "" ? { sessionId } : void 0;
30
+ return chat({
31
+ threadId: input.threadId,
32
+ adapter,
33
+ messages: input.messages,
34
+ stream: true,
35
+ ...tools !== void 0 ? { tools } : {},
36
+ ...systemPrompts !== void 0 ? { systemPrompts } : {},
37
+ ...modelOptions !== void 0 ? { modelOptions } : {},
38
+ middleware: [
39
+ this.bridgeProvisionerMiddleware(input),
40
+ withSandbox(sandbox)
41
+ ]
42
+ });
43
+ }
44
+ /** Drop the per-run bridge once the run is terminal (override from base). */
45
+ onRunSettled(runId) {
46
+ this.bridges.delete(runId);
47
+ }
48
+ // ===========================================================================
49
+ // The DO-backed tool-bridge provisioner + endpoint
50
+ // ===========================================================================
51
+ /**
52
+ * A tiny middleware that PROVIDES our DO-backed {@link ToolBridgeProvisioner}.
53
+ * The harness adapter reads it via `getOptional` and falls back to the
54
+ * `node:http` host transport when absent — here we override that so the bridge
55
+ * is served from this DO's `fetch` handler instead of a TCP listener.
56
+ */
57
+ bridgeProvisionerMiddleware(input) {
58
+ const provisioner = this.makeBridgeProvisioner(input);
59
+ return defineChatMiddleware({
60
+ name: "do-tool-bridge-provisioner",
61
+ provides: [ToolBridgeProvisionerCapability],
62
+ setup: (ctx) => {
63
+ ctx.provide(ToolBridgeProvisionerCapability, provisioner);
64
+ }
65
+ });
66
+ }
67
+ /**
68
+ * Stand up the per-run bridge: register the tool core + a fresh bearer token
69
+ * on this DO, and hand back a URL the SANDBOX can reach — the Worker's public
70
+ * hostname routed to `/_bridge/:runId`. The `threadId` query lets the Worker
71
+ * route the agent's MCP calls back to THIS coordinator. No raw socket is opened.
72
+ */
73
+ makeBridgeProvisioner(input) {
74
+ const env = this.env;
75
+ const bridges = this.bridges;
76
+ const { runId, threadId } = input;
77
+ const origin = resolveBridgeOrigin(env, input);
78
+ return {
79
+ provision(tools, options) {
80
+ const token = crypto.randomUUID() + crypto.randomUUID().replace(/-/g, "");
81
+ const core = createToolBridgeCore(tools, {
82
+ ...options.context !== void 0 ? { context: options.context } : {},
83
+ ...options.signal !== void 0 ? { signal: options.signal } : {},
84
+ ...options.permission !== void 0 ? { permission: options.permission } : {}
85
+ });
86
+ bridges.set(runId, { token, core });
87
+ return Promise.resolve({
88
+ name: "tanstack",
89
+ url: `${origin}/_bridge/${runId}?threadId=${encodeURIComponent(threadId)}`,
90
+ token,
91
+ close: () => {
92
+ bridges.delete(runId);
93
+ return Promise.resolve();
94
+ }
95
+ });
96
+ }
97
+ };
98
+ }
99
+ /** Serve `/_bridge/:runId` (the in-sandbox agent's MCP calls) from the base fetch. */
100
+ handleRoute(request, parts) {
101
+ if (parts[0] === "_bridge" && typeof parts[1] === "string") {
102
+ return this.serveBridge(parts[1], request);
103
+ }
104
+ return super.handleRoute(request, parts);
105
+ }
106
+ /** Serve one MCP JSON-RPC request for a run after a constant-time token check. */
107
+ async serveBridge(runId, request) {
108
+ const bridge = this.bridges.get(runId);
109
+ if (!bridge)
110
+ return new Response("no active bridge for run", { status: 404 });
111
+ if (!timingSafeBearerEqualWeb(
112
+ request.headers.get("authorization") ?? void 0,
113
+ bridge.token
114
+ )) {
115
+ return new Response("unauthorized", { status: 401 });
116
+ }
117
+ let message;
118
+ try {
119
+ message = await request.json();
120
+ } catch {
121
+ return this.jsonResponse({
122
+ jsonrpc: "2.0",
123
+ id: null,
124
+ error: { code: -32700, message: "Parse error" }
125
+ });
126
+ }
127
+ const reply = await handleBridgeJsonRpc(bridge.core, message);
128
+ if (reply === null) return new Response(null, { status: 202 });
129
+ return this.jsonResponse(reply);
130
+ }
131
+ }
132
+ export {
133
+ ChatSandboxCoordinator
134
+ };
135
+ //# sourceMappingURL=chat-coordinator.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"chat-coordinator.js","sources":["../../src/chat-coordinator.ts"],"sourcesContent":["/**\n * `ChatSandboxCoordinator` — the concrete {@link SandboxCoordinator} for the\n * DO-DRIVES model: the Durable Object runs `chat()` ITSELF and hosts the MCP\n * tool-bridge from its own `fetch` handler.\n *\n * Worker (stateless trigger)\n * → ChatSandboxCoordinator (this DO: runs chat(), owns the sandbox + log)\n * → Cloudflare Sandbox (the container the agent executes in)\n *\n * It implements the one per-model seam, {@link buildRunStream}, by running\n * `chat()` in the DO with two middlewares: our DO-backed tool-bridge provisioner\n * (so the bridge is served from this DO instead of a `node:http` listener) and\n * `withSandbox(...)` (the handle the harness adapter needs). The per-run config —\n * which adapter, which sandbox, which chat()-tools — is the subclass's\n * {@link config} method; everything else (run-log, streaming tail, watchdog) is\n * inherited from the base.\n *\n * The MCP tool-bridge lives at `/_bridge/:runId`, gated by a per-run bearer\n * token, served from {@link handleRoute}. The in-sandbox agent reaches it via\n * the Worker's public hostname.\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 { chat, defineChatMiddleware } from '@tanstack/ai'\nimport {\n ToolBridgeProvisionerCapability,\n createToolBridgeCore,\n handleBridgeJsonRpc,\n withSandbox,\n} from '@tanstack/ai-sandbox'\nimport { SandboxCoordinator, resolveBridgeOrigin } from './coordinator'\nimport { timingSafeBearerEqualWeb } from './web-crypto'\nimport type { StartRunInput } from './coordinator'\nimport type {\n AnyTextAdapter,\n AnyTool,\n StreamChunk,\n SystemPrompt,\n} from '@tanstack/ai'\nimport type {\n ProvisionedBridge,\n SandboxDefinition,\n ToolBridgeCore,\n ToolBridgeProvisioner,\n} from '@tanstack/ai-sandbox'\n\n/**\n * The Env bindings a {@link ChatSandboxCoordinator} requires. The bridge origin the\n * SANDBOX calls back on needs a hostname; `PUBLIC_HOSTNAME` is OPTIONAL — when\n * unset, the coordinator derives it from the trigger request (locally →\n * `host.docker.internal`; safe on Cloudflare). See {@link resolveBridgeOrigin}.\n */\nexport interface ChatCoordinatorEnv {\n /**\n * Hostname the CONTAINER uses to reach the Worker's tool-bridge (`/_bridge`).\n * Optional: unset → derived from each trigger request (deployed: the request\n * host; local dev: `host.docker.internal`). Set it only to override — e.g. a\n * stable named-tunnel host. See {@link resolveBridgeOrigin}. (Browser-facing\n * preview URLs use a separate `PREVIEW_HOSTNAME`; see {@link resolvePreviewHost}.)\n */\n PUBLIC_HOSTNAME?: string\n}\n\n/** What {@link ChatSandboxCoordinator.config} returns for one run. */\nexport interface ChatRunConfig {\n /** The harness/text adapter `chat()` runs (e.g. `claudeCodeText('sonnet')`). */\n adapter: AnyTextAdapter\n /** The sandbox the agent executes in, projected by `withSandbox`. */\n sandbox: SandboxDefinition\n /** chat()-provided server tools bridged into the harness over MCP. */\n tools?: Array<AnyTool>\n /** Base system prompts prepended to the run's `chat()` (e.g. `[PREVIEW_GUIDANCE]`). */\n systemPrompts?: Array<SystemPrompt>\n}\n\n/** Per-run bridge state so `/_bridge/:runId` can authenticate + serve. */\ninterface BridgeState {\n token: string\n core: ToolBridgeCore\n}\n\nexport abstract class ChatSandboxCoordinator<\n TEnv extends ChatCoordinatorEnv = ChatCoordinatorEnv,\n> extends SandboxCoordinator<TEnv> {\n /**\n * Live per-run bridges, keyed by runId. In-memory by design: a bridge is only\n * reachable while its run is in flight, and `ctx.waitUntil(done)` keeps THIS\n * instance alive (un-hibernated) for the run's whole lifetime — so the agent's\n * MCP calls always hit the instance that provisioned the bridge. A request for\n * a run with no live bridge (finished, or never started here) is a hard 404,\n * not a silent re-provision.\n */\n private readonly bridges = new Map<string, BridgeState>()\n\n // ===========================================================================\n // Subclass seam: the per-run configuration\n // ===========================================================================\n\n /**\n * Resolve the adapter, sandbox, and chat()-tools for one run. Implemented by\n * the app subclass (or supplied by {@link createCloudflareSandboxAgent}); this\n * is the only model-specific input the DO-drives coordinator needs.\n */\n protected abstract config(input: StartRunInput): ChatRunConfig\n\n // ===========================================================================\n // The one per-model seam: run chat() in the DO\n // ===========================================================================\n\n /**\n * Run `chat()` IN the DO, streaming its `StreamChunk`s. `stream: true` (with no\n * outputSchema) makes chat() return an `AsyncIterable<StreamChunk>` directly —\n * no cast needed for the run driver. Both middlewares run `setup` before\n * streaming begins: our middleware provides the DO-backed bridge provisioner,\n * and `withSandbox` provides the sandbox handle the harness adapter needs.\n */\n protected override buildRunStream(\n input: StartRunInput,\n ): AsyncIterable<StreamChunk> {\n const { adapter, sandbox, tools, systemPrompts } = this.config(input)\n const sessionId = input.metadata?.sessionId\n const modelOptions =\n typeof sessionId === 'string' && sessionId !== ''\n ? { sessionId }\n : undefined\n return chat({\n threadId: input.threadId,\n adapter,\n messages: input.messages,\n stream: true,\n ...(tools !== undefined ? { tools } : {}),\n ...(systemPrompts !== undefined ? { systemPrompts } : {}),\n ...(modelOptions !== undefined ? { modelOptions } : {}),\n middleware: [\n this.bridgeProvisionerMiddleware(input),\n withSandbox(sandbox),\n ],\n })\n }\n\n /** Drop the per-run bridge once the run is terminal (override from base). */\n protected override onRunSettled(runId: string): void {\n this.bridges.delete(runId)\n }\n\n // ===========================================================================\n // The DO-backed tool-bridge provisioner + endpoint\n // ===========================================================================\n\n /**\n * A tiny middleware that PROVIDES our DO-backed {@link ToolBridgeProvisioner}.\n * The harness adapter reads it via `getOptional` and falls back to the\n * `node:http` host transport when absent — here we override that so the bridge\n * is served from this DO's `fetch` handler instead of a TCP listener.\n */\n private bridgeProvisionerMiddleware(input: StartRunInput) {\n const provisioner = this.makeBridgeProvisioner(input)\n return defineChatMiddleware({\n name: 'do-tool-bridge-provisioner',\n provides: [ToolBridgeProvisionerCapability],\n setup: (ctx) => {\n ctx.provide(ToolBridgeProvisionerCapability, provisioner)\n },\n })\n }\n\n /**\n * Stand up the per-run bridge: register the tool core + a fresh bearer token\n * on this DO, and hand back a URL the SANDBOX can reach — the Worker's public\n * hostname routed to `/_bridge/:runId`. The `threadId` query lets the Worker\n * route the agent's MCP calls back to THIS coordinator. No raw socket is opened.\n */\n private makeBridgeProvisioner(input: StartRunInput): ToolBridgeProvisioner {\n const env = this.env\n const bridges = this.bridges\n const { runId, threadId } = input\n // Container→Worker origin: `PUBLIC_HOSTNAME` if set, else derived from the\n // trigger request (locally → host.docker.internal). The bearer token rides\n // this URL. See `resolveBridgeOrigin`.\n const origin = resolveBridgeOrigin(env, input)\n return {\n provision(tools, options): Promise<ProvisionedBridge> {\n const token =\n crypto.randomUUID() + crypto.randomUUID().replace(/-/g, '')\n const core = createToolBridgeCore(tools, {\n ...(options.context !== undefined\n ? { context: options.context }\n : {}),\n ...(options.signal !== undefined ? { signal: options.signal } : {}),\n ...(options.permission !== undefined\n ? { permission: options.permission }\n : {}),\n })\n bridges.set(runId, { token, core })\n return Promise.resolve({\n name: 'tanstack',\n url: `${origin}/_bridge/${runId}?threadId=${encodeURIComponent(threadId)}`,\n token,\n close: () => {\n bridges.delete(runId)\n return Promise.resolve()\n },\n })\n },\n }\n }\n\n /** Serve `/_bridge/:runId` (the in-sandbox agent's MCP calls) from the base fetch. */\n protected override handleRoute(\n request: Request,\n parts: Array<string>,\n ): Promise<Response> | Response {\n if (parts[0] === '_bridge' && typeof parts[1] === 'string') {\n return this.serveBridge(parts[1], request)\n }\n return super.handleRoute(request, parts)\n }\n\n /** Serve one MCP JSON-RPC request for a run after a constant-time token check. */\n private async serveBridge(\n runId: string,\n request: Request,\n ): Promise<Response> {\n const bridge = this.bridges.get(runId)\n if (!bridge)\n return new Response('no active bridge for run', { status: 404 })\n if (\n !timingSafeBearerEqualWeb(\n request.headers.get('authorization') ?? undefined,\n bridge.token,\n )\n ) {\n return new Response('unauthorized', { status: 401 })\n }\n let message: unknown\n try {\n message = await request.json()\n } catch {\n // A malformed body must still produce a valid JSON-RPC error so the agent's\n // MCP client can react, rather than an opaque DO 500 that can wedge the run.\n return this.jsonResponse({\n jsonrpc: '2.0',\n id: null,\n error: { code: -32700, message: 'Parse error' },\n })\n }\n const reply = await handleBridgeJsonRpc(bridge.core, message)\n // A notification (no id) yields null → MCP expects an empty 202 ack.\n if (reply === null) return new Response(null, { status: 202 })\n return this.jsonResponse(reply)\n }\n}\n"],"names":[],"mappings":"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"}
@@ -0,0 +1,114 @@
1
+ import { SandboxCoordinator, StartRunInput } from './coordinator.js';
2
+ import { HarnessId } from './protocol.js';
3
+ import { AnyTool, StreamChunk } from '@tanstack/ai';
4
+ import { WorkspaceDefinition } from '@tanstack/ai-sandbox';
5
+ import { Sandbox } from '@cloudflare/sandbox';
6
+ /**
7
+ * The Env bindings a {@link ContainerSandboxCoordinator} requires. The
8
+ * `tool-exec` URL the CONTAINER calls back on needs a hostname; `PUBLIC_HOSTNAME`
9
+ * is OPTIONAL (request-derived when unset; locally → `host.docker.internal` — see
10
+ * {@link resolveBridgeOrigin}).
11
+ *
12
+ * Auth is HARNESS-AGNOSTIC: the in-container CLI's API key is NOT a fixed field on
13
+ * this env. Instead each run's workspace DECLARES the secret names it needs (via
14
+ * `createSecrets`), and the coordinator copies those names out of the Worker `env`
15
+ * into the container env at boot. So a Claude run declares `ANTHROPIC_API_KEY`, a
16
+ * codex run declares `CODEX_API_KEY`, and neither name is baked into the package —
17
+ * the concrete key binding lives on the APP's env type, not here.
18
+ */
19
+ export interface ContainerCoordinatorEnv {
20
+ /** The `@cloudflare/sandbox` Sandbox DO namespace (the container hosts). */
21
+ Sandbox: DurableObjectNamespace<Sandbox>;
22
+ /**
23
+ * Hostname the container uses to reach the DO's `/tool-exec` endpoint. Optional:
24
+ * unset → derived from the trigger request (deployed: request host; local dev:
25
+ * `host.docker.internal`). Set it only to override. See {@link resolveBridgeOrigin}.
26
+ */
27
+ PUBLIC_HOSTNAME?: string;
28
+ }
29
+ /** What {@link ContainerSandboxCoordinator.config} returns for one run. */
30
+ export interface ContainerRunConfig {
31
+ /**
32
+ * The REAL host tools. Their `execute()` runs HERE, in the DO — the
33
+ * in-container agent only ever reaches them via `/tool-exec/:runId`. Only the
34
+ * serialized descriptors cross to the container.
35
+ */
36
+ hostTools: Array<AnyTool>;
37
+ /** Workspace the in-container runner bootstraps for the agent. */
38
+ workspace: WorkspaceDefinition;
39
+ /** Which in-sandbox harness the runner spawns. */
40
+ harness: HarnessId;
41
+ /** Model id passed to that harness. */
42
+ model: string;
43
+ /** Runtime context forwarded to each host tool's `execute()` (DB / app state). */
44
+ context?: unknown;
45
+ }
46
+ export declare abstract class ContainerSandboxCoordinator<TEnv extends ContainerCoordinatorEnv = ContainerCoordinatorEnv> extends SandboxCoordinator<TEnv> {
47
+ /**
48
+ * Live per-run tool-exec tokens, keyed by runId. In-memory by design: a run's
49
+ * tool-exec endpoint is only reachable while the run is in flight, and
50
+ * `ctx.waitUntil(done)` keeps THIS instance alive for the run's lifetime, so
51
+ * the container's callbacks always hit the instance that minted the token.
52
+ */
53
+ private readonly toolExec;
54
+ /**
55
+ * In-flight runner boot, memoized so two runs starting near-simultaneously on
56
+ * this instance don't both spawn `container-runner` (the second would hit
57
+ * EADDRINUSE on RUNNER_PORT). Cleared once boot settles.
58
+ */
59
+ private runnerBoot?;
60
+ /** Last `/health` probe error, surfaced if the runner never comes up. */
61
+ private lastProbeError?;
62
+ /**
63
+ * Resolve the host tools, workspace, harness, and model for one run.
64
+ * Implemented by the app subclass (or supplied by
65
+ * {@link createCloudflareSandboxAgent}).
66
+ */
67
+ protected abstract config(input: StartRunInput): ContainerRunConfig;
68
+ /**
69
+ * Mint the per-run tool-exec token, POST `/run` to the in-container runner, and
70
+ * yield its NDJSON chunks. The token is registered BEFORE the container is told
71
+ * to run, so a tool callback can never arrive before the token exists.
72
+ */
73
+ protected buildRunStream(input: StartRunInput): AsyncIterable<StreamChunk>;
74
+ /**
75
+ * Once the run is terminal, abort any host tool still running on its behalf
76
+ * (so a tool that outlived the run doesn't leak), then drop the per-run state.
77
+ */
78
+ protected onRunSettled(runId: string): void;
79
+ /**
80
+ * POST `/run` to the in-container runner and yield its NDJSON chunks. The DO
81
+ * reaches the runner DIRECTLY over the sandbox binding (`containerFetch` to
82
+ * RUNNER_PORT) — this internal channel needs no public hostname. The runner
83
+ * gets the host-tool descriptors plus the `/tool-exec` URL + token it calls
84
+ * back on.
85
+ */
86
+ private driveContainer;
87
+ /**
88
+ * Ensure the in-container runner is listening on RUNNER_PORT. The base image's
89
+ * ENTRYPOINT is the sandbox CONTROL server, not our runner — so we start the
90
+ * bundled runner as a background process via that control server. Idempotent
91
+ * for a thread-reused container: if `/health` already answers, we skip spawn.
92
+ */
93
+ private ensureRunner;
94
+ /**
95
+ * Copy the run's DECLARED secret names out of the Worker `env` into a plain
96
+ * record for the container env. The workspace's `createSecrets` carries only the
97
+ * names across the `/run` boundary; the VALUES come from `env` by that name —
98
+ * which is how `ANTHROPIC_API_KEY` / `CODEX_API_KEY` / any harness key reach the
99
+ * CLI without the package hardcoding which one. A declared name missing from
100
+ * `env` is skipped here and fails loudly later in the runner's
101
+ * `reconstituteWorkspace` (never a silent keyless run).
102
+ */
103
+ private secretEnvFromWorkspace;
104
+ private bootRunner;
105
+ private runnerHealthy;
106
+ protected handleRoute(request: Request, parts: Array<string>): Promise<Response> | Response;
107
+ /**
108
+ * Execute a host tool the in-container agent called back for. The token gates
109
+ * it (constant-time Web Crypto compare); the REAL tool's `execute()` runs here
110
+ * via {@link executeHostTool} and its raw result returns as `{ result }`. An
111
+ * unknown tool or a thrown `execute()` is surfaced as a 4xx/5xx, never masked.
112
+ */
113
+ private serveToolExec;
114
+ }
@@ -0,0 +1,256 @@
1
+ import { EventType } from "@tanstack/ai";
2
+ import { toolDescriptors, isToolExecRequest, executeHostTool } from "@tanstack/ai-sandbox";
3
+ import { getSandbox } from "@cloudflare/sandbox";
4
+ import { SandboxCoordinator } from "./coordinator.js";
5
+ import { timingSafeBearerEqualWeb } from "./web-crypto.js";
6
+ import { resolveBridgeOrigin } from "./public-host.js";
7
+ const RUNNER_PORT = 8080;
8
+ function isStreamChunk(value) {
9
+ return value !== null && typeof value === "object" && "type" in value;
10
+ }
11
+ 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);
33
+ }
34
+ 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
+ }
252
+ }
253
+ export {
254
+ ContainerSandboxCoordinator
255
+ };
256
+ //# sourceMappingURL=container-coordinator.js.map