@tanstack/ai-sandbox-cloudflare 0.3.3 → 0.3.6

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.
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Build in-container `sh -c` commands that write a file without putting the
3
+ * whole payload on one argv.
4
+ *
5
+ * Linux `MAX_ARG_STRLEN` is 128 KiB for a single argument. A snapshot restore
6
+ * of a lockfile or generated source exceeds that when the bytes are quoted
7
+ * into `printf`.
8
+ */
9
+ /** Stay well under `MAX_ARG_STRLEN` after base64 and quoting. */
10
+ export declare const FS_WRITE_BYTE_CHUNK = 32768;
11
+ export declare function fsWriteCommands(abs: string, data: string | Uint8Array): Array<string>;
@@ -0,0 +1,28 @@
1
+ //#region src/fs-write.ts
2
+ /**
3
+ * Build in-container `sh -c` commands that write a file without putting the
4
+ * whole payload on one argv.
5
+ *
6
+ * Linux `MAX_ARG_STRLEN` is 128 KiB for a single argument. A snapshot restore
7
+ * of a lockfile or generated source exceeds that when the bytes are quoted
8
+ * into `printf`.
9
+ */
10
+ /** Stay well under `MAX_ARG_STRLEN` after base64 and quoting. */
11
+ var FS_WRITE_BYTE_CHUNK = 32768;
12
+ function quote(value) {
13
+ return `'${value.replace(/'/g, `'\\''`)}'`;
14
+ }
15
+ function fsWriteCommands(abs, data) {
16
+ const bytes = typeof data === "string" ? Buffer.from(data, "utf8") : Buffer.from(data);
17
+ const commands = [`mkdir -p ${quote(abs.replace(/\/[^/]*$/, "") || "/")} && : > ${quote(abs)}`];
18
+ for (let offset = 0; offset < bytes.byteLength; offset += FS_WRITE_BYTE_CHUNK) {
19
+ const slice = bytes.subarray(offset, offset + FS_WRITE_BYTE_CHUNK);
20
+ const b64 = Buffer.from(slice).toString("base64");
21
+ commands.push(`printf %s ${quote(b64)} | base64 -d >> ${quote(abs)}`);
22
+ }
23
+ return commands;
24
+ }
25
+ //#endregion
26
+ export { FS_WRITE_BYTE_CHUNK, fsWriteCommands };
27
+
28
+ //# sourceMappingURL=fs-write.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fs-write.js","names":[],"sources":["../../src/fs-write.ts"],"sourcesContent":["/**\n * Build in-container `sh -c` commands that write a file without putting the\n * whole payload on one argv.\n *\n * Linux `MAX_ARG_STRLEN` is 128 KiB for a single argument. A snapshot restore\n * of a lockfile or generated source exceeds that when the bytes are quoted\n * into `printf`.\n */\n\n/** Stay well under `MAX_ARG_STRLEN` after base64 and quoting. */\nexport const FS_WRITE_BYTE_CHUNK = 32_768\n\nfunction quote(value: string): string {\n return `'${value.replace(/'/g, `'\\\\''`)}'`\n}\n\nexport function fsWriteCommands(\n abs: string,\n data: string | Uint8Array,\n): Array<string> {\n const bytes =\n typeof data === 'string' ? Buffer.from(data, 'utf8') : Buffer.from(data)\n const dir = abs.replace(/\\/[^/]*$/, '') || '/'\n const commands = [`mkdir -p ${quote(dir)} && : > ${quote(abs)}`]\n for (\n let offset = 0;\n offset < bytes.byteLength;\n offset += FS_WRITE_BYTE_CHUNK\n ) {\n const slice = bytes.subarray(offset, offset + FS_WRITE_BYTE_CHUNK)\n const b64 = Buffer.from(slice).toString('base64')\n commands.push(`printf %s ${quote(b64)} | base64 -d >> ${quote(abs)}`)\n }\n return commands\n}\n"],"mappings":";;;;;;;;;;AAUA,IAAa,sBAAsB;AAEnC,SAAS,MAAM,OAAuB;CACpC,OAAO,IAAI,MAAM,QAAQ,MAAM,OAAO,EAAE;AAC1C;AAEA,SAAgB,gBACd,KACA,MACe;CACf,MAAM,QACJ,OAAO,SAAS,WAAW,OAAO,KAAK,MAAM,MAAM,IAAI,OAAO,KAAK,IAAI;CAEzE,MAAM,WAAW,CAAC,YAAY,MADlB,IAAI,QAAQ,YAAY,EAAE,KAAK,GACJ,EAAE,UAAU,MAAM,GAAG,GAAG;CAC/D,KACE,IAAI,SAAS,GACb,SAAS,MAAM,YACf,UAAU,qBACV;EACA,MAAM,QAAQ,MAAM,SAAS,QAAQ,SAAS,mBAAmB;EACjE,MAAM,MAAM,OAAO,KAAK,KAAK,CAAC,CAAC,SAAS,QAAQ;EAChD,SAAS,KAAK,aAAa,MAAM,GAAG,EAAE,kBAAkB,MAAM,GAAG,GAAG;CACtE;CACA,OAAO;AACT"}
@@ -16,6 +16,7 @@ export declare class CloudflareHandle implements SandboxHandle {
16
16
  private readonly previewHostname;
17
17
  constructor(id: string, sandbox: Sandbox, workdir: string, previewHostname?: string);
18
18
  private abs;
19
+ private lstat;
19
20
  private exec;
20
21
  private spawnProcess;
21
22
  private connectPort;
@@ -1,11 +1,12 @@
1
+ import { fsWriteCommands } from "./fs-write.js";
1
2
  import { createExecBackedGit } from "@tanstack/ai-sandbox";
2
3
  //#region src/handle.ts
3
4
  /**
4
5
  * SandboxHandle backed by a Cloudflare Sandbox (Containers + Durable Objects),
5
6
  * via `@cloudflare/sandbox`. Runs at the edge inside a Worker.
6
7
  *
7
- * fs is implemented over `exec` with base64 piping (binary-safe), matching the
8
- * Docker provider. The container disk is EPHEMERAL (wiped to the image on
8
+ * fs is implemented over `exec` with chunked base64 piping (binary-safe),
9
+ * matching the Docker provider. The container disk is EPHEMERAL (wiped to the image on
9
10
  * restart) and snapshots are not yet GA, so `capabilities.snapshots` and
10
11
  * `durableFilesystem` are false — `withSandbox` re-bootstraps under the same
11
12
  * identity across cold starts.
@@ -39,6 +40,38 @@ var CLOUDFLARE_CAPS = {
39
40
  function q(value) {
40
41
  return `'${value.replace(/'/g, `'\\''`)}'`;
41
42
  }
43
+ var LSTAT_MISSING = "__TANSTACK_LSTAT_MISSING__";
44
+ /** Verify a missing path by listing parent entries. `test -e` also fails for inaccessible parents. */
45
+ function lstatCommand(path) {
46
+ return `tanstack_lstat_path=${q(path)}; tanstack_lstat_output=$(stat -c '%f:%s' -- "$tanstack_lstat_path" 2>&1); tanstack_lstat_status=$?; if [ "$tanstack_lstat_status" -eq 0 ]; then printf '%s\n' "$tanstack_lstat_output"; else tanstack_lstat_missing() { tanstack_missing_path=$1; case "$tanstack_missing_path" in /|.) return 1 ;; */*) tanstack_parent=\${tanstack_missing_path%/*}; tanstack_name=\${tanstack_missing_path##*/}; [ -n "$tanstack_parent" ] || tanstack_parent=/ ;; *) tanstack_parent=.; tanstack_name=$tanstack_missing_path ;; esac; tanstack_parent_mode=$(stat -L -c '%f' -- "$tanstack_parent" 2>/dev/null); tanstack_parent_status=$?; if [ "$tanstack_parent_status" -ne 0 ]; then tanstack_lstat_missing "$tanstack_parent"; else case "$tanstack_parent_mode" in 4[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]) case "$tanstack_parent" in /*) tanstack_find_parent=$tanstack_parent ;; *) tanstack_find_parent=./$tanstack_parent ;; esac; tanstack_match=$(find -H "$tanstack_find_parent" -mindepth 1 -maxdepth 1 -exec sh -c 'tanstack_target=$1; shift; for tanstack_candidate do [ "\${tanstack_candidate##*/}" = "$tanstack_target" ] && { printf 1; exit 0; }; done; exit 0' sh "$tanstack_name" '{}' + 2>/dev/null); tanstack_find_status=$?; [ "$tanstack_find_status" -eq 0 ] && [ -z "$tanstack_match" ] ;; *) return 1 ;; esac; fi; }; if tanstack_lstat_missing "$tanstack_lstat_path"; then printf '%s' '${LSTAT_MISSING}'; else printf '%s\n' "$tanstack_lstat_output" >&2; exit "$tanstack_lstat_status"; fi; fi`;
47
+ }
48
+ function parseLstatOutput(output) {
49
+ const fields = /^(?<mode>[0-9a-fA-F]{4}):(?<size>\d+)\n?$/.exec(output);
50
+ const mode = fields?.groups?.mode;
51
+ const size = fields?.groups?.size;
52
+ if (!mode || !size) throw new Error(`invalid lstat output: ${output}`);
53
+ const parsedMode = Number.parseInt(mode, 16);
54
+ const parsedSize = Number(size);
55
+ if (!Number.isSafeInteger(parsedMode) || !Number.isSafeInteger(parsedSize) || parsedSize < 0) throw new Error(`invalid lstat output: ${output}`);
56
+ const type = parsedMode & 61440;
57
+ if (type === 32768) return {
58
+ type: "file",
59
+ mode: parsedMode,
60
+ size: parsedSize
61
+ };
62
+ if (type === 16384) return {
63
+ type: "dir",
64
+ mode: parsedMode
65
+ };
66
+ if (type === 40960) return {
67
+ type: "symlink",
68
+ mode: parsedMode
69
+ };
70
+ return {
71
+ type: "other",
72
+ mode: parsedMode
73
+ };
74
+ }
42
75
  /** A push-driven async string queue used to adapt CF's onOutput callback. */
43
76
  var OutputQueue = class {
44
77
  buffer = [];
@@ -110,11 +143,10 @@ var CloudflareHandle = class {
110
143
  return new Uint8Array(Buffer.from(r.stdout, "base64"));
111
144
  },
112
145
  write: async (p, data) => {
113
- const abs = this.abs(p);
114
- const b64 = Buffer.from(typeof data === "string" ? Buffer.from(data, "utf8") : data).toString("base64");
115
- const dir = abs.replace(/\/[^/]*$/, "") || "/";
116
- const r = await this.exec(`mkdir -p ${q(dir)} && printf %s ${q(b64)} | base64 -d > ${q(abs)}`);
117
- if (r.exitCode !== 0) throw new Error(`write failed: ${r.stderr.trim()}`);
146
+ for (const command of fsWriteCommands(this.abs(p), data)) {
147
+ const r = await this.exec(command);
148
+ if (r.exitCode !== 0) throw new Error(`write failed: ${r.stderr.trim()}`);
149
+ }
118
150
  },
119
151
  list: async (p) => {
120
152
  const r = await this.exec(`ls -1Ap ${q(this.abs(p))}`);
@@ -129,6 +161,7 @@ var CloudflareHandle = class {
129
161
  };
130
162
  });
131
163
  },
