@tanstack/ai-sandbox-cloudflare 0.3.3 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/fs-write.d.ts +11 -0
- package/dist/esm/fs-write.js +28 -0
- package/dist/esm/fs-write.js.map +1 -0
- package/dist/esm/handle.d.ts +1 -0
- package/dist/esm/handle.js +46 -7
- package/dist/esm/handle.js.map +1 -1
- package/package.json +7 -7
- package/src/fs-write.ts +35 -0
- package/src/handle.ts +47 -12
|
@@ -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"}
|
package/dist/esm/handle.d.ts
CHANGED
|
@@ -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;
|
package/dist/esm/handle.js
CHANGED
|
@@ -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),
|
|
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
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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 },
|
package/dist/esm/handle.js.map
CHANGED
|
@@ -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
|
+
"version": "0.3.5",
|
|
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.
|
|
49
|
-
"@tanstack/ai-sandbox": "^0.
|
|
50
|
-
"@tanstack/ai-sandbox-local-process": "^0.2.
|
|
48
|
+
"@tanstack/ai": "^0.47.1",
|
|
49
|
+
"@tanstack/ai-sandbox": "^0.4.0",
|
|
50
|
+
"@tanstack/ai-sandbox-local-process": "^0.2.3"
|
|
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.
|
|
58
|
-
"@tanstack/ai-sandbox": "0.
|
|
59
|
-
"@tanstack/ai-sandbox-local-process": "0.2.
|
|
57
|
+
"@tanstack/ai": "0.47.1",
|
|
58
|
+
"@tanstack/ai-sandbox": "0.4.0",
|
|
59
|
+
"@tanstack/ai-sandbox-local-process": "0.2.3"
|
|
60
60
|
},
|
|
61
61
|
"scripts": {
|
|
62
62
|
"build": "vite build",
|
package/src/fs-write.ts
ADDED
|
@@ -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),
|
|
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
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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,
|