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-vercel/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
node_modules/
dist/
package-lock.json
.venv/
__pycache__/
*.egg-info/
*.pyc
.DS_Store
17 changes: 17 additions & 0 deletions sandbox-vercel/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"]
56 changes: 56 additions & 0 deletions sandbox-vercel/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# sandbox-vercel

Narrow iii worker that wraps [Vercel Sandbox](https://vercel.com/docs/vercel-sandbox) (Firecracker microVMs on Vercel's "Hive" infrastructure) via the Vercel REST API. Registers the canonical `sandbox::*` ABI under the `sandbox::provider::vercel::*` namespace so callers can spawn and drive Vercel sandboxes through `iii.trigger(...)` without depending on `@vercel/sandbox`.

The same ABI is implemented by every sandbox provider worker in this repo (`sandbox-e2b`, `sandbox-daytona`, `sandbox-morph`, `sandbox-modal`, `sandbox-cf`, ...). Callers swap providers by changing the function-id prefix.

## Functions

| Function id | Purpose |
|---|---|
| `sandbox::provider::vercel::create` | Boot a sandbox; returns `{sandbox_id, image, capabilities}` |
| `sandbox::provider::vercel::exec` | Run a command inside a live sandbox |
| `sandbox::provider::vercel::stop` | Tear down a sandbox |
| `sandbox::provider::vercel::list` | Enumerate live sandboxes plus concurrency status |
| `sandbox::provider::vercel::snapshot` | Snapshot a sandbox (Vercel shuts the parent down after) |
| `sandbox::provider::vercel::expose_port` | Public URL for a port (must be in `ports` at create time) |
| `sandbox::provider::vercel::fs::read` | Read a file out of the sandbox |
| `sandbox::provider::vercel::fs::write` | Write a file into the sandbox |

`create` advertises capabilities `["snapshot", "expose_port", "fs"]`. `branch` is not registered — Vercel Sandbox doesn't ship branching.

## Configuration

`config.yaml` next to the binary, or set `SANDBOX_VERCEL_CONFIG` to a path:

```yaml
api_base: "https://api.vercel.com"
oidc_token_env: VERCEL_OIDC_TOKEN
fallback_token_env: VERCEL_TOKEN
team_id_env: VERCEL_TEAM_ID
project_id_env: VERCEL_PROJECT_ID
max_concurrent_sandboxes: 10
default_idle_timeout_secs: 300
default_runtime: node24
image_allowlist: []
```

The worker prefers `VERCEL_OIDC_TOKEN` (auto-injected in Vercel-deployed projects, 12 h dev token via `vercel env pull`). Falls back to `VERCEL_TOKEN + VERCEL_TEAM_ID + VERCEL_PROJECT_ID`. Fails fast at startup if neither is set.

## S-codes

Provider failures map onto the same code space the rest of the sandbox worker family uses:

| Code | Cause |
|---|---|
| `S100` | Image not in `image_allowlist` |
| `S400` | Concurrency cap reached |
| `S404` | Capability not supported |
| `S500` | Provider returned 429 |
| `S501` | Provider returned 402 / quota exhausted |
| `S502` | Provider returned 5xx |
| `S503` | Provider returned 401 / 403 (auth) |

## Status

v0.1 ships function registrations, types, error mapping, concurrency cap, and a smoke test. The HTTP call bodies that talk to Vercel are stubbed and return `S502` until the next iteration wires them to the real REST endpoints. The ABI is stable.
16 changes: 16 additions & 0 deletions sandbox-vercel/iii.worker.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
iii: v1
name: sandbox-vercel
language: node
deploy: image
manifest: package.json
description: Narrow iii worker that exposes Vercel Sandbox (Firecracker microVM, "Hive" infra) via the sandbox::provider::vercel::* trigger family.
config:
api_base: "https://api.vercel.com"
oidc_token_env: VERCEL_OIDC_TOKEN
fallback_token_env: VERCEL_TOKEN
team_id_env: VERCEL_TEAM_ID
project_id_env: VERCEL_PROJECT_ID
max_concurrent_sandboxes: 10
default_idle_timeout_secs: 300
default_runtime: node24
image_allowlist: []
22 changes: 22 additions & 0 deletions sandbox-vercel/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "sandbox-vercel",
"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"
}
}
190 changes: 190 additions & 0 deletions sandbox-vercel/src/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
// Narrow Vercel REST client matching the surface documented at
// vercel.com/docs/rest-api/sandboxes/*. Every call lands at
// `https://api.vercel.com/v1/sandboxes/...` and requires `?teamId=&slug=`
// query params plus a `Bearer` token. Snapshot/fs are not yet wired —
// the v2-beta sessions endpoint is the right surface for those and
// lands in a follow-up.

