diff --git a/.lane/memory/services/compute/index.ts/01M4ZP2VMF09R0HHHKC1Y3TF8R-freeze-admission-before-admi.md b/.lane/memory/services/compute/index.ts/01M4ZP2VMF09R0HHHKC1Y3TF8R-freeze-admission-before-admi.md index fe277af0..1177d5a9 100644 --- a/.lane/memory/services/compute/index.ts/01M4ZP2VMF09R0HHHKC1Y3TF8R-freeze-admission-before-admi.md +++ b/.lane/memory/services/compute/index.ts/01M4ZP2VMF09R0HHHKC1Y3TF8R-freeze-admission-before-admi.md @@ -4,9 +4,9 @@ anchor: class Compute created: 2026-10-15T11:45:12Z norm: '1' sig: 3b062341dbce15d0 -body_hash: 87ac2186f431e5a9 -raw_hash: f37fce530dddb402 -vouched: 2026-10-16T16:31:35Z +body_hash: f01b9e5fb5980efb +raw_hash: 6e6c9ba386a99925 +vouched: 2026-10-17T00:39:18Z lines: 49-325 --- diff --git a/.lane/memory/services/compute/index.ts/01M4ZQ5V42TR3DK38BGR08FVCE-the-channel-owner-is-the-ace.md b/.lane/memory/services/compute/index.ts/01M4ZQ5V42TR3DK38BGR08FVCE-the-channel-owner-is-the-ace.md index 9fd05235..54049f65 100644 --- a/.lane/memory/services/compute/index.ts/01M4ZQ5V42TR3DK38BGR08FVCE-the-channel-owner-is-the-ace.md +++ b/.lane/memory/services/compute/index.ts/01M4ZQ5V42TR3DK38BGR08FVCE-the-channel-owner-is-the-ace.md @@ -4,9 +4,9 @@ anchor: class Compute created: 2026-10-15T12:04:18Z norm: '1' sig: 3b062341dbce15d0 -body_hash: 87ac2186f431e5a9 -raw_hash: f37fce530dddb402 -vouched: 2026-10-16T16:31:35Z +body_hash: f01b9e5fb5980efb +raw_hash: 6e6c9ba386a99925 +vouched: 2026-10-17T00:39:18Z lines: 51-331 --- diff --git a/.lane/memory/services/compute/index.ts/01M4ZR2DGB6JZ0KCPK7K8DM74P-delete-stays-idempotent-each.md b/.lane/memory/services/compute/index.ts/01M4ZR2DGB6JZ0KCPK7K8DM74P-delete-stays-idempotent-each.md index 375c10e2..1a0b6f75 100644 --- a/.lane/memory/services/compute/index.ts/01M4ZR2DGB6JZ0KCPK7K8DM74P-delete-stays-idempotent-each.md +++ b/.lane/memory/services/compute/index.ts/01M4ZR2DGB6JZ0KCPK7K8DM74P-delete-stays-idempotent-each.md @@ -4,9 +4,9 @@ anchor: class Compute created: 2026-10-15T12:19:54Z norm: '1' sig: 3b062341dbce15d0 -body_hash: 87ac2186f431e5a9 -raw_hash: f37fce530dddb402 -vouched: 2026-10-16T16:31:35Z +body_hash: f01b9e5fb5980efb +raw_hash: 6e6c9ba386a99925 +vouched: 2026-10-17T00:39:18Z lines: 51-353 --- diff --git a/.lane/memory/services/compute/index.ts/01M53GWM3DTFPKRXZ527SPHVVY-a-container-restart-loses-th.md b/.lane/memory/services/compute/index.ts/01M53GWM3DTFPKRXZ527SPHVVY-a-container-restart-loses-th.md new file mode 100644 index 00000000..2fffce2c --- /dev/null +++ b/.lane/memory/services/compute/index.ts/01M53GWM3DTFPKRXZ527SPHVVY-a-container-restart-loses-th.md @@ -0,0 +1,12 @@ +--- +id: 01M53GWM3DTFPKRXZ527SPHVVY +anchor: '@file' +created: 2026-10-16T23:31:22Z +norm: '1' +sig: 05a0174fe5ad7d26 +body_hash: ac18c9b485431b2d +raw_hash: 5191ea554b4043b4 +lines: 1-419 +--- + +a container restart loses the runner, so running means the sandbox reports running and the runner answers /health diff --git a/.lane/memory/services/compute/index.ts/01M53MS0DWRV2FSVSYXB1ECNPG-start-replaces-a-running-san.md b/.lane/memory/services/compute/index.ts/01M53MS0DWRV2FSVSYXB1ECNPG-start-replaces-a-running-san.md new file mode 100644 index 00000000..8c5ccd2d --- /dev/null +++ b/.lane/memory/services/compute/index.ts/01M53MS0DWRV2FSVSYXB1ECNPG-start-replaces-a-running-san.md @@ -0,0 +1,12 @@ +--- +id: 01M53MS0DWRV2FSVSYXB1ECNPG +anchor: class Compute +created: 2026-10-17T00:39:18Z +norm: '1' +sig: 3b062341dbce15d0 +body_hash: f01b9e5fb5980efb +raw_hash: 6e6c9ba386a99925 +lines: 58-408 +--- + +start replaces a running sandbox only when its project is gone, so work newer than the checkpoint is never destroyed diff --git a/bun.lock b/bun.lock index 9ba89d22..53d2afba 100644 --- a/bun.lock +++ b/bun.lock @@ -38,7 +38,7 @@ }, "apps/desktop": { "name": "@ace/desktop", - "version": "0.0.46", + "version": "0.0.47", "dependencies": { "@ace/host": "workspace:*", "@githubnext/desktop-tools": "catalog:desktop", @@ -145,8 +145,11 @@ "dependencies": { "@ace/channel": "workspace:*", "@ace/directory": "workspace:*", + "@cloudflare/sandbox": "0.12.9", "@earendil-works/chord": "catalog:pi", "@earendil-works/pi-durable": "catalog:pi", + "@sandbox-sdk/cloudflare": "file:./vendor/sandbox-sdk/cloudflare", + "@sandbox-sdk/core": "file:./vendor/sandbox-sdk/core", }, "devDependencies": { "@cloudflare/workers-types": "catalog:cloudflare", @@ -359,8 +362,12 @@ "@chenglou/pretext": ["@chenglou/pretext@0.0.6", "", {}, "sha512-U10s4tFeyu3oVHfXuNWwZSKqHXefhaigpcBkGj60qQFRJ+yUoQ+ez3cGJelP7BWDAB58HCgjcTSmOcg+77afBQ=="], + "@cloudflare/containers": ["@cloudflare/containers@0.3.7", "", {}, "sha512-DM9dm3FnIBSyiSJ1FLavKwl/lk3oAmTaynCzZQ9pZR0ncRPquSxkxd8Nu2MFILxmDDsPkxKsSNEh9mHHMty4Fw=="], + "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="], + "@cloudflare/sandbox": ["@cloudflare/sandbox@0.12.9", "", { "dependencies": { "@cloudflare/containers": "^0.3.5", "aws4fetch": "^1.0.20", "capnweb": "^0.8.0", "hono": "^4.13.0" }, "peerDependencies": { "@openai/agents": "^0.3.3", "@opencode-ai/sdk": "^1.1.40", "@xterm/xterm": ">=5.0.0" }, "optionalPeers": ["@openai/agents", "@opencode-ai/sdk", "@xterm/xterm"] }, "sha512-JlCQ8adVaHT3TrZO13X6US2LJTyQ4YvoEZpfh5EewRqcxPfMHZNJPB/1dsRdiFqmtzpz+lMxGazSUa2H/g2IKg=="], + "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.2", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ=="], "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260923.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-sFMyHjABspfVxAf9bmcRdYWm3rO5Vrmhb3fLboMB5Lt+Eo+X1Fj4n/3hgMYmXb6P+zW+cjWjZdH1ilE+Ad5kDw=="], @@ -885,6 +892,8 @@ "atomically": ["atomically@1.7.0", "", {}, "sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w=="], + "aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="], + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], @@ -923,6 +932,8 @@ "caniuse-lite": ["caniuse-lite@1.0.30001814", "", {}, "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA=="], + "capnweb": ["capnweb@0.8.0", "", {}, "sha512-BK/TuXUiyfLSKsmjojn70yN7oYG/JJzoURZ3tckjg5Zj2KcygPm0A5jyOlswK7SYB4f0Gh9tt+RZ132b80iLfA=="], + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], @@ -1753,9 +1764,9 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], - "@ace/desktop/@githubnext/desktop-tools": ["@githubnext/desktop-tools@github:githubnext/desktop-tools#c3e08eb", { "bin": { "desktop-tools-build": "dist/build.js", "desktop-tools-app": "dist/app.js", "desktop-tools-mcp": "dist/mcp.js" } }, "githubnext-desktop-tools-c3e08eb", "sha512-GK0to7CODjRm46xgfxjjFsAVQ+yRSfoPNWv4XAQIhP7KCov80+R7iZt6vFhd37ULjtsUVExAmierbMG58ODXHQ=="], + "@ace/compute-pilot/@sandbox-sdk/cloudflare": ["@sandbox-sdk/cloudflare@file:services/compute/vendor/sandbox-sdk/cloudflare", { "dependencies": { "@cloudflare/sandbox": "0.12.9", "@sandbox-sdk/core": "file:../core" } }], - "@ace/host/@githubnext/desktop-tools": ["@githubnext/desktop-tools@github:githubnext/desktop-tools#c3e08eb", { "bin": { "desktop-tools-build": "dist/build.js", "desktop-tools-app": "dist/app.js", "desktop-tools-mcp": "dist/mcp.js" } }, "githubnext-desktop-tools-c3e08eb", "sha512-GK0to7CODjRm46xgfxjjFsAVQ+yRSfoPNWv4XAQIhP7KCov80+R7iZt6vFhd37ULjtsUVExAmierbMG58ODXHQ=="], + "@ace/compute-pilot/@sandbox-sdk/core": ["@sandbox-sdk/core@file:services/compute/vendor/sandbox-sdk/core", {}], "@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1138.0", "", { "dependencies": { "@aws-sdk/core": "^3.978.1", "@aws-sdk/nested-clients": "^3.997.46", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw=="], @@ -1877,6 +1888,8 @@ "yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "@ace/compute-pilot/@sandbox-sdk/cloudflare/@sandbox-sdk/core": ["@sandbox-sdk/core@file:services/compute/vendor/sandbox-sdk/core", {}], + "@dotenvx/dotenvx/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], "@dotenvx/dotenvx/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], diff --git a/services/compute/Dockerfile b/services/compute/Dockerfile index 0c50db0a..67c8066d 100644 --- a/services/compute/Dockerfile +++ b/services/compute/Dockerfile @@ -1,12 +1,8 @@ -FROM oven/bun:1.4.0-debian +# The Sandbox SDK's Cloudflare adapter talks to this image's control plane on port 3000. +FROM docker.io/cloudflare/sandbox:0.12.9 -RUN apt-get update && apt-get install -y --no-install-recommends git curl ca-certificates tar gzip \ - && rm -rf /var/lib/apt/lists/* RUN curl -fsSL https://lane.lukeed.com | LANE_VERSION=v0.1.0 LANE_INSTALL=/usr/local/bin sh WORKDIR /app COPY dist/runner.js ./runner.js RUN mkdir -p /workspace - -EXPOSE 8080 -CMD ["bun", "--no-env-file", "runner.js"] diff --git a/services/compute/index.ts b/services/compute/index.ts index 6292127d..8469b628 100644 --- a/services/compute/index.ts +++ b/services/compute/index.ts @@ -1,3 +1,6 @@ +import type { Sandbox as CloudflareSandbox } from "@cloudflare/sandbox"; +import { cloudflare } from "@sandbox-sdk/cloudflare"; +import { create, type Sandbox } from "@sandbox-sdk/core"; import { DurableObject } from "cloudflare:workers"; import { validateName } from "@ace/channel"; @@ -8,10 +11,12 @@ import { authorized, refused } from "@ace/directory/scope"; import channels from "../channel/src/index"; import type { Env as ChannelEnv } from "../channel/src/object"; +export { Sandbox } from "@cloudflare/sandbox"; export { HostedChannel } from "../channel/src/object"; type Env = ChannelEnv & { COMPUTE: DurableObjectNamespace; + Sandbox: DurableObjectNamespace; GITHUB_TOKEN?: string; }; const SIZES = ["standard-1", "standard-2", "standard-3", "standard-4"] as const; @@ -35,7 +40,7 @@ type Manifest = { }; const CHUNK = 64 * 1024; const MAX = 16 * 1024 * 1024; -const INACTIVITY = 6 * 60 * 60 * 1000; +const INACTIVITY = "6h"; function size(value: unknown): Size { if (!SIZES.includes(value as Size)) throw new Error(`Size must be ${SIZES.join(", ")}`); @@ -52,14 +57,40 @@ async function digest(bytes: Uint8Array): Promise { /** Pilot checkpoints contain filesystem state only. Channel history remains in pi. */ export class Compute extends DurableObject { #queue: Promise = Promise.resolve(); + #sandbox: Promise | undefined; - constructor(ctx: DurableObjectState, env: Env) { - super(ctx, env); - if (ctx.container?.running) { - void ctx.blockConcurrencyWhile(() => ctx.container!.setInactivityTimeout(INACTIVITY)); + /** A container that restarted has lost its runner, so only a live runner counts as running. */ + async #running(): Promise { + return await (await this.#box()).status() === "running" && await this.#healthy(); + } + + async #healthy(): Promise { + try { + await this.#call("/health"); + return true; + } catch { + return false; } } + /** The workspace is a Sandbox SDK sandbox; only this adapter choice names a provider. */ + #box(): Promise { + this.#sandbox ||= create({ + adapter: cloudflare({ + binding: this.env.Sandbox, + id: this.#settings().id, + options: { sleepAfter: INACTIVITY }, + }), + }); + return this.#sandbox; + } + + async #destroy() { + const box = await this.#box(); + this.#sandbox = undefined; + await box.stop(); + } + #settings(): Settings { const settings = this.ctx.storage.kv.get("settings"); if (!settings) throw new Error("Initialize this pilot first"); @@ -87,24 +118,36 @@ export class Compute extends DurableObject { this.ctx.storage.kv.put("frozen", action === "freeze"); } + /** + * Bodies travel as files and the request runs inside the sandbox, so the runner needs no + * exposed port on any provider. + */ async #call(path: string, body?: unknown): Promise { - if (!this.ctx.container?.running) throw new Error("The sandbox is stopped"); - const response = await this.ctx.container.getTcpPort(8080).fetch(`http://container${path}`, { - method: body === undefined ? "GET" : "POST", - body: body === undefined - ? undefined - : body instanceof Uint8Array - ? body - : JSON.stringify(body), - }); + const box = await this.#box(); + let post = ""; + if (body !== undefined) { + await box.files.write( + "/tmp/ace-request", + body instanceof Uint8Array ? body : JSON.stringify(body), + ); + post = "-X POST --data-binary @/tmp/ace-request"; + } + const result = await box.process.shell( + `curl -sS -o /tmp/ace-reply -w '%{http_code}' ${post} http://localhost:8080${path}; rm -f /tmp/ace-request`, + // Marked so the runner's quiescence check ignores this request's own processes. + { timeout: 300_000, env: { ACE_CONTROL: "1" } }, + ); + const status = Number(result.stdout); + if (!status) throw new Error(`${path}: ${result.stderr}`); + const response = new Response(await box.files.read("/tmp/ace-reply"), { status }); if (!response.ok) throw new Error(`${path}: ${await response.text()}`); return response; } - async #boot(selected: Size) { - const container = this.ctx.container!; - container.start({ image: container.images.base, instance: selected, enableInternet: true }); - await container.setInactivityTimeout(INACTIVITY); + /** The container's instance type is fixed in wrangler.jsonc; the SDK has no portable size. */ + async #boot(_selected: Size) { + const box = await this.#box(); + await box.process.spawnShell("bun --no-env-file runner.js", { cwd: "/app" }); for (let attempt = 0; attempt < 120; attempt++) { try { await this.#call("/health"); @@ -186,13 +229,22 @@ export class Compute extends DurableObject { async #start(selected: Size) { const settings = this.#settings(); - if (this.ctx.container!.running) { - if (this.ctx.storage.kv.get("frozen") && !this.ctx.storage.kv.get("ready")) { - await this.ctx.container!.destroy(); - } else { + const incomplete = this.ctx.storage.kv.get("frozen") && !this.ctx.storage.kv.get("ready"); + const box = await this.#box(); + if (!incomplete && await box.status() === "running") { + if (await this.#healthy()) { throw new Error("Sandbox is already running; use resize to change its size"); } + // A restarted container comes back without the project; a runner that merely exited + // leaves work newer than the checkpoint, so it restarts in place. + if ((await box.process.shell("test -d /workspace/project")).ok) { + await this.#channel("freeze"); + await this.#boot(selected); + return this.#connect(); + } } + // Clears an incomplete start or a container that restarted without its runner. + await this.#destroy(); const manifest = this.ctx.storage.kv.get("checkpoint"); if (settings.started && !manifest) { throw new Error( @@ -207,7 +259,7 @@ export class Compute extends DurableObject { await this.#connect(false); } catch (error) { // No channel input can reach this incomplete replacement while it is frozen. - await this.ctx.container!.destroy(); + await this.#destroy(); throw error; } this.ctx.storage.kv.put("settings", { ...settings, size: selected, started: true }); @@ -220,7 +272,7 @@ export class Compute extends DurableObject { * previous attempt having done it, so a retry after a partial failure finishes the job. */ async #delete(id: string) { - if (this.ctx.container!.running) await this.ctx.container!.destroy(); + await this.#destroy(); const channel = this.env.CHANNEL.getByName(id); // A frozen channel refuses deletion; a thaw refused because it is active or gone is harmless. await channel.fetch(`https://channel/channels/${id}/transfer`, { @@ -241,12 +293,13 @@ export class Compute extends DurableObject { } const action = named || "status"; if (action === "status" && request.method === "GET") { + const running = await this.#running(); return Response.json({ - running: this.ctx.container!.running, + running, settings: this.ctx.storage.kv.get("settings"), checkpoint: this.ctx.storage.kv.get("checkpoint"), frozen: !!this.ctx.storage.kv.get("frozen"), - runner: this.ctx.container!.running ? await (await this.#call("/status")).json() : null, + runner: running ? await (await this.#call("/status")).json() : null, }); } if (request.method !== "POST") return new Response("Use POST", { status: 405 }); @@ -301,7 +354,7 @@ export class Compute extends DurableObject { } if ( ["exec", "checkpoint", "stop", "resize", "resume"].includes(action) - && !this.ctx.container!.running + && !(await this.#running()) ) { throw new Error("The sandbox is stopped; use start to restore it"); } @@ -331,14 +384,14 @@ export class Compute extends DurableObject { } case "stop": { const checkpoint = await this.#save(); - await this.ctx.container!.destroy(); + await this.#destroy(); await this.#channel("thaw"); return Response.json({ stopped: true, checkpoint }); } case "resize": { const selected = size(body.size); const checkpoint = await this.#save(); - await this.ctx.container!.destroy(); + await this.#destroy(); await this.#start(selected); return Response.json({ size: selected, checkpoint }); } diff --git a/services/compute/package.json b/services/compute/package.json index 747674fc..3cc488ed 100644 --- a/services/compute/package.json +++ b/services/compute/package.json @@ -11,8 +11,11 @@ "dependencies": { "@ace/channel": "workspace:*", "@ace/directory": "workspace:*", + "@cloudflare/sandbox": "0.12.9", "@earendil-works/chord": "catalog:pi", - "@earendil-works/pi-durable": "catalog:pi" + "@earendil-works/pi-durable": "catalog:pi", + "@sandbox-sdk/cloudflare": "file:./vendor/sandbox-sdk/cloudflare", + "@sandbox-sdk/core": "file:./vendor/sandbox-sdk/core" }, "devDependencies": { "@cloudflare/workers-types": "catalog:cloudflare", diff --git a/services/compute/quiescence.ts b/services/compute/quiescence.ts index c0d8b7ed..dcda03cb 100644 --- a/services/compute/quiescence.ts +++ b/services/compute/quiescence.ts @@ -1,5 +1,8 @@ import { readdir, readFile } from "node:fs/promises"; +/** Set by the controller on the commands it runs to reach the runner. */ +export const CONTROL = "ACE_CONTROL=1"; + let baseline: Map | undefined; // A dedicated container records its platform processes before accepting work. Start times @@ -44,7 +47,11 @@ async function processes(): Promise> { if (fields.length < 20 || !/^\d+$/.test(fields[19])) { throw new Error(`Cannot inspect process ${pid} before checkpointing`); } - if (fields[0] !== "Z") found.set(pid, fields[19]); + if (fields[0] === "Z") return; + // The controller reaches the runner through processes it marks, which are not workspace work. + const environ = await readFile(`/proc/${pid}/environ`, "utf8").catch(() => ""); + if (environ.split("\0").includes(CONTROL)) return; + found.set(pid, fields[19]); }), ); return found; diff --git a/services/compute/vendor/sandbox-sdk/LICENSE b/services/compute/vendor/sandbox-sdk/LICENSE new file mode 100644 index 00000000..96758fca --- /dev/null +++ b/services/compute/vendor/sandbox-sdk/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sandbox SDK contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/services/compute/vendor/sandbox-sdk/README.md b/services/compute/vendor/sandbox-sdk/README.md new file mode 100644 index 00000000..1edbea7f --- /dev/null +++ b/services/compute/vendor/sandbox-sdk/README.md @@ -0,0 +1,8 @@ +# Vendored Sandbox SDK + +Built `dist` output of `@sandbox-sdk/core` and `@sandbox-sdk/cloudflare` from +[githubnext/sandbox](https://github.com/githubnext/sandbox), a fork of +[dancer/sandbox](https://github.com/dancer/sandbox), at +2a7f1ff68bec23ed28beab55dbdefe68c31c7203 on its `ace` branch (MIT, see LICENSE). Change the SDK +in the fork, then rebuild and copy both `dist` directories here. The packages are not published +to npm yet; replace this directory with registry versions once they are. diff --git a/services/compute/vendor/sandbox-sdk/cloudflare/dist/bridge-client.d.ts b/services/compute/vendor/sandbox-sdk/cloudflare/dist/bridge-client.d.ts new file mode 100644 index 00000000..6810eb1a --- /dev/null +++ b/services/compute/vendor/sandbox-sdk/cloudflare/dist/bridge-client.d.ts @@ -0,0 +1,201 @@ +import type { Capabilities, Input } from "@sandbox-sdk/core"; +import type { CloudflareTunnelNames } from "./tunnels.js"; +/** minimal Fetch API contract accepted by Cloudflare bridge requests */ +export type CloudflareBridgeFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise; +/** generic JSON object returned by bridge utility routes */ +export type CloudflareBridgeJson = Readonly>; +/** + * public tunnel returned by the Cloudflare bridge + * + * quick tunnels omit `name`, while named tunnels always include the requested DNS label + */ +export type CloudflareBridgeTunnel = Readonly<{ + /** bridge tunnel id */ + id: string; + /** port exposed inside the sandbox */ + port: number; + /** public HTTPS tunnel URL */ + url: string; + /** public tunnel hostname */ + hostname: string; + /** ISO timestamp when the bridge created the tunnel */ + createdAt: string; + /** named tunnel label used for a stable Cloudflare hostname */ + name: string; +}> | Readonly<{ + /** bridge tunnel id */ + id: string; + /** port exposed inside the sandbox */ + port: number; + /** public HTTPS tunnel URL */ + url: string; + /** public tunnel hostname */ + hostname: string; + /** ISO timestamp when the bridge created the tunnel */ + createdAt: string; + /** quick tunnels do not have a named DNS label */ + name?: never; +}>; +/** options for creating a Cloudflare bridge tunnel */ +export type CloudflareBridgeTunnelOptions = Readonly<{ + /** optional DNS label for a stable named tunnel */ + name?: string; +}>; +/** + * advanced bridge operations exposed as `sandbox.raw` + * + * this is a bridge-specific contract, not the normalized snapshot or process api + */ +export type CloudflareBridgeRaw = Readonly<{ + /** create a bridge-managed sandbox and return its id */ + create(): Promise>; + /** permanently delete a bridge-managed sandbox */ + delete(id: string): Promise; + /** fetch implementation used for bridge requests */ + fetch: CloudflareBridgeFetch; + /** query the bridge health endpoint */ + health(): Promise; + /** restore a bridge workspace archive into a sandbox */ + hydrate(id: string, archive: Input): Promise; + /** mount object storage into a bridge sandbox */ + mount(id: string, input: CloudflareBridgeMount): Promise; + /** read the bridge OpenAPI document */ + openapi(): Promise; + /** bridge prewarm pool controls */ + pool: Readonly<{ + /** prewarm bridge sandbox capacity */ + prime(): Promise; + /** shut down bridge prewarmed sandboxes */ + shutdownPrewarmed(): Promise; + /** return bridge prewarm pool statistics */ + stats(): Promise; + }>; + /** export the bridge workspace as a tar archive */ + persist(id: string, options?: CloudflareBridgePersist): Promise; + /** return WebSocket connection details for an interactive terminal */ + pty(id: string, options?: CloudflareBridgePty): CloudflareBridgePtyConnection; + /** make an authenticated request to an arbitrary bridge route */ + request(path: string, init?: RequestInit): Promise; + /** return whether a bridge sandbox is still running */ + running(id: string): Promise; + /** bridge tunnel controls */ + tunnels: Readonly<{ + /** + * create or return a public HTTPS tunnel for an already-listening sandbox port + * + * ports must be integers from 1024 through 65535, excluding 3000. pass `name` only when the bridge Worker is configured for named tunnels + */ + get(id: string, port: number, options?: CloudflareBridgeTunnelOptions): Promise; + /** delete a tunnel and its tracked named-tunnel resources when present */ + destroy(id: string, port: number): Promise; + }>; + /** bridge execution session controls */ + session: Readonly<{ + /** create an execution session scoped to one sandbox */ + create(id: string, options?: CloudflareBridgeSession): Promise>; + /** delete one execution session */ + delete(id: string, session: string): Promise; + }>; + /** detach a mounted object-storage bucket from a sandbox */ + unmount(id: string, mountPath: string): Promise; + /** configured bridge base URL */ + url: string; +}>; +/** Cloudflare Sandbox bridge adapter configuration */ +export type CloudflareBridge = Readonly<{ + /** + * deployed bridge base URL + * + * falls back to `SANDBOX_API_URL` + * + * remote bridges must use https, with http supported only for local loopback development + */ + url?: string; + /** + * bearer token for the bridge + * + * falls back to `SANDBOX_API_KEY` + */ + token?: string; + /** + * default sandbox working directory below `/workspace` + * + * relative values resolve below `/workspace`, custom directories are created, and paths outside it are rejected before bridge work + * + * @default "/workspace" + */ + cwd?: string; + /** + * default environment variables for bridge execution sessions + * + * create input values override these defaults. values are copied into the bridge sandbox, so pass only values the sandbox may use. `SANDBOX_API_KEY` is rejected because it authenticates the bridge + */ + env?: Readonly>; + /** custom fetch implementation for tests or non-standard runtimes */ + fetch?: CloudflareBridgeFetch; + /** stable sandbox id used when create input omits id */ + id?: string; + /** + * optional DNS label for normalized named tunnel previews + * + * use it for one named tunnel or as the fallback for one port not listed in `tunnels`. named tunnels require the bridge Worker to have Cloudflare account and zone credentials + */ + tunnel?: string; + /** named tunnel labels keyed by port. entries override `tunnel` and labels must be unique within one sandbox */ + tunnels?: CloudflareTunnelNames; +}>; +/** connection details for the bridge PTY WebSocket route */ +export type CloudflareBridgePtyConnection = Readonly<{ + /** headers to pass when the WebSocket client supports custom headers */ + headers: Readonly>; + /** WebSocket URL for `/v1/sandbox/:id/pty` */ + url: string; +}>; +/** options for `sandbox.raw.persist()` */ +export type CloudflareBridgePersist = Readonly<{ + /** workspace-relative paths to exclude from the tar archive */ + excludes?: readonly string[]; +}>; +/** options for creating a bridge execution session */ +export type CloudflareBridgeSession = Readonly<{ + /** custom session id */ + id?: string; + /** initial working directory */ + cwd?: string; + /** session-scoped environment variables */ + env?: Readonly>; +}>; +/** options for mounting an object-storage bucket through the bridge */ +export type CloudflareBridgeMount = Readonly<{ + /** bucket name or Worker R2 binding name */ + bucket: string; + /** absolute mount path inside the sandbox */ + mountPath: string; + /** bridge mount options forwarded to Cloudflare */ + options?: Readonly>; +}>; +/** options for the raw bridge PTY WebSocket route */ +export type CloudflareBridgePty = Readonly<{ + /** terminal width in columns */ + cols?: number; + /** terminal height in rows */ + rows?: number; + /** bridge session id used to scope the terminal */ + session?: string; + /** shell binary to run inside the terminal */ + shell?: string; +}>; +export declare const provider = "cloudflare"; +export declare const bridgeCapabilities: Capabilities; +export declare const absolute: (cwd: string, path?: string) => string; +export declare const route: (path: string) => string; +export declare const fail: (response: Response, feature: string) => Promise; +export declare const bridgeBody: (input: Uint8Array | string) => ArrayBuffer | string; +export declare const bridge: (options: CloudflareBridge) => CloudflareBridgeRaw; +//# sourceMappingURL=bridge-client.d.ts.map \ No newline at end of file diff --git a/services/compute/vendor/sandbox-sdk/cloudflare/dist/bridge-client.js b/services/compute/vendor/sandbox-sdk/cloudflare/dist/bridge-client.js new file mode 100644 index 00000000..61e1a57e --- /dev/null +++ b/services/compute/vendor/sandbox-sdk/cloudflare/dist/bridge-client.js @@ -0,0 +1,282 @@ +import { bytes, sandboxError } from "@sandbox-sdk/core"; +import { tunnelName, tunnelPort, validateTunnels } from "./tunnels.js"; +export const provider = "cloudflare"; +export const bridgeCapabilities = { + environment: "separate", + fileStreaming: "native", + files: true, + ports: "dynamic", + processExec: true, + processSpawn: false, + raw: { + backup: true, + buckets: "configured", + lifecycle: "dynamic", + pty: true, + sessions: true, + tunnels: "dynamic", + }, + snapshotCreate: false, + snapshotDelete: false, + snapshotRestore: false, + streaming: "separate", +}; +export const absolute = (cwd, path = cwd) => { + const value = path.startsWith("/") ? path : `${cwd}/${path}`; + const output = new URL(value, "file:///").pathname.replace(/\/+$/u, "") || "/"; + if (output !== "/workspace" && !output.startsWith("/workspace/")) { + throw sandboxError(provider, "Cloudflare bridge paths must stay under /workspace", "path_escape"); + } + return output; +}; +export const route = (path) => path.replace(/^\/+/u, "").split("/").map(encodeURIComponent).join("/"); +export const fail = async (response, feature) => { + const body = await response.text(); + let error; + try { + error = JSON.parse(body); + } + catch { + error = { error: body }; + } + throw sandboxError(provider, `${feature} failed: ${error.error || response.statusText}`, response.status === 404 ? "not_found" : "provider", error); +}; +const env = (name) => globalThis.process?.env?.[name]?.trim() || undefined; +const trim = (value) => value.replace(/\/+$/u, ""); +const loopback = (url) => url.hostname === "localhost" || + url.hostname === "127.0.0.1" || + url.hostname === "[::1]"; +export const bridgeBody = (input) => typeof input === "string" ? input : Uint8Array.from(input).buffer; +const bridgeUrl = (value) => { + let url; + try { + url = new URL(trim(value)); + } + catch { + throw sandboxError(provider, "Cloudflare bridge URL must be a valid http or https URL", "configuration"); + } + if (url.username || url.password || url.search || url.hash) { + throw sandboxError(provider, "Cloudflare bridge URL must not include credentials, a query, or a fragment", "configuration"); + } + if (url.protocol === "https:" || + (url.protocol === "http:" && loopback(url))) { + return trim(url.toString()); + } + throw sandboxError(provider, "Cloudflare bridge URL must use HTTPS outside local loopback development", "configuration"); +}; +const ptyNumber = (name, value) => { + if (value === undefined) { + return; + } + if (Number.isInteger(value) && value > 0) { + return String(value); + } + throw sandboxError(provider, `Cloudflare bridge pty ${name} must be a positive integer`, "configuration"); +}; +const validate = (options) => { + const url = options.url?.trim() || env("SANDBOX_API_URL"); + if (url === undefined) { + throw sandboxError(provider, "Cloudflare bridge URL missing. Pass url to cloudflareBridge() or set SANDBOX_API_URL.", "configuration"); + } + const token = options.token?.trim() || env("SANDBOX_API_KEY"); + validateTunnels(options.tunnel, options.tunnels); + const value = bridgeUrl(url); + return token === undefined ? { url: value } : { token, url: value }; +}; +const pty = (url, token, id, options = {}) => { + const target = new URL(`${url}/v1/sandbox/${encodeURIComponent(id)}/pty`); + target.protocol = target.protocol === "https:" ? "wss:" : "ws:"; + const cols = ptyNumber("cols", options.cols); + if (cols !== undefined) { + target.searchParams.set("cols", cols); + } + const rows = ptyNumber("rows", options.rows); + if (rows !== undefined) { + target.searchParams.set("rows", rows); + } + if (options.shell !== undefined) { + target.searchParams.set("shell", options.shell); + } + if (options.session !== undefined) { + target.searchParams.set("session", options.session); + } + return { + headers: token === undefined ? {} : { Authorization: `Bearer ${token}` }, + url: target.toString(), + }; +}; +const parseJson = async (response) => { + try { + return (await response.json()); + } + catch (error) { + throw sandboxError(provider, "Cloudflare bridge returned invalid JSON", "provider", error); + } +}; +const bridgeRequest = (url, token, bridgeFetch, path, init = {}) => { + const headers = new Headers(init.headers); + if (token !== undefined) { + headers.set("authorization", `Bearer ${token}`); + } + return bridgeFetch(`${url}${path}`, { + ...init, + headers, + redirect: "error", + }); +}; +export const bridge = (options) => { + const { token, url } = validate(options); + const bridgeFetch = options.fetch ?? fetch; + const request = (path, init) => bridgeRequest(url, token, bridgeFetch, path, init); + return { + create: async () => { + const response = await request("/v1/sandbox", { method: "POST" }); + if (!response.ok) { + await fail(response, "bridge create"); + } + return parseJson(response); + }, + delete: async (id) => { + const response = await request(`/v1/sandbox/${encodeURIComponent(id)}`, { + method: "DELETE", + }); + if (!response.ok && response.status !== 404) { + await fail(response, "bridge delete"); + } + }, + fetch: bridgeFetch, + health: async () => { + const response = await request("/health"); + if (!response.ok) { + await fail(response, "bridge health"); + } + return parseJson(response); + }, + hydrate: async (id, archive) => { + const input = await bytes(archive); + const response = await request(`/v1/sandbox/${encodeURIComponent(id)}/hydrate`, { + body: bridgeBody(input), + method: "POST", + }); + if (!response.ok) { + await fail(response, "bridge hydrate"); + } + }, + mount: async (id, input) => { + const response = await request(`/v1/sandbox/${encodeURIComponent(id)}/mount`, { + body: JSON.stringify(input), + headers: { "content-type": "application/json" }, + method: "POST", + }); + if (!response.ok) { + await fail(response, "bridge mount"); + } + }, + openapi: async () => { + const response = await request("/v1/openapi.json"); + if (!response.ok) { + await fail(response, "bridge openapi"); + } + return parseJson(response); + }, + persist: async (id, input) => { + const search = new URLSearchParams(); + if (input?.excludes !== undefined) { + search.set("excludes", input.excludes.join(",")); + } + const suffix = search.size > 0 ? `?${search}` : ""; + const response = await request(`/v1/sandbox/${encodeURIComponent(id)}/persist${suffix}`, { method: "POST" }); + if (!response.ok) { + await fail(response, "bridge persist"); + } + return new Uint8Array(await response.arrayBuffer()); + }, + pool: { + prime: async () => { + const response = await request("/v1/pool/prime", { method: "POST" }); + if (!response.ok) { + await fail(response, "bridge pool prime"); + } + }, + shutdownPrewarmed: async () => { + const response = await request("/v1/pool/shutdown-prewarmed", { + method: "POST", + }); + if (!response.ok) { + await fail(response, "bridge pool shutdown"); + } + }, + stats: async () => { + const response = await request("/v1/pool/stats"); + if (!response.ok) { + await fail(response, "bridge pool stats"); + } + return parseJson(response); + }, + }, + pty: (id, input) => pty(url, token, id, input), + request, + running: async (id) => { + const response = await request(`/v1/sandbox/${encodeURIComponent(id)}/running`); + if (!response.ok) { + await fail(response, "bridge running"); + } + const payload = await parseJson(response); + return payload.running; + }, + session: { + create: async (id, input = {}) => { + const response = await request(`/v1/sandbox/${encodeURIComponent(id)}/session`, { + body: JSON.stringify(input), + headers: { "content-type": "application/json" }, + method: "POST", + }); + if (!response.ok) { + await fail(response, "bridge session create"); + } + return parseJson(response); + }, + delete: async (id, session) => { + const response = await request(`/v1/sandbox/${encodeURIComponent(id)}/session/${encodeURIComponent(session)}`, { method: "DELETE" }); + if (!response.ok && response.status !== 404) { + await fail(response, "bridge session delete"); + } + }, + }, + tunnels: { + destroy: async (id, value) => { + const target = tunnelPort(value); + const response = await request(`/v1/sandbox/${encodeURIComponent(id)}/tunnel/${target}`, { method: "DELETE" }); + if (!response.ok && response.status !== 404) { + await fail(response, "bridge tunnel delete"); + } + }, + get: async (id, value, input = {}) => { + const target = tunnelPort(value); + const name = input.name === undefined ? undefined : tunnelName(input.name); + const response = await request(`/v1/sandbox/${encodeURIComponent(id)}/tunnel/${target}`, name === undefined + ? { method: "POST" } + : { + body: JSON.stringify({ name }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + if (!response.ok) { + await fail(response, "bridge tunnel create"); + } + return parseJson(response); + }, + }, + unmount: async (id, mountPath) => { + const response = await request(`/v1/sandbox/${encodeURIComponent(id)}/unmount`, { + body: JSON.stringify({ mountPath }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + if (!response.ok) { + await fail(response, "bridge unmount"); + } + }, + url, + }; +}; diff --git a/services/compute/vendor/sandbox-sdk/cloudflare/dist/bridge-exec.d.ts b/services/compute/vendor/sandbox-sdk/cloudflare/dist/bridge-exec.d.ts new file mode 100644 index 00000000..8e05a35e --- /dev/null +++ b/services/compute/vendor/sandbox-sdk/cloudflare/dist/bridge-exec.d.ts @@ -0,0 +1,10 @@ +import type { Entry, Exec, Result } from "@sandbox-sdk/core"; +import type { CloudflareBridgeRaw } from "./bridge-client.js"; +export declare const rejectUnsupported: (feature: string) => Promise; +export declare const execPayload: (cwd: string, executable: string, args?: readonly string[], options?: Exec) => Record; +export declare const discard: () => WritableStreamDefaultWriter; +export declare const parseExec: (response: Response, writer: WritableStreamDefaultWriter) => Promise; +export declare const run: (raw: CloudflareBridgeRaw, id: string, session: string | undefined, cwd: string, executable: string, args?: readonly string[], options?: Exec) => Promise; +export declare const mustOk: (value: Result, feature: string) => void; +export declare const list: (raw: CloudflareBridgeRaw, id: string, session: string | undefined, cwd: string, path?: string) => Promise; +//# sourceMappingURL=bridge-exec.d.ts.map \ No newline at end of file diff --git a/services/compute/vendor/sandbox-sdk/cloudflare/dist/bridge-exec.js b/services/compute/vendor/sandbox-sdk/cloudflare/dist/bridge-exec.js new file mode 100644 index 00000000..c7cb8cb6 --- /dev/null +++ b/services/compute/vendor/sandbox-sdk/cloudflare/dist/bridge-exec.js @@ -0,0 +1,178 @@ +import { duration, sandboxError, unsupported } from "@sandbox-sdk/core"; +import { absolute, fail, provider } from "./bridge-client.js"; +export const rejectUnsupported = (feature) => { + try { + unsupported(provider, feature); + } + catch (error) { + return Promise.reject(error); + } +}; +export const execPayload = (cwd, executable, args = [], options = {}) => ({ + argv: options.env === undefined + ? [executable, ...args] + : [ + "env", + ...Object.entries(options.env).map(([key, value]) => `${key}=${value}`), + executable, + ...args, + ], + cwd: options.cwd === undefined ? cwd : absolute(cwd, options.cwd), + ...(options.timeout === undefined + ? {} + : { timeout_ms: duration(options.timeout, provider) }), +}); +const decode = (value) => { + const binary = atob(value); + const bytes = Uint8Array.from(binary, (char) => char.codePointAt(0) ?? 0); + return new TextDecoder().decode(bytes); +}; +const writeEvent = async (writer, value) => { + if (value.length > 0) { + await writer.write(new TextEncoder().encode(value)); + } +}; +const parseEvent = (block) => { + let event = "message"; + const data = []; + for (const line of block.split("\n")) { + if (line.startsWith("event:")) { + event = line.slice(6).trim(); + } + if (line.startsWith("data:")) { + data.push(line.slice(5).trimStart()); + } + } + if (data.length === 0) { + return; + } + return { data: data.join("\n"), event }; +}; +export const discard = () => new WritableStream().getWriter(); +const request = (raw, id, session, cwd, executable, args, options) => { + const headers = new Headers({ "content-type": "application/json" }); + if (session !== undefined) { + headers.set("session-id", session); + } + return raw.request(`/v1/sandbox/${encodeURIComponent(id)}/exec`, { + body: JSON.stringify(execPayload(cwd, executable, args, options)), + headers, + method: "POST", + ...(options.signal === undefined ? {} : { signal: options.signal }), + }); +}; +export const parseExec = async (response, writer) => { + if (!response.ok) { + await fail(response, "bridge exec"); + } + if (response.body === null) { + throw sandboxError(provider, "Cloudflare bridge exec returned no stream", "provider"); + } + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let stdout = ""; + let stderr = ""; + let code = 0; + const handle = async (block) => { + const event = parseEvent(block); + if (event === undefined) { + return; + } + if (event.event === "stdout") { + const value = decode(event.data); + stdout += value; + await writeEvent(writer, value); + } + if (event.event === "stderr") { + const value = decode(event.data); + stderr += value; + await writeEvent(writer, value); + } + if (event.event === "exit") { + code = Number(JSON.parse(event.data).exit_code ?? 0); + } + if (event.event === "error") { + const error = JSON.parse(event.data); + throw sandboxError(provider, `Cloudflare bridge exec failed: ${error.error || "unknown"}`, "process", error); + } + }; + while (true) { + const next = await reader.read(); + if (next.done) { + break; + } + buffer += decoder.decode(next.value, { stream: true }); + const blocks = buffer.split(/\r?\n\r?\n/u); + buffer = blocks.pop() ?? ""; + for (const block of blocks) { + await handle(block); + } + } + buffer += decoder.decode(); + if (buffer.trim().length > 0) { + await handle(buffer); + } + return { code, ok: code === 0, stderr, stdout }; +}; +export const run = async (raw, id, session, cwd, executable, args = [], options = {}) => { + const writer = discard(); + try { + const result = await parseExec(await request(raw, id, session, cwd, executable, args, options), writer); + return result; + } + finally { + await writer.close(); + } +}; +const listScript = ` +const fs = require("node:fs"); +const path = require("node:path"); +const root = process.argv[1]; +const entries = fs.readdirSync(root, { withFileTypes: true }).map((entry) => { + const absolute = path.join(root, entry.name); + const stats = fs.statSync(absolute); + return { + path: absolute, + kind: entry.isDirectory() ? "directory" : "file", + size: stats.size, + modified: stats.mtime.toISOString() + }; +}); +console.log(JSON.stringify(entries)); +`; +export const mustOk = (value, feature) => { + if (!value.ok) { + throw sandboxError(provider, `Cloudflare bridge ${feature} failed`, "provider", value); + } +}; +export const list = async (raw, id, session, cwd, path) => { + const target = absolute(cwd, path); + const writer = discard(); + try { + const value = await parseExec(await raw.request(`/v1/sandbox/${encodeURIComponent(id)}/exec`, { + body: JSON.stringify(execPayload(cwd, "node", ["-e", listScript, target])), + headers: { + ...(session === undefined ? {} : { "session-id": session }), + "content-type": "application/json", + }, + method: "POST", + }), writer); + mustOk(value, "list"); + return JSON.parse(value.stdout).map((entry) => entry.modified === undefined + ? { + kind: entry.kind, + path: entry.path, + ...(entry.size === undefined ? {} : { size: entry.size }), + } + : { + kind: entry.kind, + modified: new Date(entry.modified), + path: entry.path, + ...(entry.size === undefined ? {} : { size: entry.size }), + }); + } + finally { + await writer.close(); + } +}; diff --git a/services/compute/vendor/sandbox-sdk/cloudflare/dist/bridge.d.ts b/services/compute/vendor/sandbox-sdk/cloudflare/dist/bridge.d.ts new file mode 100644 index 00000000..a0b957e2 --- /dev/null +++ b/services/compute/vendor/sandbox-sdk/cloudflare/dist/bridge.d.ts @@ -0,0 +1,10 @@ +import type { Adapter } from "@sandbox-sdk/core"; +import type { CloudflareBridge, CloudflareBridgeRaw } from "./bridge-client.js"; +export type { CloudflareBridge, CloudflareBridgeFetch, CloudflareBridgeJson, CloudflareBridgeRaw, CloudflareBridgeMount, CloudflareBridgePersist, CloudflareBridgePty, CloudflareBridgePtyConnection, CloudflareBridgeSession, CloudflareBridgeTunnel, CloudflareBridgeTunnelOptions, } from "./bridge-client.js"; +/** + * create a Cloudflare Sandbox adapter that talks to the official HTTP bridge + * + * `ports.expose()` creates an ephemeral HTTPS quick tunnel by default. configure `tunnel` for one named port or `tunnels` for per-port labels when the bridge Worker has the required Cloudflare credentials + */ +export declare const cloudflareBridge: (options?: CloudflareBridge) => Adapter; +//# sourceMappingURL=bridge.d.ts.map \ No newline at end of file diff --git a/services/compute/vendor/sandbox-sdk/cloudflare/dist/bridge.js b/services/compute/vendor/sandbox-sdk/cloudflare/dist/bridge.js new file mode 100644 index 00000000..411e9b44 --- /dev/null +++ b/services/compute/vendor/sandbox-sdk/cloudflare/dist/bridge.js @@ -0,0 +1,141 @@ +import { bytes, fromSandboxRuntime, sandboxError } from "@sandbox-sdk/core"; +import { absolute, bridge, bridgeBody, bridgeCapabilities, fail, provider, route, } from "./bridge-client.js"; +import { list, mustOk, rejectUnsupported, run } from "./bridge-exec.js"; +import { namedTunnel, tunnelPort } from "./tunnels.js"; +const secrets = ["SANDBOX_API_KEY"]; +const assertSandboxEnv = (value) => { + const leaked = secrets.filter((name) => value[name] !== undefined); + if (leaked.length === 0) { + return; + } + throw sandboxError(provider, `Cloudflare bridge credentials cannot be forwarded into sandbox env: ${leaked.join(", ")}`, "configuration"); +}; +const execJson = async (raw, id, session, cwd, executable, args) => { + const result = await run(raw, id, session, cwd, executable, args); + mustOk(result, executable); +}; +const workspace = (cwd) => absolute("/workspace", cwd); +/** + * create a Cloudflare Sandbox adapter that talks to the official HTTP bridge + * + * `ports.expose()` creates an ephemeral HTTPS quick tunnel by default. configure `tunnel` for one named port or `tunnels` for per-port labels when the bridge Worker has the required Cloudflare credentials + */ +export const cloudflareBridge = (options = {}) => ({ + capabilities: bridgeCapabilities, + create: async (input = {}) => { + const environment = { ...options.env, ...input.env }; + assertSandboxEnv(environment); + const raw = bridge(options); + const cwd = workspace(input.cwd ?? options.cwd ?? "/workspace"); + let id = input.id ?? options.id; + const owned = id === undefined; + if (id === undefined) { + const { id: created } = await raw.create(); + id = created; + } + let session; + try { + if (cwd !== "/workspace") { + await execJson(raw, id, undefined, "/workspace", "mkdir", ["-p", cwd]); + } + const createdSession = Object.keys(environment).length === 0 + ? undefined + : await raw.session.create(id, { + cwd, + env: environment, + }); + session = createdSession?.id; + } + catch (error) { + if (owned) { + await raw.delete(id).catch(() => null); + } + throw error; + } + const headers = session === undefined ? {} : { "session-id": session }; + const labels = new Map(); + return fromSandboxRuntime({ + capabilities: bridgeCapabilities, + cwd, + files: { + exists: async (path) => { + const response = await raw.request(`/v1/sandbox/${encodeURIComponent(id)}/file/${route(absolute(cwd, path))}`, { headers }); + if (response.status === 404) { + return false; + } + if (!response.ok) { + await fail(response, "bridge file exists"); + } + await response.body?.cancel(); + return true; + }, + list: (path) => list(raw, id, session, cwd, path), + mkdir: (path) => execJson(raw, id, session, cwd, "mkdir", ["-p", absolute(cwd, path)]), + read: async (path) => { + const response = await raw.request(`/v1/sandbox/${encodeURIComponent(id)}/file/${route(absolute(cwd, path))}`, { headers }); + if (!response.ok) { + await fail(response, "bridge file read"); + } + if (response.body === null) { + throw sandboxError(provider, "Cloudflare bridge file read returned no body", "provider"); + } + return response.body; + }, + remove: async (path) => { + const target = absolute(cwd, path); + if (target === "/workspace") { + throw sandboxError(provider, "Cloudflare bridge refuses to remove /workspace", "path_escape"); + } + await execJson(raw, id, session, cwd, "rm", ["-rf", target]); + }, + write: async (path, value) => { + const inputBody = await bytes(value); + const response = await raw.request(`/v1/sandbox/${encodeURIComponent(id)}/file/${route(absolute(cwd, path))}`, { + body: bridgeBody(inputBody), + headers, + method: "PUT", + }); + if (!response.ok) { + await fail(response, "bridge file write"); + } + }, + }, + id, + ports: { + expose: async (value, portOptions = {}) => { + if ("host" in portOptions || + portOptions.token !== undefined || + (portOptions.protocol !== undefined && + portOptions.protocol !== "https")) { + throw sandboxError(provider, "Cloudflare tunnels only support the default HTTPS URL through ports.expose. Use sandbox.raw for provider-specific networking.", "unsupported"); + } + const target = tunnelPort(value); + const tunnel = await raw.tunnels.get(id, target, namedTunnel(options.tunnel, options.tunnels, target, labels)); + return { port: tunnel.port, url: tunnel.url }; + }, + }, + process: { + exec: (executable, args, execOptions) => run(raw, id, session, cwd, executable, args, execOptions), + shell: (line, execOptions) => run(raw, id, session, cwd, "sh", ["-lc", line], execOptions), + }, + provider, + raw, + snapshots: { + create: () => rejectUnsupported("bridge snapshots.create"), + delete: () => rejectUnsupported("bridge snapshots.delete"), + restore: () => rejectUnsupported("bridge snapshots.restore"), + }, + stop: async () => { + try { + if (session !== undefined) { + await raw.session.delete(id, session); + } + } + finally { + await raw.delete(id); + } + }, + }); + }, + provider, +}); diff --git a/services/compute/vendor/sandbox-sdk/cloudflare/dist/index.d.ts b/services/compute/vendor/sandbox-sdk/cloudflare/dist/index.d.ts new file mode 100644 index 00000000..0bcb0a7f --- /dev/null +++ b/services/compute/vendor/sandbox-sdk/cloudflare/dist/index.d.ts @@ -0,0 +1,83 @@ +import type { BackupOptions, ListFilesOptions, Sandbox as CloudflareSandbox, SandboxOptions } from "@cloudflare/sandbox"; +import type { Adapter } from "@sandbox-sdk/core"; +import type { CloudflareTunnelNames } from "./tunnels.js"; +export { cloudflareBridge } from "./bridge.js"; +export type { CloudflareBridge, CloudflareBridgeFetch, CloudflareBridgeJson, CloudflareBridgeMount, CloudflareBridgePersist, CloudflareBridgePty, CloudflareBridgePtyConnection, CloudflareBridgeRaw, CloudflareBridgeSession, CloudflareBridgeTunnel, CloudflareBridgeTunnelOptions, } from "./bridge.js"; +export type { Sandbox as CloudflareSandbox } from "@cloudflare/sandbox"; +export type { CloudflareTunnelNames } from "./tunnels.js"; +type Native = CloudflareSandbox; +/** native Cloudflare Sandbox object exposed as `sandbox.raw` for Worker-only features */ +export type CloudflareRaw = Native; +/** Cloudflare Worker Durable Object namespace for a native Sandbox class */ +export type CloudflareBinding = DurableObjectNamespace; +/** + * Cloudflare R2 backup options for normalized filesystem snapshots + * + * `dir` is the sandbox cwd and `name` comes from `snapshots.create(name?)` + * use `useGitignore` to match Cloudflare's public backup documentation + * configure `BACKUP_BUCKET` and production R2 credentials on the Worker before enabling this + */ +export type CloudflareBackups = Readonly<{ + /** archive compression format and worker count */ + compression?: NonNullable; + /** glob patterns excluded from the backup archive */ + excludes?: NonNullable; + /** use the Worker R2 binding directly for local development */ + localBucket?: NonNullable; + /** use multipart R2 uploads for large archives */ + multipart?: NonNullable; + /** backup lifetime in seconds */ + ttl?: NonNullable; + /** exclude files matched by .gitignore when the backup directory is a git repository */ + useGitignore?: boolean; +}>; +/** + * Cloudflare Worker-native Sandbox adapter configuration + * + * `ProviderRaw` is inferred from `binding`, so `sandbox.raw` keeps the exact native Cloudflare Sandbox environment type + * + * use `cloudflareBridge()` for a deployed HTTP bridge outside a Cloudflare Worker + */ +export type Cloudflare = Readonly<{ + /** required Durable Object binding for the Cloudflare Sandbox class, usually `env.Sandbox` */ + binding: CloudflareBinding; + /** configured R2 backups that enable normalized filesystem snapshot create and restore */ + backups?: CloudflareBackups; + /** + * default working directory for normalized file and process operations + * + * @default "/workspace" + */ + cwd?: string; + /** + * default environment variables written to the selected sandbox + * + * values cross the Worker-to-sandbox trust boundary. pass only secrets the sandbox may use, and keep host-only credentials behind a Worker proxy + */ + env?: Readonly>; + /** stable sandbox id used when create input omits id */ + id?: string; + /** list options forwarded to Cloudflare `listFiles` */ + list?: ListFilesOptions; + /** + * optional named tunnel label with lowercase letters, digits, and internal hyphens + * + * use it for one named tunnel or as the fallback for one port not listed in `tunnels`. named tunnels require Worker-side API token, account, and zone configuration + */ + tunnel?: string; + /** named tunnel labels keyed by port. entries override `tunnel` and labels must be unique within one sandbox */ + tunnels?: CloudflareTunnelNames; + /** + * low-level options forwarded to `getSandbox`, with the current RPC transport enforced + * + * normalized commands are sessionless by default. set `enableDefaultSession: true` only when native raw operations need shared shell state + */ + options?: Omit; +}>; +/** + * create a Cloudflare Sandbox adapter from a Worker binding + * + * `sandbox.raw` preserves the native Sandbox type from `binding` and defaults to `CloudflareRaw` + */ +export declare const cloudflare: (options: Cloudflare) => Adapter; +//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/services/compute/vendor/sandbox-sdk/cloudflare/dist/index.js b/services/compute/vendor/sandbox-sdk/cloudflare/dist/index.js new file mode 100644 index 00000000..2baac91c --- /dev/null +++ b/services/compute/vendor/sandbox-sdk/cloudflare/dist/index.js @@ -0,0 +1,540 @@ +import { abort, bytes, command, duration, port, preview, SandboxError, sandboxError, sandboxPath, result, unsupported, } from "@sandbox-sdk/core"; +import { namedTunnel, validateTunnels } from "./tunnels.js"; +export { cloudflareBridge } from "./bridge.js"; +const provider = "cloudflare"; +const capabilities = (backups) => ({ + environment: true, + fileStreaming: "native", + files: true, + ports: "dynamic", + processExec: true, + processSpawn: "separate", + raw: { + backup: "configured", + buckets: "configured", + git: true, + interpreter: true, + pty: true, + sessions: true, + tunnels: "dynamic", + watching: true, + websocket: true, + }, + snapshotCreate: backups ? "filesystem" : false, + snapshotDelete: false, + snapshotRestore: backups ? "filesystem" : false, + status: true, + streaming: "separate", +}); +const backupRoots = [ + "/workspace", + "/home", + "/tmp", + "/var/tmp", + "/app", +]; +const validate = (options) => { + if (options.binding === undefined) { + throw sandboxError(provider, "Cloudflare binding missing. Pass the Durable Object binding from env.Sandbox to cloudflare().", "configuration"); + } + if (options.backups !== undefined && + typeof options.backups === "object" && + options.backups !== null && + "gitignore" in options.backups) { + throw sandboxError(provider, "Cloudflare backups.gitignore is not supported. Use backups.useGitignore.", "configuration"); + } + validateTunnels(options.tunnel, options.tunnels); +}; +const nativeBackups = (backups) => { + const { useGitignore, ...options } = backups; + return { + ...options, + ...(useGitignore === undefined ? {} : { gitignore: useGitignore }), + }; +}; +const validateBackupCwd = (cwd) => { + if (!cwd.startsWith("/")) { + throw sandboxError(provider, "Cloudflare backup cwd must be absolute", "configuration"); + } + if (cwd.includes("\0")) { + throw sandboxError(provider, "Cloudflare backup cwd must not contain null bytes", "configuration"); + } + if (cwd.split("/").includes("..")) { + throw sandboxError(provider, 'Cloudflare backup cwd must not contain ".." path segments', "configuration"); + } + if (!backupRoots.some((root) => cwd === root || cwd.startsWith(`${root}/`))) { + throw sandboxError(provider, "Cloudflare backup cwd must be under /workspace, /home, /tmp, /var/tmp, or /app", "configuration"); + } +}; +const binary = (content) => Uint8Array.from(atob(content), (char) => char.codePointAt(0) ?? 0); +const base64 = (input) => { + const chunks = []; + for (let index = 0; index < input.length; index += 32_768) { + chunks.push(String.fromCodePoint(...input.subarray(index, index + 32_768))); + } + return btoa(chunks.join("")); +}; +const readable = (input) => input instanceof ReadableStream; +const write = async (raw, path, input) => { + if (readable(input)) { + await raw.writeFile(path, input); + return; + } + const value = await bytes(input); + if (typeof value === "string") { + await raw.writeFile(path, value, { encoding: "utf-8" }); + return; + } + await raw.writeFile(path, base64(value), { encoding: "base64" }); +}; +const check = (signal) => { + if (signal?.aborted) { + abort(provider, signal.reason); + } +}; +const rejectUnsupported = (feature) => { + try { + unsupported(provider, feature); + } + catch (error) { + return Promise.reject(error); + } +}; +const wrap = async (action, feature) => { + try { + return await action(); + } + catch (error) { + if (error instanceof SandboxError) { + throw error; + } + throw sandboxError(provider, `${feature} failed`, "provider", error); + } +}; +const isReset = (error) => typeof error === "object" && + error !== null && + "durableObjectReset" in error && + error.durableObjectReset === true; +const providerCode = (error) => { + if (typeof error !== "object" || error === null || !("code" in error)) { + return; + } + const { code } = error; + return typeof code === "string" ? code : undefined; +}; +const backupWrap = async (action, feature) => { + try { + return await action(); + } + catch (error) { + if (error instanceof SandboxError) { + throw error; + } + const code = providerCode(error); + if (code === "INVALID_BACKUP_CONFIG") { + throw sandboxError(provider, "Cloudflare backup configuration is invalid. Check BACKUP_BUCKET, production R2 credentials, cwd, and backup options.", "configuration", error); + } + if (code === "BACKUP_NOT_FOUND") { + throw sandboxError(provider, "Cloudflare snapshot not found", "not_found", error); + } + throw sandboxError(provider, `${feature} failed`, "provider", error); + } +}; +const validatePort = (value) => { + const target = port(value, provider); + if (target >= 1024 && target !== 3000) { + return target; + } + throw sandboxError(provider, "Cloudflare preview ports must be integers from 1024 to 65535, excluding 3000", target === 3000 ? "unsupported" : "configuration"); +}; +const executeLine = async (raw, cwd, line, options) => { + check(options.signal); + const timeout = duration(options.timeout, provider); + try { + const output = await raw.exec(line, { + cwd: options.cwd ?? cwd, + ...(options.env === undefined ? {} : { env: { ...options.env } }), + ...(options.signal === undefined ? {} : { signal: options.signal }), + ...(timeout === undefined ? {} : { timeout }), + }); + return result(output.exitCode, output.stdout, output.stderr); + } + catch (error) { + throw sandboxError(provider, "Command failed", "process", error); + } +}; +const execute = (raw, cwd, executable, args, options) => executeLine(raw, cwd, command(executable, args), options); +const wait = async (raw, process) => { + const output = await process.waitForExit(); + const logs = await raw.getProcessLogs(process.id); + return result(output.exitCode, logs.stdout, logs.stderr); +}; +const encoder = new TextEncoder(); +const parseEvent = (block) => { + let event; + const data = []; + for (const line of block.split("\n")) { + if (line.startsWith("event:")) { + event = line.slice(6).trim(); + } + if (line.startsWith("data:")) { + data.push(line.slice(5).trimStart()); + } + } + if (data.length === 0) { + return; + } + return event === undefined + ? { data: data.join("\n") } + : { data: data.join("\n"), event }; +}; +const parseLog = (block) => { + const event = parseEvent(block); + if (event === undefined) { + return; + } + try { + const payload = JSON.parse(event.data); + if ((payload.type === "stdout" || payload.type === "stderr") && + typeof payload.data === "string") { + return { + channel: payload.type, + value: encoder.encode(payload.data), + }; + } + } + catch { + if (event.event === "stdout" || event.event === "stderr") { + return { + channel: event.event, + value: encoder.encode(event.data), + }; + } + } +}; +const lazy = (factory) => { + let promise; + const get = () => { + promise ??= factory(); + return promise; + }; + return { + // oxlint-disable-next-line promise/prefer-await-to-then + catch: (onrejected) => get().catch(onrejected), + // oxlint-disable-next-line promise/prefer-await-to-then + finally: (onfinally) => get().finally(onfinally), + // oxlint-disable-next-line unicorn/no-thenable promise/prefer-await-to-then promise/prefer-catch + then: (onfulfilled, onrejected) => get().then(onfulfilled, onrejected), + [Symbol.toStringTag]: "Promise", + }; +}; +const include = (channel, log) => channel === "output" || channel === log.channel; +const logs = (raw, process) => { + const chunks = []; + const controllers = { + output: new Set(), + stderr: new Set(), + stdout: new Set(), + }; + let closed = false; + let failed; + let pump; + const emit = (log) => { + chunks.push(log); + for (const channel of ["output", log.channel]) { + for (const controller of controllers[channel]) { + controller.enqueue(log.value); + } + } + }; + const close = () => { + closed = true; + for (const group of Object.values(controllers)) { + for (const controller of group) { + controller.close(); + } + group.clear(); + } + }; + const fail = (value) => { + failed = value; + for (const group of Object.values(controllers)) { + for (const controller of group) { + controller.error(value); + } + group.clear(); + } + }; + const start = () => { + pump ??= (async () => { + const output = await raw.streamProcessLogs(process.id); + const reader = output.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + try { + while (true) { + const next = await reader.read(); + if (next.done) { + break; + } + buffer += decoder.decode(next.value, { stream: true }); + const blocks = buffer.split(/\r?\n\r?\n/u); + buffer = blocks.pop() ?? ""; + for (const block of blocks) { + const log = parseLog(block); + if (log !== undefined) { + emit(log); + } + } + } + buffer += decoder.decode(); + if (buffer.trim().length > 0) { + const log = parseLog(buffer); + if (log !== undefined) { + emit(log); + } + } + close(); + } + catch (error) { + fail(error); + } + finally { + reader.releaseLock(); + } + })(); + }; + const create = (channel) => { + let active; + return new ReadableStream({ + cancel() { + if (active !== undefined) { + controllers[channel].delete(active); + } + }, + start(controller) { + active = controller; + for (const log of chunks) { + if (include(channel, log)) { + controller.enqueue(log.value); + } + } + if (failed !== undefined) { + controller.error(failed); + return; + } + if (closed) { + controller.close(); + return; + } + controllers[channel].add(controller); + start(); + }, + }); + }; + return { + output: create("output"), + stderr: create("stderr"), + stdout: create("stdout"), + }; +}; +const spawnLine = async (raw, cwd, line, options) => { + check(options.signal); + const timeout = duration(options.timeout, provider); + try { + const process = await raw.startProcess(line, { + cwd: options.cwd ?? cwd, + ...(options.env === undefined ? {} : { env: { ...options.env } }), + ...(timeout === undefined ? {} : { timeout }), + }); + const cancel = () => { + void process.kill("SIGTERM"); + }; + const dispose = () => { + options.signal?.removeEventListener("abort", cancel); + }; + if (options.signal?.aborted) { + cancel(); + } + else { + options.signal?.addEventListener("abort", cancel, { once: true }); + } + const output = logs(raw, process); + return { + id: process.id, + kill: async (signal = "SIGTERM") => { + dispose(); + await process.kill(signal); + }, + ...output, + result: lazy(async () => { + try { + return await wait(raw, process); + } + finally { + dispose(); + } + }), + }; + } + catch (error) { + throw sandboxError(provider, "Process spawn failed", "process", error); + } +}; +const spawn = (raw, cwd, executable, args, options) => spawnLine(raw, cwd, command(executable, args), options); +const createSandbox = (raw, cwd, options, support) => { + const { backups } = options; + const labels = new Map(); + return { + capabilities: support, + cwd, + files: { + exists: async (path) => { + const output = await wrap(() => raw.exists(sandboxPath(cwd, path)), "exists"); + return output.exists; + }, + list: async (path = cwd) => { + const target = sandboxPath(cwd, path); + const entries = await wrap(() => raw.listFiles(target, options.list), "list"); + return entries.files + .map((entry) => ({ + kind: entry.type === "directory" ? "directory" : "file", + modified: new Date(entry.modifiedAt), + path: entry.absolutePath, + size: entry.size, + })) + .toSorted((left, right) => left.path.localeCompare(right.path)); + }, + mkdir: async (path) => { + await wrap(() => raw.mkdir(sandboxPath(cwd, path), { recursive: true }), "mkdir"); + }, + read: async (path) => { + const output = await wrap(() => raw.readFile(sandboxPath(cwd, path), { encoding: "base64" }), "read"); + return binary(output.content); + }, + remove: async (path) => { + await wrap(() => raw.deleteFile(sandboxPath(cwd, path)), "remove"); + }, + stream: async (path) => { + const output = await wrap(() => raw.readFile(sandboxPath(cwd, path), { encoding: "none" }), "stream"); + return output.content; + }, + text: async (path) => { + const output = await wrap(() => raw.readFile(sandboxPath(cwd, path), { encoding: "utf-8" }), "text"); + return output.content; + }, + write: (path, input) => wrap(() => write(raw, sandboxPath(cwd, path), input), "write"), + }, + id: options.id ?? "default", + ports: { + expose: async (value, input) => { + const target = validatePort(value); + if ((input !== undefined && "host" in input) || + input?.token !== undefined || + (input?.protocol !== undefined && input.protocol !== "https")) { + throw sandboxError(provider, "Cloudflare tunnels only support the default HTTPS URL through ports.expose. Use sandbox.raw for provider-specific networking.", "unsupported"); + } + const output = await wrap(() => raw.tunnels.get(target, namedTunnel(options.tunnel, options.tunnels, target, labels)), "port exposure"); + return preview(output.url, target, { provider }); + }, + }, + process: { + exec: (executable, args = [], run = {}) => execute(raw, cwd, executable, args, run), + shell: (script, run = {}) => executeLine(raw, cwd, script, run), + spawn: (executable, args = [], run = {}) => spawn(raw, cwd, executable, args, run), + spawnShell: (script, run = {}) => spawnLine(raw, cwd, script, run), + }, + provider, + raw, + snapshots: { + create: async (name) => { + if (backups === undefined) { + return rejectUnsupported("snapshots"); + } + const backup = await backupWrap(() => raw.createBackup({ + ...nativeBackups(backups), + dir: cwd, + ...(name === undefined ? {} : { name }), + }), "snapshot create"); + return { + id: backup.id, + ...(name === undefined || name.length === 0 ? {} : { name }), + }; + }, + delete: () => rejectUnsupported("snapshots"), + restore: async (id) => { + if (backups === undefined) { + return rejectUnsupported("snapshots"); + } + if (id.length === 0) { + throw sandboxError(provider, "Cloudflare snapshot id is required for restore", "configuration"); + } + const restored = await backupWrap(() => raw.restoreBackup({ + dir: cwd, + id, + ...(backups.localBucket === true ? { localBucket: true } : {}), + }), "snapshot restore"); + if (!restored.success) { + throw sandboxError(provider, "Cloudflare snapshot restore failed", "provider"); + } + }, + }, + status: () => wrap(async () => { + const { status } = await raw.getState(); + if (status === "running" || status === "healthy") { + return "running"; + } + return status === "stopping" ? "stopping" : "stopped"; + }, "status"), + stop: async () => { + try { + await raw.destroy(); + } + catch (error) { + // destroy() ends by destroying the container, which resets this Durable Object + // while the call is still waiting on it, so the reset means the stop completed + if (isReset(error)) { + return; + } + throw sandboxError(provider, "stop failed", "provider", error); + } + }, + }; +}; +/** + * create a Cloudflare Sandbox adapter from a Worker binding + * + * `sandbox.raw` preserves the native Sandbox type from `binding` and defaults to `CloudflareRaw` + */ +export const cloudflare = (options) => { + const support = capabilities(options.backups !== undefined); + return { + capabilities: support, + async create(input = {}) { + validate(options); + const id = input.id ?? options.id ?? crypto.randomUUID(); + const cwd = input.cwd ?? options.cwd ?? "/workspace"; + if (options.backups !== undefined) { + validateBackupCwd(cwd); + } + const { getSandbox } = await import("@cloudflare/sandbox"); + const raw = getSandbox(options.binding, id, { + enableDefaultSession: false, + normalizeId: true, + ...options.options, + transport: "rpc", + }); + const env = { ...options.env, ...input.env }; + if (Object.keys(env).length > 0) { + await raw.setEnvVars(env); + } + // The image ships /workspace, so connecting with the default cwd leaves the container + // untouched and status() can read it without starting it. + if (cwd !== "/workspace") { + await raw.mkdir(cwd, { recursive: true }); + } + return createSandbox(raw, cwd, { ...options, id }, support); + }, + provider, + }; +}; diff --git a/services/compute/vendor/sandbox-sdk/cloudflare/dist/tunnels.d.ts b/services/compute/vendor/sandbox-sdk/cloudflare/dist/tunnels.d.ts new file mode 100644 index 00000000..418257e4 --- /dev/null +++ b/services/compute/vendor/sandbox-sdk/cloudflare/dist/tunnels.d.ts @@ -0,0 +1,9 @@ +/** named Cloudflare tunnel labels keyed by sandbox port. labels must be unique within one sandbox */ +export type CloudflareTunnelNames = Readonly>; +export declare const tunnelName: (value: string) => string; +export declare const tunnelPort: (value: number) => number; +export declare const validateTunnels: (tunnel: string | undefined, tunnels: CloudflareTunnelNames | undefined) => void; +export declare const namedTunnel: (tunnel: string | undefined, tunnels: CloudflareTunnelNames | undefined, target: number, labels: Map) => Readonly<{ + name: string; +}> | undefined; +//# sourceMappingURL=tunnels.d.ts.map \ No newline at end of file diff --git a/services/compute/vendor/sandbox-sdk/cloudflare/dist/tunnels.js b/services/compute/vendor/sandbox-sdk/cloudflare/dist/tunnels.js new file mode 100644 index 00000000..09f67aaa --- /dev/null +++ b/services/compute/vendor/sandbox-sdk/cloudflare/dist/tunnels.js @@ -0,0 +1,53 @@ +import { port, sandboxError } from "@sandbox-sdk/core"; +const provider = "cloudflare"; +const pattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u; +export const tunnelName = (value) => { + if (pattern.test(value)) { + return value; + } + throw sandboxError(provider, "Cloudflare named tunnel labels must use 1 to 63 lowercase letters, digits, and internal hyphens", "configuration"); +}; +export const tunnelPort = (value) => { + const target = port(value, provider); + if (target >= 1024 && target !== 3000) { + return target; + } + throw sandboxError(provider, "Cloudflare tunnel ports must be integers from 1024 to 65535, excluding 3000", target === 3000 ? "unsupported" : "configuration"); +}; +const mappedPort = (value) => { + const target = tunnelPort(Number(value)); + if (String(target) === value) { + return target; + } + throw sandboxError(provider, "Cloudflare named tunnel ports must use canonical integer keys", "configuration"); +}; +const collision = (name, current, next) => { + throw sandboxError(provider, `Cloudflare named tunnel ${name} is already assigned to port ${current}. Configure a distinct label for port ${next}.`, "configuration"); +}; +export const validateTunnels = (tunnel, tunnels) => { + if (tunnel !== undefined) { + tunnelName(tunnel); + } + const labels = new Map(); + for (const [value, name] of Object.entries(tunnels ?? {})) { + const target = mappedPort(value); + const label = tunnelName(name); + const current = labels.get(label); + if (current !== undefined && current !== target) { + collision(label, current, target); + } + labels.set(label, target); + } +}; +export const namedTunnel = (tunnel, tunnels, target, labels) => { + const name = tunnels?.[target] ?? tunnel; + if (name === undefined) { + return; + } + const current = labels.get(name); + if (current !== undefined && current !== target) { + collision(name, current, target); + } + labels.set(name, target); + return { name }; +}; diff --git a/services/compute/vendor/sandbox-sdk/cloudflare/package.json b/services/compute/vendor/sandbox-sdk/cloudflare/package.json new file mode 100644 index 00000000..b49bf9a8 --- /dev/null +++ b/services/compute/vendor/sandbox-sdk/cloudflare/package.json @@ -0,0 +1,12 @@ +{ + "name": "@sandbox-sdk/cloudflare", + "version": "0.0.0", + "private": true, + "type": "module", + "license": "MIT", + "exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } }, + "dependencies": { + "@cloudflare/sandbox": "0.12.9", + "@sandbox-sdk/core": "file:../core" + } +} diff --git a/services/compute/vendor/sandbox-sdk/core/dist/index.d.ts b/services/compute/vendor/sandbox-sdk/core/dist/index.d.ts new file mode 100644 index 00000000..8256cfec --- /dev/null +++ b/services/compute/vendor/sandbox-sdk/core/dist/index.d.ts @@ -0,0 +1,137 @@ +import type { Adapter, Capabilities, Capability, CapabilityModes, Cause, Code, Input, Options, Port, Preview, PreviewOptions, RawCapability, Result, Sandbox, SandboxRuntime } from "./types.js"; +export type { Adapter, Capabilities, Capability, CapabilityModes, Cause, Code, Entry, Exec, Files, Input, Mode, Options, Port, Ports, Preview, PreviewOptions, Process, RawCapability, Result, Running, Sandbox, SandboxRuntimeFiles, SandboxRuntimeProcess, SandboxRuntimePorts, Status, SandboxRuntimePreview, SandboxRuntime, Snapshot, Snapshots, Spawn, Timer, Url, } from "./types.js"; +export { rawCapabilities } from "./types.js"; +/** + * normalized error emitted by public sandbox sdk operations + * + * use `code` for portable error handling and `provider` to identify the adapter + * that raised the error + */ +export declare class SandboxError extends Error { + /** stable sandbox sdk error code for portable error handling */ + readonly code: Code; + /** adapter provider name when the failing operation is provider-specific */ + readonly provider?: string; + /** create a normalized error from an adapter or public helper */ + constructor(message: string, options: Cause & { + code: Code; + provider?: string; + }); +} +/** + * return whether a value is a normalized sandbox sdk error + * + * use this instead of matching provider-specific error messages when handling + * errors from multiple adapters + */ +export declare const isSandboxError: (error: unknown) => error is SandboxError; +/** + * create a sandbox through an adapter + * + * adapter configuration stays on the adapter and per-sandbox options stay in + * `input`, keeping provider setup separate from a single sandbox request + */ +export declare const create: (input: Options & { + adapter: Adapter; +}) => Promise>; +/** + * create a sandbox, run work, and always attempt cleanup + * + * use this for short-lived work where retaining the sandbox after the callback + * completes would be unexpected + * consume streams and finish background work inside the callback + * returning a stream or process handle does not extend the sandbox lifetime + * + * @example + * import { withSandbox } from "@sandbox-sdk/core" + * import { local } from "@sandbox-sdk/local" + * + * const result = await withSandbox({ adapter: local() }, (sandbox) => + * sandbox.process.shell("printf hello") + * ) + */ +export declare const withSandbox: (input: Options & { + adapter: Adapter; +}, use: (sandbox: Sandbox) => Output | Promise) => Promise; +/** return the advertised mode for a capability or undefined when absent */ +export declare const capabilityMode: (subject: { + capabilities: Capabilities; +}, capability: Key) => Exclude | undefined; +/** throw a normalized unsupported feature error */ +export declare const unsupported: (provider: string, feature: string) => never; +/** require a capability and throw a typed unsupported error when missing */ +export declare const requireCapability: (subject: { + capabilities: Capabilities; + provider?: string; +}, capability: Key) => Exclude; +/** true when a subject advertises a capability */ +export declare const supports: (subject: { + capabilities: Capabilities; +}, capability: Key) => boolean; +/** return the advertised mode for a provider-specific raw capability */ +export declare const rawCapabilityMode: (subject: { + capabilities: Capabilities; +}, capability: Key) => Exclude[Key], false | undefined> | undefined; +/** require a provider-specific raw capability and throw when missing */ +export declare const requireRawCapability: (subject: { + capabilities: Capabilities; + provider?: string; +}, capability: Key) => Exclude[Key], false | undefined>; +/** true when a provider-specific feature is available through `sandbox.raw` */ +export declare const supportsRaw: (subject: { + capabilities: Capabilities; +}, capability: Key) => boolean; +/** validate and return a normalized tcp port number */ +export declare const port: (value: number, provider?: string) => number; +/** + * create a provider-aware preview with access headers kept out of serializable data + * + * adapter authors pass provider-required request headers here instead of placing credentials in the returned url. requests are limited to the preview origin and handle redirects manually + */ +export declare const preview: (url: string, value: number, options?: PreviewOptions) => Preview; +/** + * validate options against an adapter's provider-derived preview URL + * + * call this before provider work when URL tokens or a chosen protocol are unavailable. custom domains stay on adapter-specific configuration or `sandbox.raw` + */ +export declare const portOptions: (provider: string, options: Port | undefined, protocol?: "http" | "https") => void; +/** validate and return a normalized millisecond duration */ +export declare const duration: (value?: number, provider?: string, name?: string) => number | undefined; +/** create a normalized provider error */ +export declare const sandboxError: (provider: string, message: string, code?: Code, cause?: unknown) => SandboxError; +/** throw a normalized aborted error for an adapter operation */ +export declare const abort: (provider: string, cause?: unknown) => never; +/** + * normalize supported file input into text or bytes + * + * passing a readable stream consumes it exactly once + */ +export declare const bytes: (input: Input) => Promise; +/** + * decode supported file input as utf-8 text + * + * passing a readable stream consumes it exactly once + */ +export declare const text: (input: Input) => Promise; +/** resolve a POSIX sandbox path against cwd without interpreting filenames as URLs */ +export declare const sandboxPath: (cwd: string, value?: string) => string; +/** + * lift a stream-first provider runtime into the public sandbox api + * + * adapter authors implement this lower-level contract to preserve streaming + * for large files and processes while callers receive the normalized api + */ +export declare const fromSandboxRuntime: (input: SandboxRuntime) => Sandbox; +/** build a normalized command result */ +export declare const result: (code: number, stdout?: string, stderr?: string, signal?: string) => Result; +/** quote one shell argument for POSIX shell execution */ +export declare const quote: (value: string) => string; +/** build a shell command string from argv parts */ +export declare const command: (value: string, args?: readonly string[]) => string; +/** create an abort signal that fires when timeout or parent signal fires */ +export declare const timeout: (value?: number, signal?: AbortSignal, provider?: string) => { + aborted(): boolean; + clear(): void; + signal?: AbortSignal; +}; +//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/services/compute/vendor/sandbox-sdk/core/dist/index.js b/services/compute/vendor/sandbox-sdk/core/dist/index.js new file mode 100644 index 00000000..ae70371b --- /dev/null +++ b/services/compute/vendor/sandbox-sdk/core/dist/index.js @@ -0,0 +1,464 @@ +export { rawCapabilities } from "./types.js"; +/** + * normalized error emitted by public sandbox sdk operations + * + * use `code` for portable error handling and `provider` to identify the adapter + * that raised the error + */ +export class SandboxError extends Error { + /** stable sandbox sdk error code for portable error handling */ + code; + /** adapter provider name when the failing operation is provider-specific */ + provider; + /** create a normalized error from an adapter or public helper */ + constructor(message, options) { + super(message, { cause: options.cause }); + this.name = "SandboxError"; + this.code = options.code; + if (options.provider !== undefined) { + this.provider = options.provider; + } + } +} +/** + * return whether a value is a normalized sandbox sdk error + * + * use this instead of matching provider-specific error messages when handling + * errors from multiple adapters + */ +export const isSandboxError = (error) => error instanceof SandboxError; +/** + * create a sandbox through an adapter + * + * adapter configuration stays on the adapter and per-sandbox options stay in + * `input`, keeping provider setup separate from a single sandbox request + */ +export const create = (input) => { + const { adapter, ...options } = input; + return adapter.create(options); +}; +const stopAfterError = async (sandbox, cause) => { + try { + await sandbox.stop(); + } + catch (error) { + const failure = new AggregateError([cause, error], "Sandbox use and cleanup failed"); + throw Object.assign(failure, { cause: error }); + } +}; +/** + * create a sandbox, run work, and always attempt cleanup + * + * use this for short-lived work where retaining the sandbox after the callback + * completes would be unexpected + * consume streams and finish background work inside the callback + * returning a stream or process handle does not extend the sandbox lifetime + * + * @example + * import { withSandbox } from "@sandbox-sdk/core" + * import { local } from "@sandbox-sdk/local" + * + * const result = await withSandbox({ adapter: local() }, (sandbox) => + * sandbox.process.shell("printf hello") + * ) + */ +export const withSandbox = async (input, use) => { + const sandbox = await create(input); + let value; + try { + value = await use(sandbox); + } + catch (error) { + await stopAfterError(sandbox, error); + throw error; + } + await sandbox.stop(); + return value; +}; +const available = (value) => { + if (value === undefined || value === false) { + return undefined; + } + return value; +}; +/** return the advertised mode for a capability or undefined when absent */ +export const capabilityMode = (subject, capability) => available(subject.capabilities[capability]); +/** throw a normalized unsupported feature error */ +export const unsupported = (provider, feature) => { + throw new SandboxError(`${provider} does not support ${feature}`, { + code: "unsupported", + provider, + }); +}; +/** require a capability and throw a typed unsupported error when missing */ +export const requireCapability = (subject, capability) => { + const value = capabilityMode(subject, capability); + if (value !== undefined) { + return value; + } + return unsupported(subject.provider ?? "sandbox", capability); +}; +/** true when a subject advertises a capability */ +export const supports = (subject, capability) => capabilityMode(subject, capability) !== undefined; +/** return the advertised mode for a provider-specific raw capability */ +export const rawCapabilityMode = (subject, capability) => available(subject.capabilities.raw?.[capability]); +/** require a provider-specific raw capability and throw when missing */ +export const requireRawCapability = (subject, capability) => { + const value = rawCapabilityMode(subject, capability); + if (value !== undefined) { + return value; + } + return unsupported(subject.provider ?? "sandbox", `raw ${capability}`); +}; +/** true when a provider-specific feature is available through `sandbox.raw` */ +export const supportsRaw = (subject, capability) => rawCapabilityMode(subject, capability) !== undefined; +/** validate and return a normalized tcp port number */ +export const port = (value, provider = "sandbox") => { + if (Number.isInteger(value) && value >= 1 && value <= 65_535) { + return value; + } + throw new SandboxError("Port must be an integer from 1 to 65535", { + code: "configuration", + provider, + }); +}; +/** + * create a provider-aware preview with access headers kept out of serializable data + * + * adapter authors pass provider-required request headers here instead of placing credentials in the returned url. requests are limited to the preview origin and handle redirects manually + */ +export const preview = (url, value, options = {}) => { + const provider = options.provider ?? "sandbox"; + const targetPort = port(value, provider); + let base; + try { + base = new URL(url); + } + catch (error) { + throw new SandboxError("Preview URL is invalid", { + cause: error, + code: "provider", + provider, + }); + } + if (base.protocol !== "http:" && base.protocol !== "https:") { + throw new SandboxError("Preview URL must use HTTP or HTTPS", { + code: "provider", + provider, + }); + } + const configured = new Headers(options.headers); + const request = (path, init) => { + let target; + try { + target = path === undefined ? new URL(base) : new URL(path, base); + } + catch (error) { + return Promise.reject(new SandboxError("Preview request URL is invalid", { + cause: error, + code: "configuration", + provider, + })); + } + if (target.origin !== base.origin) { + return Promise.reject(new SandboxError("Preview requests must stay on the preview origin", { + code: "configuration", + provider, + })); + } + if (init?.redirect === "follow") { + return Promise.reject(new SandboxError("Preview requests cannot follow redirects because provider credentials may be attached", { + code: "configuration", + provider, + })); + } + for (const [name] of base.searchParams) { + target.searchParams.delete(name); + } + for (const [name, entry] of base.searchParams) { + target.searchParams.append(name, entry); + } + const headers = new Headers(init?.headers); + for (const [name, entry] of configured) { + headers.set(name, entry); + } + return fetch(target, { + ...init, + headers, + redirect: init?.redirect ?? "manual", + }); + }; + const output = { port: targetPort, request, url }; + Object.defineProperty(output, "request", { + enumerable: false, + value: request, + }); + return Object.freeze(output); +}; +/** + * validate options against an adapter's provider-derived preview URL + * + * call this before provider work when URL tokens or a chosen protocol are unavailable. custom domains stay on adapter-specific configuration or `sandbox.raw` + */ +export const portOptions = (provider, options, protocol) => { + if (options !== undefined && "host" in options) { + unsupported(provider, "custom preview hosts"); + } + if (options?.token !== undefined) { + unsupported(provider, "preview URL tokens"); + } + if (protocol !== undefined && + options?.protocol !== undefined && + options.protocol !== protocol) { + unsupported(provider, `${options.protocol} preview URLs`); + } +}; +/** validate and return a normalized millisecond duration */ +export const duration = (value, provider = "sandbox", name = "timeout") => { + if (value === undefined) { + return undefined; + } + if (Number.isInteger(value) && value >= 0) { + return value; + } + throw new SandboxError(`${name} must be a non-negative integer`, { + code: "configuration", + provider, + }); +}; +/** create a normalized provider error */ +export const sandboxError = (provider, message, code = "provider", cause) => new SandboxError(message, { + cause, + code, + provider, +}); +/** throw a normalized aborted error for an adapter operation */ +export const abort = (provider, cause) => { + throw sandboxError(provider, "Operation aborted", "aborted", cause); +}; +/** + * normalize supported file input into text or bytes + * + * passing a readable stream consumes it exactly once + */ +export const bytes = async (input) => { + if (typeof input === "string" || input instanceof Uint8Array) { + return input; + } + if (input instanceof ArrayBuffer) { + return new Uint8Array(input); + } + if (input instanceof Blob) { + return new Uint8Array(await input.arrayBuffer()); + } + const chunks = []; + const reader = input.getReader(); + while (true) { + const next = await reader.read(); + if (next.done) { + break; + } + chunks.push(next.value); + } + const size = chunks.reduce((total, chunk) => total + chunk.byteLength, 0); + const output = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.byteLength; + } + return output; +}; +/** + * decode supported file input as utf-8 text + * + * passing a readable stream consumes it exactly once + */ +export const text = async (input) => { + const value = await bytes(input); + return typeof value === "string" ? value : new TextDecoder().decode(value); +}; +/** resolve a POSIX sandbox path against cwd without interpreting filenames as URLs */ +export const sandboxPath = (cwd, value) => { + const input = value === undefined || value.length === 0 ? cwd : value; + const path = input.startsWith("/") ? input : `${cwd}/${input}`; + const parts = []; + for (const part of path.split("/")) { + if (part === "..") { + parts.pop(); + } + else if (part !== "" && part !== ".") { + parts.push(part); + } + } + const suffix = path.endsWith("/") && parts.length > 0 ? "/" : ""; + return `/${parts.join("/")}${suffix}`; +}; +const read = async (value) => { + const output = await bytes(value); + return typeof output === "string" ? new TextEncoder().encode(output) : output; +}; +const normalize = async (provider, feature, operation) => { + try { + return await operation(); + } + catch (error) { + if (isSandboxError(error)) { + throw error; + } + throw sandboxError(provider, `${feature} failed`, "provider", error); + } +}; +const ensure = (sandbox, capability, feature) => { + if (!supports(sandbox, capability)) { + unsupported(sandbox.provider, feature); + } +}; +const settle = async (running) => { + const [output, value] = await Promise.all([ + text(running.output), + running.result, + ]); + return value.stdout.length > 0 || value.stderr.length > 0 + ? value + : { ...value, stdout: output }; +}; +const guarded = (input, capability, feature, operation) => { + try { + ensure(input, capability, feature); + return normalize(input.provider, feature, operation); + } + catch (error) { + return Promise.reject(error); + } +}; +const missingRuntime = (provider, feature) => { + throw sandboxError(provider, `SandboxRuntime ${feature} implementation missing for its advertised capability`, "configuration"); +}; +/** + * lift a stream-first provider runtime into the public sandbox api + * + * adapter authors implement this lower-level contract to preserve streaming + * for large files and processes while callers receive the normalized api + */ +export const fromSandboxRuntime = (input) => ({ + capabilities: input.capabilities, + cwd: input.cwd, + files: { + exists: (path) => guarded(input, "files", "files.exists", () => input.files.exists(path)), + list: (path) => guarded(input, "files", "files.list", () => input.files.list(path)), + mkdir: (path) => guarded(input, "files", "files.mkdir", () => input.files.mkdir(path)), + read: (path) => guarded(input, "files", "files.read", async () => read(await input.files.read(path))), + remove: (path) => guarded(input, "files", "files.remove", () => input.files.remove(path)), + stream: (path) => guarded(input, "files", "files.stream", () => input.files.read(path)), + text: (path) => guarded(input, "files", "files.text", async () => text(await read(await input.files.read(path)))), + write: (path, value) => guarded(input, "files", "files.write", () => input.files.write(path, value)), + }, + id: input.id, + ports: { + expose: async (value, options) => { + const exposed = await guarded(input, "ports", "ports.expose", () => input.ports.expose(port(value, input.provider), options)); + return preview(exposed.url, exposed.port, { + ...(exposed.headers === undefined ? {} : { headers: exposed.headers }), + provider: input.provider, + }); + }, + }, + process: { + exec: (executable, args, options) => guarded(input, "processExec", "process.exec", async () => { + if (input.process.exec !== undefined) { + return input.process.exec(executable, args, options); + } + if (input.process.spawn !== undefined) { + return settle(await input.process.spawn(executable, args, options)); + } + return missingRuntime(input.provider, "process.exec"); + }), + shell: (command, options) => guarded(input, "processExec", "process.shell", async () => { + if (input.process.shell !== undefined) { + return input.process.shell(command, options); + } + if (input.process.spawnShell !== undefined) { + return settle(await input.process.spawnShell(command, options)); + } + return missingRuntime(input.provider, "process.shell"); + }), + spawn: (executable, args, options) => guarded(input, "processSpawn", "process.spawn", () => { + if (input.process.spawn === undefined) { + return missingRuntime(input.provider, "process.spawn"); + } + return input.process.spawn(executable, args, options); + }), + spawnShell: (command, options) => guarded(input, "processSpawn", "process.spawnShell", () => { + if (input.process.spawnShell === undefined) { + return missingRuntime(input.provider, "process.spawnShell"); + } + return input.process.spawnShell(command, options); + }), + }, + provider: input.provider, + raw: input.raw, + snapshots: { + create: (name) => guarded(input, "snapshotCreate", "snapshots.create", () => input.snapshots.create(name)), + delete: (id) => guarded(input, "snapshotDelete", "snapshots.delete", () => input.snapshots.delete(id)), + restore: (id) => guarded(input, "snapshotRestore", "snapshots.restore", () => input.snapshots.restore(id)), + }, + status: () => guarded(input, "status", "status", () => { + if (input.status === undefined) { + return missingRuntime(input.provider, "status"); + } + return input.status(); + }), + stop: input.stop, +}); +/** build a normalized command result */ +export const result = (code, stdout = "", stderr = "", signal) => ({ + code, + ok: code === 0, + ...(signal === undefined ? {} : { signal }), + stderr, + stdout, +}); +/** quote one shell argument for POSIX shell execution */ +export const quote = (value) => { + if (/^[\w./:@%+=,-]+$/u.test(value)) { + return value; + } + return `'${value.replaceAll("'", "'\\''")}'`; +}; +/** build a shell command string from argv parts */ +export const command = (value, args = []) => [value, ...args].map(quote).join(" "); +const noop = () => void 0; +/** create an abort signal that fires when timeout or parent signal fires */ +export const timeout = (value, signal, provider = "sandbox") => { + const delay = duration(value, provider); + if (delay === undefined) { + return signal === undefined + ? { aborted: () => false, clear: noop } + : { aborted: () => signal.aborted, clear: noop, signal }; + } + let aborted = false; + const controller = new AbortController(); + const cancel = () => { + aborted = false; + controller.abort(signal?.reason); + }; + const timer = setTimeout(() => { + aborted = true; + controller.abort(); + }, delay); + if (signal?.aborted) { + cancel(); + } + else { + signal?.addEventListener("abort", cancel, { once: true }); + } + return { + aborted: () => aborted, + clear: () => { + clearTimeout(timer); + signal?.removeEventListener("abort", cancel); + }, + signal: controller.signal, + }; +}; diff --git a/services/compute/vendor/sandbox-sdk/core/dist/types.d.ts b/services/compute/vendor/sandbox-sdk/core/dist/types.d.ts new file mode 100644 index 00000000..2f218213 --- /dev/null +++ b/services/compute/vendor/sandbox-sdk/core/dist/types.d.ts @@ -0,0 +1,359 @@ +/** normalized feature flags adapters expose for runtime branching */ +export type Capability = "environment" | "files" | "fileStreaming" | "ports" | "processExec" | "processSpawn" | "snapshotCreate" | "snapshotDelete" | "snapshotRestore" | "snapshotSource" | "status" | "streaming"; +/** provider-specific capability keys available through `sandbox.raw` */ +export declare const rawCapabilities: readonly ["backup", "buckets", "codegen", "desktop", "drives", "git", "gpu", "interpreter", "lifecycle", "lsp", "mcp", "metrics", "network", "previews", "pty", "resources", "secrets", "sessions", "ssh", "system", "tunnels", "volumes", "watching", "websocket"]; +/** provider-specific capabilities available through `sandbox.raw` */ +export type RawCapability = (typeof rawCapabilities)[number]; +/** generic capability mode vocabulary for raw capabilities and custom type composition */ +export type Mode = boolean | "buffered" | "combined" | "configured" | "create-time" | "derived" | "disk" | "dynamic" | "filesystem" | "memory" | "native" | "separate" | "volume"; +/** allowed modes for each normalized capability */ +export type CapabilityModes = Readonly<{ + /** sandbox creation environment support */ + environment: boolean | "separate"; + /** file delivery behavior for `files.stream()` */ + fileStreaming: boolean | "buffered" | "native"; + /** normalized filesystem support */ + files: boolean; + /** preview port exposure behavior */ + ports: boolean | "create-time" | "derived" | "dynamic"; + /** one-shot process execution support */ + processExec: boolean; + /** lifecycle-safe background process support */ + processSpawn: boolean | "separate"; + /** normalized snapshot creation behavior */ + snapshotCreate: boolean | "disk" | "filesystem" | "memory" | "volume"; + /** snapshot deletion support */ + snapshotDelete: boolean; + /** normalized in-place snapshot restore behavior */ + snapshotRestore: boolean | "disk" | "filesystem" | "memory" | "volume"; + /** fresh sandbox creation from a snapshot behavior */ + snapshotSource: boolean | "create-time"; + /** lifecycle state reads that do not start or wake the sandbox */ + status: boolean; + /** background process output stream behavior */ + streaming: boolean | "combined" | "separate"; +}>; +/** + * provider capability map used by `supports`, `capabilityMode`, and docs + * + * snapshot create, delete, restore, and source capabilities are intentionally + * separate because providers do not expose the same snapshot lifecycle + * + * each normalized capability only accepts its own documented modes + */ +export type Capabilities = Readonly & { + /** provider-specific powers available through `sandbox.raw` */ + raw?: Partial>; +}>; +/** + * file write input accepted by every adapter + * + * readable streams are consumed once by the receiving operation + */ +export type Input = string | Uint8Array | ArrayBuffer | Blob | ReadableStream; +/** file or directory entry returned by `files.list` */ +export type Entry = Readonly<{ + /** sandbox-relative absolute path */ + path: string; + /** entry type reported by the provider */ + kind: "file" | "directory"; + /** byte size when the provider reports it */ + size?: number; + /** last modified time when the provider reports it */ + modified?: Date; +}>; +/** completed process result with buffered stdout and stderr */ +export type Result = Readonly<{ + /** process exit code */ + code: number; + /** true when `code` is 0 and the provider did not report abort or timeout */ + ok: boolean; + /** termination signal when the provider reports one */ + signal?: string; + /** buffered stdout as utf-8 text */ + stdout: string; + /** buffered stderr as utf-8 text */ + stderr: string; +}>; +/** normalized process namespace for one-shot and background commands */ +export type Process = Readonly<{ + /** run an executable with explicit argv arguments */ + exec(command: string, args?: readonly string[], options?: Exec): Promise; + /** run a shell command string in the sandbox */ + shell(command: string, options?: Exec): Promise; + /** start an executable with explicit argv arguments and stream output */ + spawn(command: string, args?: readonly string[], options?: Spawn): Promise; + /** start a shell command string and stream output */ + spawnShell(command: string, options?: Spawn): Promise; +}>; +/** handle returned by background process APIs */ +export type Running = Readonly<{ + /** provider process id or generated handle id */ + id: string; + /** combined output stream when the provider exposes streaming output */ + output: ReadableStream; + /** stdout stream when the provider exposes stdout separately */ + stdout?: ReadableStream; + /** stderr stream when the provider exposes stderr separately */ + stderr?: ReadableStream; + /** final process result */ + result: Promise; + /** request process termination */ + kill(signal?: string): Promise; +}>; +/** normalized filesystem namespace scoped to a sandbox root */ +export type Files = Readonly<{ + /** read a file as a byte stream and check `fileStreaming` before relying on incremental delivery */ + stream(path: string): Promise>; + /** read a file as bytes */ + read(path: string): Promise; + /** read a file as utf-8 text */ + text(path: string): Promise; + /** write text, bytes, blobs, array buffers, or readable streams */ + write(path: string, input: Input): Promise; + /** list entries in a directory */ + list(path?: string): Promise; + /** + * return whether a file or directory exists + * + * resolve relative paths from `sandbox.cwd` and preserve absolute sandbox paths + */ + exists(path: string): Promise; + /** create a directory and missing parents */ + mkdir(path: string): Promise; + /** remove a file or directory */ + remove(path: string): Promise; +}>; +/** normalized preview URL namespace */ +export type Ports = Readonly<{ + /** expose a sandbox port and return a provider-aware preview; adapters reject options they cannot honor */ + expose(port: number, options?: Port): Promise; +}>; +/** + * normalized snapshot namespace for capability-gated state capture + * + * check snapshot capabilities independently because creating a snapshot does + * not imply an adapter can restore or delete one + */ +export type Snapshots = Readonly<{ + /** create a snapshot, optionally naming it when the provider persists snapshot names */ + create(name?: string): Promise; + /** delete a snapshot when `snapshotDelete` is supported */ + delete(id: string): Promise; + /** restore a snapshot when `snapshotRestore` is supported */ + restore(id: string): Promise; +}>; +/** serializable preview endpoint returned by a low-level adapter */ +export type Url = Readonly<{ + /** public or local URL for the exposed port */ + url: string; + /** exposed sandbox port */ + port: number; +}>; +/** preview endpoint returned by a low-level adapter before public wrapping */ +export type SandboxRuntimePreview = Readonly>; +}>; +/** private provider options used by the `preview()` helper */ +export type PreviewOptions = Readonly<{ + /** provider headers applied only when `Preview.request` runs and override caller-supplied values */ + headers?: Readonly>; + /** provider name used when preview request validation fails */ + provider?: string; +}>; +/** + * provider-aware preview returned by `ports.expose` + * + * `request` adds provider-required access headers without exposing them in returned data. it preserves provider URL query parameters, so treat provider-issued signed or tokenized urls as credentials. redirects are manual by default because provider credentials must not leave the preview origin + * + * @example + * const preview = await sandbox.ports.expose(3000) + * const response = await preview.request("/health") + */ +export type Preview = Readonly; +}>; +/** snapshot identifier returned by `snapshots.create` */ +export type Snapshot = Readonly<{ + /** provider snapshot id */ + id: string; + /** provider-persisted snapshot name when supported; may differ from the requested label */ + name?: string; +}>; +/** + * command execution options shared by exec, shell, spawn, and spawnShell + * + * command environments apply only to that process and are not adapter credentials + * command strings and argv are forwarded to the provider, so use paths relative + * to `cwd` when portable command behavior matters + */ +export type Exec = Readonly<{ + /** command working directory inside the sandbox */ + cwd?: string; + /** environment variables for this command */ + env?: Readonly>; + /** abort signal for canceling this command */ + signal?: AbortSignal; + /** command timeout in milliseconds */ + timeout?: number; +}>; +/** background process options shared with one-shot execution */ +export type Spawn = Exec; +/** + * preview URL options supported by one or more adapters + * + * adapters reject unsupported values instead of silently ignoring them. custom domains and other provider-specific preview controls stay on adapter options or `sandbox.raw` + */ +export type Port = Readonly<{ + /** preview http(s) protocol preference when supported, use typed provider options or `sandbox.raw` for tcp tunnels */ + protocol?: "http" | "https"; + /** provider-issued preview URL token when the adapter supports it; treat it as a bearer credential */ + token?: string; +}>; +/** provider lifecycle state normalized across adapters */ +export type Status = "running" | "stopping" | "stopped"; +/** + * normalized sandbox instance returned by every adapter + * + * use `capabilities` before optional operations and `raw` only when the + * provider-specific capability is explicitly needed + */ +export type Sandbox = Readonly<{ + /** advertised runtime feature support */ + capabilities: Capabilities; + /** default sandbox working directory */ + cwd: string; + /** filesystem operations scoped to the sandbox */ + files: Files; + /** provider sandbox id */ + id: string; + /** process operations scoped to the sandbox */ + process: Process; + /** provider name */ + provider: string; + /** preview URL operations */ + ports: Ports; + /** raw provider object for advanced provider-specific usage outside the normalized contract */ + raw: Raw; + /** snapshot operations gated by capabilities */ + snapshots: Snapshots; + /** current lifecycle state, read without starting or waking the sandbox */ + status(): Promise; + /** stop, destroy, or release the sandbox according to adapter semantics */ + stop(): Promise; +}>; +/** stream-first filesystem contract for low-level provider adapters */ +export type SandboxRuntimeFiles = Readonly<{ + /** read a file as a byte stream */ + read(path: string): Promise>; + /** write text, bytes, blobs, array buffers, or readable streams */ + write(path: string, input: Input): Promise; + /** list entries in a directory */ + list(path?: string): Promise; + /** + * return whether a file or directory exists + * + * resolve relative paths before this call because `fromSandboxRuntime()` preserves paths unchanged + */ + exists(path: string): Promise; + /** create a directory and missing parents */ + mkdir(path: string): Promise; + /** remove a file or directory */ + remove(path: string): Promise; +}>; +/** process contract for low-level provider adapters */ +export type SandboxRuntimeProcess = Readonly<{ + /** run a bounded executable when the provider exposes a direct command result */ + exec?: (command: string, args?: readonly string[], options?: Exec) => Promise; + /** run a bounded shell command when the provider exposes a direct command result */ + shell?: (command: string, options?: Exec) => Promise; + /** start an executable with explicit argv arguments and stream output */ + spawn?: (command: string, args?: readonly string[], options?: Spawn) => Promise; + /** start a shell command string and stream output */ + spawnShell?: (command: string, options?: Spawn) => Promise; +}>; +/** preview url contract for low-level provider adapters */ +export type SandboxRuntimePorts = Readonly<{ + /** expose a sandbox port and keep provider access headers inside the public preview request wrapper */ + expose(port: number, options?: Port): Promise; +}>; +/** + * low-level vendor contract that keeps large I/O stream-first + * + * adapter authors should implement this contract and use `fromSandboxRuntime` + * instead of buffering provider I/O in an adapter + */ +export type SandboxRuntime = Readonly<{ + /** advertised runtime feature support */ + capabilities: Capabilities; + /** default sandbox working directory */ + cwd: string; + /** stream-first filesystem operations scoped to the sandbox */ + files: SandboxRuntimeFiles; + /** provider sandbox id */ + id: string; + /** preview URL operations */ + ports: SandboxRuntimePorts; + /** process operations scoped to the sandbox */ + process: SandboxRuntimeProcess; + /** provider name */ + provider: string; + /** raw provider object for advanced provider-specific usage */ + raw: Raw; + /** snapshot operations gated by capabilities */ + snapshots: Snapshots; + /** current lifecycle state when `status` is advertised */ + status?(): Promise; + /** stop, destroy, or release the sandbox according to adapter semantics */ + stop(): Promise; +}>; +/** + * provider adapter contract implemented by each package + * + * adapter configuration belongs in the factory and per-sandbox configuration + * belongs in `create` + */ +export type Adapter = Readonly<{ + /** provider name */ + provider: string; + /** adapter capability map */ + capabilities: Capabilities; + /** create or connect to a sandbox */ + create(options?: Options): Promise>; +}>; +/** + * sandbox creation options shared across providers + * + * individual adapters document which options they support and how they map to + * their provider + */ +export type Options = Readonly<{ + /** default sandbox working directory */ + cwd?: string; + /** environment variables applied at sandbox creation */ + env?: Readonly>; + /** stable sandbox id or provider name when supported */ + id?: string; + /** provider metadata or labels */ + metadata?: Readonly>; + /** ports declared at create time for providers that require it */ + ports?: readonly number[]; + /** snapshot id used as the sandbox source when supported */ + snapshot?: string; + /** template id, image, or runtime source depending on provider */ + template?: string; + /** sandbox lifetime timeout in milliseconds when supported */ + timeout?: number; +}>; +/** optional cause metadata used by normalized errors */ +export type Cause = Readonly<{ + /** original provider, runtime, or platform failure */ + cause?: unknown; +}>; +/** runtime timeout handle type used across browser and node targets */ +export type Timer = ReturnType; +/** normalized error code for sandbox sdk failures */ +export type Code = "aborted" | "configuration" | "not_found" | "path_escape" | "policy" | "process" | "provider" | "timeout" | "unsupported"; +//# sourceMappingURL=types.d.ts.map \ No newline at end of file diff --git a/services/compute/vendor/sandbox-sdk/core/dist/types.js b/services/compute/vendor/sandbox-sdk/core/dist/types.js new file mode 100644 index 00000000..f4028e8b --- /dev/null +++ b/services/compute/vendor/sandbox-sdk/core/dist/types.js @@ -0,0 +1,27 @@ +/** provider-specific capability keys available through `sandbox.raw` */ +export const rawCapabilities = [ + "backup", + "buckets", + "codegen", + "desktop", + "drives", + "git", + "gpu", + "interpreter", + "lifecycle", + "lsp", + "mcp", + "metrics", + "network", + "previews", + "pty", + "resources", + "secrets", + "sessions", + "ssh", + "system", + "tunnels", + "volumes", + "watching", + "websocket", +]; diff --git a/services/compute/vendor/sandbox-sdk/core/package.json b/services/compute/vendor/sandbox-sdk/core/package.json new file mode 100644 index 00000000..590394fa --- /dev/null +++ b/services/compute/vendor/sandbox-sdk/core/package.json @@ -0,0 +1,8 @@ +{ + "name": "@sandbox-sdk/core", + "version": "0.0.0", + "private": true, + "type": "module", + "license": "MIT", + "exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } } +} diff --git a/services/compute/wrangler.jsonc b/services/compute/wrangler.jsonc index bf557ac0..a54aace7 100644 --- a/services/compute/wrangler.jsonc +++ b/services/compute/wrangler.jsonc @@ -8,13 +8,17 @@ "bindings": [ { "name": "CHANNEL", "class_name": "HostedChannel" }, { "name": "COMPUTE", "class_name": "Compute" }, + { "name": "Sandbox", "class_name": "Sandbox" }, ], }, "containers": [{ - "class_name": "Compute", - "scheduling_policy": "durable_object", - "images": { "base": { "dockerfile": "./Dockerfile" } }, + "class_name": "Sandbox", + "instance_type": "standard-1", + "image": "./Dockerfile", }], - "migrations": [{ "tag": "v1", "new_sqlite_classes": ["HostedChannel", "Compute"] }], + "migrations": [ + { "tag": "v1", "new_sqlite_classes": ["HostedChannel", "Compute"] }, + { "tag": "v2", "new_sqlite_classes": ["Sandbox"] }, + ], "observability": { "enabled": true }, }