@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/handle.ts ADDED
@@ -0,0 +1,292 @@
1
+ /**
2
+ * SandboxHandle backed by a Cloudflare Sandbox (Containers + Durable Objects),
3
+ * via `@cloudflare/sandbox`. Runs at the edge inside a Worker.
4
+ *
5
+ * fs is implemented over `exec` with base64 piping (binary-safe), matching the
6
+ * Docker provider. The container disk is EPHEMERAL (wiped to the image on
7
+ * restart) and snapshots are not yet GA, so `capabilities.snapshots` and
8
+ * `durableFilesystem` are false — `withSandbox` re-bootstraps under the same
9
+ * identity across cold starts.
10
+ *
11
+ * LIMITATION: Cloudflare background processes do not expose a writable host→
12
+ * process stdin, so `spawn().stdin.write` throws. This is advertised via
13
+ * `capabilities.writableStdin: false`; harness adapters that feed a prompt over
14
+ * stdin (e.g. the Claude Code adapter) detect this and instead deliver the
15
+ * prompt via a file + shell stdin-redirection (`claude -p … < file`), which the
16
+ * in-container shell handles with no host-side stdin write. `exec` (one-shot)
17
+ * and streamed stdout from `spawn` both work fully.
18
+ *
19
+ * NOTE: not runtime-verified in this repo (requires a Workers runtime); it
20
+ * compiles against the real `@cloudflare/sandbox` types and follows the proven
21
+ * provider contract.
22
+ */
23
+ import { createExecBackedGit } from '@tanstack/ai-sandbox'
24
+ import type { Sandbox } from '@cloudflare/sandbox'
25
+ import type {
26
+ ExecResult,
27
+ ProcessOptions,
28
+ SandboxCapabilities,
29
+ SandboxChannel,
30
+ SandboxHandle,
31
+ SpawnHandle,
32
+ } from '@tanstack/ai-sandbox'
33
+
34
+ export const CLOUDFLARE_CAPS: SandboxCapabilities = {
35
+ fs: true,
36
+ exec: true,
37
+ env: true,
38
+ ports: true,
39
+ backgroundProcesses: true,
40
+ // No writable host→process stdin; stdin-fed harnesses use file-redirection.
41
+ writableStdin: false,
42
+ snapshots: false,
43
+ networkPolicy: false,
44
+ durableFilesystem: false,
45
+ fork: false,
46
+ }
47
+
48
+ /** POSIX single-quote escape for embedding paths in `sh -c`. */
49
+ function q(value: string): string {
50
+ return `'${value.replace(/'/g, `'\\''`)}'`
51
+ }
52
+
53
+ /** A push-driven async string queue used to adapt CF's onOutput callback. */
54
+ class OutputQueue {
55
+ private readonly buffer: Array<string> = []
56
+ private readonly waiters: Array<(r: IteratorResult<string>) => void> = []
57
+ private ended = false
58
+
59
+ push(value: string): void {
60
+ const waiter = this.waiters.shift()
61
+ if (waiter) waiter({ value, done: false })
62
+ else this.buffer.push(value)
63
+ }
64
+
65
+ end(): void {
66
+ this.ended = true
67
+ let waiter = this.waiters.shift()
68
+ while (waiter) {
69
+ waiter({ value: undefined, done: true })
70
+ waiter = this.waiters.shift()
71
+ }
72
+ }
73
+
74
+ async *[Symbol.asyncIterator](): AsyncIterator<string> {
75
+ while (!this.ended || this.buffer.length > 0) {
76
+ if (this.buffer.length > 0) {
77
+ yield this.buffer.shift() as string
78
+ continue
79
+ }
80
+ const next = await new Promise<IteratorResult<string>>((resolve) =>
81
+ this.waiters.push(resolve),
82
+ )
83
+ if (next.done) return
84
+ yield next.value
85
+ }
86
+ }
87
+ }
88
+
89
+ export class CloudflareHandle implements SandboxHandle {
90
+ readonly id: string
91
+ readonly provider = 'cloudflare'
92
+ readonly workspaceRoot: string
93
+ readonly capabilities = CLOUDFLARE_CAPS
94
+ readonly fs: SandboxHandle['fs']
95
+ readonly git: SandboxHandle['git']
96
+ readonly process: SandboxHandle['process']
97
+ readonly ports: SandboxHandle['ports']
98
+ readonly env: SandboxHandle['env']
99
+
100
+ private readonly sandbox: Sandbox
101
+ private readonly workdir: string
102
+ private readonly previewHostname: string | undefined
103
+
104
+ constructor(
105
+ id: string,
106
+ sandbox: Sandbox,
107
+ workdir: string,
108
+ previewHostname?: string,
109
+ ) {
110
+ this.id = id
111
+ this.sandbox = sandbox
112
+ this.workdir = workdir
113
+ this.workspaceRoot = workdir
114
+ this.previewHostname = previewHostname
115
+
116
+ this.process = {
117
+ exec: (command, opts) => this.exec(command, opts),
118
+ spawn: (command, opts) => this.spawnProcess(command, opts),
119
+ }
120
+
121
+ this.fs = {
122
+ read: async (p) => {
123
+ const r = await this.exec(`base64 ${q(this.abs(p))}`)
124
+ if (r.exitCode !== 0) throw new Error(`read failed: ${r.stderr.trim()}`)
125
+ return Buffer.from(r.stdout, 'base64').toString('utf8')
126
+ },
127
+ readBytes: async (p) => {
128
+ const r = await this.exec(`base64 ${q(this.abs(p))}`)
129
+ if (r.exitCode !== 0) throw new Error(`read failed: ${r.stderr.trim()}`)
130
+ return new Uint8Array(Buffer.from(r.stdout, 'base64'))
131
+ },
132
+ write: async (p, data) => {
133
+ const abs = this.abs(p)
134
+ const b64 = Buffer.from(
135
+ typeof data === 'string' ? Buffer.from(data, 'utf8') : data,
136
+ ).toString('base64')
137
+ const dir = abs.replace(/\/[^/]*$/, '') || '/'
138
+ const r = await this.exec(
139
+ `mkdir -p ${q(dir)} && printf %s ${q(b64)} | base64 -d > ${q(abs)}`,
140
+ )
141
+ if (r.exitCode !== 0)
142
+ throw new Error(`write failed: ${r.stderr.trim()}`)
143
+ },
144
+ list: async (p) => {
145
+ const r = await this.exec(`ls -1Ap ${q(this.abs(p))}`)
146
+ if (r.exitCode !== 0) throw new Error(`list failed: ${r.stderr.trim()}`)
147
+ return r.stdout
148
+ .split('\n')
149
+ .filter((line) => line.trim() !== '')
150
+ .map((entry) => {
151
+ const isDir = entry.endsWith('/')
152
+ const name = isDir ? entry.slice(0, -1) : entry
153
+ return {
154
+ name,
155
+ path: `${p.replace(/\/$/, '')}/${name}`,
156
+ type: isDir ? ('dir' as const) : ('file' as const),
157
+ }
158
+ })
159
+ },
160
+ mkdir: async (p) => {
161
+ await this.exec(`mkdir -p ${q(this.abs(p))}`)
162
+ },
163
+ remove: async (p) => {
164
+ await this.exec(`rm -rf ${q(this.abs(p))}`)
165
+ },
166
+ rename: async (from, to) => {
167
+ await this.exec(`mv ${q(this.abs(from))} ${q(this.abs(to))}`)
168
+ },
169
+ exists: async (p) => {
170
+ const r = await this.exec(`test -e ${q(this.abs(p))}`)
171
+ return r.exitCode === 0
172
+ },
173
+ }
174
+
175
+ this.git = createExecBackedGit(this.process, this.workdir)
176
+
177
+ this.ports = {
178
+ connect: (port) => this.connectPort(port),
179
+ }
180
+
181
+ this.env = {
182
+ set: (vars) => this.sandbox.setEnvVars(vars),
183
+ }
184
+ }
185
+
186
+ private abs(p: string): string {
187
+ if (this.workdir === '/workspace') return p
188
+ if (p === '/workspace') return this.workdir
189
+ if (p.startsWith('/workspace/')) {
190
+ return `${this.workdir}/${p.slice('/workspace/'.length)}`
191
+ }
192
+ return p
193
+ }
194
+
195
+ private async exec(
196
+ command: string,
197
+ opts?: ProcessOptions,
198
+ ): Promise<ExecResult> {
199
+ const result = await this.sandbox.exec(command, {
200
+ ...(opts?.cwd ? { cwd: this.abs(opts.cwd) } : { cwd: this.workdir }),
201
+ ...(opts?.env ? { env: opts.env } : {}),
202
+ })
203
+ return {
204
+ stdout: result.stdout,
205
+ stderr: result.stderr,
206
+ exitCode: result.exitCode,
207
+ }
208
+ }
209
+
210
+ private spawnProcess(
211
+ command: string,
212
+ opts?: ProcessOptions,
213
+ ): Promise<SpawnHandle> {
214
+ const stdout = new OutputQueue()
215
+ const stderr = new OutputQueue()
216
+
217
+ // Stream over `exec({ stream: true, onOutput })` — the SAME proven command
218
+ // path as one-shot `exec`. The background-process API (`startProcess` +
219
+ // `streamProcessLogs`) does NOT deliver its `onOutput`/`onExit` callbacks
220
+ // here (verified under `wrangler dev`: the process runs and exits cleanly,
221
+ // yet no log events ever arrive), so a stdout-NDJSON harness spawned that
222
+ // way hangs forever. exec's streaming path emits each chunk via `onOutput`
223
+ // and resolves with the exit code on completion. The prompt still reaches
224
+ // the CLI via in-shell stdin redirection (`… < file`), which this session
225
+ // shell honors — `writableStdin` stays false.
226
+ //
227
+ // The caller's AbortSignal is intentionally NOT forwarded: `exec` is a
228
+ // Durable Object RPC and Workers RPC cannot serialize an AbortSignal
229
+ // ("AbortSignal serialization is not enabled"), so passing one throws
230
+ // before the command runs. Mid-run cancellation is therefore unavailable
231
+ // on this provider; a stuck run is bounded by the coordinator's watchdog
232
+ // and the Durable Object lifecycle instead. `kill()` is a best-effort no-op.
233
+ const settled = this.sandbox.exec(command, {
234
+ ...(opts?.cwd ? { cwd: this.abs(opts.cwd) } : { cwd: this.workdir }),
235
+ ...(opts?.env ? { env: opts.env } : {}),
236
+ stream: true,
237
+ onOutput: (stream, data) => {
238
+ if (stream === 'stdout') stdout.push(data)
239
+ else stderr.push(data)
240
+ },
241
+ })
242
+ // End the output queues once the command settles either way (so the stdout
243
+ // reader terminates), but let a failure REJECT `wait()` rather than masking
244
+ // it as a clean exit — the harness adapter turns that into a RUN_ERROR
245
+ // instead of a silent zero-output run.
246
+ const exitPromise = settled.then(
247
+ (result) => {
248
+ stdout.end()
249
+ stderr.end()
250
+ return result.exitCode
251
+ },
252
+ (error: unknown) => {
253
+ stdout.end()
254
+ stderr.end()
255
+ throw error
256
+ },
257
+ )
258
+
259
+ return Promise.resolve({
260
+ pid: -1,
261
+ stdout,
262
+ stderr,
263
+ stdin: {
264
+ write: () =>
265
+ Promise.reject(
266
+ new Error(
267
+ 'cloudflare: background processes do not expose stdin. Use exec(), or a stdin-capable provider (local-process / docker) for stdin-fed harnesses.',
268
+ ),
269
+ ),
270
+ end: () => Promise.resolve(),
271
+ },
272
+ wait: () => exitPromise,
273
+ kill: () => Promise.resolve(),
274
+ })
275
+ }
276
+
277
+ private async connectPort(port: number): Promise<SandboxChannel> {
278
+ if (this.previewHostname === undefined) {
279
+ throw new Error(
280
+ 'cloudflare: ports.connect requires a previewHostname. Pass previewHostname (your Worker request hostname) to cloudflareSandbox(...).',
281
+ )
282
+ }
283
+ const { url } = await this.sandbox.exposePort(port, {
284
+ hostname: this.previewHostname,
285
+ })
286
+ return { url }
287
+ }
288
+
289
+ async destroy(): Promise<void> {
290
+ await this.sandbox.destroy()
291
+ }
292
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export { cloudflareSandbox } from './provider'
2
+ export type { CloudflareSandboxConfig } from './provider'
3
+ export { CloudflareHandle, CLOUDFLARE_CAPS } from './handle'
4
+ // Re-export the Sandbox class so users can wire the Durable Object binding.
5
+ export { Sandbox } from '@cloudflare/sandbox'
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The browser-preview capability, as reusable building blocks rather than
3
+ * per-app glue: a `chat()` server tool that mints a preview URL for a dev server
4
+ * running inside the sandbox, plus the system-prompt guidance an agent needs to
5
+ * produce a preview that works.
6
+ *
7
+ * Previews go over a **Cloudflare quick tunnel** (`sandbox.tunnels.get(port)` →
8
+ * `https://<name>.trycloudflare.com`), served by `cloudflared` INSIDE the sandbox.
9
+ * We deliberately do NOT use `exposePort` + `proxyToSandbox` here: that routes the
10
+ * preview through the Worker's own origin, which in local dev is the example's Vite
11
+ * dev server — and Vite's middleware then serves the preview's module/asset
12
+ * requests (`/@vite/client`, `/src/*`, `/@fs/*`) from the HOST instead of the
13
+ * container, breaking the page. A tunnel bypasses the Vite port entirely, needs no
14
+ * custom domain on a deploy, and forwards WebSockets (so the app's HMR works).
15
+ *
16
+ * Both exports belong to THIS package because the transport is its concern, not any
17
+ * particular app's. Wire them explicitly into your agent:
18
+ *
19
+ * ```ts
20
+ * import {
21
+ * exposePreviewTool,
22
+ * PREVIEW_GUIDANCE,
23
+ * } from '@tanstack/ai-sandbox-cloudflare/agent'
24
+ *
25
+ * createCloudflareSandboxAgent({
26
+ * adapter: () => claudeCodeText('sonnet'),
27
+ * tools: (input, env) => [exposePreviewTool(input, env)],
28
+ * systemPrompts: [PREVIEW_GUIDANCE],
29
+ * })
30
+ * ```
31
+ *
32
+ * Workers-only (imports `@cloudflare/sandbox`) — exported from the `/agent` entry.
33
+ */
34
+ import { toolDefinition } from '@tanstack/ai'
35
+ import { z } from 'zod'
36
+ import { getSandbox } from '@cloudflare/sandbox'
37
+ import type { Sandbox } from '@cloudflare/sandbox'
38
+ import type { StartRunInput } from './coordinator'
39
+
40
+ /**
41
+ * The minimum env an {@link exposePreviewTool} needs: the Sandbox namespace it
42
+ * addresses the run's container in. `SandboxAgentEnv` satisfies this structurally,
43
+ * so the factory's `tools` resolver passes its env straight in.
44
+ */
45
+ export interface PreviewToolEnv {
46
+ Sandbox: DurableObjectNamespace<Sandbox>
47
+ }
48
+
49
+ /**
50
+ * System-prompt guidance for any agent that exposes a dev server as a browser
51
+ * preview. App-agnostic: the only requirement a quick tunnel imposes is that the
52
+ * dev server accept the tunnel hostname (Vite/webpack reject unknown hosts by
53
+ * default), so the rule is "bind wide + allow all hosts", not "disable HMR" — the
54
+ * tunnel forwards WebSockets, so HMR works.
55
+ */
56
+ export const PREVIEW_GUIDANCE: string = [
57
+ 'PREVIEW SERVERS: to show the user a running web app, start its dev server bound',
58
+ 'to 0.0.0.0 on a port OTHER than 3000 (3000 is reserved by the sandbox control',
59
+ 'plane), then call the `exposePreview` tool with that port. It returns a public',
60
+ 'Cloudflare quick-tunnel URL (https://<name>.trycloudflare.com) served straight',
61
+ 'from the sandbox — no custom domain needed, and HMR / live-reload WebSockets',
62
+ 'work through the tunnel (you do NOT need to disable HMR). The ONE requirement:',
63
+ 'the dev server must ACCEPT the tunnel hostname, which servers reject by default,',
64
+ 'so allow all hosts in its config before starting:',
65
+ '• Vite — `server: { host: true, allowedHosts: true }` in vite.config.',
66
+ "• webpack-dev-server — `allowedHosts: 'all'` (and `host: '0.0.0.0'`).",
67
+ '• Other dev servers — bind 0.0.0.0 and allow all hosts equivalently.',
68
+ 'Once it is listening, call `exposePreview` with that port, then share the URL.',
69
+ ].join('\n')
70
+
71
+ /**
72
+ * Build the `exposePreview` server tool for one run. Starting a tunnel is a
73
+ * HOST-side call on the Sandbox DO stub, so an in-sandbox agent cannot make it from
74
+ * bash — it calls this bridged tool instead. We address the run's container by
75
+ * `threadId` and open (or reuse) a quick tunnel to the given port.
76
+ *
77
+ * Closes over the run's `input` + `env`, so build it inside the `tools` resolver
78
+ * (`tools: (input, env) => [exposePreviewTool(input, env)]`).
79
+ */
80
+ export function exposePreviewTool(input: StartRunInput, env: PreviewToolEnv) {
81
+ return toolDefinition({
82
+ name: 'exposePreview',
83
+ description:
84
+ 'Expose a port a dev server is listening on inside the sandbox and return a public preview URL (a Cloudflare quick tunnel) to show the user. Call this AFTER the server is up. The dev server must allow all hosts (e.g. Vite `server.allowedHosts: true`) so it accepts the tunnel hostname.',
85
+ inputSchema: z.object({
86
+ port: z
87
+ .number()
88
+ .int()
89
+ .min(1024)
90
+ .max(65535)
91
+ .describe('The port the dev server is listening on, e.g. 5173.'),
92
+ }),
93
+ }).server(async ({ port }) => {
94
+ // `sandbox.tunnels` only exists on the RPC transport (on HTTP/WebSocket it's a
95
+ // stub that throws "requires the RPC transport"), so we must obtain the stub
96
+ // with `transport: 'rpc'`. IMPORTANT: this must MATCH how the sandbox was
97
+ // created — pass `transport: 'rpc'` on EVERY `getSandbox()` for this id (in your
98
+ // sandbox provider too), or the differing transport disconnects the run's active
99
+ // client. See the SDK `SandboxOptions.transport` note.
100
+ const sandbox = getSandbox(env.Sandbox, input.threadId, {
101
+ transport: 'rpc',
102
+ })
103
+ // A Cloudflare quick tunnel (`*.trycloudflare.com`) run by `cloudflared` INSIDE
104
+ // the sandbox: it bypasses the local Vite dev server's port entirely (so Vite
105
+ // can't hijack the preview's asset requests) and needs no custom domain on a
106
+ // deploy. `get(port)` is idempotent per port. See the Sandbox SDK `tunnels` API.
107
+ const tunnel = await sandbox.tunnels.get(port)
108
+ return { url: tunnel.url }
109
+ })
110
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * The wire contract for the ONE request that crosses the DO → container boundary
3
+ * to start a run: `POST /run` on the in-container runner.
4
+ *
5
+ * Defined ONCE here so both sides import the same shape AND the same narrowing
6
+ * guard, with NO runtime-specific imports (no `cloudflare:*`, no `node:*`):
7
+ * - the `ContainerSandboxCoordinator` (Workers side) builds a
8
+ * {@link ContainerRunRequest} and POSTs it (re-exported from `/agent`);
9
+ * - the in-container `runInContainerHarness` (Node side) validates the body
10
+ * with {@link parseContainerRunRequest} before running `chat()` (imported
11
+ * from `/runner`).
12
+ *
13
+ * It carries the run identity + conversation + serialized host-tool descriptors
14
+ * + the tool-exec callback, plus the `harness`/`model`/`workspace` the runner
15
+ * needs to build the right adapter and sandbox.
16
+ *
17
+ * NOTE: the workspace's secret VALUES do NOT cross this boundary — `createSecrets`
18
+ * stores them under a non-enumerable symbol, so JSON-serializing the workspace
19
+ * carries only the secret NAMES. The runner reconstructs runtime secrets from
20
+ * the container env (the DO injects them via `sandbox.setEnvVars`).
21
+ */
22
+ import type { ModelMessage } from '@tanstack/ai'
23
+ import type { ToolDescriptor, WorkspaceDefinition } from '@tanstack/ai-sandbox'
24
+
25
+ /**
26
+ * The in-sandbox harnesses the runner can spawn. Single source of truth: the
27
+ * {@link HarnessId} type is DERIVED from this list, and {@link isHarnessId}
28
+ * validates against it — so the runtime guard and the compile-time type can
29
+ * never drift.
30
+ */
31
+ const HARNESS_IDS = ['claude-code', 'codex', 'opencode'] as const
32
+
33
+ /**
34
+ * Identifier for the in-sandbox harness the runner spawns. The runner maps this
35
+ * to the matching `*Text` adapter (via the caller's `resolveAdapter`); the DO
36
+ * never imports the adapter packages.
37
+ */
38
+ export type HarnessId = (typeof HARNESS_IDS)[number]
39
+
40
+ /**
41
+ * The body of `POST /run`: the run identity + conversation + serialized
42
+ * host-tool descriptors + the tool-exec callback, plus the harness/model/
43
+ * workspace the runner needs to build the right adapter.
44
+ */
45
+ export interface ContainerRunRequest {
46
+ runId: string
47
+ threadId: string
48
+ messages: Array<ModelMessage>
49
+ harness: HarnessId
50
+ model: string
51
+ workspace: WorkspaceDefinition
52
+ /** Host-tool descriptors serialized by `toolDescriptors()` on the DO. */
53
+ toolDescriptors: Array<ToolDescriptor>
54
+ /** DO endpoint the in-container `httpRemoteToolExecutor` POSTs tool calls to. */
55
+ toolExecUrl: string
56
+ /** Per-run bearer token gating that tool-exec endpoint. */
57
+ toolExecToken: string
58
+ }
59
+
60
+ function isHarnessId(value: unknown): value is HarnessId {
61
+ return (
62
+ typeof value === 'string' &&
63
+ (HARNESS_IDS as ReadonlyArray<string>).includes(value)
64
+ )
65
+ }
66
+
67
+ function isToolDescriptor(value: unknown): value is ToolDescriptor {
68
+ return (
69
+ value !== null &&
70
+ typeof value === 'object' &&
71
+ 'name' in value &&
72
+ typeof value.name === 'string'
73
+ )
74
+ }
75
+
76
+ function isWorkspaceDefinition(value: unknown): value is WorkspaceDefinition {
77
+ return (
78
+ value !== null &&
79
+ typeof value === 'object' &&
80
+ 'source' in value &&
81
+ value.source !== null &&
82
+ typeof value.source === 'object'
83
+ )
84
+ }
85
+
86
+ /**
87
+ * Assert enough of a message to fail fast on garbage (a non-empty `role` and a
88
+ * `content` field). The chat engine validates the full shape downstream; this
89
+ * narrows the array element to {@link ModelMessage} without a cast.
90
+ */
91
+ function isModelMessage(value: unknown): value is ModelMessage {
92
+ return (
93
+ value !== null &&
94
+ typeof value === 'object' &&
95
+ 'role' in value &&
96
+ typeof value.role === 'string' &&
97
+ 'content' in value
98
+ )
99
+ }
100
+
101
+ /** Narrow `unknown` to an indexable record (a predicate, not a cast). */
102
+ function isRecord(value: unknown): value is Record<string, unknown> {
103
+ return value !== null && typeof value === 'object'
104
+ }
105
+
106
+ function requireNonEmptyString(
107
+ value: Record<string, unknown>,
108
+ key: string,
109
+ ): string {
110
+ const found = value[key]
111
+ if (typeof found !== 'string' || found === '') {
112
+ throw new Error(`run request: ${key} must be a non-empty string`)
113
+ }
114
+ return found
115
+ }
116
+
117
+ /**
118
+ * Narrow an unknown `POST /run` body into a {@link ContainerRunRequest} (project
119
+ * rule: no `as`). The message and descriptor shapes are validated downstream by
120
+ * the chat engine and the tool bridge; here we only assert enough to fail fast
121
+ * with a clear error on a malformed request.
122
+ */
123
+ export function parseContainerRunRequest(value: unknown): ContainerRunRequest {
124
+ if (!isRecord(value)) {
125
+ throw new Error('run request must be a JSON object')
126
+ }
127
+ const runId = requireNonEmptyString(value, 'runId')
128
+ const threadId = requireNonEmptyString(value, 'threadId')
129
+ const model = requireNonEmptyString(value, 'model')
130
+ const toolExecUrl = requireNonEmptyString(value, 'toolExecUrl')
131
+ const toolExecToken = requireNonEmptyString(value, 'toolExecToken')
132
+
133
+ const messages = value['messages']
134
+ if (
135
+ !Array.isArray(messages) ||
136
+ messages.length === 0 ||
137
+ !messages.every(isModelMessage)
138
+ ) {
139
+ throw new Error('run request: messages must be a non-empty ModelMessage[]')
140
+ }
141
+
142
+ const harness = value['harness']
143
+ if (!isHarnessId(harness)) {
144
+ throw new Error('run request: harness must be a known harness id')
145
+ }
146
+
147
+ const workspace = value['workspace']
148
+ if (!isWorkspaceDefinition(workspace)) {
149
+ throw new Error('run request: workspace must be a WorkspaceDefinition')
150
+ }
151
+
152
+ const toolDescriptors = value['toolDescriptors']
153
+ if (
154
+ !Array.isArray(toolDescriptors) ||
155
+ !toolDescriptors.every(isToolDescriptor)
156
+ ) {
157
+ throw new Error('run request: toolDescriptors must be a ToolDescriptor[]')
158
+ }
159
+
160
+ return {
161
+ runId,
162
+ threadId,
163
+ messages,
164
+ harness,
165
+ model,
166
+ workspace,
167
+ toolDescriptors,
168
+ toolExecUrl,
169
+ toolExecToken,
170
+ }
171
+ }
@@ -0,0 +1,111 @@
1
+ import { getSandbox } from '@cloudflare/sandbox'
2
+ import { CLOUDFLARE_CAPS, CloudflareHandle } from './handle'
3
+ import type { Sandbox, SandboxTransport } from '@cloudflare/sandbox'
4
+ import type {
5
+ SandboxCapabilities,
6
+ SandboxCreateInput,
7
+ SandboxDestroyInput,
8
+ SandboxHandle,
9
+ SandboxProvider,
10
+ SandboxResumeInput,
11
+ } from '@tanstack/ai-sandbox'
12
+
13
+ const DEFAULT_WORKDIR = '/workspace'
14
+
15
+ export interface CloudflareSandboxConfig {
16
+ /**
17
+ * The Sandbox Durable Object namespace binding (e.g. `env.Sandbox`).
18
+ * Available inside a Worker `fetch` handler.
19
+ */
20
+ binding: DurableObjectNamespace<Sandbox>
21
+ /** Working directory inside the container. Defaults to `/workspace`. */
22
+ workdir?: string
23
+ /**
24
+ * Your Worker's request hostname, required by `ports.connect` to expose a
25
+ * preview URL (Cloudflare routes exposed ports by hostname).
26
+ */
27
+ previewHostname?: string
28
+ /**
29
+ * Container-control transport. Defaults to `'rpc'` (the SDK's primary path)
30
+ * because `sandbox.tunnels` — used by `exposePreviewTool` to mint quick-tunnel
31
+ * preview URLs — ONLY exists on the RPC transport; on `'http'`/`'websocket'` it
32
+ * throws "requires the RPC transport". The transport must be the same for every
33
+ * `getSandbox()` of a given id, so this provider applies it to create/resume/
34
+ * destroy alike. Override to `'http'` only if you don't use preview tunnels.
35
+ */
36
+ transport?: SandboxTransport
37
+ }
38
+
39
+ class CloudflareProvider implements SandboxProvider {
40
+ readonly name = 'cloudflare'
41
+
42
+ constructor(private readonly config: CloudflareSandboxConfig) {}
43
+
44
+ capabilities(): SandboxCapabilities {
45
+ return CLOUDFLARE_CAPS
46
+ }
47
+
48
+ private get workdir(): string {
49
+ return this.config.workdir ?? DEFAULT_WORKDIR
50
+ }
51
+
52
+ // `transport: 'rpc'` by default so `sandbox.tunnels` (preview URLs) works; must be
53
+ // identical across every `getSandbox()` for an id, so all three paths share this.
54
+ private get sandboxOptions(): { transport: SandboxTransport } {
55
+ return { transport: this.config.transport ?? 'rpc' }
56
+ }
57
+
58
+ async create(input: SandboxCreateInput): Promise<SandboxHandle> {
59
+ const id = crypto.randomUUID()
60
+ const sandbox = getSandbox(this.config.binding, id, this.sandboxOptions)
61
+ if (input.env && Object.keys(input.env).length > 0) {
62
+ await sandbox.setEnvVars(input.env)
63
+ }
64
+ await sandbox.mkdir(this.workdir, { recursive: true })
65
+ return new CloudflareHandle(
66
+ id,
67
+ sandbox,
68
+ this.workdir,
69
+ this.config.previewHostname,
70
+ )
71
+ }
72
+
73
+ resume(input: SandboxResumeInput): Promise<SandboxHandle | null> {
74
+ // The Durable Object is durable, so the sandbox is always addressable by
75
+ // id. (The container disk may have been wiped on cold start — withSandbox
76
+ // re-bootstraps under the same identity when durableFilesystem is false.)
77
+ const sandbox = getSandbox(
78
+ this.config.binding,
79
+ input.id,
80
+ this.sandboxOptions,
81
+ )
82
+ return Promise.resolve(
83
+ new CloudflareHandle(
84
+ input.id,
85
+ sandbox,
86
+ this.workdir,
87
+ this.config.previewHostname,
88
+ ),
89
+ )
90
+ }
91
+
92
+ async destroy(input: SandboxDestroyInput): Promise<void> {
93
+ const sandbox = getSandbox(
94
+ this.config.binding,
95
+ input.id,
96
+ this.sandboxOptions,
97
+ )
98
+ await sandbox.destroy()
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Cloudflare sandbox provider — runs harness adapters inside Cloudflare
104
+ * Containers at the edge. Construct it inside a Worker with the Sandbox Durable
105
+ * Object namespace binding. See the stdin/snapshot limitations in `handle.ts`.
106
+ */
107
+ export function cloudflareSandbox(
108
+ config: CloudflareSandboxConfig,
109
+ ): SandboxProvider {
110
+ return new CloudflareProvider(config)
111
+ }