import { type CreatedSandbox, type ExecResult, mapHttpStatus, S, SandboxError, type SandboxRecord } from './sandbox.js'

export interface VercelAuth {
token: string
team_id?: string
project_id?: string
}

interface CreateInput {
image: string
idle_timeout_secs: number
runtime: string
source?: { url: string; revision?: string; depth?: number; username?: string; password?: string }
resources?: { vcpus?: number; memory?: number }
ports?: number[]
env?: Record<string, string>
}

export class VercelClient {
api_base: string
auth: VercelAuth

constructor(api_base: string, auth: VercelAuth) {
this.api_base = api_base.replace(/\/$/, '')
this.auth = auth
}

private headers(): HeadersInit {
return {
Authorization: `Bearer ${this.auth.token}`,
'Content-Type': 'application/json',
}
}

private query(): string {
const params = new URLSearchParams()
if (this.auth.team_id) params.set('teamId', this.auth.team_id)
const slug = process.env.VERCEL_TEAM_SLUG
if (slug) params.set('slug', slug)
const q = params.toString()
return q ? `?${q}` : ''
}

private async readBody(resp: Response): Promise<string> {
try {
return await resp.text()
} catch {
return ''
}
}

/**
* Create a sandbox. POST /v1/sandboxes?teamId=&slug=
* Body requires `source` (a git ref to clone) and `runtime`. `timeout`
* is in milliseconds upstream — we convert from idle_timeout_secs.
*/
async create(input: CreateInput): Promise<CreatedSandbox> {
const body: Record<string, unknown> = {
runtime: input.runtime,
timeout: String(input.idle_timeout_secs * 1000),
}
if (input.source) body.source = { type: 'value', ...input.source }
if (input.resources) body.resources = input.resources
if (input.ports) body.ports = input.ports
if (input.env) body.env = input.env
if (this.auth.project_id) body.projectId = this.auth.project_id

const resp = await fetch(`${this.api_base}/v1/sandboxes${this.query()}`, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify(body),
})
if (!resp.ok) throw mapHttpStatus(resp.status, await this.readBody(resp))
const parsed = (await resp.json()) as { sandboxId?: string; id?: string; runtime?: string; createdAt?: string }
const sandbox_id = parsed.sandboxId ?? parsed.id ?? ''
if (!sandbox_id) {
throw new SandboxError(S.PROVIDER_UNAVAILABLE, 'vercel response missing sandboxId/id')
}
return {
sandbox_id,
image: input.image || parsed.runtime || input.runtime,
started_at: Math.floor(Date.now() / 1000),
}
}

/**
* POST /v1/sandboxes/{id}/commands?teamId=&slug=
* Vercel returns a Command object with stdout/stderr after completion;
* for streaming output use the runtime SDK directly.
*/
async exec(sandbox_id: string, cmd: string, args: string[], _timeout_ms?: number): Promise<ExecResult> {
const resp = await fetch(`${this.api_base}/v1/sandboxes/${sandbox_id}/commands${this.query()}`, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify({ command: cmd, args, cwd: '/vercel/sandbox' }),
})
if (!resp.ok) throw mapHttpStatus(resp.status, await this.readBody(resp))
const parsed = (await resp.json()) as {
stdout?: string
stderr?: string
exitCode?: number
timedOut?: boolean
}
return {
stdout: parsed.stdout ?? '',
stderr: parsed.stderr ?? '',
exit_code: parsed.exitCode ?? 0,
timed_out: parsed.timedOut ?? false,
}
}

/**
* Stop a sandbox. POST /v1/sandboxes/{id}/stop?teamId=&slug=
* Vercel uses POST-stop, not DELETE, and surfaces 404 when the
* sandbox is gone. 404 is treated as idempotent success.
*/
async stop(sandbox_id: string): Promise<void> {
const resp = await fetch(`${this.api_base}/v1/sandboxes/${sandbox_id}/stop${this.query()}`, {
method: 'POST',
headers: this.headers(),
})
if (resp.ok || resp.status === 404 || resp.status === 409) return
throw mapHttpStatus(resp.status, await this.readBody(resp))
}

