diff --git a/sandbox-cloudflare/.gitignore b/sandbox-cloudflare/.gitignore new file mode 100644 index 00000000..21a40322 --- /dev/null +++ b/sandbox-cloudflare/.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-cloudflare/Dockerfile b/sandbox-cloudflare/Dockerfile new file mode 100644 index 00000000..1b3d0f15 --- /dev/null +++ b/sandbox-cloudflare/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-cloudflare/README.md b/sandbox-cloudflare/README.md new file mode 100644 index 00000000..9ba03bc8 --- /dev/null +++ b/sandbox-cloudflare/README.md @@ -0,0 +1,69 @@ +# sandbox-cloudflare + +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::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. + +## Functions + +| Function id | Purpose | +|---|---| +| `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. + +## Deploy (two steps) + +1. **Deploy the bridge** (see `bridge/README.md`): + ```bash + cd bridge && npm install + wrangler secret put CLOUDFLARE_BRIDGE_TOKEN + wrangler deploy + ``` + 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 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_CLOUDFLARE_CONFIG` to a path: + +```yaml +bridge_url_env: CLOUDFLARE_BRIDGE_URL +bridge_token_env: CLOUDFLARE_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-cloudflare/bridge/README.md b/sandbox-cloudflare/bridge/README.md new file mode 100644 index 00000000..1324b3a0 --- /dev/null +++ b/sandbox-cloudflare/bridge/README.md @@ -0,0 +1,39 @@ +# sandbox-cloudflare bridge + +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 + +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 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-cloudflare-bridge..workers.dev`). Set that as `CLOUDFLARE_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-cloudflare/bridge/package.json b/sandbox-cloudflare/bridge/package.json new file mode 100644 index 00000000..e9db2446 --- /dev/null +++ b/sandbox-cloudflare/bridge/package.json @@ -0,0 +1,19 @@ +{ + "name": "sandbox-cloudflare-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-cloudflare/bridge/src/index.ts b/sandbox-cloudflare/bridge/src/index.ts new file mode 100644 index 00000000..23991bc7 --- /dev/null +++ b/sandbox-cloudflare/bridge/src/index.ts @@ -0,0 +1,203 @@ +// 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 CLOUDFLARE_BRIDGE_TOKEN`. +// +// 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 + CLOUDFLARE_BRIDGE_TOKEN: string + PREVIEW_HOSTNAME?: string +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + 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 { + try { + return (await req.json()) as T + } catch { + 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.CLOUDFLARE_BRIDGE_TOKEN) { + return unauthorized() + } + + const url = new URL(req.url) + const route = `${req.method} ${url.pathname}` + + 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}`) + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return bridgeError(502, `bridge error: ${message}`) + } + }, +} + +// 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-cloudflare/bridge/tsconfig.json b/sandbox-cloudflare/bridge/tsconfig.json new file mode 100644 index 00000000..51799af9 --- /dev/null +++ b/sandbox-cloudflare/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-cloudflare/bridge/wrangler.jsonc b/sandbox-cloudflare/bridge/wrangler.jsonc new file mode 100644 index 00000000..6bee0ba7 --- /dev/null +++ b/sandbox-cloudflare/bridge/wrangler.jsonc @@ -0,0 +1,39 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "sandbox-cloudflare-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 CLOUDFLARE_BRIDGE_TOKEN + "vars": {} +} diff --git a/sandbox-cloudflare/iii.worker.yaml b/sandbox-cloudflare/iii.worker.yaml new file mode 100644 index 00000000..cf68e19c --- /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::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 + max_concurrent_sandboxes: 10 + default_idle_timeout_secs: 300 + image_allowlist: [] diff --git a/sandbox-cloudflare/package.json b/sandbox-cloudflare/package.json new file mode 100644 index 00000000..e19f6247 --- /dev/null +++ b/sandbox-cloudflare/package.json @@ -0,0 +1,22 @@ +{ + "name": "sandbox-cloudflare", + "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.6" + }, + "devDependencies": { + "tsx": "^4.21.0", + "@types/node": "^24.10.1", + "typescript": "^5.9.3", + "vitest": "^4.1.5" + } +} diff --git a/sandbox-cloudflare/src/client.ts b/sandbox-cloudflare/src/client.ts new file mode 100644 index 00000000..1110f57e --- /dev/null +++ b/sandbox-cloudflare/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-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-cloudflare/src/config.ts b/sandbox-cloudflare/src/config.ts new file mode 100644 index 00000000..6e19ae98 --- /dev/null +++ b/sandbox-cloudflare/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: 'CLOUDFLARE_BRIDGE_URL', + bridge_token_env: 'CLOUDFLARE_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-cloudflare/src/handlers.ts b/sandbox-cloudflare/src/handlers.ts new file mode 100644 index 00000000..6cb81f6e --- /dev/null +++ b/sandbox-cloudflare/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-cloudflare/src/index.ts b/sandbox-cloudflare/src/index.ts new file mode 100644 index 00000000..b17efbe8 --- /dev/null +++ b/sandbox-cloudflare/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_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-cloudflare') + +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::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') + +process.on('SIGTERM', () => { + logger.info?.('SIGTERM received, shutting down') + process.exit(0) +}) diff --git a/sandbox-cloudflare/src/sandbox.ts b/sandbox-cloudflare/src/sandbox.ts new file mode 100644 index 00000000..c7cdc873 --- /dev/null +++ b/sandbox-cloudflare/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::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 + +// 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-cloudflare/tests/handlers.test.ts b/sandbox-cloudflare/tests/handlers.test.ts new file mode 100644 index 00000000..4fdfb5e5 --- /dev/null +++ b/sandbox-cloudflare/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::cloudflare 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-cloudflare/tsconfig.json b/sandbox-cloudflare/tsconfig.json new file mode 100644 index 00000000..dfa87e9a --- /dev/null +++ b/sandbox-cloudflare/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"] +}