Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions sandbox-cloudflare/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
node_modules/
dist/
package-lock.json
bridge/node_modules/
bridge/dist/
bridge/package-lock.json
bridge/.wrangler/
.DS_Store
17 changes: 17 additions & 0 deletions sandbox-cloudflare/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
69 changes: 69 additions & 0 deletions sandbox-cloudflare/README.md
Original file line number Diff line number Diff line change
@@ -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.<account>.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.<account>.workers.dev"
export CLOUDFLARE_BRIDGE_TOKEN="<same token you set with wrangler secret>"
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.
39 changes: 39 additions & 0 deletions sandbox-cloudflare/bridge/README.md
Original file line number Diff line number Diff line change
@@ -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 <CLOUDFLARE_BRIDGE_TOKEN>` (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.<account>.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.
19 changes: 19 additions & 0 deletions sandbox-cloudflare/bridge/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
203 changes: 203 additions & 0 deletions sandbox-cloudflare/bridge/src/index.ts
Original file line number Diff line number Diff line change
@@ -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<Sandbox>
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<T = Record<string, unknown>>(req: Request): Promise<T> {
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<string, string>
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<Response> {
// 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<CreatePayload>(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<ExecPayload>(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<IdPayload>(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<ExposePortPayload>(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<FsReadPayload>(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<FsWritePayload>(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'
13 changes: 13 additions & 0 deletions sandbox-cloudflare/bridge/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}
Loading
Loading