From 31efd6d4e0c4ca89845279b9d40477dc733fbca6 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Thu, 7 May 2026 11:36:27 +0100 Subject: [PATCH 1/5] feat(sandbox-vercel): scaffold Vercel Sandbox provider worker Mirrors the sandbox::e2b::* ABI under sandbox::vercel::* with capability negotiation, per-worker concurrency tracking, and S-code error mapping. Same 8 functions (create, exec, stop, list, snapshot, expose_port, fs::read, fs::write). Auth: prefers VERCEL_OIDC_TOKEN; falls back to VERCEL_TOKEN + VERCEL_TEAM_ID + VERCEL_PROJECT_ID. Default base URL: https://api.vercel.com. Default runtime: node24. 5/5 vitest tests pass; biome lint clean; tsc clean. HTTP call bodies stubbed pending verified pass against Vercel's REST surface. --- sandbox-vercel/Dockerfile | 17 ++++ sandbox-vercel/README.md | 56 +++++++++++++ sandbox-vercel/iii.worker.yaml | 16 ++++ sandbox-vercel/package.json | 22 +++++ sandbox-vercel/src/client.ts | 78 ++++++++++++++++++ sandbox-vercel/src/config.ts | 83 +++++++++++++++++++ sandbox-vercel/src/handlers.ts | 112 ++++++++++++++++++++++++++ sandbox-vercel/src/index.ts | 53 ++++++++++++ sandbox-vercel/src/sandbox.ts | 54 +++++++++++++ sandbox-vercel/tests/handlers.test.ts | 51 ++++++++++++ sandbox-vercel/tsconfig.json | 13 +++ 11 files changed, 555 insertions(+) create mode 100644 sandbox-vercel/Dockerfile create mode 100644 sandbox-vercel/README.md create mode 100644 sandbox-vercel/iii.worker.yaml create mode 100644 sandbox-vercel/package.json create mode 100644 sandbox-vercel/src/client.ts create mode 100644 sandbox-vercel/src/config.ts create mode 100644 sandbox-vercel/src/handlers.ts create mode 100644 sandbox-vercel/src/index.ts create mode 100644 sandbox-vercel/src/sandbox.ts create mode 100644 sandbox-vercel/tests/handlers.test.ts create mode 100644 sandbox-vercel/tsconfig.json diff --git a/sandbox-vercel/Dockerfile b/sandbox-vercel/Dockerfile new file mode 100644 index 00000000..1b3d0f15 --- /dev/null +++ b/sandbox-vercel/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-vercel/README.md b/sandbox-vercel/README.md new file mode 100644 index 00000000..40621ba2 --- /dev/null +++ b/sandbox-vercel/README.md @@ -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::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::vercel::create` | Boot a sandbox; returns `{sandbox_id, image, capabilities}` | +| `sandbox::vercel::exec` | Run a command inside a live sandbox | +| `sandbox::vercel::stop` | Tear down a sandbox | +| `sandbox::vercel::list` | Enumerate live sandboxes plus concurrency status | +| `sandbox::vercel::snapshot` | Snapshot a sandbox (Vercel shuts the parent down after) | +| `sandbox::vercel::expose_port` | Public URL for a port (must be in `ports` at create time) | +| `sandbox::vercel::fs::read` | Read a file out of the sandbox | +| `sandbox::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. diff --git a/sandbox-vercel/iii.worker.yaml b/sandbox-vercel/iii.worker.yaml new file mode 100644 index 00000000..196a8f4b --- /dev/null +++ b/sandbox-vercel/iii.worker.yaml @@ -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::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: [] diff --git a/sandbox-vercel/package.json b/sandbox-vercel/package.json new file mode 100644 index 00000000..c74e93af --- /dev/null +++ b/sandbox-vercel/package.json @@ -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.3" + }, + "devDependencies": { + "tsx": "^4.21.0", + "@types/node": "^24.10.1", + "typescript": "^5.9.3", + "vitest": "^4.1.5" + } +} diff --git a/sandbox-vercel/src/client.ts b/sandbox-vercel/src/client.ts new file mode 100644 index 00000000..cd11f791 --- /dev/null +++ b/sandbox-vercel/src/client.ts @@ -0,0 +1,78 @@ +// Narrow Vercel REST client. Holds the base URL + auth token and a small +// helper for building requests. Endpoint paths and bodies are still stubbed +// pending a verified pass against the live Vercel Sandbox REST surface; +// every call below returns SandboxError(S502) with a TODO marker. + +import { S, SandboxError, type CreatedSandbox, type ExecResult, type SandboxRecord } from './sandbox.js' + +export interface VercelAuth { + token: string + team_id?: string + project_id?: string +} + +export class VercelClient { + api_base: string + auth: VercelAuth + + constructor(api_base: string, auth: VercelAuth) { + this.api_base = api_base.replace(/\/$/, '') + this.auth = auth + } + + // headers() will be re-introduced when the real REST surface lands. For + // now the stub paths short-circuit before any HTTP request is built, so + // we omit it to keep the lint surface clean. + async create(_image: string, _idle_timeout_secs: number, _runtime: string): Promise { + // TODO: POST /v1/sandboxes (or whichever Vercel ships); parse response; + // map non-2xx via mapHttpStatus. + throw new SandboxError(S.PROVIDER_UNAVAILABLE, 'TODO: wire Vercel Sandbox create') + } + + async exec(_sandbox_id: string, _cmd: string, _args: string[], _timeout_ms?: number): Promise { + throw new SandboxError(S.PROVIDER_UNAVAILABLE, 'TODO: wire Vercel runCommand') + } + + async stop(_sandbox_id: string): Promise { + throw new SandboxError(S.PROVIDER_UNAVAILABLE, 'TODO: wire Vercel sandbox.stop') + } + + async list(): Promise { + throw new SandboxError(S.PROVIDER_UNAVAILABLE, 'TODO: wire Vercel list') + } + + async snapshot(_sandbox_id: string): Promise { + throw new SandboxError(S.PROVIDER_UNAVAILABLE, 'TODO: wire Vercel snapshot') + } + + async exposePort(_sandbox_id: string, _port: number): Promise { + throw new SandboxError(S.PROVIDER_UNAVAILABLE, 'TODO: wire Vercel sandbox.domain(port)') + } + + async fsRead(_sandbox_id: string, _path: string): Promise { + throw new SandboxError(S.PROVIDER_UNAVAILABLE, 'TODO: wire Vercel fs read') + } + + async fsWrite(_sandbox_id: string, _path: string, _bytes: Uint8Array, _mode?: number): Promise { + throw new SandboxError(S.PROVIDER_UNAVAILABLE, 'TODO: wire Vercel fs write') + } +} + +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], + } +} diff --git a/sandbox-vercel/src/config.ts b/sandbox-vercel/src/config.ts new file mode 100644 index 00000000..281ccdb3 --- /dev/null +++ b/sandbox-vercel/src/config.ts @@ -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 { + const out: Record = {} + 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), + } + } 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-vercel/src/handlers.ts b/sandbox-vercel/src/handlers.ts new file mode 100644 index 00000000..ce032321 --- /dev/null +++ b/sandbox-vercel/src/handlers.ts @@ -0,0 +1,112 @@ +import type { Config } from './config.js' +import { imageAllowed } from './config.js' +import type { VercelClient } from './client.js' +import { CAPABILITIES, S, SandboxError } from './sandbox.js' + +export interface HandlerCtx { + config: Config + client: VercelClient + 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 + const runtime = typeof input.runtime === 'string' ? input.runtime : ctx.config.default_runtime + + 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, runtime) + 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) { + // Best-effort upstream fetch. Local capacity envelope is always returned + // so callers can still trust in_flight/cap/remaining when the provider + // is down. + 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 doSnapshot(ctx: HandlerCtx, input: Record) { + const sandbox_id = requireStr(input, 'sandbox_id') + const snapshot_id = await ctx.client.snapshot(sandbox_id) + return { snapshot_id } +} + +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-vercel/src/index.ts b/sandbox-vercel/src/index.ts new file mode 100644 index 00000000..e92bc913 --- /dev/null +++ b/sandbox-vercel/src/index.ts @@ -0,0 +1,53 @@ +import { Logger, registerWorker } from 'iii-sdk' +import { authFromEnv, VercelClient } from './client.js' +import { DEFAULT_CONFIG, loadConfig } from './config.js' +import { + doCreate, + doExec, + doExposePort, + doFsRead, + doFsWrite, + doList, + doSnapshot, + doStop, + type HandlerCtx, +} from './handlers.js' + +const cfgPath = process.env.SANDBOX_VERCEL_CONFIG ?? './config.yaml' +const config = loadConfig(cfgPath) +const auth = authFromEnv(config) +const client = new VercelClient(config.api_base, 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-vercel') + +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::vercel::create', (i) => doCreate(ctx, i)) +reg('sandbox::vercel::exec', (i) => doExec(ctx, i)) +reg('sandbox::vercel::stop', (i) => doStop(ctx, i)) +reg('sandbox::vercel::list', (i) => doList(ctx, i)) +reg('sandbox::vercel::snapshot', (i) => doSnapshot(ctx, i)) +reg('sandbox::vercel::expose_port', (i) => doExposePort(ctx, i)) +reg('sandbox::vercel::fs::read', (i) => doFsRead(ctx, i)) +reg('sandbox::vercel::fs::write', (i) => doFsWrite(ctx, i)) + +logger.info?.('sandbox-vercel registered, awaiting invocations') + +const _keepConfig = DEFAULT_CONFIG // keep symbol referenced for tree-shake friendliness + +process.on('SIGTERM', async () => { + logger.info?.('SIGTERM received, shutting down') + process.exit(0) +}) diff --git a/sandbox-vercel/src/sandbox.ts b/sandbox-vercel/src/sandbox.ts new file mode 100644 index 00000000..8bad1600 --- /dev/null +++ b/sandbox-vercel/src/sandbox.ts @@ -0,0 +1,54 @@ +// 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 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::vercel::create. +export const CAPABILITIES = ['snapshot', 'expose_port', 'fs'] as const + +// Map a non-2xx HTTP status from Vercel onto the right S-code. +export function mapHttpStatus(status: number, body: string): SandboxError { + if (status === 401 || status === 403) return new SandboxError(S.AUTH_INVALID, 'auth invalid or expired') + if (status === 402) return new SandboxError(S.QUOTA_EXHAUSTED, 'quota exhausted') + if (status === 429) return new SandboxError(S.RATE_LIMITED, 'rate limited by provider') + if (status >= 500) return new SandboxError(S.PROVIDER_UNAVAILABLE, `status ${status}: ${body}`) + return new SandboxError(S.PROVIDER_UNAVAILABLE, `unexpected status ${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-vercel/tests/handlers.test.ts b/sandbox-vercel/tests/handlers.test.ts new file mode 100644 index 00000000..9f6b65a5 --- /dev/null +++ b/sandbox-vercel/tests/handlers.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { VercelClient } 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 VercelClient(config.api_base, { token: 'test-token' }) + return { config, client, inFlight: { value: 0 } } +} + +describe('sandbox::vercel 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 upstream 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 returns empty object via stub error path', async () => { + const c = ctx(10) + // Stub client returns S502; the handler still maps cleanly. + 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-vercel/tsconfig.json b/sandbox-vercel/tsconfig.json new file mode 100644 index 00000000..dfa87e9a --- /dev/null +++ b/sandbox-vercel/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 59f989aa8c3b159cc7a94900c70e49388c1a5016 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Thu, 7 May 2026 11:36:49 +0100 Subject: [PATCH 2/5] fix(sandbox-vercel): biome import-sort cleanup Re-orders type and value imports per biome's organizeImports check so 'biome ci .' passes cleanly. No behavior change. --- sandbox-vercel/src/client.ts | 2 +- sandbox-vercel/src/handlers.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sandbox-vercel/src/client.ts b/sandbox-vercel/src/client.ts index cd11f791..36afe3f7 100644 --- a/sandbox-vercel/src/client.ts +++ b/sandbox-vercel/src/client.ts @@ -3,7 +3,7 @@ // pending a verified pass against the live Vercel Sandbox REST surface; // every call below returns SandboxError(S502) with a TODO marker. -import { S, SandboxError, type CreatedSandbox, type ExecResult, type SandboxRecord } from './sandbox.js' +import { type CreatedSandbox, type ExecResult, S, SandboxError, type SandboxRecord } from './sandbox.js' export interface VercelAuth { token: string diff --git a/sandbox-vercel/src/handlers.ts b/sandbox-vercel/src/handlers.ts index ce032321..637d8a6f 100644 --- a/sandbox-vercel/src/handlers.ts +++ b/sandbox-vercel/src/handlers.ts @@ -1,6 +1,6 @@ +import type { VercelClient } from './client.js' import type { Config } from './config.js' import { imageAllowed } from './config.js' -import type { VercelClient } from './client.js' import { CAPABILITIES, S, SandboxError } from './sandbox.js' export interface HandlerCtx { From 86fe4590175940ad1f321229f60bcbaa1400c141 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Thu, 7 May 2026 13:34:04 +0100 Subject: [PATCH 3/5] feat(sandbox-vercel): wire real Vercel REST endpoints from official docs Replaces stubs with the surface verified at vercel.com/docs/rest-api/sandboxes via context7: - create: POST /v1/sandboxes?teamId=&slug= body {runtime, source, timeout} - timeout is in MILLISECONDS (Vercel contract); converted from idle_timeout_secs at the boundary - source is required (git ref); caller passes optional source_url + source_revision in the payload - exec: POST /v1/sandboxes/{id}/commands body {command, args, cwd} - stop: POST /v1/sandboxes/{id}/stop (POST not DELETE) - 404 and 409 treated as idempotent success - list: GET /v1/sandboxes?teamId=&slug= response {sandboxes:[...]} or array teamId from VERCEL_TEAM_ID; slug from VERCEL_TEAM_SLUG. Bearer token from VERCEL_OIDC_TOKEN with VERCEL_TOKEN fallback. SandboxRecord started_at is now a pass-through string to match the rest of the family. snapshot, expose_port, and fs ops remain stubbed pending v2-beta sessions surface. Adds .gitignore for node_modules + .venv + package-lock.json. tsc + biome + tests clean. --- sandbox-vercel/.gitignore | 8 ++ sandbox-vercel/src/client.ts | 154 ++++++++++++++++++++++++++++----- sandbox-vercel/src/handlers.ts | 9 +- sandbox-vercel/src/sandbox.ts | 3 +- 4 files changed, 151 insertions(+), 23 deletions(-) create mode 100644 sandbox-vercel/.gitignore diff --git a/sandbox-vercel/.gitignore b/sandbox-vercel/.gitignore new file mode 100644 index 00000000..6013e875 --- /dev/null +++ b/sandbox-vercel/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +package-lock.json +.venv/ +__pycache__/ +*.egg-info/ +*.pyc +.DS_Store diff --git a/sandbox-vercel/src/client.ts b/sandbox-vercel/src/client.ts index 36afe3f7..a207f348 100644 --- a/sandbox-vercel/src/client.ts +++ b/sandbox-vercel/src/client.ts @@ -1,9 +1,11 @@ -// Narrow Vercel REST client. Holds the base URL + auth token and a small -// helper for building requests. Endpoint paths and bodies are still stubbed -// pending a verified pass against the live Vercel Sandbox REST surface; -// every call below returns SandboxError(S502) with a TODO marker. +// 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, S, SandboxError, type SandboxRecord } from './sandbox.js' +import { type CreatedSandbox, type ExecResult, mapHttpStatus, S, SandboxError, type SandboxRecord } from './sandbox.js' export interface VercelAuth { token: string @@ -11,6 +13,16 @@ export interface VercelAuth { 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 +} + export class VercelClient { api_base: string auth: VercelAuth @@ -20,41 +32,141 @@ export class VercelClient { this.auth = auth } - // headers() will be re-introduced when the real REST surface lands. For - // now the stub paths short-circuit before any HTTP request is built, so - // we omit it to keep the lint surface clean. - async create(_image: string, _idle_timeout_secs: number, _runtime: string): Promise { - // TODO: POST /v1/sandboxes (or whichever Vercel ships); parse response; - // map non-2xx via mapHttpStatus. - throw new SandboxError(S.PROVIDER_UNAVAILABLE, 'TODO: wire Vercel Sandbox create') + 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 { + 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 { + const body: Record = { + 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), + } } - async exec(_sandbox_id: string, _cmd: string, _args: string[], _timeout_ms?: number): Promise { - throw new SandboxError(S.PROVIDER_UNAVAILABLE, 'TODO: wire Vercel runCommand') + /** + * 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 { + 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, + } } - async stop(_sandbox_id: string): Promise { - throw new SandboxError(S.PROVIDER_UNAVAILABLE, 'TODO: wire Vercel sandbox.stop') + /** + * 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 { + 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 { - throw new SandboxError(S.PROVIDER_UNAVAILABLE, 'TODO: wire Vercel list') + 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 { - throw new SandboxError(S.PROVIDER_UNAVAILABLE, 'TODO: wire Vercel snapshot') + // 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 { - throw new SandboxError(S.PROVIDER_UNAVAILABLE, 'TODO: wire Vercel sandbox.domain(port)') + // 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 { - throw new SandboxError(S.PROVIDER_UNAVAILABLE, 'TODO: wire Vercel fs read') + 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 { - throw new SandboxError(S.PROVIDER_UNAVAILABLE, 'TODO: wire Vercel fs write') + throw new SandboxError(S.PROVIDER_UNAVAILABLE, 'TODO: wire vercel v2-beta write-files') } } diff --git a/sandbox-vercel/src/handlers.ts b/sandbox-vercel/src/handlers.ts index 637d8a6f..a9fe8b45 100644 --- a/sandbox-vercel/src/handlers.ts +++ b/sandbox-vercel/src/handlers.ts @@ -31,7 +31,14 @@ export async function doCreate(ctx: HandlerCtx, input: Record) } ctx.inFlight.value += 1 try { - const created = await ctx.client.create(image, idle, runtime) + const sourceUrl = typeof input.source_url === 'string' ? input.source_url : undefined + const sourceRev = typeof input.source_revision === 'string' ? input.source_revision : undefined + const created = await ctx.client.create({ + image, + idle_timeout_secs: idle, + runtime, + source: sourceUrl ? { url: sourceUrl, revision: sourceRev } : undefined, + }) return { sandbox_id: created.sandbox_id, image: created.image, diff --git a/sandbox-vercel/src/sandbox.ts b/sandbox-vercel/src/sandbox.ts index 8bad1600..855ac8aa 100644 --- a/sandbox-vercel/src/sandbox.ts +++ b/sandbox-vercel/src/sandbox.ts @@ -50,5 +50,6 @@ export interface ExecResult { export interface SandboxRecord { sandbox_id: string image: string - started_at: number + /** RFC3339 timestamp passed through from upstream, or empty if absent. */ + started_at: string } From b069cb6862352a51192078e60778d2d700309f4e Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Thu, 7 May 2026 13:45:50 +0100 Subject: [PATCH 4/5] =?UTF-8?q?chore(sandbox-vercel):=20bump=20iii-sdk=200?= =?UTF-8?q?.11.3=20=E2=86=92=200.11.6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit registerFunction(id, async (input) => ...) handler signature unchanged between versions; tsc + biome + vitest still clean. --- sandbox-vercel/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sandbox-vercel/package.json b/sandbox-vercel/package.json index c74e93af..143a766b 100644 --- a/sandbox-vercel/package.json +++ b/sandbox-vercel/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 f87d93124fedcb4b5b6ac6767921c3534dcb9b1c Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Mon, 11 May 2026 20:26:36 +0100 Subject: [PATCH 5/5] refactor(sandbox-vercel): adapter under sandbox::provider::vercel::* Stops shadowing the bare sandbox::* namespace. Routes through the sandbox router worker (provider="vercel"); direct invocation stays supported and stable. Handler logic, tests, S-code mapping unchanged. --- sandbox-vercel/README.md | 18 +++++++++--------- sandbox-vercel/iii.worker.yaml | 2 +- sandbox-vercel/src/index.ts | 16 ++++++++-------- sandbox-vercel/src/sandbox.ts | 2 +- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/sandbox-vercel/README.md b/sandbox-vercel/README.md index 40621ba2..d2f139e3 100644 --- a/sandbox-vercel/README.md +++ b/sandbox-vercel/README.md @@ -1,6 +1,6 @@ # 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::vercel::*` namespace so callers can spawn and drive Vercel sandboxes through `iii.trigger(...)` without depending on `@vercel/sandbox`. +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. @@ -8,14 +8,14 @@ The same ABI is implemented by every sandbox provider worker in this repo (`sand | Function id | Purpose | |---|---| -| `sandbox::vercel::create` | Boot a sandbox; returns `{sandbox_id, image, capabilities}` | -| `sandbox::vercel::exec` | Run a command inside a live sandbox | -| `sandbox::vercel::stop` | Tear down a sandbox | -| `sandbox::vercel::list` | Enumerate live sandboxes plus concurrency status | -| `sandbox::vercel::snapshot` | Snapshot a sandbox (Vercel shuts the parent down after) | -| `sandbox::vercel::expose_port` | Public URL for a port (must be in `ports` at create time) | -| `sandbox::vercel::fs::read` | Read a file out of the sandbox | -| `sandbox::vercel::fs::write` | Write a file into the sandbox | +| `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. diff --git a/sandbox-vercel/iii.worker.yaml b/sandbox-vercel/iii.worker.yaml index 196a8f4b..b09e0e38 100644 --- a/sandbox-vercel/iii.worker.yaml +++ b/sandbox-vercel/iii.worker.yaml @@ -3,7 +3,7 @@ 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::vercel::* trigger family. +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 diff --git a/sandbox-vercel/src/index.ts b/sandbox-vercel/src/index.ts index e92bc913..a99d7358 100644 --- a/sandbox-vercel/src/index.ts +++ b/sandbox-vercel/src/index.ts @@ -34,14 +34,14 @@ function reg(id: string, handler: (input: Record) => Promise doCreate(ctx, i)) -reg('sandbox::vercel::exec', (i) => doExec(ctx, i)) -reg('sandbox::vercel::stop', (i) => doStop(ctx, i)) -reg('sandbox::vercel::list', (i) => doList(ctx, i)) -reg('sandbox::vercel::snapshot', (i) => doSnapshot(ctx, i)) -reg('sandbox::vercel::expose_port', (i) => doExposePort(ctx, i)) -reg('sandbox::vercel::fs::read', (i) => doFsRead(ctx, i)) -reg('sandbox::vercel::fs::write', (i) => doFsWrite(ctx, i)) +reg('sandbox::provider::vercel::create', (i) => doCreate(ctx, i)) +reg('sandbox::provider::vercel::exec', (i) => doExec(ctx, i)) +reg('sandbox::provider::vercel::stop', (i) => doStop(ctx, i)) +reg('sandbox::provider::vercel::list', (i) => doList(ctx, i)) +reg('sandbox::provider::vercel::snapshot', (i) => doSnapshot(ctx, i)) +reg('sandbox::provider::vercel::expose_port', (i) => doExposePort(ctx, i)) +reg('sandbox::provider::vercel::fs::read', (i) => doFsRead(ctx, i)) +reg('sandbox::provider::vercel::fs::write', (i) => doFsWrite(ctx, i)) logger.info?.('sandbox-vercel registered, awaiting invocations') diff --git a/sandbox-vercel/src/sandbox.ts b/sandbox-vercel/src/sandbox.ts index 855ac8aa..d3fc41c0 100644 --- a/sandbox-vercel/src/sandbox.ts +++ b/sandbox-vercel/src/sandbox.ts @@ -22,7 +22,7 @@ export class SandboxError extends Error { } } -// Capabilities advertised by sandbox::vercel::create. +// Capabilities advertised by sandbox::provider::vercel::create. export const CAPABILITIES = ['snapshot', 'expose_port', 'fs'] as const // Map a non-2xx HTTP status from Vercel onto the right S-code.