@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,208 @@
1
+ import { createExecBackedGit } from "@tanstack/ai-sandbox";
2
+ const CLOUDFLARE_CAPS = {
3
+ fs: true,
4
+ exec: true,
5
+ env: true,
6
+ ports: true,
7
+ backgroundProcesses: true,
8
+ // No writable host→process stdin; stdin-fed harnesses use file-redirection.
9
+ writableStdin: false,
10
+ snapshots: false,
11
+ networkPolicy: false,
12
+ durableFilesystem: false,
13
+ fork: false
14
+ };
15
+ function q(value) {
16
+ return `'${value.replace(/'/g, `'\\''`)}'`;
17
+ }
18
+ class OutputQueue {
19
+ buffer = [];
20
+ waiters = [];
21
+ ended = false;
22
+ push(value) {
23
+ const waiter = this.waiters.shift();
24
+ if (waiter) waiter({ value, done: false });
25
+ else this.buffer.push(value);
26
+ }
27
+ end() {
28
+ this.ended = true;
29
+ let waiter = this.waiters.shift();
30
+ while (waiter) {
31
+ waiter({ value: void 0, done: true });
32
+ waiter = this.waiters.shift();
33
+ }
34
+ }
35
+ async *[Symbol.asyncIterator]() {
36
+ while (!this.ended || this.buffer.length > 0) {
37
+ if (this.buffer.length > 0) {
38
+ yield this.buffer.shift();
39
+ continue;
40
+ }
41
+ const next = await new Promise(
42
+ (resolve) => this.waiters.push(resolve)
43
+ );
44
+ if (next.done) return;
45
+ yield next.value;
46
+ }
47
+ }
48
+ }
49
+ class CloudflareHandle {
50
+ id;
51
+ provider = "cloudflare";
52
+ workspaceRoot;
53
+ capabilities = CLOUDFLARE_CAPS;
54
+ fs;
55
+ git;
56
+ process;
57
+ ports;
58
+ env;
59
+ sandbox;
60
+ workdir;
61
+ previewHostname;
62
+ constructor(id, sandbox, workdir, previewHostname) {
63
+ this.id = id;
64
+ this.sandbox = sandbox;
65
+ this.workdir = workdir;
66
+ this.workspaceRoot = workdir;
67
+ this.previewHostname = previewHostname;
68
+ this.process = {
69
+ exec: (command, opts) => this.exec(command, opts),
70
+ spawn: (command, opts) => this.spawnProcess(command, opts)
71
+ };
72
+ this.fs = {
73
+ read: async (p) => {
74
+ const r = await this.exec(`base64 ${q(this.abs(p))}`);
75
+ if (r.exitCode !== 0) throw new Error(`read failed: ${r.stderr.trim()}`);
76
+ return Buffer.from(r.stdout, "base64").toString("utf8");
77
+ },
78
+ readBytes: async (p) => {
79
+ const r = await this.exec(`base64 ${q(this.abs(p))}`);
80
+ if (r.exitCode !== 0) throw new Error(`read failed: ${r.stderr.trim()}`);
81
+ return new Uint8Array(Buffer.from(r.stdout, "base64"));
82
+ },
83
+ write: async (p, data) => {
84
+ const abs = this.abs(p);
85
+ const b64 = Buffer.from(
86
+ typeof data === "string" ? Buffer.from(data, "utf8") : data
87
+ ).toString("base64");
88
+ const dir = abs.replace(/\/[^/]*$/, "") || "/";
89
+ const r = await this.exec(
90
+ `mkdir -p ${q(dir)} && printf %s ${q(b64)} | base64 -d > ${q(abs)}`
91
+ );
92
+ if (r.exitCode !== 0)
93
+ throw new Error(`write failed: ${r.stderr.trim()}`);
94
+ },
95
+ list: async (p) => {
96
+ const r = await this.exec(`ls -1Ap ${q(this.abs(p))}`);
97
+ if (r.exitCode !== 0) throw new Error(`list failed: ${r.stderr.trim()}`);
98
+ return r.stdout.split("\n").filter((line) => line.trim() !== "").map((entry) => {
99
+ const isDir = entry.endsWith("/");
100
+ const name = isDir ? entry.slice(0, -1) : entry;
101
+ return {
102
+ name,
103
+ path: `${p.replace(/\/$/, "")}/${name}`,
104
+ type: isDir ? "dir" : "file"
105
+ };
106
+ });
107
+ },
108
+ mkdir: async (p) => {
109
+ await this.exec(`mkdir -p ${q(this.abs(p))}`);
110
+ },
111
+ remove: async (p) => {
112
+ await this.exec(`rm -rf ${q(this.abs(p))}`);
113
+ },
114
+ rename: async (from, to) => {
115
+ await this.exec(`mv ${q(this.abs(from))} ${q(this.abs(to))}`);
116
+ },
117
+ exists: async (p) => {
118
+ const r = await this.exec(`test -e ${q(this.abs(p))}`);
119
+ return r.exitCode === 0;
120
+ }
121
+ };
122
+ this.git = createExecBackedGit(this.process, this.workdir);
123
+ this.ports = {
124
+ connect: (port) => this.connectPort(port)
125
+ };
126
+ this.env = {
127
+ set: (vars) => this.sandbox.setEnvVars(vars)
128
+ };
129
+ }
130
+ abs(p) {
131
+ if (this.workdir === "/workspace") return p;
132
+ if (p === "/workspace") return this.workdir;
133
+ if (p.startsWith("/workspace/")) {
134
+ return `${this.workdir}/${p.slice("/workspace/".length)}`;
135
+ }
136
+ return p;
137
+ }
138
+ async exec(command, opts) {
139
+ const result = await this.sandbox.exec(command, {
140
+ ...opts?.cwd ? { cwd: this.abs(opts.cwd) } : { cwd: this.workdir },
141
+ ...opts?.env ? { env: opts.env } : {}
142
+ });
143
+ return {
144
+ stdout: result.stdout,
145
+ stderr: result.stderr,
146
+ exitCode: result.exitCode
147
+ };
148
+ }
149
+ spawnProcess(command, opts) {
150
+ const stdout = new OutputQueue();
151
+ const stderr = new OutputQueue();
152
+ const settled = this.sandbox.exec(command, {
153
+ ...opts?.cwd ? { cwd: this.abs(opts.cwd) } : { cwd: this.workdir },
154
+ ...opts?.env ? { env: opts.env } : {},
155
+ stream: true,
156
+ onOutput: (stream, data) => {
157
+ if (stream === "stdout") stdout.push(data);
158
+ else stderr.push(data);
159
+ }
160
+ });
161
+ const exitPromise = settled.then(
162
+ (result) => {
163
+ stdout.end();
164
+ stderr.end();
165
+ return result.exitCode;
166
+ },
167
+ (error) => {
168
+ stdout.end();
169
+ stderr.end();
170
+ throw error;
171
+ }
172
+ );
173
+ return Promise.resolve({
174
+ pid: -1,
175
+ stdout,
176
+ stderr,
177
+ stdin: {
178
+ write: () => Promise.reject(
179
+ new Error(
180
+ "cloudflare: background processes do not expose stdin. Use exec(), or a stdin-capable provider (local-process / docker) for stdin-fed harnesses."
181
+ )
182
+ ),
183
+ end: () => Promise.resolve()
184
+ },
185
+ wait: () => exitPromise,
186
+ kill: () => Promise.resolve()
187
+ });
188
+ }
189
+ async connectPort(port) {
190
+ if (this.previewHostname === void 0) {
191
+ throw new Error(
192
+ "cloudflare: ports.connect requires a previewHostname. Pass previewHostname (your Worker request hostname) to cloudflareSandbox(...)."
193
+ );
194
+ }
195
+ const { url } = await this.sandbox.exposePort(port, {
196
+ hostname: this.previewHostname
197
+ });
198
+ return { url };
199
+ }
200
+ async destroy() {
201
+ await this.sandbox.destroy();
202
+ }
203
+ }
204
+ export {
205
+ CLOUDFLARE_CAPS,
206
+ CloudflareHandle
207
+ };
208
+ //# sourceMappingURL=handle.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"handle.js","sources":["../../src/handle.ts"],"sourcesContent":["/**\n * SandboxHandle backed by a Cloudflare Sandbox (Containers + Durable Objects),\n * via `@cloudflare/sandbox`. Runs at the edge inside a Worker.\n *\n * fs is implemented over `exec` with base64 piping (binary-safe), matching the\n * Docker provider. The container disk is EPHEMERAL (wiped to the image on\n * restart) and snapshots are not yet GA, so `capabilities.snapshots` and\n * `durableFilesystem` are false — `withSandbox` re-bootstraps under the same\n * identity across cold starts.\n *\n * LIMITATION: Cloudflare background processes do not expose a writable host→\n * process stdin, so `spawn().stdin.write` throws. This is advertised via\n * `capabilities.writableStdin: false`; harness adapters that feed a prompt over\n * stdin (e.g. the Claude Code adapter) detect this and instead deliver the\n * prompt via a file + shell stdin-redirection (`claude -p … < file`), which the\n * in-container shell handles with no host-side stdin write. `exec` (one-shot)\n * and streamed stdout from `spawn` both work fully.\n *\n * NOTE: not runtime-verified in this repo (requires a Workers runtime); it\n * compiles against the real `@cloudflare/sandbox` types and follows the proven\n * provider contract.\n */\nimport { createExecBackedGit } from '@tanstack/ai-sandbox'\nimport type { Sandbox } from '@cloudflare/sandbox'\nimport type {\n ExecResult,\n ProcessOptions,\n SandboxCapabilities,\n SandboxChannel,\n SandboxHandle,\n SpawnHandle,\n} from '@tanstack/ai-sandbox'\n\nexport const CLOUDFLARE_CAPS: SandboxCapabilities = {\n fs: true,\n exec: true,\n env: true,\n ports: true,\n backgroundProcesses: true,\n // No writable host→process stdin; stdin-fed harnesses use file-redirection.\n writableStdin: false,\n snapshots: false,\n networkPolicy: false,\n durableFilesystem: false,\n fork: false,\n}\n\n/** POSIX single-quote escape for embedding paths in `sh -c`. */\nfunction q(value: string): string {\n return `'${value.replace(/'/g, `'\\\\''`)}'`\n}\n\n/** A push-driven async string queue used to adapt CF's onOutput callback. */\nclass OutputQueue {\n private readonly buffer: Array<string> = []\n private readonly waiters: Array<(r: IteratorResult<string>) => void> = []\n private ended = false\n\n push(value: string): void {\n const waiter = this.waiters.shift()\n if (waiter) waiter({ value, done: false })\n else this.buffer.push(value)\n }\n\n end(): void {\n this.ended = true\n let waiter = this.waiters.shift()\n while (waiter) {\n waiter({ value: undefined, done: true })\n waiter = this.waiters.shift()\n }\n }\n\n async *[Symbol.asyncIterator](): AsyncIterator<string> {\n while (!this.ended || this.buffer.length > 0) {\n if (this.buffer.length > 0) {\n yield this.buffer.shift() as string\n continue\n }\n const next = await new Promise<IteratorResult<string>>((resolve) =>\n this.waiters.push(resolve),\n )\n if (next.done) return\n yield next.value\n }\n }\n}\n\nexport class CloudflareHandle implements SandboxHandle {\n readonly id: string\n readonly provider = 'cloudflare'\n readonly workspaceRoot: string\n readonly capabilities = CLOUDFLARE_CAPS\n readonly fs: SandboxHandle['fs']\n readonly git: SandboxHandle['git']\n readonly process: SandboxHandle['process']\n readonly ports: SandboxHandle['ports']\n readonly env: SandboxHandle['env']\n\n private readonly sandbox: Sandbox\n private readonly workdir: string\n private readonly previewHostname: string | undefined\n\n constructor(\n id: string,\n sandbox: Sandbox,\n workdir: string,\n previewHostname?: string,\n ) {\n this.id = id\n this.sandbox = sandbox\n this.workdir = workdir\n this.workspaceRoot = workdir\n this.previewHostname = previewHostname\n\n this.process = {\n exec: (command, opts) => this.exec(command, opts),\n spawn: (command, opts) => this.spawnProcess(command, opts),\n }\n\n this.fs = {\n read: async (p) => {\n const r = await this.exec(`base64 ${q(this.abs(p))}`)\n if (r.exitCode !== 0) throw new Error(`read failed: ${r.stderr.trim()}`)\n return Buffer.from(r.stdout, 'base64').toString('utf8')\n },\n readBytes: async (p) => {\n const r = await this.exec(`base64 ${q(this.abs(p))}`)\n if (r.exitCode !== 0) throw new Error(`read failed: ${r.stderr.trim()}`)\n return new Uint8Array(Buffer.from(r.stdout, 'base64'))\n },\n write: async (p, data) => {\n const abs = this.abs(p)\n const b64 = Buffer.from(\n typeof data === 'string' ? Buffer.from(data, 'utf8') : data,\n ).toString('base64')\n const dir = abs.replace(/\\/[^/]*$/, '') || '/'\n const r = await this.exec(\n `mkdir -p ${q(dir)} && printf %s ${q(b64)} | base64 -d > ${q(abs)}`,\n )\n if (r.exitCode !== 0)\n throw new Error(`write failed: ${r.stderr.trim()}`)\n },\n list: async (p) => {\n const r = await this.exec(`ls -1Ap ${q(this.abs(p))}`)\n if (r.exitCode !== 0) throw new Error(`list failed: ${r.stderr.trim()}`)\n return r.stdout\n .split('\\n')\n .filter((line) => line.trim() !== '')\n .map((entry) => {\n const isDir = entry.endsWith('/')\n const name = isDir ? entry.slice(0, -1) : entry\n return {\n name,\n path: `${p.replace(/\\/$/, '')}/${name}`,\n type: isDir ? ('dir' as const) : ('file' as const),\n }\n })\n },\n mkdir: async (p) => {\n await this.exec(`mkdir -p ${q(this.abs(p))}`)\n },\n remove: async (p) => {\n await this.exec(`rm -rf ${q(this.abs(p))}`)\n },\n rename: async (from, to) => {\n await this.exec(`mv ${q(this.abs(from))} ${q(this.abs(to))}`)\n },\n exists: async (p) => {\n const r = await this.exec(`test -e ${q(this.abs(p))}`)\n return r.exitCode === 0\n },\n }\n\n this.git = createExecBackedGit(this.process, this.workdir)\n\n this.ports = {\n connect: (port) => this.connectPort(port),\n }\n\n this.env = {\n set: (vars) => this.sandbox.setEnvVars(vars),\n }\n }\n\n private abs(p: string): string {\n if (this.workdir === '/workspace') return p\n if (p === '/workspace') return this.workdir\n if (p.startsWith('/workspace/')) {\n return `${this.workdir}/${p.slice('/workspace/'.length)}`\n }\n return p\n }\n\n private async exec(\n command: string,\n opts?: ProcessOptions,\n ): Promise<ExecResult> {\n const result = await this.sandbox.exec(command, {\n ...(opts?.cwd ? { cwd: this.abs(opts.cwd) } : { cwd: this.workdir }),\n ...(opts?.env ? { env: opts.env } : {}),\n })\n return {\n stdout: result.stdout,\n stderr: result.stderr,\n exitCode: result.exitCode,\n }\n }\n\n private spawnProcess(\n command: string,\n opts?: ProcessOptions,\n ): Promise<SpawnHandle> {\n const stdout = new OutputQueue()\n const stderr = new OutputQueue()\n\n // Stream over `exec({ stream: true, onOutput })` — the SAME proven command\n // path as one-shot `exec`. The background-process API (`startProcess` +\n // `streamProcessLogs`) does NOT deliver its `onOutput`/`onExit` callbacks\n // here (verified under `wrangler dev`: the process runs and exits cleanly,\n // yet no log events ever arrive), so a stdout-NDJSON harness spawned that\n // way hangs forever. exec's streaming path emits each chunk via `onOutput`\n // and resolves with the exit code on completion. The prompt still reaches\n // the CLI via in-shell stdin redirection (`… < file`), which this session\n // shell honors — `writableStdin` stays false.\n //\n // The caller's AbortSignal is intentionally NOT forwarded: `exec` is a\n // Durable Object RPC and Workers RPC cannot serialize an AbortSignal\n // (\"AbortSignal serialization is not enabled\"), so passing one throws\n // before the command runs. Mid-run cancellation is therefore unavailable\n // on this provider; a stuck run is bounded by the coordinator's watchdog\n // and the Durable Object lifecycle instead. `kill()` is a best-effort no-op.\n const settled = this.sandbox.exec(command, {\n ...(opts?.cwd ? { cwd: this.abs(opts.cwd) } : { cwd: this.workdir }),\n ...(opts?.env ? { env: opts.env } : {}),\n stream: true,\n onOutput: (stream, data) => {\n if (stream === 'stdout') stdout.push(data)\n else stderr.push(data)\n },\n })\n // End the output queues once the command settles either way (so the stdout\n // reader terminates), but let a failure REJECT `wait()` rather than masking\n // it as a clean exit — the harness adapter turns that into a RUN_ERROR\n // instead of a silent zero-output run.\n const exitPromise = settled.then(\n (result) => {\n stdout.end()\n stderr.end()\n return result.exitCode\n },\n (error: unknown) => {\n stdout.end()\n stderr.end()\n throw error\n },\n )\n\n return Promise.resolve({\n pid: -1,\n stdout,\n stderr,\n stdin: {\n write: () =>\n Promise.reject(\n new Error(\n 'cloudflare: background processes do not expose stdin. Use exec(), or a stdin-capable provider (local-process / docker) for stdin-fed harnesses.',\n ),\n ),\n end: () => Promise.resolve(),\n },\n wait: () => exitPromise,\n kill: () => Promise.resolve(),\n })\n }\n\n private async connectPort(port: number): Promise<SandboxChannel> {\n if (this.previewHostname === undefined) {\n throw new Error(\n 'cloudflare: ports.connect requires a previewHostname. Pass previewHostname (your Worker request hostname) to cloudflareSandbox(...).',\n )\n }\n const { url } = await this.sandbox.exposePort(port, {\n hostname: this.previewHostname,\n })\n return { url }\n }\n\n async destroy(): Promise<void> {\n await this.sandbox.destroy()\n }\n}\n"],"names":[],"mappings":";AAiCO,MAAM,kBAAuC;AAAA,EAClD,IAAI;AAAA,EACJ,MAAM;AAAA,EACN,KAAK;AAAA,EACL,OAAO;AAAA,EACP,qBAAqB;AAAA;AAAA,EAErB,eAAe;AAAA,EACf,WAAW;AAAA,EACX,eAAe;AAAA,EACf,mBAAmB;AAAA,EACnB,MAAM;AACR;AAGA,SAAS,EAAE,OAAuB;AAChC,SAAO,IAAI,MAAM,QAAQ,MAAM,OAAO,CAAC;AACzC;AAGA,MAAM,YAAY;AAAA,EACC,SAAwB,CAAA;AAAA,EACxB,UAAsD,CAAA;AAAA,EAC/D,QAAQ;AAAA,EAEhB,KAAK,OAAqB;AACxB,UAAM,SAAS,KAAK,QAAQ,MAAA;AAC5B,QAAI,OAAQ,QAAO,EAAE,OAAO,MAAM,OAAO;AAAA,QACpC,MAAK,OAAO,KAAK,KAAK;AAAA,EAC7B;AAAA,EAEA,MAAY;AACV,SAAK,QAAQ;AACb,QAAI,SAAS,KAAK,QAAQ,MAAA;AAC1B,WAAO,QAAQ;AACb,aAAO,EAAE,OAAO,QAAW,MAAM,MAAM;AACvC,eAAS,KAAK,QAAQ,MAAA;AAAA,IACxB;AAAA,EACF;AAAA,EAEA,QAAQ,OAAO,aAAa,IAA2B;AACrD,WAAO,CAAC,KAAK,SAAS,KAAK,OAAO,SAAS,GAAG;AAC5C,UAAI,KAAK,OAAO,SAAS,GAAG;AAC1B,cAAM,KAAK,OAAO,MAAA;AAClB;AAAA,MACF;AACA,YAAM,OAAO,MAAM,IAAI;AAAA,QAAgC,CAAC,YACtD,KAAK,QAAQ,KAAK,OAAO;AAAA,MAAA;AAE3B,UAAI,KAAK,KAAM;AACf,YAAM,KAAK;AAAA,IACb;AAAA,EACF;AACF;AAEO,MAAM,iBAA0C;AAAA,EAC5C;AAAA,EACA,WAAW;AAAA,EACX;AAAA,EACA,eAAe;AAAA,EACf;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EAEQ;AAAA,EACA;AAAA,EACA;AAAA,EAEjB,YACE,IACA,SACA,SACA,iBACA;AACA,SAAK,KAAK;AACV,SAAK,UAAU;AACf,SAAK,UAAU;AACf,SAAK,gBAAgB;AACrB,SAAK,kBAAkB;AAEvB,SAAK,UAAU;AAAA,MACb,MAAM,CAAC,SAAS,SAAS,KAAK,KAAK,SAAS,IAAI;AAAA,MAChD,OAAO,CAAC,SAAS,SAAS,KAAK,aAAa,SAAS,IAAI;AAAA,IAAA;AAG3D,SAAK,KAAK;AAAA,MACR,MAAM,OAAO,MAAM;AACjB,cAAM,IAAI,MAAM,KAAK,KAAK,UAAU,EAAE,KAAK,IAAI,CAAC,CAAC,CAAC,EAAE;AACpD,YAAI,EAAE,aAAa,EAAG,OAAM,IAAI,MAAM,gBAAgB,EAAE,OAAO,KAAA,CAAM,EAAE;AACvE,eAAO,OAAO,KAAK,EAAE,QAAQ,QAAQ,EAAE,SAAS,MAAM;AAAA,MACxD;AAAA,MACA,WAAW,OAAO,MAAM;AACtB,cAAM,IAAI,MAAM,KAAK,KAAK,UAAU,EAAE,KAAK,IAAI,CAAC,CAAC,CAAC,EAAE;AACpD,YAAI,EAAE,aAAa,EAAG,OAAM,IAAI,MAAM,gBAAgB,EAAE,OAAO,KAAA,CAAM,EAAE;AACvE,eAAO,IAAI,WAAW,OAAO,KAAK,EAAE,QAAQ,QAAQ,CAAC;AAAA,MACvD;AAAA,MACA,OAAO,OAAO,GAAG,SAAS;AACxB,cAAM,MAAM,KAAK,IAAI,CAAC;AACtB,cAAM,MAAM,OAAO;AAAA,UACjB,OAAO,SAAS,WAAW,OAAO,KAAK,MAAM,MAAM,IAAI;AAAA,QAAA,EACvD,SAAS,QAAQ;AACnB,cAAM,MAAM,IAAI,QAAQ,YAAY,EAAE,KAAK;AAC3C,cAAM,IAAI,MAAM,KAAK;AAAA,UACnB,YAAY,EAAE,GAAG,CAAC,iBAAiB,EAAE,GAAG,CAAC,kBAAkB,EAAE,GAAG,CAAC;AAAA,QAAA;AAEnE,YAAI,EAAE,aAAa;AACjB,gBAAM,IAAI,MAAM,iBAAiB,EAAE,OAAO,KAAA,CAAM,EAAE;AAAA,MACtD;AAAA,MACA,MAAM,OAAO,MAAM;AACjB,cAAM,IAAI,MAAM,KAAK,KAAK,WAAW,EAAE,KAAK,IAAI,CAAC,CAAC,CAAC,EAAE;AACrD,YAAI,EAAE,aAAa,EAAG,OAAM,IAAI,MAAM,gBAAgB,EAAE,OAAO,KAAA,CAAM,EAAE;AACvE,eAAO,EAAE,OACN,MAAM,IAAI,EACV,OAAO,CAAC,SAAS,KAAK,WAAW,EAAE,EACnC,IAAI,CAAC,UAAU;AACd,gBAAM,QAAQ,MAAM,SAAS,GAAG;AAChC,gBAAM,OAAO,QAAQ,MAAM,MAAM,GAAG,EAAE,IAAI;AAC1C,iBAAO;AAAA,YACL;AAAA,YACA,MAAM,GAAG,EAAE,QAAQ,OAAO,EAAE,CAAC,IAAI,IAAI;AAAA,YACrC,MAAM,QAAS,QAAmB;AAAA,UAAA;AAAA,QAEtC,CAAC;AAAA,MACL;AAAA,MACA,OAAO,OAAO,MAAM;AAClB,cAAM,KAAK,KAAK,YAAY,EAAE,KAAK,IAAI,CAAC,CAAC,CAAC,EAAE;AAAA,MAC9C;AAAA,MACA,QAAQ,OAAO,MAAM;AACnB,cAAM,KAAK,KAAK,UAAU,EAAE,KAAK,IAAI,CAAC,CAAC,CAAC,EAAE;AAAA,MAC5C;AAAA,MACA,QAAQ,OAAO,MAAM,OAAO;AAC1B,cAAM,KAAK,KAAK,MAAM,EAAE,KAAK,IAAI,IAAI,CAAC,CAAC,IAAI,EAAE,KAAK,IAAI,EAAE,CAAC,CAAC,EAAE;AAAA,MAC9D;AAAA,MACA,QAAQ,OAAO,MAAM;AACnB,cAAM,IAAI,MAAM,KAAK,KAAK,WAAW,EAAE,KAAK,IAAI,CAAC,CAAC,CAAC,EAAE;AACrD,eAAO,EAAE,aAAa;AAAA,MACxB;AAAA,IAAA;AAGF,SAAK,MAAM,oBAAoB,KAAK,SAAS,KAAK,OAAO;AAEzD,SAAK,QAAQ;AAAA,MACX,SAAS,CAAC,SAAS,KAAK,YAAY,IAAI;AAAA,IAAA;AAG1C,SAAK,MAAM;AAAA,MACT,KAAK,CAAC,SAAS,KAAK,QAAQ,WAAW,IAAI;AAAA,IAAA;AAAA,EAE/C;AAAA,EAEQ,IAAI,GAAmB;AAC7B,QAAI,KAAK,YAAY,aAAc,QAAO;AAC1C,QAAI,MAAM,aAAc,QAAO,KAAK;AACpC,QAAI,EAAE,WAAW,aAAa,GAAG;AAC/B,aAAO,GAAG,KAAK,OAAO,IAAI,EAAE,MAAM,cAAc,MAAM,CAAC;AAAA,IACzD;AACA,WAAO;AAAA,EACT;AAAA,EAEA,MAAc,KACZ,SACA,MACqB;AACrB,UAAM,SAAS,MAAM,KAAK,QAAQ,KAAK,SAAS;AAAA,MAC9C,GAAI,MAAM,MAAM,EAAE,KAAK,KAAK,IAAI,KAAK,GAAG,EAAA,IAAM,EAAE,KAAK,KAAK,QAAA;AAAA,MAC1D,GAAI,MAAM,MAAM,EAAE,KAAK,KAAK,IAAA,IAAQ,CAAA;AAAA,IAAC,CACtC;AACD,WAAO;AAAA,MACL,QAAQ,OAAO;AAAA,MACf,QAAQ,OAAO;AAAA,MACf,UAAU,OAAO;AAAA,IAAA;AAAA,EAErB;AAAA,EAEQ,aACN,SACA,MACsB;AACtB,UAAM,SAAS,IAAI,YAAA;AACnB,UAAM,SAAS,IAAI,YAAA;AAkBnB,UAAM,UAAU,KAAK,QAAQ,KAAK,SAAS;AAAA,MACzC,GAAI,MAAM,MAAM,EAAE,KAAK,KAAK,IAAI,KAAK,GAAG,EAAA,IAAM,EAAE,KAAK,KAAK,QAAA;AAAA,MAC1D,GAAI,MAAM,MAAM,EAAE,KAAK,KAAK,IAAA,IAAQ,CAAA;AAAA,MACpC,QAAQ;AAAA,MACR,UAAU,CAAC,QAAQ,SAAS;AAC1B,YAAI,WAAW,SAAU,QAAO,KAAK,IAAI;AAAA,YACpC,QAAO,KAAK,IAAI;AAAA,MACvB;AAAA,IAAA,CACD;AAKD,UAAM,cAAc,QAAQ;AAAA,MAC1B,CAAC,WAAW;AACV,eAAO,IAAA;AACP,eAAO,IAAA;AACP,eAAO,OAAO;AAAA,MAChB;AAAA,MACA,CAAC,UAAmB;AAClB,eAAO,IAAA;AACP,eAAO,IAAA;AACP,cAAM;AAAA,MACR;AAAA,IAAA;AAGF,WAAO,QAAQ,QAAQ;AAAA,MACrB,KAAK;AAAA,MACL;AAAA,MACA;AAAA,MACA,OAAO;AAAA,QACL,OAAO,MACL,QAAQ;AAAA,UACN,IAAI;AAAA,YACF;AAAA,UAAA;AAAA,QACF;AAAA,QAEJ,KAAK,MAAM,QAAQ,QAAA;AAAA,MAAQ;AAAA,MAE7B,MAAM,MAAM;AAAA,MACZ,MAAM,MAAM,QAAQ,QAAA;AAAA,IAAQ,CAC7B;AAAA,EACH;AAAA,EAEA,MAAc,YAAY,MAAuC;AAC/D,QAAI,KAAK,oBAAoB,QAAW;AACtC,YAAM,IAAI;AAAA,QACR;AAAA,MAAA;AAAA,IAEJ;AACA,UAAM,EAAE,IAAA,IAAQ,MAAM,KAAK,QAAQ,WAAW,MAAM;AAAA,MAClD,UAAU,KAAK;AAAA,IAAA,CAChB;AACD,WAAO,EAAE,IAAA;AAAA,EACX;AAAA,EAEA,MAAM,UAAyB;AAC7B,UAAM,KAAK,QAAQ,QAAA;AAAA,EACrB;AACF;"}
@@ -0,0 +1,4 @@
1
+ export { cloudflareSandbox } from './provider.js';
2
+ export type { CloudflareSandboxConfig } from './provider.js';
3
+ export { CloudflareHandle, CLOUDFLARE_CAPS } from './handle.js';
4
+ export { Sandbox } from '@cloudflare/sandbox';
@@ -0,0 +1,10 @@
1
+ import { cloudflareSandbox } from "./provider.js";
2
+ import { CLOUDFLARE_CAPS, CloudflareHandle } from "./handle.js";
3
+ import { Sandbox } from "@cloudflare/sandbox";
4
+ export {
5
+ CLOUDFLARE_CAPS,
6
+ CloudflareHandle,
7
+ Sandbox,
8
+ cloudflareSandbox
9
+ };
10
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;"}
@@ -0,0 +1,31 @@
1
+ import { z } from 'zod';
2
+ import { Sandbox } from '@cloudflare/sandbox';
3
+ import { StartRunInput } from './coordinator.js';
4
+ /**
5
+ * The minimum env an {@link exposePreviewTool} needs: the Sandbox namespace it
6
+ * addresses the run's container in. `SandboxAgentEnv` satisfies this structurally,
7
+ * so the factory's `tools` resolver passes its env straight in.
8
+ */
9
+ export interface PreviewToolEnv {
10
+ Sandbox: DurableObjectNamespace<Sandbox>;
11
+ }
12
+ /**
13
+ * System-prompt guidance for any agent that exposes a dev server as a browser
14
+ * preview. App-agnostic: the only requirement a quick tunnel imposes is that the
15
+ * dev server accept the tunnel hostname (Vite/webpack reject unknown hosts by
16
+ * default), so the rule is "bind wide + allow all hosts", not "disable HMR" — the
17
+ * tunnel forwards WebSockets, so HMR works.
18
+ */
19
+ export declare const PREVIEW_GUIDANCE: string;
20
+ /**
21
+ * Build the `exposePreview` server tool for one run. Starting a tunnel is a
22
+ * HOST-side call on the Sandbox DO stub, so an in-sandbox agent cannot make it from
23
+ * bash — it calls this bridged tool instead. We address the run's container by
24
+ * `threadId` and open (or reuse) a quick tunnel to the given port.
25
+ *
26
+ * Closes over the run's `input` + `env`, so build it inside the `tools` resolver
27
+ * (`tools: (input, env) => [exposePreviewTool(input, env)]`).
28
+ */
29
+ export declare function exposePreviewTool(input: StartRunInput, env: PreviewToolEnv): import('@tanstack/ai').ServerTool<z.ZodObject<{
30
+ port: z.ZodNumber;
31
+ }, z.core.$strip>, import('@tanstack/ai').SchemaInput, "exposePreview", unknown>;
@@ -0,0 +1,37 @@
1
+ import { toolDefinition } from "@tanstack/ai";
2
+ import { z } from "zod";
3
+ import { getSandbox } from "@cloudflare/sandbox";
4
+ const PREVIEW_GUIDANCE = [
5
+ "PREVIEW SERVERS: to show the user a running web app, start its dev server bound",
6
+ "to 0.0.0.0 on a port OTHER than 3000 (3000 is reserved by the sandbox control",
7
+ "plane), then call the `exposePreview` tool with that port. It returns a public",
8
+ "Cloudflare quick-tunnel URL (https://<name>.trycloudflare.com) served straight",
9
+ "from the sandbox — no custom domain needed, and HMR / live-reload WebSockets",
10
+ "work through the tunnel (you do NOT need to disable HMR). The ONE requirement:",
11
+ "the dev server must ACCEPT the tunnel hostname, which servers reject by default,",
12
+ "so allow all hosts in its config before starting:",
13
+ "• Vite — `server: { host: true, allowedHosts: true }` in vite.config.",
14
+ "• webpack-dev-server — `allowedHosts: 'all'` (and `host: '0.0.0.0'`).",
15
+ "• Other dev servers — bind 0.0.0.0 and allow all hosts equivalently.",
16
+ "Once it is listening, call `exposePreview` with that port, then share the URL."
17
+ ].join("\n");
18
+ function exposePreviewTool(input, env) {
19
+ return toolDefinition({
20
+ name: "exposePreview",
21
+ description: "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.",
22
+ inputSchema: z.object({
23
+ port: z.number().int().min(1024).max(65535).describe("The port the dev server is listening on, e.g. 5173.")
24
+ })
25
+ }).server(async ({ port }) => {
26
+ const sandbox = getSandbox(env.Sandbox, input.threadId, {
27
+ transport: "rpc"
28
+ });
29
+ const tunnel = await sandbox.tunnels.get(port);
30
+ return { url: tunnel.url };
31
+ });
32
+ }
33
+ export {
34
+ PREVIEW_GUIDANCE,
35
+ exposePreviewTool
36
+ };
37
+ //# sourceMappingURL=preview-tool.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"preview-tool.js","sources":["../../src/preview-tool.ts"],"sourcesContent":["/**\n * The browser-preview capability, as reusable building blocks rather than\n * per-app glue: a `chat()` server tool that mints a preview URL for a dev server\n * running inside the sandbox, plus the system-prompt guidance an agent needs to\n * produce a preview that works.\n *\n * Previews go over a **Cloudflare quick tunnel** (`sandbox.tunnels.get(port)` →\n * `https://<name>.trycloudflare.com`), served by `cloudflared` INSIDE the sandbox.\n * We deliberately do NOT use `exposePort` + `proxyToSandbox` here: that routes the\n * preview through the Worker's own origin, which in local dev is the example's Vite\n * dev server — and Vite's middleware then serves the preview's module/asset\n * requests (`/@vite/client`, `/src/*`, `/@fs/*`) from the HOST instead of the\n * container, breaking the page. A tunnel bypasses the Vite port entirely, needs no\n * custom domain on a deploy, and forwards WebSockets (so the app's HMR works).\n *\n * Both exports belong to THIS package because the transport is its concern, not any\n * particular app's. Wire them explicitly into your agent:\n *\n * ```ts\n * import {\n * exposePreviewTool,\n * PREVIEW_GUIDANCE,\n * } from '@tanstack/ai-sandbox-cloudflare/agent'\n *\n * createCloudflareSandboxAgent({\n * adapter: () => claudeCodeText('sonnet'),\n * tools: (input, env) => [exposePreviewTool(input, env)],\n * systemPrompts: [PREVIEW_GUIDANCE],\n * })\n * ```\n *\n * Workers-only (imports `@cloudflare/sandbox`) — exported from the `/agent` entry.\n */\nimport { toolDefinition } from '@tanstack/ai'\nimport { z } from 'zod'\nimport { getSandbox } from '@cloudflare/sandbox'\nimport type { Sandbox } from '@cloudflare/sandbox'\nimport type { StartRunInput } from './coordinator'\n\n/**\n * The minimum env an {@link exposePreviewTool} needs: the Sandbox namespace it\n * addresses the run's container in. `SandboxAgentEnv` satisfies this structurally,\n * so the factory's `tools` resolver passes its env straight in.\n */\nexport interface PreviewToolEnv {\n Sandbox: DurableObjectNamespace<Sandbox>\n}\n\n/**\n * System-prompt guidance for any agent that exposes a dev server as a browser\n * preview. App-agnostic: the only requirement a quick tunnel imposes is that the\n * dev server accept the tunnel hostname (Vite/webpack reject unknown hosts by\n * default), so the rule is \"bind wide + allow all hosts\", not \"disable HMR\" — the\n * tunnel forwards WebSockets, so HMR works.\n */\nexport const PREVIEW_GUIDANCE: string = [\n 'PREVIEW SERVERS: to show the user a running web app, start its dev server bound',\n 'to 0.0.0.0 on a port OTHER than 3000 (3000 is reserved by the sandbox control',\n 'plane), then call the `exposePreview` tool with that port. It returns a public',\n 'Cloudflare quick-tunnel URL (https://<name>.trycloudflare.com) served straight',\n 'from the sandbox — no custom domain needed, and HMR / live-reload WebSockets',\n 'work through the tunnel (you do NOT need to disable HMR). The ONE requirement:',\n 'the dev server must ACCEPT the tunnel hostname, which servers reject by default,',\n 'so allow all hosts in its config before starting:',\n '• Vite — `server: { host: true, allowedHosts: true }` in vite.config.',\n \"• webpack-dev-server — `allowedHosts: 'all'` (and `host: '0.0.0.0'`).\",\n '• Other dev servers — bind 0.0.0.0 and allow all hosts equivalently.',\n 'Once it is listening, call `exposePreview` with that port, then share the URL.',\n].join('\\n')\n\n/**\n * Build the `exposePreview` server tool for one run. Starting a tunnel is a\n * HOST-side call on the Sandbox DO stub, so an in-sandbox agent cannot make it from\n * bash — it calls this bridged tool instead. We address the run's container by\n * `threadId` and open (or reuse) a quick tunnel to the given port.\n *\n * Closes over the run's `input` + `env`, so build it inside the `tools` resolver\n * (`tools: (input, env) => [exposePreviewTool(input, env)]`).\n */\nexport function exposePreviewTool(input: StartRunInput, env: PreviewToolEnv) {\n return toolDefinition({\n name: 'exposePreview',\n description:\n '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.',\n inputSchema: z.object({\n port: z\n .number()\n .int()\n .min(1024)\n .max(65535)\n .describe('The port the dev server is listening on, e.g. 5173.'),\n }),\n }).server(async ({ port }) => {\n // `sandbox.tunnels` only exists on the RPC transport (on HTTP/WebSocket it's a\n // stub that throws \"requires the RPC transport\"), so we must obtain the stub\n // with `transport: 'rpc'`. IMPORTANT: this must MATCH how the sandbox was\n // created — pass `transport: 'rpc'` on EVERY `getSandbox()` for this id (in your\n // sandbox provider too), or the differing transport disconnects the run's active\n // client. See the SDK `SandboxOptions.transport` note.\n const sandbox = getSandbox(env.Sandbox, input.threadId, {\n transport: 'rpc',\n })\n // A Cloudflare quick tunnel (`*.trycloudflare.com`) run by `cloudflared` INSIDE\n // the sandbox: it bypasses the local Vite dev server's port entirely (so Vite\n // can't hijack the preview's asset requests) and needs no custom domain on a\n // deploy. `get(port)` is idempotent per port. See the Sandbox SDK `tunnels` API.\n const tunnel = await sandbox.tunnels.get(port)\n return { url: tunnel.url }\n })\n}\n"],"names":[],"mappings":";;;AAuDO,MAAM,mBAA2B;AAAA,EACtC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,EAAE,KAAK,IAAI;AAWJ,SAAS,kBAAkB,OAAsB,KAAqB;AAC3E,SAAO,eAAe;AAAA,IACpB,MAAM;AAAA,IACN,aACE;AAAA,IACF,aAAa,EAAE,OAAO;AAAA,MACpB,MAAM,EACH,OAAA,EACA,IAAA,EACA,IAAI,IAAI,EACR,IAAI,KAAK,EACT,SAAS,qDAAqD;AAAA,IAAA,CAClE;AAAA,EAAA,CACF,EAAE,OAAO,OAAO,EAAE,WAAW;AAO5B,UAAM,UAAU,WAAW,IAAI,SAAS,MAAM,UAAU;AAAA,MACtD,WAAW;AAAA,IAAA,CACZ;AAKD,UAAM,SAAS,MAAM,QAAQ,QAAQ,IAAI,IAAI;AAC7C,WAAO,EAAE,KAAK,OAAO,IAAA;AAAA,EACvB,CAAC;AACH;"}
@@ -0,0 +1,42 @@
1
+ import { ModelMessage } from '@tanstack/ai';
2
+ import { ToolDescriptor, WorkspaceDefinition } from '@tanstack/ai-sandbox';
3
+ /**
4
+ * The in-sandbox harnesses the runner can spawn. Single source of truth: the
5
+ * {@link HarnessId} type is DERIVED from this list, and {@link isHarnessId}
6
+ * validates against it — so the runtime guard and the compile-time type can
7
+ * never drift.
8
+ */
9
+ declare const HARNESS_IDS: readonly ["claude-code", "codex", "opencode"];
10
+ /**
11
+ * Identifier for the in-sandbox harness the runner spawns. The runner maps this
12
+ * to the matching `*Text` adapter (via the caller's `resolveAdapter`); the DO
13
+ * never imports the adapter packages.
14
+ */
15
+ export type HarnessId = (typeof HARNESS_IDS)[number];
16
+ /**
17
+ * The body of `POST /run`: the run identity + conversation + serialized
18
+ * host-tool descriptors + the tool-exec callback, plus the harness/model/
19
+ * workspace the runner needs to build the right adapter.
20
+ */
21
+ export interface ContainerRunRequest {
22
+ runId: string;
23
+ threadId: string;
24
+ messages: Array<ModelMessage>;
25
+ harness: HarnessId;
26
+ model: string;
27
+ workspace: WorkspaceDefinition;
28
+ /** Host-tool descriptors serialized by `toolDescriptors()` on the DO. */
29
+ toolDescriptors: Array<ToolDescriptor>;
30
+ /** DO endpoint the in-container `httpRemoteToolExecutor` POSTs tool calls to. */
31
+ toolExecUrl: string;
32
+ /** Per-run bearer token gating that tool-exec endpoint. */
33
+ toolExecToken: string;
34
+ }
35
+ /**
36
+ * Narrow an unknown `POST /run` body into a {@link ContainerRunRequest} (project
37
+ * rule: no `as`). The message and descriptor shapes are validated downstream by
38
+ * the chat engine and the tool bridge; here we only assert enough to fail fast
39
+ * with a clear error on a malformed request.
40
+ */
41
+ export declare function parseContainerRunRequest(value: unknown): ContainerRunRequest;
42
+ export {};
@@ -0,0 +1,64 @@
1
+ const HARNESS_IDS = ["claude-code", "codex", "opencode"];
2
+ function isHarnessId(value) {
3
+ return typeof value === "string" && HARNESS_IDS.includes(value);
4
+ }
5
+ function isToolDescriptor(value) {
6
+ return value !== null && typeof value === "object" && "name" in value && typeof value.name === "string";
7
+ }
8
+ function isWorkspaceDefinition(value) {
9
+ return value !== null && typeof value === "object" && "source" in value && value.source !== null && typeof value.source === "object";
10
+ }
11
+ function isModelMessage(value) {
12
+ return value !== null && typeof value === "object" && "role" in value && typeof value.role === "string" && "content" in value;
13
+ }
14
+ function isRecord(value) {
15
+ return value !== null && typeof value === "object";
16
+ }
17
+ function requireNonEmptyString(value, key) {
18
+ const found = value[key];
19
+ if (typeof found !== "string" || found === "") {
20
+ throw new Error(`run request: ${key} must be a non-empty string`);
21
+ }
22
+ return found;
23
+ }
24
+ function parseContainerRunRequest(value) {
25
+ if (!isRecord(value)) {
26
+ throw new Error("run request must be a JSON object");
27
+ }
28
+ const runId = requireNonEmptyString(value, "runId");
29
+ const threadId = requireNonEmptyString(value, "threadId");
30
+ const model = requireNonEmptyString(value, "model");
31
+ const toolExecUrl = requireNonEmptyString(value, "toolExecUrl");
32
+ const toolExecToken = requireNonEmptyString(value, "toolExecToken");
33
+ const messages = value["messages"];
34
+ if (!Array.isArray(messages) || messages.length === 0 || !messages.every(isModelMessage)) {
35
+ throw new Error("run request: messages must be a non-empty ModelMessage[]");
36
+ }
37
+ const harness = value["harness"];
38
+ if (!isHarnessId(harness)) {
39
+ throw new Error("run request: harness must be a known harness id");
40
+ }
41
+ const workspace = value["workspace"];
42
+ if (!isWorkspaceDefinition(workspace)) {
43
+ throw new Error("run request: workspace must be a WorkspaceDefinition");
44
+ }
45
+ const toolDescriptors = value["toolDescriptors"];
46
+ if (!Array.isArray(toolDescriptors) || !toolDescriptors.every(isToolDescriptor)) {
47
+ throw new Error("run request: toolDescriptors must be a ToolDescriptor[]");
48
+ }
49
+ return {
50
+ runId,
51
+ threadId,
52
+ messages,
53
+ harness,
54
+ model,
55
+ workspace,
56
+ toolDescriptors,
57
+ toolExecUrl,
58
+ toolExecToken
59
+ };
60
+ }
61
+ export {
62
+ parseContainerRunRequest
63
+ };
64
+ //# sourceMappingURL=protocol.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"protocol.js","sources":["../../src/protocol.ts"],"sourcesContent":["/**\n * The wire contract for the ONE request that crosses the DO → container boundary\n * to start a run: `POST /run` on the in-container runner.\n *\n * Defined ONCE here so both sides import the same shape AND the same narrowing\n * guard, with NO runtime-specific imports (no `cloudflare:*`, no `node:*`):\n * - the `ContainerSandboxCoordinator` (Workers side) builds a\n * {@link ContainerRunRequest} and POSTs it (re-exported from `/agent`);\n * - the in-container `runInContainerHarness` (Node side) validates the body\n * with {@link parseContainerRunRequest} before running `chat()` (imported\n * from `/runner`).\n *\n * It carries the run identity + conversation + serialized host-tool descriptors\n * + the tool-exec callback, plus the `harness`/`model`/`workspace` the runner\n * needs to build the right adapter and sandbox.\n *\n * NOTE: the workspace's secret VALUES do NOT cross this boundary — `createSecrets`\n * stores them under a non-enumerable symbol, so JSON-serializing the workspace\n * carries only the secret NAMES. The runner reconstructs runtime secrets from\n * the container env (the DO injects them via `sandbox.setEnvVars`).\n */\nimport type { ModelMessage } from '@tanstack/ai'\nimport type { ToolDescriptor, WorkspaceDefinition } from '@tanstack/ai-sandbox'\n\n/**\n * The in-sandbox harnesses the runner can spawn. Single source of truth: the\n * {@link HarnessId} type is DERIVED from this list, and {@link isHarnessId}\n * validates against it — so the runtime guard and the compile-time type can\n * never drift.\n */\nconst HARNESS_IDS = ['claude-code', 'codex', 'opencode'] as const\n\n/**\n * Identifier for the in-sandbox harness the runner spawns. The runner maps this\n * to the matching `*Text` adapter (via the caller's `resolveAdapter`); the DO\n * never imports the adapter packages.\n */\nexport type HarnessId = (typeof HARNESS_IDS)[number]\n\n/**\n * The body of `POST /run`: the run identity + conversation + serialized\n * host-tool descriptors + the tool-exec callback, plus the harness/model/\n * workspace the runner needs to build the right adapter.\n */\nexport interface ContainerRunRequest {\n runId: string\n threadId: string\n messages: Array<ModelMessage>\n harness: HarnessId\n model: string\n workspace: WorkspaceDefinition\n /** Host-tool descriptors serialized by `toolDescriptors()` on the DO. */\n toolDescriptors: Array<ToolDescriptor>\n /** DO endpoint the in-container `httpRemoteToolExecutor` POSTs tool calls to. */\n toolExecUrl: string\n /** Per-run bearer token gating that tool-exec endpoint. */\n toolExecToken: string\n}\n\nfunction isHarnessId(value: unknown): value is HarnessId {\n return (\n typeof value === 'string' &&\n (HARNESS_IDS as ReadonlyArray<string>).includes(value)\n )\n}\n\nfunction isToolDescriptor(value: unknown): value is ToolDescriptor {\n return (\n value !== null &&\n typeof value === 'object' &&\n 'name' in value &&\n typeof value.name === 'string'\n )\n}\n\nfunction isWorkspaceDefinition(value: unknown): value is WorkspaceDefinition {\n return (\n value !== null &&\n typeof value === 'object' &&\n 'source' in value &&\n value.source !== null &&\n typeof value.source === 'object'\n )\n}\n\n/**\n * Assert enough of a message to fail fast on garbage (a non-empty `role` and a\n * `content` field). The chat engine validates the full shape downstream; this\n * narrows the array element to {@link ModelMessage} without a cast.\n */\nfunction isModelMessage(value: unknown): value is ModelMessage {\n return (\n value !== null &&\n typeof value === 'object' &&\n 'role' in value &&\n typeof value.role === 'string' &&\n 'content' in value\n )\n}\n\n/** Narrow `unknown` to an indexable record (a predicate, not a cast). */\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return value !== null && typeof value === 'object'\n}\n\nfunction requireNonEmptyString(\n value: Record<string, unknown>,\n key: string,\n): string {\n const found = value[key]\n if (typeof found !== 'string' || found === '') {\n throw new Error(`run request: ${key} must be a non-empty string`)\n }\n return found\n}\n\n/**\n * Narrow an unknown `POST /run` body into a {@link ContainerRunRequest} (project\n * rule: no `as`). The message and descriptor shapes are validated downstream by\n * the chat engine and the tool bridge; here we only assert enough to fail fast\n * with a clear error on a malformed request.\n */\nexport function parseContainerRunRequest(value: unknown): ContainerRunRequest {\n if (!isRecord(value)) {\n throw new Error('run request must be a JSON object')\n }\n const runId = requireNonEmptyString(value, 'runId')\n const threadId = requireNonEmptyString(value, 'threadId')\n const model = requireNonEmptyString(value, 'model')\n const toolExecUrl = requireNonEmptyString(value, 'toolExecUrl')\n const toolExecToken = requireNonEmptyString(value, 'toolExecToken')\n\n const messages = value['messages']\n if (\n !Array.isArray(messages) ||\n messages.length === 0 ||\n !messages.every(isModelMessage)\n ) {\n throw new Error('run request: messages must be a non-empty ModelMessage[]')\n }\n\n const harness = value['harness']\n if (!isHarnessId(harness)) {\n throw new Error('run request: harness must be a known harness id')\n }\n\n const workspace = value['workspace']\n if (!isWorkspaceDefinition(workspace)) {\n throw new Error('run request: workspace must be a WorkspaceDefinition')\n }\n\n const toolDescriptors = value['toolDescriptors']\n if (\n !Array.isArray(toolDescriptors) ||\n !toolDescriptors.every(isToolDescriptor)\n ) {\n throw new Error('run request: toolDescriptors must be a ToolDescriptor[]')\n }\n\n return {\n runId,\n threadId,\n messages,\n harness,\n model,\n workspace,\n toolDescriptors,\n toolExecUrl,\n toolExecToken,\n }\n}\n"],"names":[],"mappings":"AA8BA,MAAM,cAAc,CAAC,eAAe,SAAS,UAAU;AA6BvD,SAAS,YAAY,OAAoC;AACvD,SACE,OAAO,UAAU,YAChB,YAAsC,SAAS,KAAK;AAEzD;AAEA,SAAS,iBAAiB,OAAyC;AACjE,SACE,UAAU,QACV,OAAO,UAAU,YACjB,UAAU,SACV,OAAO,MAAM,SAAS;AAE1B;AAEA,SAAS,sBAAsB,OAA8C;AAC3E,SACE,UAAU,QACV,OAAO,UAAU,YACjB,YAAY,SACZ,MAAM,WAAW,QACjB,OAAO,MAAM,WAAW;AAE5B;AAOA,SAAS,eAAe,OAAuC;AAC7D,SACE,UAAU,QACV,OAAO,UAAU,YACjB,UAAU,SACV,OAAO,MAAM,SAAS,YACtB,aAAa;AAEjB;AAGA,SAAS,SAAS,OAAkD;AAClE,SAAO,UAAU,QAAQ,OAAO,UAAU;AAC5C;AAEA,SAAS,sBACP,OACA,KACQ;AACR,QAAM,QAAQ,MAAM,GAAG;AACvB,MAAI,OAAO,UAAU,YAAY,UAAU,IAAI;AAC7C,UAAM,IAAI,MAAM,gBAAgB,GAAG,6BAA6B;AAAA,EAClE;AACA,SAAO;AACT;AAQO,SAAS,yBAAyB,OAAqC;AAC5E,MAAI,CAAC,SAAS,KAAK,GAAG;AACpB,UAAM,IAAI,MAAM,mCAAmC;AAAA,EACrD;AACA,QAAM,QAAQ,sBAAsB,OAAO,OAAO;AAClD,QAAM,WAAW,sBAAsB,OAAO,UAAU;AACxD,QAAM,QAAQ,sBAAsB,OAAO,OAAO;AAClD,QAAM,cAAc,sBAAsB,OAAO,aAAa;AAC9D,QAAM,gBAAgB,sBAAsB,OAAO,eAAe;AAElE,QAAM,WAAW,MAAM,UAAU;AACjC,MACE,CAAC,MAAM,QAAQ,QAAQ,KACvB,SAAS,WAAW,KACpB,CAAC,SAAS,MAAM,cAAc,GAC9B;AACA,UAAM,IAAI,MAAM,0DAA0D;AAAA,EAC5E;AAEA,QAAM,UAAU,MAAM,SAAS;AAC/B,MAAI,CAAC,YAAY,OAAO,GAAG;AACzB,UAAM,IAAI,MAAM,iDAAiD;AAAA,EACnE;AAEA,QAAM,YAAY,MAAM,WAAW;AACnC,MAAI,CAAC,sBAAsB,SAAS,GAAG;AACrC,UAAM,IAAI,MAAM,sDAAsD;AAAA,EACxE;AAEA,QAAM,kBAAkB,MAAM,iBAAiB;AAC/C,MACE,CAAC,MAAM,QAAQ,eAAe,KAC9B,CAAC,gBAAgB,MAAM,gBAAgB,GACvC;AACA,UAAM,IAAI,MAAM,yDAAyD;AAAA,EAC3E;AAEA,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EAAA;AAEJ;"}
@@ -0,0 +1,31 @@
1
+ import { Sandbox, SandboxTransport } from '@cloudflare/sandbox';
2
+ import { SandboxProvider } from '@tanstack/ai-sandbox';
3
+ export interface CloudflareSandboxConfig {
4
+ /**
5
+ * The Sandbox Durable Object namespace binding (e.g. `env.Sandbox`).
6
+ * Available inside a Worker `fetch` handler.
7
+ */
8
+ binding: DurableObjectNamespace<Sandbox>;
9
+ /** Working directory inside the container. Defaults to `/workspace`. */
10
+ workdir?: string;
11
+ /**
12
+ * Your Worker's request hostname, required by `ports.connect` to expose a
13
+ * preview URL (Cloudflare routes exposed ports by hostname).
14
+ */
15
+ previewHostname?: string;
16
+ /**
17
+ * Container-control transport. Defaults to `'rpc'` (the SDK's primary path)
18
+ * because `sandbox.tunnels` — used by `exposePreviewTool` to mint quick-tunnel
19
+ * preview URLs — ONLY exists on the RPC transport; on `'http'`/`'websocket'` it
20
+ * throws "requires the RPC transport". The transport must be the same for every
21
+ * `getSandbox()` of a given id, so this provider applies it to create/resume/
22
+ * destroy alike. Override to `'http'` only if you don't use preview tunnels.
23
+ */
24
+ transport?: SandboxTransport;
25
+ }
26
+ /**
27
+ * Cloudflare sandbox provider — runs harness adapters inside Cloudflare
28
+ * Containers at the edge. Construct it inside a Worker with the Sandbox Durable
29
+ * Object namespace binding. See the stdin/snapshot limitations in `handle.ts`.
30
+ */
31
+ export declare function cloudflareSandbox(config: CloudflareSandboxConfig): SandboxProvider;
@@ -0,0 +1,65 @@
1
+ import { getSandbox } from "@cloudflare/sandbox";
2
+ import { CLOUDFLARE_CAPS, CloudflareHandle } from "./handle.js";
3
+ const DEFAULT_WORKDIR = "/workspace";
4
+ class CloudflareProvider {
5
+ constructor(config) {
6
+ this.config = config;
7
+ }
8
+ config;
9
+ name = "cloudflare";
10
+ capabilities() {
11
+ return CLOUDFLARE_CAPS;
12
+ }
13
+ get workdir() {
14
+ return this.config.workdir ?? DEFAULT_WORKDIR;
15
+ }
16
+ // `transport: 'rpc'` by default so `sandbox.tunnels` (preview URLs) works; must be
17
+ // identical across every `getSandbox()` for an id, so all three paths share this.
18
+ get sandboxOptions() {
19
+ return { transport: this.config.transport ?? "rpc" };
20
+ }
21
+ async create(input) {
22
+ const id = crypto.randomUUID();
23
+ const sandbox = getSandbox(this.config.binding, id, this.sandboxOptions);
24
+ if (input.env && Object.keys(input.env).length > 0) {
25
+ await sandbox.setEnvVars(input.env);
26
+ }
27
+ await sandbox.mkdir(this.workdir, { recursive: true });
28
+ return new CloudflareHandle(
29
+ id,
30
+ sandbox,
31
+ this.workdir,
32
+ this.config.previewHostname
33
+ );
34
+ }
35
+ resume(input) {
36
+ const sandbox = getSandbox(
37
+ this.config.binding,
38
+ input.id,
39
+ this.sandboxOptions
40
+ );
41
+ return Promise.resolve(
42
+ new CloudflareHandle(
43
+ input.id,
44
+ sandbox,
45
+ this.workdir,
46
+ this.config.previewHostname
47
+ )
48
+ );
49
+ }
50
+ async destroy(input) {
51
+ const sandbox = getSandbox(
52
+ this.config.binding,
53
+ input.id,
54
+ this.sandboxOptions
55
+ );
56
+ await sandbox.destroy();
57
+ }
58
+ }
59
+ function cloudflareSandbox(config) {
60
+ return new CloudflareProvider(config);
61
+ }
62
+ export {
63
+ cloudflareSandbox
64
+ };
65
+ //# sourceMappingURL=provider.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"provider.js","sources":["../../src/provider.ts"],"sourcesContent":["import { getSandbox } from '@cloudflare/sandbox'\nimport { CLOUDFLARE_CAPS, CloudflareHandle } from './handle'\nimport type { Sandbox, SandboxTransport } from '@cloudflare/sandbox'\nimport type {\n SandboxCapabilities,\n SandboxCreateInput,\n SandboxDestroyInput,\n SandboxHandle,\n SandboxProvider,\n SandboxResumeInput,\n} from '@tanstack/ai-sandbox'\n\nconst DEFAULT_WORKDIR = '/workspace'\n\nexport interface CloudflareSandboxConfig {\n /**\n * The Sandbox Durable Object namespace binding (e.g. `env.Sandbox`).\n * Available inside a Worker `fetch` handler.\n */\n binding: DurableObjectNamespace<Sandbox>\n /** Working directory inside the container. Defaults to `/workspace`. */\n workdir?: string\n /**\n * Your Worker's request hostname, required by `ports.connect` to expose a\n * preview URL (Cloudflare routes exposed ports by hostname).\n */\n previewHostname?: string\n /**\n * Container-control transport. Defaults to `'rpc'` (the SDK's primary path)\n * because `sandbox.tunnels` — used by `exposePreviewTool` to mint quick-tunnel\n * preview URLs — ONLY exists on the RPC transport; on `'http'`/`'websocket'` it\n * throws \"requires the RPC transport\". The transport must be the same for every\n * `getSandbox()` of a given id, so this provider applies it to create/resume/\n * destroy alike. Override to `'http'` only if you don't use preview tunnels.\n */\n transport?: SandboxTransport\n}\n\nclass CloudflareProvider implements SandboxProvider {\n readonly name = 'cloudflare'\n\n constructor(private readonly config: CloudflareSandboxConfig) {}\n\n capabilities(): SandboxCapabilities {\n return CLOUDFLARE_CAPS\n }\n\n private get workdir(): string {\n return this.config.workdir ?? DEFAULT_WORKDIR\n }\n\n // `transport: 'rpc'` by default so `sandbox.tunnels` (preview URLs) works; must be\n // identical across every `getSandbox()` for an id, so all three paths share this.\n private get sandboxOptions(): { transport: SandboxTransport } {\n return { transport: this.config.transport ?? 'rpc' }\n }\n\n async create(input: SandboxCreateInput): Promise<SandboxHandle> {\n const id = crypto.randomUUID()\n const sandbox = getSandbox(this.config.binding, id, this.sandboxOptions)\n if (input.env && Object.keys(input.env).length > 0) {\n await sandbox.setEnvVars(input.env)\n }\n await sandbox.mkdir(this.workdir, { recursive: true })\n return new CloudflareHandle(\n id,\n sandbox,\n this.workdir,\n this.config.previewHostname,\n )\n }\n\n resume(input: SandboxResumeInput): Promise<SandboxHandle | null> {\n // The Durable Object is durable, so the sandbox is always addressable by\n // id. (The container disk may have been wiped on cold start — withSandbox\n // re-bootstraps under the same identity when durableFilesystem is false.)\n const sandbox = getSandbox(\n this.config.binding,\n input.id,\n this.sandboxOptions,\n )\n return Promise.resolve(\n new CloudflareHandle(\n input.id,\n sandbox,\n this.workdir,\n this.config.previewHostname,\n ),\n )\n }\n\n async destroy(input: SandboxDestroyInput): Promise<void> {\n const sandbox = getSandbox(\n this.config.binding,\n input.id,\n this.sandboxOptions,\n )\n await sandbox.destroy()\n }\n}\n\n/**\n * Cloudflare sandbox provider — runs harness adapters inside Cloudflare\n * Containers at the edge. Construct it inside a Worker with the Sandbox Durable\n * Object namespace binding. See the stdin/snapshot limitations in `handle.ts`.\n */\nexport function cloudflareSandbox(\n config: CloudflareSandboxConfig,\n): SandboxProvider {\n return new CloudflareProvider(config)\n}\n"],"names":[],"mappings":";;AAYA,MAAM,kBAAkB;AA0BxB,MAAM,mBAA8C;AAAA,EAGlD,YAA6B,QAAiC;AAAjC,SAAA,SAAA;AAAA,EAAkC;AAAA,EAAlC;AAAA,EAFpB,OAAO;AAAA,EAIhB,eAAoC;AAClC,WAAO;AAAA,EACT;AAAA,EAEA,IAAY,UAAkB;AAC5B,WAAO,KAAK,OAAO,WAAW;AAAA,EAChC;AAAA;AAAA;AAAA,EAIA,IAAY,iBAAkD;AAC5D,WAAO,EAAE,WAAW,KAAK,OAAO,aAAa,MAAA;AAAA,EAC/C;AAAA,EAEA,MAAM,OAAO,OAAmD;AAC9D,UAAM,KAAK,OAAO,WAAA;AAClB,UAAM,UAAU,WAAW,KAAK,OAAO,SAAS,IAAI,KAAK,cAAc;AACvE,QAAI,MAAM,OAAO,OAAO,KAAK,MAAM,GAAG,EAAE,SAAS,GAAG;AAClD,YAAM,QAAQ,WAAW,MAAM,GAAG;AAAA,IACpC;AACA,UAAM,QAAQ,MAAM,KAAK,SAAS,EAAE,WAAW,MAAM;AACrD,WAAO,IAAI;AAAA,MACT;AAAA,MACA;AAAA,MACA,KAAK;AAAA,MACL,KAAK,OAAO;AAAA,IAAA;AAAA,EAEhB;AAAA,EAEA,OAAO,OAA0D;AAI/D,UAAM,UAAU;AAAA,MACd,KAAK,OAAO;AAAA,MACZ,MAAM;AAAA,MACN,KAAK;AAAA,IAAA;AAEP,WAAO,QAAQ;AAAA,MACb,IAAI;AAAA,QACF,MAAM;AAAA,QACN;AAAA,QACA,KAAK;AAAA,QACL,KAAK,OAAO;AAAA,MAAA;AAAA,IACd;AAAA,EAEJ;AAAA,EAEA,MAAM,QAAQ,OAA2C;AACvD,UAAM,UAAU;AAAA,MACd,KAAK,OAAO;AAAA,MACZ,MAAM;AAAA,MACN,KAAK;AAAA,IAAA;AAEP,UAAM,QAAQ,QAAA;AAAA,EAChB;AACF;AAOO,SAAS,kBACd,QACiB;AACjB,SAAO,IAAI,mBAAmB,MAAM;AACtC;"}
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Host resolution for the two DISTINCT public surfaces the sandbox layer exposes.
3
+ * Kept in its own (Workers-free) module so it stays pure and unit-testable.
4
+ *
5
+ * These were once a single `PUBLIC_HOSTNAME`, but they have different reachers and
6
+ * therefore different correct values:
7
+ *
8
+ * - **Bridge / tool-exec** — the off-isolate CONTAINER calls back into the Worker
9
+ * (`/_bridge`, `/tool-exec`). It must reach the Worker, so locally that's
10
+ * `host.docker.internal` (the container can't reach the host's `localhost`).
11
+ * - **Preview** — the BROWSER opens an `exposePort` URL that `proxyToSandbox`
12
+ * routes into the container. It needs WILDCARD DNS, so locally that's
13
+ * `*.localhost` (browsers resolve it to loopback with zero setup) and in
14
+ * production a CUSTOM DOMAIN (`*.workers.dev` has no wildcard subdomains).
15
+ */
16
+ /**
17
+ * Resolve the ORIGIN the off-isolate sandbox CONTAINER uses to call back into the
18
+ * Worker — the MCP tool-bridge (`/_bridge`) and host-tool execution (`/tool-exec`).
19
+ * Returns a full origin (scheme + host + optional port), e.g.
20
+ * `http://host.docker.internal:3001` locally or `https://app.example.com` deployed.
21
+ *
22
+ * `PUBLIC_HOSTNAME` wins when set; otherwise we derive from the host the trigger
23
+ * request arrived on (`input.publicHost`).
24
+ *
25
+ * ── Why a callback hostname is unavoidable ──────────────────────────────────────
26
+ * The container is SEPARATE compute from the Worker isolate; it can only reach the
27
+ * Worker over the network, so the callback URL must be an absolute host.
28
+ *
29
+ * ── Why request-derivation is SAFE on Cloudflare ────────────────────────────────
30
+ * On a generic Node server the `Host` header is attacker-controlled and trusting it
31
+ * is a Host-injection / token-exfil vector (the per-run bearer token rides this
32
+ * URL). Not so behind Cloudflare: the edge dispatches a request to your Worker only
33
+ * when its hostname matches a route you OWN, so `input.publicHost` is always one of
34
+ * your own hostnames — never an attacker's.
35
+ *
36
+ * ── Local dev: localhost → host.docker.internal ─────────────────────────────────
37
+ * Locally the trigger arrives on `localhost`, which the container CANNOT reach
38
+ * (that's the container's own loopback). So we rewrite it to `host.docker.internal`
39
+ * (the Docker host gateway), keeping the port, over `http`. This removes the need
40
+ * for a dev tunnel for the bridge entirely.
41
+ */
42
+ export declare function resolveBridgeOrigin(env: {
43
+ PUBLIC_HOSTNAME?: string;
44
+ }, input: {
45
+ publicHost?: string;
46
+ }): string;
47
+ /**
48
+ * Resolve the HOST passed to `exposePort` for browser-facing preview URLs (the app
49
+ * the agent builds). Returns a bare host (the `@cloudflare/sandbox` SDK builds the
50
+ * `<port>-<id>-<token>.<host>` URL + scheme itself).
51
+ *
52
+ * `PREVIEW_HOSTNAME` wins when set; otherwise we derive from the trigger request.
53
+ *
54
+ * Preview URLs require WILDCARD DNS, which constrains the value:
55
+ * - **Local** → `localhost:<port>`. The SDK's localhost path yields
56
+ * `http://<port>-<id>-<token>.localhost:<port>`, which browsers resolve to
57
+ * loopback with no DNS setup — so previews work locally with no tunnel.
58
+ * - **Deployed** → a CUSTOM DOMAIN with a `*.<domain>` route. `*.workers.dev` has
59
+ * no wildcard subdomains (the SDK's `exposePort` throws on it), so we throw a
60
+ * clear error pointing at `PREVIEW_HOSTNAME` rather than letting the run fail
61
+ * deep in the agent.
62
+ */
63
+ export declare function resolvePreviewHost(env: {
64
+ PREVIEW_HOSTNAME?: string;
65
+ }, input: {
66
+ publicHost?: string;
67
+ }): string;