164
+ lstat: async (p) => this.lstat(this.abs(p)),
132
165
  mkdir: async (p) => {
133
166
  await this.exec(`mkdir -p ${q(this.abs(p))}`);
134
167
  },
@@ -152,6 +185,12 @@ var CloudflareHandle = class {
152
185
  if (p.startsWith("/workspace/")) return `${this.workdir}/${p.slice(11)}`;
153
186
  return p;
154
187
  }
188
+ async lstat(path) {
189
+ const r = await this.exec(lstatCommand(path));
190
+ if (r.exitCode === 0 && r.stdout.trim() === LSTAT_MISSING) return void 0;
191
+ if (r.exitCode !== 0) throw new Error(`lstat failed: ${r.stderr.trim()}`);
192
+ return parseLstatOutput(r.stdout);
193
+ }
155
194
  async exec(command, opts) {
156
195
  const result = await this.sandbox.exec(command, {
157
196
  ...opts?.cwd ? { cwd: this.abs(opts.cwd) } : { cwd: this.workdir },
@@ -1 +1 @@
1
- {"version":3,"file":"handle.js","names":[],"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 // `spawnProcess.kill()` is a no-op (see the comment in `spawnProcess`) and\n // the caller's abort signal is never forwarded to `sandbox.exec` — on\n // `exec` as well as `spawn` — so a spawned follower process (e.g. `tail -f`)\n // can never be stopped by the caller here.\n killableProcesses: 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"],"mappings":"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"}
1
+ {"version":3,"file":"handle.js","names":[],"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 chunked base64 piping (binary-safe),\n * matching the 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 { fsWriteCommands } from './fs-write'\nimport type { Sandbox } from '@cloudflare/sandbox'\nimport type {\n ExecResult,\n ProcessOptions,\n SandboxCapabilities,\n SandboxChannel,\n SandboxHandle,\n SpawnHandle,\n SandboxFsStat,\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 // `spawnProcess.kill()` is a no-op (see the comment in `spawnProcess`) and\n // the caller's abort signal is never forwarded to `sandbox.exec` — on\n // `exec` as well as `spawn` — so a spawned follower process (e.g. `tail -f`)\n // can never be stopped by the caller here.\n killableProcesses: 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\nconst LSTAT_MISSING = '__TANSTACK_LSTAT_MISSING__'\n\n/** Verify a missing path by listing parent entries. `test -e` also fails for inaccessible parents. */\nfunction lstatCommand(path: string): string {\n return `tanstack_lstat_path=${q(path)}; tanstack_lstat_output=$(stat -c '%f:%s' -- \"$tanstack_lstat_path\" 2>&1); tanstack_lstat_status=$?; if [ \"$tanstack_lstat_status\" -eq 0 ]; then printf '%s\\n' \"$tanstack_lstat_output\"; else tanstack_lstat_missing() { tanstack_missing_path=$1; case \"$tanstack_missing_path\" in /|.) return 1 ;; */*) tanstack_parent=${'$'}{tanstack_missing_path%/*}; tanstack_name=${'$'}{tanstack_missing_path##*/}; [ -n \"$tanstack_parent\" ] || tanstack_parent=/ ;; *) tanstack_parent=.; tanstack_name=$tanstack_missing_path ;; esac; tanstack_parent_mode=$(stat -L -c '%f' -- \"$tanstack_parent\" 2>/dev/null); tanstack_parent_status=$?; if [ \"$tanstack_parent_status\" -ne 0 ]; then tanstack_lstat_missing \"$tanstack_parent\"; else case \"$tanstack_parent_mode\" in 4[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]) case \"$tanstack_parent\" in /*) tanstack_find_parent=$tanstack_parent ;; *) tanstack_find_parent=./$tanstack_parent ;; esac; tanstack_match=$(find -H \"$tanstack_find_parent\" -mindepth 1 -maxdepth 1 -exec sh -c 'tanstack_target=$1; shift; for tanstack_candidate do [ \"${'$'}{tanstack_candidate##*/}\" = \"$tanstack_target\" ] && { printf 1; exit 0; }; done; exit 0' sh \"$tanstack_name\" '{}' + 2>/dev/null); tanstack_find_status=$?; [ \"$tanstack_find_status\" -eq 0 ] && [ -z \"$tanstack_match\" ] ;; *) return 1 ;; esac; fi; }; if tanstack_lstat_missing \"$tanstack_lstat_path\"; then printf '%s' '${LSTAT_MISSING}'; else printf '%s\\n' \"$tanstack_lstat_output\" >&2; exit \"$tanstack_lstat_status\"; fi; fi`\n}\n\nfunction parseLstatOutput(output: string): SandboxFsStat {\n const fields = /^(?<mode>[0-9a-fA-F]{4}):(?<size>\\d+)\\n?$/.exec(output)\n const mode = fields?.groups?.mode\n const size = fields?.groups?.size\n if (!mode || !size) throw new Error(`invalid lstat output: ${output}`)\n const parsedMode = Number.parseInt(mode, 16)\n const parsedSize = Number(size)\n if (\n !Number.isSafeInteger(parsedMode) ||\n !Number.isSafeInteger(parsedSize) ||\n parsedSize < 0\n )\n throw new Error(`invalid lstat output: ${output}`)\n const type = parsedMode & 0xf000\n if (type === 0x8000)\n return { type: 'file', mode: parsedMode, size: parsedSize }\n if (type === 0x4000) return { type: 'dir', mode: parsedMode }\n if (type === 0xa000) return { type: 'symlink', mode: parsedMode }\n return { type: 'other', mode: parsedMode }\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 for (const command of fsWriteCommands(this.abs(p), data)) {\n const r = await this.exec(command)\n if (r.exitCode !== 0)\n throw new Error(`write failed: ${r.stderr.trim()}`)\n }\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 lstat: async (p) => this.lstat(this.abs(p)),\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 lstat(path: string): Promise<SandboxFsStat | undefined> {\n const r = await this.exec(lstatCommand(path))\n if (r.exitCode === 0 && r.stdout.trim() === LSTAT_MISSING) return undefined\n if (r.exitCode !== 0) {\n throw new Error(`lstat failed: ${r.stderr.trim()}`)\n }\n return parseLstatOutput(r.stdout)\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"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/ai-sandbox-cloudflare",
3
- "version": "0.3.3",
3
+ "version": "0.3.6",
4
4
  "description": "Cloudflare sandbox provider for TanStack AI — run harness adapters inside Cloudflare Containers (edge) through the uniform SandboxHandle.",
5
5
  "author": "",
6
6
  "license": "MIT",
@@ -45,18 +45,18 @@
45
45
  },
46
46
  "peerDependencies": {
47
47
  "zod": "^4.0.0",
48
- "@tanstack/ai": "^0.46.0",
49
- "@tanstack/ai-sandbox": "^0.3.4",
50
- "@tanstack/ai-sandbox-local-process": "^0.2.2"
48
+ "@tanstack/ai": "^0.48.0",
49
+ "@tanstack/ai-sandbox": "^0.5.0",
50
+ "@tanstack/ai-sandbox-local-process": "^0.2.4"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@cloudflare/workers-types": "^4.20260317.1",
54
54
  "@types/node": "^24.10.1",
55
55
  "@vitest/coverage-v8": "4.1.10",
56
56
  "zod": "^4.2.0",
57
- "@tanstack/ai": "0.46.0",
58
- "@tanstack/ai-sandbox": "0.3.4",
59
- "@tanstack/ai-sandbox-local-process": "0.2.2"
57
+ "@tanstack/ai": "0.48.0",
58
+ "@tanstack/ai-sandbox": "0.5.0",
59
+ "@tanstack/ai-sandbox-local-process": "0.2.4"
60
60
  },
61
61
  "scripts": {
62
62
  "build": "vite build",
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Build in-container `sh -c` commands that write a file without putting the
3
+ * whole payload on one argv.
4
+ *
5
+ * Linux `MAX_ARG_STRLEN` is 128 KiB for a single argument. A snapshot restore
6
+ * of a lockfile or generated source exceeds that when the bytes are quoted
7
+ * into `printf`.
8
+ */
9
+
10
+ /** Stay well under `MAX_ARG_STRLEN` after base64 and quoting. */
11
+ export const FS_WRITE_BYTE_CHUNK = 32_768
12
+
13
+ function quote(value: string): string {
14
+ return `'${value.replace(/'/g, `'\\''`)}'`
15
+ }
16
+
17
+ export function fsWriteCommands(
18
+ abs: string,
19
+ data: string | Uint8Array,
20
+ ): Array<string> {
21
+ const bytes =
22
+ typeof data === 'string' ? Buffer.from(data, 'utf8') : Buffer.from(data)
23
+ const dir = abs.replace(/\/[^/]*$/, '') || '/'
24
+ const commands = [`mkdir -p ${quote(dir)} && : > ${quote(abs)}`]
25
+ for (
26
+ let offset = 0;
27
+ offset < bytes.byteLength;
28
+ offset += FS_WRITE_BYTE_CHUNK
29
+ ) {
30
+ const slice = bytes.subarray(offset, offset + FS_WRITE_BYTE_CHUNK)
31
+ const b64 = Buffer.from(slice).toString('base64')
32
+ commands.push(`printf %s ${quote(b64)} | base64 -d >> ${quote(abs)}`)
33
+ }
34
+ return commands
35
+ }
package/src/handle.ts CHANGED
@@ -2,8 +2,8 @@
2
2
  * SandboxHandle backed by a Cloudflare Sandbox (Containers + Durable Objects),
3
3
  * via `@cloudflare/sandbox`. Runs at the edge inside a Worker.
4
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
5
+ * fs is implemented over `exec` with chunked base64 piping (binary-safe),
6
+ * matching the Docker provider. The container disk is EPHEMERAL (wiped to the image on
7
7
  * restart) and snapshots are not yet GA, so `capabilities.snapshots` and
8
8
  * `durableFilesystem` are false — `withSandbox` re-bootstraps under the same
9
9
  * identity across cold starts.
@@ -21,6 +21,7 @@
21
21
  * provider contract.
22
22
  */
23
23
  import { createExecBackedGit } from '@tanstack/ai-sandbox'
24
+ import { fsWriteCommands } from './fs-write'
24
25
  import type { Sandbox } from '@cloudflare/sandbox'
25
26
  import type {
26
27
  ExecResult,
@@ -29,6 +30,7 @@ import type {
29
30
  SandboxChannel,
30
31
  SandboxHandle,
31
32
  SpawnHandle,
33
+ SandboxFsStat,
32
34
  } from '@tanstack/ai-sandbox'
33
35
 
34
36
  export const CLOUDFLARE_CAPS: SandboxCapabilities = {
@@ -55,6 +57,34 @@ function q(value: string): string {
55
57
  return `'${value.replace(/'/g, `'\\''`)}'`
56
58
  }
57
59
 
60
+ const LSTAT_MISSING = '__TANSTACK_LSTAT_MISSING__'
61
+
62
+ /** Verify a missing path by listing parent entries. `test -e` also fails for inaccessible parents. */
63
+ function lstatCommand(path: string): string {
64
+ return `tanstack_lstat_path=${q(path)}; tanstack_lstat_output=$(stat -c '%f:%s' -- "$tanstack_lstat_path" 2>&1); tanstack_lstat_status=$?; if [ "$tanstack_lstat_status" -eq 0 ]; then printf '%s\n' "$tanstack_lstat_output"; else tanstack_lstat_missing() { tanstack_missing_path=$1; case "$tanstack_missing_path" in /|.) return 1 ;; */*) tanstack_parent=${'$'}{tanstack_missing_path%/*}; tanstack_name=${'$'}{tanstack_missing_path##*/}; [ -n "$tanstack_parent" ] || tanstack_parent=/ ;; *) tanstack_parent=.; tanstack_name=$tanstack_missing_path ;; esac; tanstack_parent_mode=$(stat -L -c '%f' -- "$tanstack_parent" 2>/dev/null); tanstack_parent_status=$?; if [ "$tanstack_parent_status" -ne 0 ]; then tanstack_lstat_missing "$tanstack_parent"; else case "$tanstack_parent_mode" in 4[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]) case "$tanstack_parent" in /*) tanstack_find_parent=$tanstack_parent ;; *) tanstack_find_parent=./$tanstack_parent ;; esac; tanstack_match=$(find -H "$tanstack_find_parent" -mindepth 1 -maxdepth 1 -exec sh -c 'tanstack_target=$1; shift; for tanstack_candidate do [ "${'$'}{tanstack_candidate##*/}" = "$tanstack_target" ] && { printf 1; exit 0; }; done; exit 0' sh "$tanstack_name" '{}' + 2>/dev/null); tanstack_find_status=$?; [ "$tanstack_find_status" -eq 0 ] && [ -z "$tanstack_match" ] ;; *) return 1 ;; esac; fi; }; if tanstack_lstat_missing "$tanstack_lstat_path"; then printf '%s' '${LSTAT_MISSING}'; else printf '%s\n' "$tanstack_lstat_output" >&2; exit "$tanstack_lstat_status"; fi; fi`
65
+ }
66
+
67
+ function parseLstatOutput(output: string): SandboxFsStat {
68
+ const fields = /^(?<mode>[0-9a-fA-F]{4}):(?<size>\d+)\n?$/.exec(output)
69
+ const mode = fields?.groups?.mode
70
+ const size = fields?.groups?.size
71
+ if (!mode || !size) throw new Error(`invalid lstat output: ${output}`)
72
+ const parsedMode = Number.parseInt(mode, 16)
73
+ const parsedSize = Number(size)
74
+ if (
75
+ !Number.isSafeInteger(parsedMode) ||
76
+ !Number.isSafeInteger(parsedSize) ||
77
+ parsedSize < 0
78
+ )
79
+ throw new Error(`invalid lstat output: ${output}`)
80
+ const type = parsedMode & 0xf000
81
+ if (type === 0x8000)
82
+ return { type: 'file', mode: parsedMode, size: parsedSize }
83
+ if (type === 0x4000) return { type: 'dir', mode: parsedMode }
84
+ if (type === 0xa000) return { type: 'symlink', mode: parsedMode }
85
+ return { type: 'other', mode: parsedMode }
86
+ }
87
+
58
88
  /** A push-driven async string queue used to adapt CF's onOutput callback. */
59
89
  class OutputQueue {
60
90
  private readonly buffer: Array<string> = []
@@ -135,16 +165,11 @@ export class CloudflareHandle implements SandboxHandle {
135
165
  return new Uint8Array(Buffer.from(r.stdout, 'base64'))
136
166
  },
137
167
  write: async (p, data) => {
138
- const abs = this.abs(p)
139
- const b64 = Buffer.from(
140
- typeof data === 'string' ? Buffer.from(data, 'utf8') : data,
141
- ).toString('base64')
142
- const dir = abs.replace(/\/[^/]*$/, '') || '/'
143
- const r = await this.exec(
144
- `mkdir -p ${q(dir)} && printf %s ${q(b64)} | base64 -d > ${q(abs)}`,
145
- )
146
- if (r.exitCode !== 0)
147
- throw new Error(`write failed: ${r.stderr.trim()}`)
168
+ for (const command of fsWriteCommands(this.abs(p), data)) {
169
+ const r = await this.exec(command)
170
+ if (r.exitCode !== 0)
171
+ throw new Error(`write failed: ${r.stderr.trim()}`)
172
+ }
148
173
  },
149
174
  list: async (p) => {
150
175
  const r = await this.exec(`ls -1Ap ${q(this.abs(p))}`)
@@ -162,6 +187,7 @@ export class CloudflareHandle implements SandboxHandle {
162
187
  }
163
188
  })
164
189
  },
190
+ lstat: async (p) => this.lstat(this.abs(p)),
165
191
  mkdir: async (p) => {
166
192
  await this.exec(`mkdir -p ${q(this.abs(p))}`)
167
193
  },
@@ -197,6 +223,15 @@ export class CloudflareHandle implements SandboxHandle {
197
223
  return p
198
224
  }
199
225
 
226
+ private async lstat(path: string): Promise<SandboxFsStat | undefined> {
227
+ const r = await this.exec(lstatCommand(path))
228
+ if (r.exitCode === 0 && r.stdout.trim() === LSTAT_MISSING) return undefined
229
+ if (r.exitCode !== 0) {
230
+ throw new Error(`lstat failed: ${r.stderr.trim()}`)
231
+ }
232
+ return parseLstatOutput(r.stdout)
233
+ }
234
+
200
235
  private async exec(
201
236
  command: string,
202
237
  opts?: ProcessOptions,