@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.
- package/dist/esm/agent.d.ts +30 -0
- package/dist/esm/agent.js +25 -0
- package/dist/esm/agent.js.map +1 -0
- package/dist/esm/chat-coordinator.d.ts +75 -0
- package/dist/esm/chat-coordinator.js +135 -0
- package/dist/esm/chat-coordinator.js.map +1 -0
- package/dist/esm/container-coordinator.d.ts +114 -0
- package/dist/esm/container-coordinator.js +256 -0
- package/dist/esm/container-coordinator.js.map +1 -0
- package/dist/esm/coordinator.d.ts +68 -0
- package/dist/esm/coordinator.js +188 -0
- package/dist/esm/coordinator.js.map +1 -0
- package/dist/esm/factory.d.ts +80 -0
- package/dist/esm/factory.js +69 -0
- package/dist/esm/factory.js.map +1 -0
- package/dist/esm/handle.d.ts +23 -0
- package/dist/esm/handle.js +208 -0
- package/dist/esm/handle.js.map +1 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.js +10 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/preview-tool.d.ts +31 -0
- package/dist/esm/preview-tool.js +37 -0
- package/dist/esm/preview-tool.js.map +1 -0
- package/dist/esm/protocol.d.ts +42 -0
- package/dist/esm/protocol.js +64 -0
- package/dist/esm/protocol.js.map +1 -0
- package/dist/esm/provider.d.ts +31 -0
- package/dist/esm/provider.js +65 -0
- package/dist/esm/provider.js.map +1 -0
- package/dist/esm/public-host.d.ts +67 -0
- package/dist/esm/public-host.js +49 -0
- package/dist/esm/public-host.js.map +1 -0
- package/dist/esm/run-log-do.d.ts +25 -0
- package/dist/esm/run-log-do.js +122 -0
- package/dist/esm/run-log-do.js.map +1 -0
- package/dist/esm/runner.d.ts +32 -0
- package/dist/esm/runner.js +107 -0
- package/dist/esm/runner.js.map +1 -0
- package/dist/esm/web-crypto.d.ts +11 -0
- package/dist/esm/web-crypto.js +18 -0
- package/dist/esm/web-crypto.js.map +1 -0
- package/dist/esm/worker.d.ts +8 -0
- package/dist/esm/worker.js +83 -0
- package/dist/esm/worker.js.map +1 -0
- package/package.json +74 -0
- package/src/agent.ts +66 -0
- package/src/chat-coordinator.ts +253 -0
- package/src/container-coordinator.ts +437 -0
- package/src/coordinator.ts +338 -0
- package/src/factory.ts +225 -0
- package/src/handle.ts +292 -0
- package/src/index.ts +5 -0
- package/src/preview-tool.ts +110 -0
- package/src/protocol.ts +171 -0
- package/src/provider.ts +111 -0
- package/src/public-host.ts +121 -0
- package/src/run-log-do.ts +171 -0
- package/src/runner.ts +226 -0
- package/src/web-crypto.ts +31 -0
- 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
|
+
}
|
package/src/protocol.ts
ADDED
|
@@ -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
|
+
}
|
package/src/provider.ts
ADDED
|
@@ -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
|
+
}
|