@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
package/src/agent.ts ADDED
@@ -0,0 +1,66 @@
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
+
16
+ // The headline factory + its config/result types.
17
+ export { createCloudflareSandboxAgent } from './factory'
18
+ export type {
19
+ CloudflareSandboxAgent,
20
+ CloudflareSandboxAgentConfig,
21
+ DoDrivesAgentConfig,
22
+ ColocatedAgentConfig,
23
+ SandboxAgentEnv,
24
+ } from './factory'
25
+
26
+ // The abstract base + its run input + the host resolvers (so apps that build their
27
+ // own host tools / sandbox providers resolve the callback hosts the same way the
28
+ // coordinators do): `resolveBridgeOrigin` for the container→Worker bridge/tool-exec
29
+ // origin, `resolvePreviewHost` for browser-facing `exposePort` preview URLs.
30
+ export {
31
+ SandboxCoordinator,
32
+ resolveBridgeOrigin,
33
+ resolvePreviewHost,
34
+ } from './coordinator'
35
+ export type { StartRunInput } from './coordinator'
36
+
37
+ // The browser-preview building blocks: a ready-made `exposePreview` server tool
38
+ // (mints a preview URL for an in-sandbox dev server) and the system-prompt guidance
39
+ // that keeps previews from reload-looping (the proxy can't tunnel HMR). Wire both
40
+ // into the agent — `tools: (i, e) => [exposePreviewTool(i, e)]`,
41
+ // `systemPrompts: [PREVIEW_GUIDANCE]`. Owned here because the limitation is the
42
+ // transport's, not any app's.
43
+ export { exposePreviewTool, PREVIEW_GUIDANCE } from './preview-tool'
44
+ export type { PreviewToolEnv } from './preview-tool'
45
+
46
+ // The two concrete coordinators + their per-run config + Env types.
47
+ export { ChatSandboxCoordinator } from './chat-coordinator'
48
+ export type { ChatCoordinatorEnv, ChatRunConfig } from './chat-coordinator'
49
+ export { ContainerSandboxCoordinator } from './container-coordinator'
50
+ export type {
51
+ ContainerCoordinatorEnv,
52
+ ContainerRunConfig,
53
+ } from './container-coordinator'
54
+
55
+ // The shared `POST /run` wire contract (built by the coordinator, validated by
56
+ // the `/runner` entry). Defined runtime-agnostically in `./protocol`.
57
+ export { parseContainerRunRequest } from './protocol'
58
+ export type { ContainerRunRequest, HarnessId } from './protocol'
59
+
60
+ // The Worker fetch-handler factory + its resolver type.
61
+ export { createSandboxAgentWorker } from './worker'
62
+ export type { ResolveCoordinator } from './worker'
63
+
64
+ // The durable run-log + the Web Crypto bearer helper (for direct composition).
65
+ export { DurableObjectRunEventLog } from './run-log-do'
66
+ export { timingSafeBearerEqualWeb } from './web-crypto'
@@ -0,0 +1,253 @@
1
+ /**
2
+ * `ChatSandboxCoordinator` — the concrete {@link SandboxCoordinator} for the
3
+ * DO-DRIVES model: the Durable Object runs `chat()` ITSELF and hosts the MCP
4
+ * tool-bridge from its own `fetch` handler.
5
+ *
6
+ * Worker (stateless trigger)
7
+ * → ChatSandboxCoordinator (this DO: runs chat(), owns the sandbox + log)
8
+ * → Cloudflare Sandbox (the container the agent executes in)
9
+ *
10
+ * It implements the one per-model seam, {@link buildRunStream}, by running
11
+ * `chat()` in the DO with two middlewares: our DO-backed tool-bridge provisioner
12
+ * (so the bridge is served from this DO instead of a `node:http` listener) and
13
+ * `withSandbox(...)` (the handle the harness adapter needs). The per-run config —
14
+ * which adapter, which sandbox, which chat()-tools — is the subclass's
15
+ * {@link config} method; everything else (run-log, streaming tail, watchdog) is
16
+ * inherited from the base.
17
+ *
18
+ * The MCP tool-bridge lives at `/_bridge/:runId`, gated by a per-run bearer
19
+ * token, served from {@link handleRoute}. The in-sandbox agent reaches it via
20
+ * the Worker's public hostname.
21
+ *
22
+ * NOTE: Workers-runtime code — compiles against the real Cloudflare + TanStack
23
+ * AI types; not runtime-verified in this repo (no Workers runtime here).
24
+ */
25
+ import { chat, defineChatMiddleware } from '@tanstack/ai'
26
+ import {
27
+ ToolBridgeProvisionerCapability,
28
+ createToolBridgeCore,
29
+ handleBridgeJsonRpc,
30
+ withSandbox,
31
+ } from '@tanstack/ai-sandbox'
32
+ import { SandboxCoordinator, resolveBridgeOrigin } from './coordinator'
33
+ import { timingSafeBearerEqualWeb } from './web-crypto'
34
+ import type { StartRunInput } from './coordinator'
35
+ import type {
36
+ AnyTextAdapter,
37
+ AnyTool,
38
+ StreamChunk,
39
+ SystemPrompt,
40
+ } from '@tanstack/ai'
41
+ import type {
42
+ ProvisionedBridge,
43
+ SandboxDefinition,
44
+ ToolBridgeCore,
45
+ ToolBridgeProvisioner,
46
+ } from '@tanstack/ai-sandbox'
47
+
48
+ /**
49
+ * The Env bindings a {@link ChatSandboxCoordinator} requires. The bridge origin the
50
+ * SANDBOX calls back on needs a hostname; `PUBLIC_HOSTNAME` is OPTIONAL — when
51
+ * unset, the coordinator derives it from the trigger request (locally →
52
+ * `host.docker.internal`; safe on Cloudflare). See {@link resolveBridgeOrigin}.
53
+ */
54
+ export interface ChatCoordinatorEnv {
55
+ /**
56
+ * Hostname the CONTAINER uses to reach the Worker's tool-bridge (`/_bridge`).
57
+ * Optional: unset → derived from each trigger request (deployed: the request
58
+ * host; local dev: `host.docker.internal`). Set it only to override — e.g. a
59
+ * stable named-tunnel host. See {@link resolveBridgeOrigin}. (Browser-facing
60
+ * preview URLs use a separate `PREVIEW_HOSTNAME`; see {@link resolvePreviewHost}.)
61
+ */
62
+ PUBLIC_HOSTNAME?: string
63
+ }
64
+
65
+ /** What {@link ChatSandboxCoordinator.config} returns for one run. */
66
+ export interface ChatRunConfig {
67
+ /** The harness/text adapter `chat()` runs (e.g. `claudeCodeText('sonnet')`). */
68
+ adapter: AnyTextAdapter
69
+ /** The sandbox the agent executes in, projected by `withSandbox`. */
70
+ sandbox: SandboxDefinition
71
+ /** chat()-provided server tools bridged into the harness over MCP. */
72
+ tools?: Array<AnyTool>
73
+ /** Base system prompts prepended to the run's `chat()` (e.g. `[PREVIEW_GUIDANCE]`). */
74
+ systemPrompts?: Array<SystemPrompt>
75
+ }
76
+
77
+ /** Per-run bridge state so `/_bridge/:runId` can authenticate + serve. */
78
+ interface BridgeState {
79
+ token: string
80
+ core: ToolBridgeCore
81
+ }
82
+
83
+ export abstract class ChatSandboxCoordinator<
84
+ TEnv extends ChatCoordinatorEnv = ChatCoordinatorEnv,
85
+ > extends SandboxCoordinator<TEnv> {
86
+ /**
87
+ * Live per-run bridges, keyed by runId. In-memory by design: a bridge is only
88
+ * reachable while its run is in flight, and `ctx.waitUntil(done)` keeps THIS
89
+ * instance alive (un-hibernated) for the run's whole lifetime — so the agent's
90
+ * MCP calls always hit the instance that provisioned the bridge. A request for
91
+ * a run with no live bridge (finished, or never started here) is a hard 404,
92
+ * not a silent re-provision.
93
+ */
94
+ private readonly bridges = new Map<string, BridgeState>()
95
+
96
+ // ===========================================================================
97
+ // Subclass seam: the per-run configuration
98
+ // ===========================================================================
99
+
100
+ /**
101
+ * Resolve the adapter, sandbox, and chat()-tools for one run. Implemented by
102
+ * the app subclass (or supplied by {@link createCloudflareSandboxAgent}); this
103
+ * is the only model-specific input the DO-drives coordinator needs.
104
+ */
105
+ protected abstract config(input: StartRunInput): ChatRunConfig
106
+
107
+ // ===========================================================================
108
+ // The one per-model seam: run chat() in the DO
109
+ // ===========================================================================
110
+
111
+ /**
112
+ * Run `chat()` IN the DO, streaming its `StreamChunk`s. `stream: true` (with no
113
+ * outputSchema) makes chat() return an `AsyncIterable<StreamChunk>` directly —
114
+ * no cast needed for the run driver. Both middlewares run `setup` before
115
+ * streaming begins: our middleware provides the DO-backed bridge provisioner,
116
+ * and `withSandbox` provides the sandbox handle the harness adapter needs.
117
+ */
118
+ protected override buildRunStream(
119
+ input: StartRunInput,
120
+ ): AsyncIterable<StreamChunk> {
121
+ const { adapter, sandbox, tools, systemPrompts } = this.config(input)
122
+ const sessionId = input.metadata?.sessionId
123
+ const modelOptions =
124
+ typeof sessionId === 'string' && sessionId !== ''
125
+ ? { sessionId }
126
+ : undefined
127
+ return chat({
128
+ threadId: input.threadId,
129
+ adapter,
130
+ messages: input.messages,
131
+ stream: true,
132
+ ...(tools !== undefined ? { tools } : {}),
133
+ ...(systemPrompts !== undefined ? { systemPrompts } : {}),
134
+ ...(modelOptions !== undefined ? { modelOptions } : {}),
135
+ middleware: [
136
+ this.bridgeProvisionerMiddleware(input),
137
+ withSandbox(sandbox),
138
+ ],
139
+ })
140
+ }
141
+
142
+ /** Drop the per-run bridge once the run is terminal (override from base). */
143
+ protected override onRunSettled(runId: string): void {
144
+ this.bridges.delete(runId)
145
+ }
146
+
147
+ // ===========================================================================
148
+ // The DO-backed tool-bridge provisioner + endpoint
149
+ // ===========================================================================
150
+
151
+ /**
152
+ * A tiny middleware that PROVIDES our DO-backed {@link ToolBridgeProvisioner}.
153
+ * The harness adapter reads it via `getOptional` and falls back to the
154
+ * `node:http` host transport when absent — here we override that so the bridge
155
+ * is served from this DO's `fetch` handler instead of a TCP listener.
156
+ */
157
+ private bridgeProvisionerMiddleware(input: StartRunInput) {
158
+ const provisioner = this.makeBridgeProvisioner(input)
159
+ return defineChatMiddleware({
160
+ name: 'do-tool-bridge-provisioner',
161
+ provides: [ToolBridgeProvisionerCapability],
162
+ setup: (ctx) => {
163
+ ctx.provide(ToolBridgeProvisionerCapability, provisioner)
164
+ },
165
+ })
166
+ }
167
+
168
+ /**
169
+ * Stand up the per-run bridge: register the tool core + a fresh bearer token
170
+ * on this DO, and hand back a URL the SANDBOX can reach — the Worker's public
171
+ * hostname routed to `/_bridge/:runId`. The `threadId` query lets the Worker
172
+ * route the agent's MCP calls back to THIS coordinator. No raw socket is opened.
173
+ */
174
+ private makeBridgeProvisioner(input: StartRunInput): ToolBridgeProvisioner {
175
+ const env = this.env
176
+ const bridges = this.bridges
177
+ const { runId, threadId } = input
178
+ // Container→Worker origin: `PUBLIC_HOSTNAME` if set, else derived from the
179
+ // trigger request (locally → host.docker.internal). The bearer token rides
180
+ // this URL. See `resolveBridgeOrigin`.
181
+ const origin = resolveBridgeOrigin(env, input)
182
+ return {
183
+ provision(tools, options): Promise<ProvisionedBridge> {
184
+ const token =
185
+ crypto.randomUUID() + crypto.randomUUID().replace(/-/g, '')
186
+ const core = createToolBridgeCore(tools, {
187
+ ...(options.context !== undefined
188
+ ? { context: options.context }
189
+ : {}),
190
+ ...(options.signal !== undefined ? { signal: options.signal } : {}),
191
+ ...(options.permission !== undefined
192
+ ? { permission: options.permission }
193
+ : {}),
194
+ })
195
+ bridges.set(runId, { token, core })
196
+ return Promise.resolve({
197
+ name: 'tanstack',
198
+ url: `${origin}/_bridge/${runId}?threadId=${encodeURIComponent(threadId)}`,
199
+ token,
200
+ close: () => {
201
+ bridges.delete(runId)
202
+ return Promise.resolve()
203
+ },
204
+ })
205
+ },
206
+ }
207
+ }
208
+
209
+ /** Serve `/_bridge/:runId` (the in-sandbox agent's MCP calls) from the base fetch. */
210
+ protected override handleRoute(
211
+ request: Request,
212
+ parts: Array<string>,
213
+ ): Promise<Response> | Response {
214
+ if (parts[0] === '_bridge' && typeof parts[1] === 'string') {
215
+ return this.serveBridge(parts[1], request)
216
+ }
217
+ return super.handleRoute(request, parts)
218
+ }
219
+
220
+ /** Serve one MCP JSON-RPC request for a run after a constant-time token check. */
221
+ private async serveBridge(
222
+ runId: string,
223
+ request: Request,
224
+ ): Promise<Response> {
225
+ const bridge = this.bridges.get(runId)
226
+ if (!bridge)
227
+ return new Response('no active bridge for run', { status: 404 })
228
+ if (
229
+ !timingSafeBearerEqualWeb(
230
+ request.headers.get('authorization') ?? undefined,
231
+ bridge.token,
232
+ )
233
+ ) {
234
+ return new Response('unauthorized', { status: 401 })
235
+ }
236
+ let message: unknown
237
+ try {
238
+ message = await request.json()
239
+ } catch {
240
+ // A malformed body must still produce a valid JSON-RPC error so the agent's
241
+ // MCP client can react, rather than an opaque DO 500 that can wedge the run.
242
+ return this.jsonResponse({
243
+ jsonrpc: '2.0',
244
+ id: null,
245
+ error: { code: -32700, message: 'Parse error' },
246
+ })
247
+ }
248
+ const reply = await handleBridgeJsonRpc(bridge.core, message)
249
+ // A notification (no id) yields null → MCP expects an empty 202 ack.
250
+ if (reply === null) return new Response(null, { status: 202 })
251
+ return this.jsonResponse(reply)
252
+ }
253
+ }