async list(): Promise<SandboxRecord[]> {
const resp = await fetch(`${this.api_base}/v1/sandboxes${this.query()}`, {
method: 'GET',
headers: this.headers(),
})
if (!resp.ok) throw mapHttpStatus(resp.status, await this.readBody(resp))
const parsed = (await resp.json()) as
| { sandboxes?: Array<{ id?: string; sandboxId?: string; runtime?: string; createdAt?: string }> }
| Array<{ id?: string; sandboxId?: string; runtime?: string; createdAt?: string }>
const items = Array.isArray(parsed) ? parsed : (parsed.sandboxes ?? [])
return items.map((it) => ({
sandbox_id: it.sandboxId ?? it.id ?? '',
image: it.runtime ?? '',
started_at: it.createdAt ?? '',
}))
}

async snapshot(_sandbox_id: string): Promise<string> {
// Vercel's snapshot lives under v2-beta sessions; not yet exposed
// in the canonical /v1 surface. Wire when the v2 endpoints
// graduate or callers need it.
throw new SandboxError(S.PROVIDER_UNAVAILABLE, 'TODO: wire vercel snapshot via v2-beta sessions')
}

async exposePort(_sandbox_id: string, _port: number): Promise<string> {
// The runtime SDK derives port URLs deterministically via
// `sandbox.domain(port)`. Reproducing that requires the sandbox's
// public domain which isn't exposed via REST today.
throw new SandboxError(S.PROVIDER_UNAVAILABLE, 'TODO: wire vercel sandbox.domain(port)')
}

async fsRead(_sandbox_id: string, _path: string): Promise<Uint8Array> {
throw new SandboxError(S.PROVIDER_UNAVAILABLE, 'TODO: wire vercel v2-beta read-files')
}

async fsWrite(_sandbox_id: string, _path: string, _bytes: Uint8Array, _mode?: number): Promise<void> {
throw new SandboxError(S.PROVIDER_UNAVAILABLE, 'TODO: wire vercel v2-beta write-files')
}
}

export function authFromEnv(cfg: {
oidc_token_env: string
fallback_token_env: string
team_id_env: string
project_id_env: string
}): VercelAuth {
const oidc = process.env[cfg.oidc_token_env]
if (oidc) return { token: oidc }
const token = process.env[cfg.fallback_token_env]
if (!token) {
throw new Error(`sandbox-vercel: neither ${cfg.oidc_token_env} nor ${cfg.fallback_token_env} is set`)
}
return {
token,
team_id: process.env[cfg.team_id_env],
project_id: process.env[cfg.project_id_env],
}
}
83 changes: 83 additions & 0 deletions sandbox-vercel/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { readFileSync } from 'node:fs'

export interface Config {
api_base: string
oidc_token_env: string
fallback_token_env: string
team_id_env: string
project_id_env: string
max_concurrent_sandboxes: number
default_idle_timeout_secs: number
default_runtime: string
image_allowlist: string[]
}

export const DEFAULT_CONFIG: Config = {
api_base: 'https://api.vercel.com',
oidc_token_env: 'VERCEL_OIDC_TOKEN',
fallback_token_env: 'VERCEL_TOKEN',
team_id_env: 'VERCEL_TEAM_ID',
project_id_env: 'VERCEL_PROJECT_ID',
max_concurrent_sandboxes: 10,
default_idle_timeout_secs: 300,
default_runtime: 'node24',
image_allowlist: [],
}

// Minimal YAML-ish loader that handles the flat key:value shape the rest of
// the worker family uses for config.yaml. Avoids pulling in a YAML dep for
// what is essentially a key/value file.
function parseYamlish(raw: string): Record<string, string | number | string[]> {
const out: Record<string, string | number | string[]> = {}
let currentList: string[] | null = null
let currentKey = ''
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() === '') {
currentKey = key
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
}
if (currentKey && key !== currentKey) currentKey = ''
}
return out
}

export function loadConfig(path: string): Config {
try {
const raw = readFileSync(path, 'utf8')
const parsed = parseYamlish(raw)
return {
...DEFAULT_CONFIG,
...(parsed as Partial<Config>),
}
} catch {
return DEFAULT_CONFIG
}
}

export function imageAllowed(cfg: Config, image: string): boolean {
return cfg.image_allowlist.length === 0 || cfg.image_allowlist.includes(image)
}
Loading
Loading