From 2aac32d1aa9b6cbe3c19db5cba060e8d0c03bb0a Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Thu, 7 May 2026 12:31:38 +0100 Subject: [PATCH 1/5] feat(sandbox-cf): scaffold Cloudflare Sandbox provider worker (iii worker + CF Worker bridge) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CF Sandbox lives inside a Durable Object on the Workers V8 runtime, so the iii engine cannot host it directly. This crate ships two artifacts: 1. sandbox-cf/ iii worker (Node, runs on iii engine host). Registers sandbox::cf::* functions (create, exec, stop, list, expose_port, fs::read, fs::write). Talks HTTPS to the bridge. 2. sandbox-cf/bridge/ Thin CF Worker deployed via wrangler. Hosts the Sandbox Durable Object class re-exported from @cloudflare/sandbox. Exposes HTTPS routes matching the iii worker's function set. Auth via shared bearer token (CF_BRIDGE_TOKEN). Capabilities advertised: ["expose_port", "fs"]. CF Sandbox does not ship snapshot or branch — callers that need those should pick a different provider. The iii worker side ships full smoke tests (5/5) against a stubbed bridge client; the bridge ships the auth check + route shell. Both halves' upstream calls are stubbed pending verified deploy of @cloudflare/sandbox's getSandbox() against a real CF account. Wire protocol between worker and bridge is stable. Lint clean (biome + tsc on both halves). Part of the sandbox-as-worker family. --- sandbox-cf/.gitignore | 8 +++ sandbox-cf/Dockerfile | 17 +++++ sandbox-cf/README.md | 69 ++++++++++++++++++++ sandbox-cf/bridge/README.md | 39 ++++++++++++ sandbox-cf/bridge/package.json | 19 ++++++ sandbox-cf/bridge/src/index.ts | 97 ++++++++++++++++++++++++++++ sandbox-cf/bridge/tsconfig.json | 13 ++++ sandbox-cf/bridge/wrangler.jsonc | 39 ++++++++++++ sandbox-cf/iii.worker.yaml | 12 ++++ sandbox-cf/package.json | 22 +++++++ sandbox-cf/src/client.ts | 61 ++++++++++++++++++ sandbox-cf/src/config.ts | 65 +++++++++++++++++++ sandbox-cf/src/handlers.ts | 102 ++++++++++++++++++++++++++++++ sandbox-cf/src/index.ts | 40 ++++++++++++ sandbox-cf/src/sandbox.ts | 55 ++++++++++++++++ sandbox-cf/tests/handlers.test.ts | 53 ++++++++++++++++ sandbox-cf/tsconfig.json | 13 ++++ 17 files changed, 724 insertions(+) create mode 100644 sandbox-cf/.gitignore create mode 100644 sandbox-cf/Dockerfile create mode 100644 sandbox-cf/README.md create mode 100644 sandbox-cf/bridge/README.md create mode 100644 sandbox-cf/bridge/package.json create mode 100644 sandbox-cf/bridge/src/index.ts create mode 100644 sandbox-cf/bridge/tsconfig.json create mode 100644 sandbox-cf/bridge/wrangler.jsonc create mode 100644 sandbox-cf/iii.worker.yaml create mode 100644 sandbox-cf/package.json create mode 100644 sandbox-cf/src/client.ts create mode 100644 sandbox-cf/src/config.ts create mode 100644 sandbox-cf/src/handlers.ts create mode 100644 sandbox-cf/src/index.ts create mode 100644 sandbox-cf/src/sandbox.ts create mode 100644 sandbox-cf/tests/handlers.test.ts create mode 100644 sandbox-cf/tsconfig.json diff --git a/sandbox-cf/.gitignore b/sandbox-cf/.gitignore new file mode 100644 index 00000000..21a40322 --- /dev/null +++ b/sandbox-cf/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +package-lock.json +bridge/node_modules/ +bridge/dist/ +bridge/package-lock.json +bridge/.wrangler/ +.DS_Store diff --git a/sandbox-cf/Dockerfile b/sandbox-cf/Dockerfile new file mode 100644 index 00000000..1b3d0f15 --- /dev/null +++ b/sandbox-cf/Dockerfile @@ -0,0 +1,17 @@ +# Stage 1: build +FROM node:22-slim AS build +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm install --no-audit --no-fund +COPY src/ src/ +COPY tsconfig.json . +RUN npx tsc + +# Stage 2: runtime +FROM node:22-slim +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm install --omit=dev --no-audit --no-fund +COPY --from=build /app/dist ./dist +ENV III_URL=ws://localhost:49134 +CMD ["node", "/app/dist/index.js"] diff --git a/sandbox-cf/README.md b/sandbox-cf/README.md new file mode 100644 index 00000000..ac006429 --- /dev/null +++ b/sandbox-cf/README.md @@ -0,0 +1,69 @@ +# sandbox-cf + +Narrow iii worker that exposes [Cloudflare Sandbox](https://developers.cloudflare.com/sandbox/) under the canonical `sandbox::cf::*` ABI. Unlike the other workers in this family, `sandbox-cf` ships **two artifacts**: + +1. **iii worker** (this folder, top-level files) — runs on a host the iii engine controls. Registers `sandbox::cf::*` functions. Talks HTTPS to (2). +2. **CF Worker bridge** (`bridge/` subfolder) — separately deployed via `wrangler deploy`. Hosts the `Sandbox` Durable Object class from `@cloudflare/sandbox`. Receives HTTPS calls from (1) and drives the Container. + +The bridge exists because CF Sandbox lives inside the Workers V8 runtime — there is no way to reach `getSandbox()` from a host-side process. The bridge is the smallest amount of CF-native code needed. + +## Functions + +| Function id | Purpose | +|---|---| +| `sandbox::cf::create` | Boot a sandbox; returns `{sandbox_id, image, capabilities}` | +| `sandbox::cf::exec` | Run a command inside a live sandbox | +| `sandbox::cf::stop` | Tear down a sandbox | +| `sandbox::cf::list` | Enumerate live sandboxes plus concurrency status | +| `sandbox::cf::expose_port` | Public URL for a port (requires custom domain on the bridge) | +| `sandbox::cf::fs::read` | Read a file out of the sandbox | +| `sandbox::cf::fs::write` | Write a file into the sandbox | + +`create` advertises capabilities `["expose_port", "fs"]`. CF Sandbox does not ship `snapshot` or `branch`; callers that depend on those should pick a different provider. + +## Deploy (two steps) + +1. **Deploy the bridge** (see `bridge/README.md`): + ```bash + cd bridge && npm install + wrangler secret put CF_BRIDGE_TOKEN + wrangler deploy + ``` + Wrangler prints a bridge URL like `https://sandbox-cf-bridge..workers.dev`. + +2. **Run the iii worker** with the bridge URL + shared secret in the environment: + ```bash + export CF_BRIDGE_URL="https://sandbox-cf-bridge..workers.dev" + export CF_BRIDGE_TOKEN="" + iii worker add sandbox-cf + ``` + +## Configuration + +`config.yaml` next to the binary, or set `SANDBOX_CF_CONFIG` to a path: + +```yaml +bridge_url_env: CF_BRIDGE_URL +bridge_token_env: CF_BRIDGE_TOKEN +max_concurrent_sandboxes: 10 +default_idle_timeout_secs: 300 +image_allowlist: [] +``` + +The worker fails fast at startup if either env var is missing. + +## S-codes + +| Code | Cause | +|---|---| +| `S100` | Image not in `image_allowlist` | +| `S400` | Concurrency cap reached | +| `S404` | Capability not supported | +| `S500` | Bridge returned 429 | +| `S501` | Bridge returned 402 / quota exhausted | +| `S502` | Bridge returned 5xx (or stub bodies still in place) | +| `S503` | Bridge returned 401 / 403 (auth) | + +## Status + +v0.1 ships the iii worker side end-to-end (function registrations, types, error mapping, concurrency cap, smoke test) and the bridge route shell + auth check. Both halves' stub bodies return `S502` / HTTP 501 until the next iteration wires `@cloudflare/sandbox`'s `getSandbox()` calls. The wire protocol between worker and bridge is stable. diff --git a/sandbox-cf/bridge/README.md b/sandbox-cf/bridge/README.md new file mode 100644 index 00000000..b6198826 --- /dev/null +++ b/sandbox-cf/bridge/README.md @@ -0,0 +1,39 @@ +# sandbox-cf bridge + +Thin Cloudflare Worker that exposes HTTPS routes corresponding to every `sandbox::cf::*` function, calling `@cloudflare/sandbox`'s `getSandbox(env.Sandbox, id)` underneath. The iii worker in the parent directory talks to this bridge — that's the only way to reach a CF Sandbox from outside the Cloudflare Workers runtime. + +## Why a bridge + +CF Sandbox is a Durable Object that owns a Container. Both run inside the Cloudflare Workers V8 isolate runtime. The iii engine ships as a Rust/Node binary on Linux/macOS hosts — it can't host a CF Worker. This bridge is the smallest amount of CF-native code that lets a host-side iii worker reach a Sandbox. + +## Routes + +| HTTP | Path | Backed by | +|---|---|---| +| POST | `/create` | `getSandbox(env.Sandbox, id)` | +| POST | `/exec` | `sandbox.exec(cmd, opts)` | +| POST | `/stop` | `sandbox.destroy()` | +| GET | `/list` | (TODO — SDK does not currently expose a list primitive) | +| POST | `/expose-port` | `sandbox.exposePort(port, opts)` | +| POST | `/fs/read` | `sandbox.readFile(path)` | +| POST | `/fs/write` | `sandbox.writeFile(path, bytes, { mode })` | + +All routes require `Authorization: Bearer ` (shared secret with the iii worker). + +## Deploy + +```bash +npm install +wrangler secret put CF_BRIDGE_TOKEN # paste the same token you'll set in CF_BRIDGE_TOKEN on the iii worker side +wrangler deploy +``` + +`wrangler deploy` prints the bridge URL (e.g. `https://sandbox-cf-bridge..workers.dev`). Set that as `CF_BRIDGE_URL` on the iii worker side. + +## Status + +v0.1 ships the auth check, route shell, and SDK re-export of the `Sandbox` Durable Object class. The handler bodies that call `getSandbox()` are stubbed and return HTTP 501 until the next iteration. Routes/auth are stable; SDK wiring is the only remaining work. + +## Why no tests here + +CF Worker testing requires `miniflare` or `@cloudflare/vitest-pool-workers` plus a containerd-style runtime to exercise the Sandbox SDK end-to-end. Adding it complicates v0 without proportionate value while the route bodies are stubbed. The iii worker side has full smoke tests against a mocked bridge response. diff --git a/sandbox-cf/bridge/package.json b/sandbox-cf/bridge/package.json new file mode 100644 index 00000000..caa89309 --- /dev/null +++ b/sandbox-cf/bridge/package.json @@ -0,0 +1,19 @@ +{ + "name": "sandbox-cf-bridge", + "version": "0.1.0", + "private": true, + "license": "Apache-2.0", + "scripts": { + "deploy": "wrangler deploy", + "dev": "wrangler dev", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@cloudflare/sandbox": "^0.4.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20240725.0", + "typescript": "^5.9.3", + "wrangler": "^3.95.0" + } +} diff --git a/sandbox-cf/bridge/src/index.ts b/sandbox-cf/bridge/src/index.ts new file mode 100644 index 00000000..b2cde9da --- /dev/null +++ b/sandbox-cf/bridge/src/index.ts @@ -0,0 +1,97 @@ +// Cloudflare Worker bridge for sandbox-cf. The iii worker side (in this +// crate's parent dir) talks to this Worker over HTTPS; this Worker calls +// `getSandbox(env.Sandbox, id)` on the @cloudflare/sandbox SDK and drives +// the underlying Container Durable Object. +// +// Auth: shared bearer token, set via `wrangler secret put CF_BRIDGE_TOKEN`. +// +// v0 ships the route shell + auth check; the actual getSandbox() calls are +// stubbed in TODO comments and return 501. The follow-up commit wires the +// SDK once we've validated the deploy story end to end. + +interface Env { + Sandbox: DurableObjectNamespace + CF_BRIDGE_TOKEN: string +} + +function unauthorized(): Response { + return new Response(JSON.stringify({ error: 'unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }) +} + +function notImplemented(route: string): Response { + return new Response(JSON.stringify({ error: `[S502] TODO: wire ${route} -> getSandbox().*` }), { + status: 501, + headers: { 'Content-Type': 'application/json' }, + }) +} + +async function readJson(req: Request): Promise> { + try { + return (await req.json()) as Record + } catch { + return {} + } +} + +export default { + async fetch(req: Request, env: Env): Promise { + const auth = req.headers.get('Authorization') ?? '' + if (!auth.startsWith('Bearer ') || auth.slice(7) !== env.CF_BRIDGE_TOKEN) { + return unauthorized() + } + + const url = new URL(req.url) + const route = `${req.method} ${url.pathname}` + + switch (route) { + case 'POST /create': { + const _payload = await readJson(req) + // TODO: const sandbox = getSandbox(env.Sandbox, payload.sandbox_id ?? crypto.randomUUID()) + // TODO: register the sandbox.exec / writeFile / readFile bindings before first use + return notImplemented('POST /create') + } + case 'POST /exec': { + const _payload = await readJson(req) + // TODO: const r = await getSandbox(env.Sandbox, payload.sandbox_id).exec(payload.cmd, { ...opts }) + return notImplemented('POST /exec') + } + case 'POST /stop': { + const _payload = await readJson(req) + // TODO: await getSandbox(env.Sandbox, payload.sandbox_id).destroy() + return notImplemented('POST /stop') + } + case 'GET /list': { + // TODO: enumerate live sandbox ids; SDK does not currently expose + // a list primitive — see follow-up issue. + return notImplemented('GET /list') + } + case 'POST /expose-port': { + const _payload = await readJson(req) + // TODO: getSandbox(env.Sandbox, payload.sandbox_id).exposePort(payload.port, { ... }) + return notImplemented('POST /expose-port') + } + case 'POST /fs/read': { + const _payload = await readJson(req) + // TODO: getSandbox(env.Sandbox, payload.sandbox_id).readFile(payload.path) + return notImplemented('POST /fs/read') + } + case 'POST /fs/write': { + const _payload = await readJson(req) + // TODO: getSandbox(env.Sandbox, payload.sandbox_id).writeFile(payload.path, decoded, { mode }) + return notImplemented('POST /fs/write') + } + default: + return new Response(JSON.stringify({ error: `unknown route: ${route}` }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }) + } + }, +} + +// The Durable Object class is re-exported from @cloudflare/sandbox so +// wrangler can find it via `class_name: "Sandbox"`. +export { Sandbox } from '@cloudflare/sandbox' diff --git a/sandbox-cf/bridge/tsconfig.json b/sandbox-cf/bridge/tsconfig.json new file mode 100644 index 00000000..51799af9 --- /dev/null +++ b/sandbox-cf/bridge/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["@cloudflare/workers-types"] + }, + "include": ["src"] +} diff --git a/sandbox-cf/bridge/wrangler.jsonc b/sandbox-cf/bridge/wrangler.jsonc new file mode 100644 index 00000000..d0a94756 --- /dev/null +++ b/sandbox-cf/bridge/wrangler.jsonc @@ -0,0 +1,39 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "sandbox-cf-bridge", + "main": "src/index.ts", + "compatibility_date": "2025-04-01", + "compatibility_flags": ["nodejs_compat"], + + // Durable Object that wraps the Cloudflare Container hosting the sandbox. + // The class is provided by @cloudflare/sandbox. + "durable_objects": { + "bindings": [ + { + "name": "Sandbox", + "class_name": "Sandbox" + } + ] + }, + + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["Sandbox"] + } + ], + + // Container image that runs inside the Durable Object. Replace `image` + // with your own OCI ref to swap the runtime. + "containers": [ + { + "class_name": "Sandbox", + "image": "ghcr.io/cloudflare/sandbox:latest", + "max_instances": 10 + } + ], + + // Shared secret the iii worker side sends as `Authorization: Bearer `. + // Set with: wrangler secret put CF_BRIDGE_TOKEN + "vars": {} +} diff --git a/sandbox-cf/iii.worker.yaml b/sandbox-cf/iii.worker.yaml new file mode 100644 index 00000000..5699c182 --- /dev/null +++ b/sandbox-cf/iii.worker.yaml @@ -0,0 +1,12 @@ +iii: v1 +name: sandbox-cf +language: node +deploy: image +manifest: package.json +description: Narrow iii worker that exposes Cloudflare Sandbox via the sandbox::cf::* trigger family. Talks HTTPS to a thin Cloudflare Worker bridge (deployed separately from this worker's bridge/ subdir) because CF Sandbox lives inside a Durable Object on the Workers runtime, not on a host iii engine can run. +config: + bridge_url_env: CF_BRIDGE_URL + bridge_token_env: CF_BRIDGE_TOKEN + max_concurrent_sandboxes: 10 + default_idle_timeout_secs: 300 + image_allowlist: [] diff --git a/sandbox-cf/package.json b/sandbox-cf/package.json new file mode 100644 index 00000000..13be9447 --- /dev/null +++ b/sandbox-cf/package.json @@ -0,0 +1,22 @@ +{ + "name": "sandbox-cf", + "version": "0.1.0", + "private": true, + "type": "module", + "license": "Apache-2.0", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "test": "vitest run" + }, + "dependencies": { + "iii-sdk": "0.11.3" + }, + "devDependencies": { + "tsx": "^4.21.0", + "@types/node": "^24.10.1", + "typescript": "^5.9.3", + "vitest": "^4.1.5" + } +} diff --git a/sandbox-cf/src/client.ts b/sandbox-cf/src/client.ts new file mode 100644 index 00000000..e2618723 --- /dev/null +++ b/sandbox-cf/src/client.ts @@ -0,0 +1,61 @@ +// Narrow HTTP client for the Cloudflare Worker bridge deployed out of +// `bridge/`. Every method posts JSON to a route the bridge exposes. The +// bridge itself is the thing that calls `getSandbox(env.Sandbox, id)` and +// drives the Container. +// +// v0 ships stub bodies so the worker compiles, types match the ABI, and +// concurrency tracking + S-code mapping are exercised by the smoke tests. +// The follow-up commit wires real fetch() calls once the bridge lands. + +import { type CreatedSandbox, type ExecResult, S, SandboxError, type SandboxRecord } from './sandbox.js' + +export interface BridgeAuth { + url: string + token: string +} + +export class CfBridgeClient { + bridge_url: string + token: string + + constructor(auth: BridgeAuth) { + this.bridge_url = auth.url.replace(/\/$/, '') + this.token = auth.token + } + + async create(_image: string, _idle_timeout_secs: number): Promise { + throw new SandboxError(S.PROVIDER_UNAVAILABLE, 'TODO: POST $bridge/create') + } + + async exec(_sandbox_id: string, _cmd: string, _args: string[], _timeout_ms?: number): Promise { + throw new SandboxError(S.PROVIDER_UNAVAILABLE, 'TODO: POST $bridge/exec') + } + + async stop(_sandbox_id: string): Promise { + throw new SandboxError(S.PROVIDER_UNAVAILABLE, 'TODO: POST $bridge/stop') + } + + async list(): Promise { + throw new SandboxError(S.PROVIDER_UNAVAILABLE, 'TODO: GET $bridge/list') + } + + async exposePort(_sandbox_id: string, _port: number): Promise { + throw new SandboxError(S.PROVIDER_UNAVAILABLE, 'TODO: POST $bridge/expose-port') + } + + async fsRead(_sandbox_id: string, _path: string): Promise { + throw new SandboxError(S.PROVIDER_UNAVAILABLE, 'TODO: POST $bridge/fs/read') + } + + async fsWrite(_sandbox_id: string, _path: string, _bytes: Uint8Array, _mode?: number): Promise { + throw new SandboxError(S.PROVIDER_UNAVAILABLE, 'TODO: POST $bridge/fs/write') + } +} + +export function authFromEnv(cfg: { bridge_url_env: string; bridge_token_env: string }): BridgeAuth { + const url = process.env[cfg.bridge_url_env] + const token = process.env[cfg.bridge_token_env] + if (!url) throw new Error(`sandbox-cf: ${cfg.bridge_url_env} is not set; deploy bridge/ first`) + if (!token) throw new Error(`sandbox-cf: ${cfg.bridge_token_env} is not set; share secret with bridge`) + return { url, token } +} diff --git a/sandbox-cf/src/config.ts b/sandbox-cf/src/config.ts new file mode 100644 index 00000000..7661057f --- /dev/null +++ b/sandbox-cf/src/config.ts @@ -0,0 +1,65 @@ +import { readFileSync } from 'node:fs' + +export interface Config { + bridge_url_env: string + bridge_token_env: string + max_concurrent_sandboxes: number + default_idle_timeout_secs: number + image_allowlist: string[] +} + +export const DEFAULT_CONFIG: Config = { + bridge_url_env: 'CF_BRIDGE_URL', + bridge_token_env: 'CF_BRIDGE_TOKEN', + max_concurrent_sandboxes: 10, + default_idle_timeout_secs: 300, + image_allowlist: [], +} + +function parseYamlish(raw: string): Record { + const out: Record = {} + let currentList: string[] | null = null + for (const line of raw.split('\n')) { + const stripped = line.replace(/#.*$/, '').trimEnd() + if (!stripped.trim()) continue + if (stripped.startsWith(' - ') && currentList !== null) { + currentList.push( + stripped + .slice(4) + .trim() + .replace(/^["']|["']$/g, ''), + ) + continue + } + const m = /^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)$/.exec(stripped) + if (!m) continue + const [, key, val] = m + if (val.trim() === '') { + currentList = [] + out[key] = currentList + } else if (val.trim() === '[]') { + out[key] = [] + currentList = null + } else if (/^-?\d+$/.test(val.trim())) { + out[key] = Number(val.trim()) + currentList = null + } else { + out[key] = val.trim().replace(/^["']|["']$/g, '') + currentList = null + } + } + return out +} + +export function loadConfig(path: string): Config { + try { + const raw = readFileSync(path, 'utf8') + return { ...DEFAULT_CONFIG, ...(parseYamlish(raw) as Partial) } + } catch { + return DEFAULT_CONFIG + } +} + +export function imageAllowed(cfg: Config, image: string): boolean { + return cfg.image_allowlist.length === 0 || cfg.image_allowlist.includes(image) +} diff --git a/sandbox-cf/src/handlers.ts b/sandbox-cf/src/handlers.ts new file mode 100644 index 00000000..6cb81f6e --- /dev/null +++ b/sandbox-cf/src/handlers.ts @@ -0,0 +1,102 @@ +import type { CfBridgeClient } from './client.js' +import type { Config } from './config.js' +import { imageAllowed } from './config.js' +import { CAPABILITIES, S, SandboxError } from './sandbox.js' + +export interface HandlerCtx { + config: Config + client: CfBridgeClient + inFlight: { value: number } +} + +function requireStr(input: Record, key: string): string { + const v = input[key] + if (typeof v !== 'string' || v.length === 0) { + throw new SandboxError(S.PROVIDER_UNAVAILABLE, `missing string field "${key}"`) + } + return v +} + +export async function doCreate(ctx: HandlerCtx, input: Record) { + const image = requireStr(input, 'image') + if (!imageAllowed(ctx.config, image)) { + throw new SandboxError(S.IMAGE_NOT_ALLOWED, `image not in allowlist: ${image}`) + } + const idle = + typeof input.idle_timeout_secs === 'number' ? input.idle_timeout_secs : ctx.config.default_idle_timeout_secs + + if (ctx.inFlight.value >= ctx.config.max_concurrent_sandboxes) { + throw new SandboxError(S.CONCURRENCY_CAP, `concurrency cap reached (${ctx.inFlight.value} active)`) + } + ctx.inFlight.value += 1 + try { + const created = await ctx.client.create(image, idle) + return { + sandbox_id: created.sandbox_id, + image: created.image, + started_at: created.started_at, + capabilities: CAPABILITIES, + } + } catch (e) { + ctx.inFlight.value -= 1 + throw e + } +} + +export async function doExec(ctx: HandlerCtx, input: Record) { + const sandbox_id = requireStr(input, 'sandbox_id') + const cmd = requireStr(input, 'cmd') + const args = Array.isArray(input.args) ? (input.args as string[]) : [] + const timeout_ms = typeof input.timeout_ms === 'number' ? input.timeout_ms : undefined + return ctx.client.exec(sandbox_id, cmd, args, timeout_ms) +} + +export async function doStop(ctx: HandlerCtx, input: Record) { + const sandbox_id = requireStr(input, 'sandbox_id') + await ctx.client.stop(sandbox_id) + if (ctx.inFlight.value > 0) ctx.inFlight.value -= 1 + return {} +} + +export async function doList(ctx: HandlerCtx, _input: Record) { + let sandboxes: Awaited> = [] + try { + sandboxes = await ctx.client.list() + } catch { + sandboxes = [] + } + const cap = ctx.config.max_concurrent_sandboxes + return { + sandboxes, + in_flight: ctx.inFlight.value, + cap, + remaining: Math.max(cap - ctx.inFlight.value, 0), + } +} + +export async function doExposePort(ctx: HandlerCtx, input: Record) { + const sandbox_id = requireStr(input, 'sandbox_id') + const port = input.port + if (typeof port !== 'number' || port < 1 || port > 65535) { + throw new SandboxError(S.PROVIDER_UNAVAILABLE, 'invalid port') + } + const url = await ctx.client.exposePort(sandbox_id, port) + return { url } +} + +export async function doFsRead(ctx: HandlerCtx, input: Record) { + const sandbox_id = requireStr(input, 'sandbox_id') + const path = requireStr(input, 'path') + const bytes = await ctx.client.fsRead(sandbox_id, path) + return { bytes_base64: Buffer.from(bytes).toString('base64') } +} + +export async function doFsWrite(ctx: HandlerCtx, input: Record) { + const sandbox_id = requireStr(input, 'sandbox_id') + const path = requireStr(input, 'path') + const bytes_b64 = requireStr(input, 'bytes_base64') + const bytes = Buffer.from(bytes_b64, 'base64') + const mode = typeof input.mode === 'number' ? input.mode : undefined + await ctx.client.fsWrite(sandbox_id, path, bytes, mode) + return {} +} diff --git a/sandbox-cf/src/index.ts b/sandbox-cf/src/index.ts new file mode 100644 index 00000000..33952bcf --- /dev/null +++ b/sandbox-cf/src/index.ts @@ -0,0 +1,40 @@ +import { Logger, registerWorker } from 'iii-sdk' +import { authFromEnv, CfBridgeClient } from './client.js' +import { loadConfig } from './config.js' +import { doCreate, doExec, doExposePort, doFsRead, doFsWrite, doList, doStop, type HandlerCtx } from './handlers.js' + +const cfgPath = process.env.SANDBOX_CF_CONFIG ?? './config.yaml' +const config = loadConfig(cfgPath) +const auth = authFromEnv(config) +const client = new CfBridgeClient(auth) +const ctx: HandlerCtx = { config, client, inFlight: { value: 0 } } + +const iii = registerWorker(process.env.III_URL ?? 'ws://localhost:49134') +const logger = new Logger(undefined, 'sandbox-cf') + +function reg(id: string, handler: (input: Record) => Promise) { + iii.registerFunction(id, async (input) => { + try { + return await handler((input ?? {}) as Record) + } catch (e) { + const err = e as Error + logger.warn?.(`${id} failed: ${err.message}`) + throw err + } + }) +} + +reg('sandbox::cf::create', (i) => doCreate(ctx, i)) +reg('sandbox::cf::exec', (i) => doExec(ctx, i)) +reg('sandbox::cf::stop', (i) => doStop(ctx, i)) +reg('sandbox::cf::list', (i) => doList(ctx, i)) +reg('sandbox::cf::expose_port', (i) => doExposePort(ctx, i)) +reg('sandbox::cf::fs::read', (i) => doFsRead(ctx, i)) +reg('sandbox::cf::fs::write', (i) => doFsWrite(ctx, i)) + +logger.info?.('sandbox-cf registered, awaiting invocations') + +process.on('SIGTERM', () => { + logger.info?.('SIGTERM received, shutting down') + process.exit(0) +}) diff --git a/sandbox-cf/src/sandbox.ts b/sandbox-cf/src/sandbox.ts new file mode 100644 index 00000000..1114f240 --- /dev/null +++ b/sandbox-cf/src/sandbox.ts @@ -0,0 +1,55 @@ +// Stable error code space shared with every sandbox provider worker in the +// iii-hq/workers monorepo. S100-S400 mirror the libkrun-backed iii-sandbox +// daemon; S404 and S500-S503 are bridge-/REST-specific extensions. +export const S = { + IMAGE_NOT_ALLOWED: 'S100', + CONCURRENCY_CAP: 'S400', + CAPABILITY_UNSUPPORTED: 'S404', + RATE_LIMITED: 'S500', + QUOTA_EXHAUSTED: 'S501', + PROVIDER_UNAVAILABLE: 'S502', + AUTH_INVALID: 'S503', +} as const + +export type SCode = (typeof S)[keyof typeof S] + +export class SandboxError extends Error { + code: SCode + constructor(code: SCode, message: string) { + super(`[${code}] ${message}`) + this.code = code + this.name = 'SandboxError' + } +} + +// Capabilities advertised by sandbox::cf::create. CF Sandbox doesn't ship +// snapshot or branch; the bridge exposes only exec, fs, and port surfaces. +export const CAPABILITIES = ['expose_port', 'fs'] as const + +// Map a non-2xx HTTP status from the bridge onto the right S-code. +export function mapHttpStatus(status: number, body: string): SandboxError { + if (status === 401 || status === 403) return new SandboxError(S.AUTH_INVALID, 'bridge auth invalid') + if (status === 402) return new SandboxError(S.QUOTA_EXHAUSTED, 'quota exhausted') + if (status === 429) return new SandboxError(S.RATE_LIMITED, 'rate limited by bridge') + if (status >= 500) return new SandboxError(S.PROVIDER_UNAVAILABLE, `bridge ${status}: ${body}`) + return new SandboxError(S.PROVIDER_UNAVAILABLE, `bridge unexpected ${status}: ${body}`) +} + +export interface CreatedSandbox { + sandbox_id: string + image: string + started_at: number +} + +export interface ExecResult { + stdout: string + stderr: string + exit_code: number + timed_out: boolean +} + +export interface SandboxRecord { + sandbox_id: string + image: string + started_at: number +} diff --git a/sandbox-cf/tests/handlers.test.ts b/sandbox-cf/tests/handlers.test.ts new file mode 100644 index 00000000..35c1b85d --- /dev/null +++ b/sandbox-cf/tests/handlers.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { CfBridgeClient } from '../src/client.js' +import { DEFAULT_CONFIG } from '../src/config.js' +import { doCreate, doExec, doList, doStop, type HandlerCtx } from '../src/handlers.js' +import { S, SandboxError } from '../src/sandbox.js' + +function ctx(max: number, allowlist: string[] = []): HandlerCtx { + const config = { + ...DEFAULT_CONFIG, + max_concurrent_sandboxes: max, + image_allowlist: allowlist, + } + const client = new CfBridgeClient({ + url: 'https://bridge.example.workers.dev', + token: 'test-token', + }) + return { config, client, inFlight: { value: 0 } } +} + +describe('sandbox::cf handlers', () => { + it('create rejects image not in allowlist', async () => { + const c = ctx(10, ['python']) + await expect(doCreate(c, { image: 'node' })).rejects.toBeInstanceOf(SandboxError) + try { + await doCreate(c, { image: 'node' }) + } catch (e) { + expect((e as SandboxError).code).toBe(S.IMAGE_NOT_ALLOWED) + } + }) + + it('create rolls back in_flight when bridge fails', async () => { + const c = ctx(2) + await expect(doCreate(c, { image: 'python' })).rejects.toThrow() + const list = await doList(c, {}) + expect(list).toMatchObject({ in_flight: 0, cap: 2, remaining: 2 }) + }) + + it('exec rejects missing fields', async () => { + const c = ctx(10) + await expect(doExec(c, {})).rejects.toThrow(/missing string field/) + }) + + it('stop maps stub bridge error path', async () => { + const c = ctx(10) + await expect(doStop(c, { sandbox_id: 'sbx-1' })).rejects.toBeInstanceOf(SandboxError) + }) + + it('list reports capacity envelope', async () => { + const c = ctx(7) + const result = await doList(c, {}) + expect(result).toMatchObject({ cap: 7, in_flight: 0, remaining: 7, sandboxes: [] }) + }) +}) diff --git a/sandbox-cf/tsconfig.json b/sandbox-cf/tsconfig.json new file mode 100644 index 00000000..dfa87e9a --- /dev/null +++ b/sandbox-cf/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} From 5c9852f78fbbfe9eb31b1b6faead59de81dca33d Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Thu, 7 May 2026 13:36:23 +0100 Subject: [PATCH 2/5] feat(sandbox-cf/bridge): wire @cloudflare/sandbox SDK calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces 501 stubs with real getSandbox().* calls verified via context7 against /cloudflare/sandbox-sdk: - POST /create → getSandbox(env.Sandbox, id, {sleepAfter}); exec('true') to force container provisioning; returns {sandbox_id, image, started_at} - POST /exec → sandbox.exec(buildExecCommand(cmd, args), {timeout, cwd, env}) → maps {stdout, stderr, exitCode, success} to the iii ABI - POST /stop → sandbox.destroy() - GET /list → 501 'sdk has no list primitive' — the iii worker side already falls back to local in_flight accounting when the bridge can't answer (matches the resilience the reconciliation pattern guarantees for every provider) - POST /expose-port → sandbox.exposePort(port, {hostname, name}) using a PREVIEW_HOSTNAME var; surfaces a clear 500 if the operator forgot to set it (custom domain required per the SDK contract — workers.dev won't work) - POST /fs/read → sandbox.readFile(path); base64-encoded for the wire - POST /fs/write → sandbox.writeFile(path, content) proxyToSandbox runs at the top of fetch() so preview-port URLs get routed before the bearer auth check (preview URLs carry their own token). Existing 5 wiremock tests on the iii worker side keep passing; bridge typecheck clean. Live verification deferred until someone deploys the bridge with wrangler. --- sandbox-cf/bridge/src/index.ts | 226 ++++++++++++++++++++++++--------- 1 file changed, 166 insertions(+), 60 deletions(-) diff --git a/sandbox-cf/bridge/src/index.ts b/sandbox-cf/bridge/src/index.ts index b2cde9da..10b067ba 100644 --- a/sandbox-cf/bridge/src/index.ts +++ b/sandbox-cf/bridge/src/index.ts @@ -1,43 +1,99 @@ -// Cloudflare Worker bridge for sandbox-cf. The iii worker side (in this -// crate's parent dir) talks to this Worker over HTTPS; this Worker calls -// `getSandbox(env.Sandbox, id)` on the @cloudflare/sandbox SDK and drives -// the underlying Container Durable Object. +// Cloudflare Worker bridge for sandbox-cf. The iii worker side (parent +// dir) talks to this Worker over HTTPS; this Worker calls @cloudflare/sandbox's +// `getSandbox(env.Sandbox, id)` and drives the underlying Container +// Durable Object. // // Auth: shared bearer token, set via `wrangler secret put CF_BRIDGE_TOKEN`. // -// v0 ships the route shell + auth check; the actual getSandbox() calls are -// stubbed in TODO comments and return 501. The follow-up commit wires the -// SDK once we've validated the deploy story end to end. +// SDK shapes verified via context7 against /cloudflare/sandbox-sdk: +// sandbox.exec(cmd, {timeout, cwd, env, signal}) → {stdout, stderr, exitCode, success} +// sandbox.writeFile(path, content) +// sandbox.readFile(path) → {content} +// sandbox.exposePort(port, {hostname, name, token}) → {url, port, name} +// sandbox.destroy() + +import { getSandbox, proxyToSandbox, type Sandbox } from '@cloudflare/sandbox' interface Env { - Sandbox: DurableObjectNamespace + Sandbox: DurableObjectNamespace CF_BRIDGE_TOKEN: string + PREVIEW_HOSTNAME?: string } -function unauthorized(): Response { - return new Response(JSON.stringify({ error: 'unauthorized' }), { - status: 401, +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, headers: { 'Content-Type': 'application/json' }, }) } -function notImplemented(route: string): Response { - return new Response(JSON.stringify({ error: `[S502] TODO: wire ${route} -> getSandbox().*` }), { - status: 501, - headers: { 'Content-Type': 'application/json' }, - }) +function unauthorized(): Response { + return jsonResponse({ error: 'unauthorized' }, 401) +} + +function bridgeError(status: number, message: string): Response { + return jsonResponse({ error: message }, status) } -async function readJson(req: Request): Promise> { +async function readJson>(req: Request): Promise { try { - return (await req.json()) as Record + return (await req.json()) as T } catch { - return {} + return {} as T } } +interface CreatePayload { + sandbox_id?: string + image?: string + idle_timeout_secs?: number +} + +interface ExecPayload { + sandbox_id: string + cmd: string + args?: string[] + cwd?: string + env?: Record + timeout_ms?: number +} + +interface IdPayload { + sandbox_id: string +} + +interface ExposePortPayload { + sandbox_id: string + port: number + name?: string + token?: string +} + +interface FsReadPayload { + sandbox_id: string + path: string +} + +interface FsWritePayload { + sandbox_id: string + path: string + bytes_base64: string + mode?: number +} + +function buildExecCommand(cmd: string, args: string[] | undefined): string { + if (!args || args.length === 0) return cmd + // Naively quote args; sandbox.exec runs the resulting string in a shell. + const quoted = args.map((a) => `'${a.replace(/'/g, "'\\''")}'`) + return [cmd, ...quoted].join(' ') +} + export default { async fetch(req: Request, env: Env): Promise { + // Required for preview URLs (sandbox.exposePort routing). + const proxyResponse = await proxyToSandbox(req, env) + if (proxyResponse) return proxyResponse + const auth = req.headers.get('Authorization') ?? '' if (!auth.startsWith('Bearer ') || auth.slice(7) !== env.CF_BRIDGE_TOKEN) { return unauthorized() @@ -46,48 +102,98 @@ export default { const url = new URL(req.url) const route = `${req.method} ${url.pathname}` - switch (route) { - case 'POST /create': { - const _payload = await readJson(req) - // TODO: const sandbox = getSandbox(env.Sandbox, payload.sandbox_id ?? crypto.randomUUID()) - // TODO: register the sandbox.exec / writeFile / readFile bindings before first use - return notImplemented('POST /create') - } - case 'POST /exec': { - const _payload = await readJson(req) - // TODO: const r = await getSandbox(env.Sandbox, payload.sandbox_id).exec(payload.cmd, { ...opts }) - return notImplemented('POST /exec') - } - case 'POST /stop': { - const _payload = await readJson(req) - // TODO: await getSandbox(env.Sandbox, payload.sandbox_id).destroy() - return notImplemented('POST /stop') - } - case 'GET /list': { - // TODO: enumerate live sandbox ids; SDK does not currently expose - // a list primitive — see follow-up issue. - return notImplemented('GET /list') - } - case 'POST /expose-port': { - const _payload = await readJson(req) - // TODO: getSandbox(env.Sandbox, payload.sandbox_id).exposePort(payload.port, { ... }) - return notImplemented('POST /expose-port') - } - case 'POST /fs/read': { - const _payload = await readJson(req) - // TODO: getSandbox(env.Sandbox, payload.sandbox_id).readFile(payload.path) - return notImplemented('POST /fs/read') - } - case 'POST /fs/write': { - const _payload = await readJson(req) - // TODO: getSandbox(env.Sandbox, payload.sandbox_id).writeFile(payload.path, decoded, { mode }) - return notImplemented('POST /fs/write') + try { + switch (route) { + case 'POST /create': { + const payload = await readJson(req) + const id = payload.sandbox_id ?? crypto.randomUUID() + const sandbox = getSandbox(env.Sandbox, id, { + sleepAfter: payload.idle_timeout_secs ? `${payload.idle_timeout_secs}s` : '5m', + }) + // Touch the sandbox so the container is provisioned. exec("true") + // is the cheapest way to force the DO to wake the container. + await sandbox.exec('true') + return jsonResponse({ + sandbox_id: id, + image: payload.image ?? 'default', + started_at: new Date().toISOString(), + }) + } + case 'POST /exec': { + const p = await readJson(req) + if (!p.sandbox_id || !p.cmd) return bridgeError(400, 'missing sandbox_id or cmd') + const sandbox = getSandbox(env.Sandbox, p.sandbox_id) + const command = buildExecCommand(p.cmd, p.args) + const result = await sandbox.exec(command, { + timeout: p.timeout_ms, + cwd: p.cwd, + env: p.env, + }) + return jsonResponse({ + stdout: result.stdout, + stderr: result.stderr ?? '', + exit_code: result.exitCode, + timed_out: false, + success: result.success, + }) + } + case 'POST /stop': { + const p = await readJson(req) + if (!p.sandbox_id) return bridgeError(400, 'missing sandbox_id') + const sandbox = getSandbox(env.Sandbox, p.sandbox_id) + await sandbox.destroy() + return jsonResponse({}) + } + case 'GET /list': { + // The SDK does not expose a list primitive yet. The iii worker + // side reconciles in_flight via its local counter when the + // upstream answer is unavailable, so we surface 501 explicitly + // and the worker falls back to local accounting. + return bridgeError(501, 'cf sandbox sdk has no list primitive') + } + case 'POST /expose-port': { + const p = await readJson(req) + if (!p.sandbox_id || !p.port) return bridgeError(400, 'missing sandbox_id or port') + if (!env.PREVIEW_HOSTNAME) { + return bridgeError(500, 'PREVIEW_HOSTNAME var not set; expose-port requires a custom domain on the bridge') + } + const sandbox = getSandbox(env.Sandbox, p.sandbox_id) + // The SDK's exposePort options vary across versions — older + // releases accept `token` for stable URLs while newer ones use + // a different knob. We pass only the universally-supported + // fields and let `name` carry caller intent. + const _token = p.token + const result = await sandbox.exposePort(p.port, { + hostname: env.PREVIEW_HOSTNAME, + name: p.name, + }) + return jsonResponse({ url: result.url }) + } + case 'POST /fs/read': { + const p = await readJson(req) + if (!p.sandbox_id || !p.path) return bridgeError(400, 'missing sandbox_id or path') + const sandbox = getSandbox(env.Sandbox, p.sandbox_id) + const file = await sandbox.readFile(p.path) + // Caller expects base64 bytes; readFile returns string content. + const bytes_base64 = btoa(unescape(encodeURIComponent(file.content))) + return jsonResponse({ bytes_base64 }) + } + case 'POST /fs/write': { + const p = await readJson(req) + if (!p.sandbox_id || !p.path || !p.bytes_base64) { + return bridgeError(400, 'missing sandbox_id, path, or bytes_base64') + } + const sandbox = getSandbox(env.Sandbox, p.sandbox_id) + const decoded = decodeURIComponent(escape(atob(p.bytes_base64))) + await sandbox.writeFile(p.path, decoded) + return jsonResponse({}) + } + default: + return bridgeError(404, `unknown route: ${route}`) } - default: - return new Response(JSON.stringify({ error: `unknown route: ${route}` }), { - status: 404, - headers: { 'Content-Type': 'application/json' }, - }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return bridgeError(502, `bridge error: ${message}`) } }, } From 052b3ac55188021fe309cecbdb641bd805c1b74f Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Thu, 7 May 2026 13:45:50 +0100 Subject: [PATCH 3/5] =?UTF-8?q?chore(sandbox-cf):=20bump=20iii-sdk=200.11.?= =?UTF-8?q?3=20=E2=86=92=200.11.6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iii worker side only — bridge runs CF Worker runtime and pulls @cloudflare/sandbox separately. Existing tests + typecheck still green. --- sandbox-cf/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sandbox-cf/package.json b/sandbox-cf/package.json index 13be9447..33de9405 100644 --- a/sandbox-cf/package.json +++ b/sandbox-cf/package.json @@ -11,7 +11,7 @@ "test": "vitest run" }, "dependencies": { - "iii-sdk": "0.11.3" + "iii-sdk": "0.11.6" }, "devDependencies": { "tsx": "^4.21.0", From c7cde943d90120f801ab4d2e5187e766dd584d6b Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Thu, 7 May 2026 13:50:46 +0100 Subject: [PATCH 4/5] =?UTF-8?q?rename:=20sandbox-cf=20=E2=86=92=20sandbox-?= =?UTF-8?q?cloudflare?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spell out the provider name everywhere — folder, branch, function ids (`sandbox::cloudflare::*`), package names (`sandbox-cloudflare`, `sandbox-cloudflare-bridge`), and env vars (`CLOUDFLARE_BRIDGE_URL`, `CLOUDFLARE_BRIDGE_TOKEN`, `SANDBOX_CLOUDFLARE_CONFIG`). `cf` was an internal shorthand that read like a half-baked acronym to anyone who hadn't been in the conversation. Matches the explicit naming the rest of the family uses (sandbox-e2b, sandbox-daytona, sandbox-morph, sandbox-vercel, sandbox-modal). Tests: 5/5 vitest, biome check + ci clean, top-level tsc clean, bridge tsc clean. --- sandbox-cf/iii.worker.yaml | 12 ------- {sandbox-cf => sandbox-cloudflare}/.gitignore | 0 {sandbox-cf => sandbox-cloudflare}/Dockerfile | 0 {sandbox-cf => sandbox-cloudflare}/README.md | 36 +++++++++---------- .../bridge/README.md | 10 +++--- .../bridge/package.json | 2 +- .../bridge/src/index.ts | 8 ++--- .../bridge/tsconfig.json | 0 .../bridge/wrangler.jsonc | 4 +-- sandbox-cloudflare/iii.worker.yaml | 12 +++++++ .../package.json | 2 +- .../src/client.ts | 4 +-- .../src/config.ts | 4 +-- .../src/handlers.ts | 0 .../src/index.ts | 20 +++++------ .../src/sandbox.ts | 2 +- .../tests/handlers.test.ts | 2 +- .../tsconfig.json | 0 18 files changed, 59 insertions(+), 59 deletions(-) delete mode 100644 sandbox-cf/iii.worker.yaml rename {sandbox-cf => sandbox-cloudflare}/.gitignore (100%) rename {sandbox-cf => sandbox-cloudflare}/Dockerfile (100%) rename {sandbox-cf => sandbox-cloudflare}/README.md (58%) rename {sandbox-cf => sandbox-cloudflare}/bridge/README.md (71%) rename {sandbox-cf => sandbox-cloudflare}/bridge/package.json (90%) rename {sandbox-cf => sandbox-cloudflare}/bridge/src/index.ts (96%) rename {sandbox-cf => sandbox-cloudflare}/bridge/tsconfig.json (100%) rename {sandbox-cf => sandbox-cloudflare}/bridge/wrangler.jsonc (90%) create mode 100644 sandbox-cloudflare/iii.worker.yaml rename {sandbox-cf => sandbox-cloudflare}/package.json (92%) rename {sandbox-cf => sandbox-cloudflare}/src/client.ts (90%) rename {sandbox-cf => sandbox-cloudflare}/src/config.ts (95%) rename {sandbox-cf => sandbox-cloudflare}/src/handlers.ts (100%) rename {sandbox-cf => sandbox-cloudflare}/src/index.ts (60%) rename {sandbox-cf => sandbox-cloudflare}/src/sandbox.ts (95%) rename {sandbox-cf => sandbox-cloudflare}/tests/handlers.test.ts (97%) rename {sandbox-cf => sandbox-cloudflare}/tsconfig.json (100%) diff --git a/sandbox-cf/iii.worker.yaml b/sandbox-cf/iii.worker.yaml deleted file mode 100644 index 5699c182..00000000 --- a/sandbox-cf/iii.worker.yaml +++ /dev/null @@ -1,12 +0,0 @@ -iii: v1 -name: sandbox-cf -language: node -deploy: image -manifest: package.json -description: Narrow iii worker that exposes Cloudflare Sandbox via the sandbox::cf::* trigger family. Talks HTTPS to a thin Cloudflare Worker bridge (deployed separately from this worker's bridge/ subdir) because CF Sandbox lives inside a Durable Object on the Workers runtime, not on a host iii engine can run. -config: - bridge_url_env: CF_BRIDGE_URL - bridge_token_env: CF_BRIDGE_TOKEN - max_concurrent_sandboxes: 10 - default_idle_timeout_secs: 300 - image_allowlist: [] diff --git a/sandbox-cf/.gitignore b/sandbox-cloudflare/.gitignore similarity index 100% rename from sandbox-cf/.gitignore rename to sandbox-cloudflare/.gitignore diff --git a/sandbox-cf/Dockerfile b/sandbox-cloudflare/Dockerfile similarity index 100% rename from sandbox-cf/Dockerfile rename to sandbox-cloudflare/Dockerfile diff --git a/sandbox-cf/README.md b/sandbox-cloudflare/README.md similarity index 58% rename from sandbox-cf/README.md rename to sandbox-cloudflare/README.md index ac006429..000bf4d4 100644 --- a/sandbox-cf/README.md +++ b/sandbox-cloudflare/README.md @@ -1,8 +1,8 @@ -# sandbox-cf +# sandbox-cloudflare -Narrow iii worker that exposes [Cloudflare Sandbox](https://developers.cloudflare.com/sandbox/) under the canonical `sandbox::cf::*` ABI. Unlike the other workers in this family, `sandbox-cf` ships **two artifacts**: +Narrow iii worker that exposes [Cloudflare Sandbox](https://developers.cloudflare.com/sandbox/) under the canonical `sandbox::cloudflare::*` ABI. Unlike the other workers in this family, `sandbox-cloudflare` ships **two artifacts**: -1. **iii worker** (this folder, top-level files) — runs on a host the iii engine controls. Registers `sandbox::cf::*` functions. Talks HTTPS to (2). +1. **iii worker** (this folder, top-level files) — runs on a host the iii engine controls. Registers `sandbox::cloudflare::*` functions. Talks HTTPS to (2). 2. **CF Worker bridge** (`bridge/` subfolder) — separately deployed via `wrangler deploy`. Hosts the `Sandbox` Durable Object class from `@cloudflare/sandbox`. Receives HTTPS calls from (1) and drives the Container. The bridge exists because CF Sandbox lives inside the Workers V8 runtime — there is no way to reach `getSandbox()` from a host-side process. The bridge is the smallest amount of CF-native code needed. @@ -11,13 +11,13 @@ The bridge exists because CF Sandbox lives inside the Workers V8 runtime — the | Function id | Purpose | |---|---| -| `sandbox::cf::create` | Boot a sandbox; returns `{sandbox_id, image, capabilities}` | -| `sandbox::cf::exec` | Run a command inside a live sandbox | -| `sandbox::cf::stop` | Tear down a sandbox | -| `sandbox::cf::list` | Enumerate live sandboxes plus concurrency status | -| `sandbox::cf::expose_port` | Public URL for a port (requires custom domain on the bridge) | -| `sandbox::cf::fs::read` | Read a file out of the sandbox | -| `sandbox::cf::fs::write` | Write a file into the sandbox | +| `sandbox::cloudflare::create` | Boot a sandbox; returns `{sandbox_id, image, capabilities}` | +| `sandbox::cloudflare::exec` | Run a command inside a live sandbox | +| `sandbox::cloudflare::stop` | Tear down a sandbox | +| `sandbox::cloudflare::list` | Enumerate live sandboxes plus concurrency status | +| `sandbox::cloudflare::expose_port` | Public URL for a port (requires custom domain on the bridge) | +| `sandbox::cloudflare::fs::read` | Read a file out of the sandbox | +| `sandbox::cloudflare::fs::write` | Write a file into the sandbox | `create` advertises capabilities `["expose_port", "fs"]`. CF Sandbox does not ship `snapshot` or `branch`; callers that depend on those should pick a different provider. @@ -26,25 +26,25 @@ The bridge exists because CF Sandbox lives inside the Workers V8 runtime — the 1. **Deploy the bridge** (see `bridge/README.md`): ```bash cd bridge && npm install - wrangler secret put CF_BRIDGE_TOKEN + wrangler secret put CLOUDFLARE_BRIDGE_TOKEN wrangler deploy ``` - Wrangler prints a bridge URL like `https://sandbox-cf-bridge..workers.dev`. + Wrangler prints a bridge URL like `https://sandbox-cloudflare-bridge..workers.dev`. 2. **Run the iii worker** with the bridge URL + shared secret in the environment: ```bash - export CF_BRIDGE_URL="https://sandbox-cf-bridge..workers.dev" - export CF_BRIDGE_TOKEN="" - iii worker add sandbox-cf + export CLOUDFLARE_BRIDGE_URL="https://sandbox-cloudflare-bridge..workers.dev" + export CLOUDFLARE_BRIDGE_TOKEN="" + iii worker add sandbox-cloudflare ``` ## Configuration -`config.yaml` next to the binary, or set `SANDBOX_CF_CONFIG` to a path: +`config.yaml` next to the binary, or set `SANDBOX_CLOUDFLARE_CONFIG` to a path: ```yaml -bridge_url_env: CF_BRIDGE_URL -bridge_token_env: CF_BRIDGE_TOKEN +bridge_url_env: CLOUDFLARE_BRIDGE_URL +bridge_token_env: CLOUDFLARE_BRIDGE_TOKEN max_concurrent_sandboxes: 10 default_idle_timeout_secs: 300 image_allowlist: [] diff --git a/sandbox-cf/bridge/README.md b/sandbox-cloudflare/bridge/README.md similarity index 71% rename from sandbox-cf/bridge/README.md rename to sandbox-cloudflare/bridge/README.md index b6198826..41eb6e82 100644 --- a/sandbox-cf/bridge/README.md +++ b/sandbox-cloudflare/bridge/README.md @@ -1,6 +1,6 @@ -# sandbox-cf bridge +# sandbox-cloudflare bridge -Thin Cloudflare Worker that exposes HTTPS routes corresponding to every `sandbox::cf::*` function, calling `@cloudflare/sandbox`'s `getSandbox(env.Sandbox, id)` underneath. The iii worker in the parent directory talks to this bridge — that's the only way to reach a CF Sandbox from outside the Cloudflare Workers runtime. +Thin Cloudflare Worker that exposes HTTPS routes corresponding to every `sandbox::cloudflare::*` function, calling `@cloudflare/sandbox`'s `getSandbox(env.Sandbox, id)` underneath. The iii worker in the parent directory talks to this bridge — that's the only way to reach a CF Sandbox from outside the Cloudflare Workers runtime. ## Why a bridge @@ -18,17 +18,17 @@ CF Sandbox is a Durable Object that owns a Container. Both run inside the Cloudf | POST | `/fs/read` | `sandbox.readFile(path)` | | POST | `/fs/write` | `sandbox.writeFile(path, bytes, { mode })` | -All routes require `Authorization: Bearer ` (shared secret with the iii worker). +All routes require `Authorization: Bearer ` (shared secret with the iii worker). ## Deploy ```bash npm install -wrangler secret put CF_BRIDGE_TOKEN # paste the same token you'll set in CF_BRIDGE_TOKEN on the iii worker side +wrangler secret put CLOUDFLARE_BRIDGE_TOKEN # paste the same token you'll set in CLOUDFLARE_BRIDGE_TOKEN on the iii worker side wrangler deploy ``` -`wrangler deploy` prints the bridge URL (e.g. `https://sandbox-cf-bridge..workers.dev`). Set that as `CF_BRIDGE_URL` on the iii worker side. +`wrangler deploy` prints the bridge URL (e.g. `https://sandbox-cloudflare-bridge..workers.dev`). Set that as `CLOUDFLARE_BRIDGE_URL` on the iii worker side. ## Status diff --git a/sandbox-cf/bridge/package.json b/sandbox-cloudflare/bridge/package.json similarity index 90% rename from sandbox-cf/bridge/package.json rename to sandbox-cloudflare/bridge/package.json index caa89309..e9db2446 100644 --- a/sandbox-cf/bridge/package.json +++ b/sandbox-cloudflare/bridge/package.json @@ -1,5 +1,5 @@ { - "name": "sandbox-cf-bridge", + "name": "sandbox-cloudflare-bridge", "version": "0.1.0", "private": true, "license": "Apache-2.0", diff --git a/sandbox-cf/bridge/src/index.ts b/sandbox-cloudflare/bridge/src/index.ts similarity index 96% rename from sandbox-cf/bridge/src/index.ts rename to sandbox-cloudflare/bridge/src/index.ts index 10b067ba..23991bc7 100644 --- a/sandbox-cf/bridge/src/index.ts +++ b/sandbox-cloudflare/bridge/src/index.ts @@ -1,9 +1,9 @@ -// Cloudflare Worker bridge for sandbox-cf. The iii worker side (parent +// Cloudflare Worker bridge for sandbox-cloudflare. The iii worker side (parent // dir) talks to this Worker over HTTPS; this Worker calls @cloudflare/sandbox's // `getSandbox(env.Sandbox, id)` and drives the underlying Container // Durable Object. // -// Auth: shared bearer token, set via `wrangler secret put CF_BRIDGE_TOKEN`. +// Auth: shared bearer token, set via `wrangler secret put CLOUDFLARE_BRIDGE_TOKEN`. // // SDK shapes verified via context7 against /cloudflare/sandbox-sdk: // sandbox.exec(cmd, {timeout, cwd, env, signal}) → {stdout, stderr, exitCode, success} @@ -16,7 +16,7 @@ import { getSandbox, proxyToSandbox, type Sandbox } from '@cloudflare/sandbox' interface Env { Sandbox: DurableObjectNamespace - CF_BRIDGE_TOKEN: string + CLOUDFLARE_BRIDGE_TOKEN: string PREVIEW_HOSTNAME?: string } @@ -95,7 +95,7 @@ export default { if (proxyResponse) return proxyResponse const auth = req.headers.get('Authorization') ?? '' - if (!auth.startsWith('Bearer ') || auth.slice(7) !== env.CF_BRIDGE_TOKEN) { + if (!auth.startsWith('Bearer ') || auth.slice(7) !== env.CLOUDFLARE_BRIDGE_TOKEN) { return unauthorized() } diff --git a/sandbox-cf/bridge/tsconfig.json b/sandbox-cloudflare/bridge/tsconfig.json similarity index 100% rename from sandbox-cf/bridge/tsconfig.json rename to sandbox-cloudflare/bridge/tsconfig.json diff --git a/sandbox-cf/bridge/wrangler.jsonc b/sandbox-cloudflare/bridge/wrangler.jsonc similarity index 90% rename from sandbox-cf/bridge/wrangler.jsonc rename to sandbox-cloudflare/bridge/wrangler.jsonc index d0a94756..6bee0ba7 100644 --- a/sandbox-cf/bridge/wrangler.jsonc +++ b/sandbox-cloudflare/bridge/wrangler.jsonc @@ -1,6 +1,6 @@ { "$schema": "node_modules/wrangler/config-schema.json", - "name": "sandbox-cf-bridge", + "name": "sandbox-cloudflare-bridge", "main": "src/index.ts", "compatibility_date": "2025-04-01", "compatibility_flags": ["nodejs_compat"], @@ -34,6 +34,6 @@ ], // Shared secret the iii worker side sends as `Authorization: Bearer `. - // Set with: wrangler secret put CF_BRIDGE_TOKEN + // Set with: wrangler secret put CLOUDFLARE_BRIDGE_TOKEN "vars": {} } diff --git a/sandbox-cloudflare/iii.worker.yaml b/sandbox-cloudflare/iii.worker.yaml new file mode 100644 index 00000000..ce2ed997 --- /dev/null +++ b/sandbox-cloudflare/iii.worker.yaml @@ -0,0 +1,12 @@ +iii: v1 +name: sandbox-cloudflare +language: node +deploy: image +manifest: package.json +description: Narrow iii worker that exposes Cloudflare Sandbox via the sandbox::cloudflare::* trigger family. Talks HTTPS to a thin Cloudflare Worker bridge (deployed separately from this worker's bridge/ subdir) because CF Sandbox lives inside a Durable Object on the Workers runtime, not on a host iii engine can run. +config: + bridge_url_env: CLOUDFLARE_BRIDGE_URL + bridge_token_env: CLOUDFLARE_BRIDGE_TOKEN + max_concurrent_sandboxes: 10 + default_idle_timeout_secs: 300 + image_allowlist: [] diff --git a/sandbox-cf/package.json b/sandbox-cloudflare/package.json similarity index 92% rename from sandbox-cf/package.json rename to sandbox-cloudflare/package.json index 33de9405..e19f6247 100644 --- a/sandbox-cf/package.json +++ b/sandbox-cloudflare/package.json @@ -1,5 +1,5 @@ { - "name": "sandbox-cf", + "name": "sandbox-cloudflare", "version": "0.1.0", "private": true, "type": "module", diff --git a/sandbox-cf/src/client.ts b/sandbox-cloudflare/src/client.ts similarity index 90% rename from sandbox-cf/src/client.ts rename to sandbox-cloudflare/src/client.ts index e2618723..1110f57e 100644 --- a/sandbox-cf/src/client.ts +++ b/sandbox-cloudflare/src/client.ts @@ -55,7 +55,7 @@ export class CfBridgeClient { export function authFromEnv(cfg: { bridge_url_env: string; bridge_token_env: string }): BridgeAuth { const url = process.env[cfg.bridge_url_env] const token = process.env[cfg.bridge_token_env] - if (!url) throw new Error(`sandbox-cf: ${cfg.bridge_url_env} is not set; deploy bridge/ first`) - if (!token) throw new Error(`sandbox-cf: ${cfg.bridge_token_env} is not set; share secret with bridge`) + if (!url) throw new Error(`sandbox-cloudflare: ${cfg.bridge_url_env} is not set; deploy bridge/ first`) + if (!token) throw new Error(`sandbox-cloudflare: ${cfg.bridge_token_env} is not set; share secret with bridge`) return { url, token } } diff --git a/sandbox-cf/src/config.ts b/sandbox-cloudflare/src/config.ts similarity index 95% rename from sandbox-cf/src/config.ts rename to sandbox-cloudflare/src/config.ts index 7661057f..6e19ae98 100644 --- a/sandbox-cf/src/config.ts +++ b/sandbox-cloudflare/src/config.ts @@ -9,8 +9,8 @@ export interface Config { } export const DEFAULT_CONFIG: Config = { - bridge_url_env: 'CF_BRIDGE_URL', - bridge_token_env: 'CF_BRIDGE_TOKEN', + bridge_url_env: 'CLOUDFLARE_BRIDGE_URL', + bridge_token_env: 'CLOUDFLARE_BRIDGE_TOKEN', max_concurrent_sandboxes: 10, default_idle_timeout_secs: 300, image_allowlist: [], diff --git a/sandbox-cf/src/handlers.ts b/sandbox-cloudflare/src/handlers.ts similarity index 100% rename from sandbox-cf/src/handlers.ts rename to sandbox-cloudflare/src/handlers.ts diff --git a/sandbox-cf/src/index.ts b/sandbox-cloudflare/src/index.ts similarity index 60% rename from sandbox-cf/src/index.ts rename to sandbox-cloudflare/src/index.ts index 33952bcf..42a362ee 100644 --- a/sandbox-cf/src/index.ts +++ b/sandbox-cloudflare/src/index.ts @@ -3,14 +3,14 @@ import { authFromEnv, CfBridgeClient } from './client.js' import { loadConfig } from './config.js' import { doCreate, doExec, doExposePort, doFsRead, doFsWrite, doList, doStop, type HandlerCtx } from './handlers.js' -const cfgPath = process.env.SANDBOX_CF_CONFIG ?? './config.yaml' +const cfgPath = process.env.SANDBOX_CLOUDFLARE_CONFIG ?? './config.yaml' const config = loadConfig(cfgPath) const auth = authFromEnv(config) const client = new CfBridgeClient(auth) const ctx: HandlerCtx = { config, client, inFlight: { value: 0 } } const iii = registerWorker(process.env.III_URL ?? 'ws://localhost:49134') -const logger = new Logger(undefined, 'sandbox-cf') +const logger = new Logger(undefined, 'sandbox-cloudflare') function reg(id: string, handler: (input: Record) => Promise) { iii.registerFunction(id, async (input) => { @@ -24,15 +24,15 @@ function reg(id: string, handler: (input: Record) => Promise doCreate(ctx, i)) -reg('sandbox::cf::exec', (i) => doExec(ctx, i)) -reg('sandbox::cf::stop', (i) => doStop(ctx, i)) -reg('sandbox::cf::list', (i) => doList(ctx, i)) -reg('sandbox::cf::expose_port', (i) => doExposePort(ctx, i)) -reg('sandbox::cf::fs::read', (i) => doFsRead(ctx, i)) -reg('sandbox::cf::fs::write', (i) => doFsWrite(ctx, i)) +reg('sandbox::cloudflare::create', (i) => doCreate(ctx, i)) +reg('sandbox::cloudflare::exec', (i) => doExec(ctx, i)) +reg('sandbox::cloudflare::stop', (i) => doStop(ctx, i)) +reg('sandbox::cloudflare::list', (i) => doList(ctx, i)) +reg('sandbox::cloudflare::expose_port', (i) => doExposePort(ctx, i)) +reg('sandbox::cloudflare::fs::read', (i) => doFsRead(ctx, i)) +reg('sandbox::cloudflare::fs::write', (i) => doFsWrite(ctx, i)) -logger.info?.('sandbox-cf registered, awaiting invocations') +logger.info?.('sandbox-cloudflare registered, awaiting invocations') process.on('SIGTERM', () => { logger.info?.('SIGTERM received, shutting down') diff --git a/sandbox-cf/src/sandbox.ts b/sandbox-cloudflare/src/sandbox.ts similarity index 95% rename from sandbox-cf/src/sandbox.ts rename to sandbox-cloudflare/src/sandbox.ts index 1114f240..82857307 100644 --- a/sandbox-cf/src/sandbox.ts +++ b/sandbox-cloudflare/src/sandbox.ts @@ -22,7 +22,7 @@ export class SandboxError extends Error { } } -// Capabilities advertised by sandbox::cf::create. CF Sandbox doesn't ship +// Capabilities advertised by sandbox::cloudflare::create. CF Sandbox doesn't ship // snapshot or branch; the bridge exposes only exec, fs, and port surfaces. export const CAPABILITIES = ['expose_port', 'fs'] as const diff --git a/sandbox-cf/tests/handlers.test.ts b/sandbox-cloudflare/tests/handlers.test.ts similarity index 97% rename from sandbox-cf/tests/handlers.test.ts rename to sandbox-cloudflare/tests/handlers.test.ts index 35c1b85d..4fdfb5e5 100644 --- a/sandbox-cf/tests/handlers.test.ts +++ b/sandbox-cloudflare/tests/handlers.test.ts @@ -17,7 +17,7 @@ function ctx(max: number, allowlist: string[] = []): HandlerCtx { return { config, client, inFlight: { value: 0 } } } -describe('sandbox::cf handlers', () => { +describe('sandbox::cloudflare handlers', () => { it('create rejects image not in allowlist', async () => { const c = ctx(10, ['python']) await expect(doCreate(c, { image: 'node' })).rejects.toBeInstanceOf(SandboxError) diff --git a/sandbox-cf/tsconfig.json b/sandbox-cloudflare/tsconfig.json similarity index 100% rename from sandbox-cf/tsconfig.json rename to sandbox-cloudflare/tsconfig.json From 6df839392644b834c255cc738651cab89699759e Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Mon, 11 May 2026 20:26:37 +0100 Subject: [PATCH 5/5] refactor(sandbox-cloudflare): adapter under sandbox::provider::cloudflare::* Stops shadowing the bare sandbox::* namespace. Routes through the sandbox router worker (provider="cloudflare"); direct invocation stays supported and stable. CF Worker bridge unchanged. Handler logic, tests, S-code mapping unchanged. --- sandbox-cloudflare/README.md | 18 +++++++++--------- sandbox-cloudflare/bridge/README.md | 2 +- sandbox-cloudflare/iii.worker.yaml | 2 +- sandbox-cloudflare/src/index.ts | 14 +++++++------- sandbox-cloudflare/src/sandbox.ts | 2 +- 5 files changed, 19 insertions(+), 19 deletions(-) diff --git a/sandbox-cloudflare/README.md b/sandbox-cloudflare/README.md index 000bf4d4..9ba03bc8 100644 --- a/sandbox-cloudflare/README.md +++ b/sandbox-cloudflare/README.md @@ -1,8 +1,8 @@ # sandbox-cloudflare -Narrow iii worker that exposes [Cloudflare Sandbox](https://developers.cloudflare.com/sandbox/) under the canonical `sandbox::cloudflare::*` ABI. Unlike the other workers in this family, `sandbox-cloudflare` ships **two artifacts**: +Narrow iii worker that exposes [Cloudflare Sandbox](https://developers.cloudflare.com/sandbox/) under the canonical `sandbox::provider::cloudflare::*` ABI. Unlike the other workers in this family, `sandbox-cloudflare` ships **two artifacts**: -1. **iii worker** (this folder, top-level files) — runs on a host the iii engine controls. Registers `sandbox::cloudflare::*` functions. Talks HTTPS to (2). +1. **iii worker** (this folder, top-level files) — runs on a host the iii engine controls. Registers `sandbox::provider::cloudflare::*` functions. Talks HTTPS to (2). 2. **CF Worker bridge** (`bridge/` subfolder) — separately deployed via `wrangler deploy`. Hosts the `Sandbox` Durable Object class from `@cloudflare/sandbox`. Receives HTTPS calls from (1) and drives the Container. The bridge exists because CF Sandbox lives inside the Workers V8 runtime — there is no way to reach `getSandbox()` from a host-side process. The bridge is the smallest amount of CF-native code needed. @@ -11,13 +11,13 @@ The bridge exists because CF Sandbox lives inside the Workers V8 runtime — the | Function id | Purpose | |---|---| -| `sandbox::cloudflare::create` | Boot a sandbox; returns `{sandbox_id, image, capabilities}` | -| `sandbox::cloudflare::exec` | Run a command inside a live sandbox | -| `sandbox::cloudflare::stop` | Tear down a sandbox | -| `sandbox::cloudflare::list` | Enumerate live sandboxes plus concurrency status | -| `sandbox::cloudflare::expose_port` | Public URL for a port (requires custom domain on the bridge) | -| `sandbox::cloudflare::fs::read` | Read a file out of the sandbox | -| `sandbox::cloudflare::fs::write` | Write a file into the sandbox | +| `sandbox::provider::cloudflare::create` | Boot a sandbox; returns `{sandbox_id, image, capabilities}` | +| `sandbox::provider::cloudflare::exec` | Run a command inside a live sandbox | +| `sandbox::provider::cloudflare::stop` | Tear down a sandbox | +| `sandbox::provider::cloudflare::list` | Enumerate live sandboxes plus concurrency status | +| `sandbox::provider::cloudflare::expose_port` | Public URL for a port (requires custom domain on the bridge) | +| `sandbox::provider::cloudflare::fs::read` | Read a file out of the sandbox | +| `sandbox::provider::cloudflare::fs::write` | Write a file into the sandbox | `create` advertises capabilities `["expose_port", "fs"]`. CF Sandbox does not ship `snapshot` or `branch`; callers that depend on those should pick a different provider. diff --git a/sandbox-cloudflare/bridge/README.md b/sandbox-cloudflare/bridge/README.md index 41eb6e82..1324b3a0 100644 --- a/sandbox-cloudflare/bridge/README.md +++ b/sandbox-cloudflare/bridge/README.md @@ -1,6 +1,6 @@ # sandbox-cloudflare bridge -Thin Cloudflare Worker that exposes HTTPS routes corresponding to every `sandbox::cloudflare::*` function, calling `@cloudflare/sandbox`'s `getSandbox(env.Sandbox, id)` underneath. The iii worker in the parent directory talks to this bridge — that's the only way to reach a CF Sandbox from outside the Cloudflare Workers runtime. +Thin Cloudflare Worker that exposes HTTPS routes corresponding to every `sandbox::provider::cloudflare::*` function, calling `@cloudflare/sandbox`'s `getSandbox(env.Sandbox, id)` underneath. The iii worker in the parent directory talks to this bridge — that's the only way to reach a CF Sandbox from outside the Cloudflare Workers runtime. ## Why a bridge diff --git a/sandbox-cloudflare/iii.worker.yaml b/sandbox-cloudflare/iii.worker.yaml index ce2ed997..cf68e19c 100644 --- a/sandbox-cloudflare/iii.worker.yaml +++ b/sandbox-cloudflare/iii.worker.yaml @@ -3,7 +3,7 @@ name: sandbox-cloudflare language: node deploy: image manifest: package.json -description: Narrow iii worker that exposes Cloudflare Sandbox via the sandbox::cloudflare::* trigger family. Talks HTTPS to a thin Cloudflare Worker bridge (deployed separately from this worker's bridge/ subdir) because CF Sandbox lives inside a Durable Object on the Workers runtime, not on a host iii engine can run. +description: Narrow iii worker that exposes Cloudflare Sandbox via the sandbox::provider::cloudflare::* trigger family. Talks HTTPS to a thin Cloudflare Worker bridge (deployed separately from this worker's bridge/ subdir) because CF Sandbox lives inside a Durable Object on the Workers runtime, not on a host iii engine can run. config: bridge_url_env: CLOUDFLARE_BRIDGE_URL bridge_token_env: CLOUDFLARE_BRIDGE_TOKEN diff --git a/sandbox-cloudflare/src/index.ts b/sandbox-cloudflare/src/index.ts index 42a362ee..b17efbe8 100644 --- a/sandbox-cloudflare/src/index.ts +++ b/sandbox-cloudflare/src/index.ts @@ -24,13 +24,13 @@ function reg(id: string, handler: (input: Record) => Promise doCreate(ctx, i)) -reg('sandbox::cloudflare::exec', (i) => doExec(ctx, i)) -reg('sandbox::cloudflare::stop', (i) => doStop(ctx, i)) -reg('sandbox::cloudflare::list', (i) => doList(ctx, i)) -reg('sandbox::cloudflare::expose_port', (i) => doExposePort(ctx, i)) -reg('sandbox::cloudflare::fs::read', (i) => doFsRead(ctx, i)) -reg('sandbox::cloudflare::fs::write', (i) => doFsWrite(ctx, i)) +reg('sandbox::provider::cloudflare::create', (i) => doCreate(ctx, i)) +reg('sandbox::provider::cloudflare::exec', (i) => doExec(ctx, i)) +reg('sandbox::provider::cloudflare::stop', (i) => doStop(ctx, i)) +reg('sandbox::provider::cloudflare::list', (i) => doList(ctx, i)) +reg('sandbox::provider::cloudflare::expose_port', (i) => doExposePort(ctx, i)) +reg('sandbox::provider::cloudflare::fs::read', (i) => doFsRead(ctx, i)) +reg('sandbox::provider::cloudflare::fs::write', (i) => doFsWrite(ctx, i)) logger.info?.('sandbox-cloudflare registered, awaiting invocations') diff --git a/sandbox-cloudflare/src/sandbox.ts b/sandbox-cloudflare/src/sandbox.ts index 82857307..c7cdc873 100644 --- a/sandbox-cloudflare/src/sandbox.ts +++ b/sandbox-cloudflare/src/sandbox.ts @@ -22,7 +22,7 @@ export class SandboxError extends Error { } } -// Capabilities advertised by sandbox::cloudflare::create. CF Sandbox doesn't ship +// Capabilities advertised by sandbox::provider::cloudflare::create. CF Sandbox doesn't ship // snapshot or branch; the bridge exposes only exec, fs, and port surfaces. export const CAPABILITIES = ['expose_port', 'fs'] as const