# Orca Cloud sandboxes on Vercel — white-label driver design (v0) Status: design + mock-tested code in `nwparker/orca-private` (integration branch `nwparker/vercel-driver`). Nothing here has touched a real Vercel or Cloudflare resource. Inputs: bake-off (`reports/provider-bakeoff.md`), `conductor-parity-matrix.html` (Vercel-first, 24h cap), `vm-just-works.html`, `reports/credentials.md`, stablyai/orca#20777 (recipe that talks to Vercel with the user's own account), billing design (`cloud/docs/accounts-billing-design.md`). ## Goal and non-goals - One click "Orca Cloud" workspace. Orca's Vercel Pro team owns every sandbox. Users never see a Vercel account, token, dashboard or brand. They pay Orca (billing Worker), and Orca pays Vercel. - Not in v0: memory pause (Vercel has none), BYO Vercel account (that is #20777's recipe path), live fork, Drives. ## Shape ``` desktop (Electron main) Orca sandbox control plane (CF Worker) Vercel (Orca's team) HostedSandboxDriver ──HTTPS, Orca JWT────────▶ /v1/sandboxes/* ──Vercel token (secret)──▶ /v2/sandboxes/* implements EnvironmentProviderDriver D1: sandboxes, sessions, usage cursor persistent sandbox └ recipe adapter → existing runtime record billing: /v1/internal/entitlements/check, per workspace, / pairing / resume pipeline /v1/internal/usage (CONTROL_PLANE_TOKEN) golden snapshot desktop ◀───────── wss pairing (orcad, E2EE) ─────────────────────────────────────────────▶ orcad :6768 ``` - **Control plane = Cloudflare Worker + D1** (`cloud/apps/sandbox-control`). Same stack as billing and relay-edge, so one toolchain, JWKS auth pattern and deploy story. It is the only holder of `VERCEL_TOKEN`/team/project. Workers can't run `@vercel/sandbox` (undici, fs, xdg paths), so the Worker calls Vercel's REST API (`/v2`, `/v3` endpoints read from SDK 3.9.1's api-client) through a `SandboxProviderApi` interface. Tests use an in-memory fake; the REST client is tested against a fake `fetch`. A Durable Object per sandbox is the upgrade path if D1 conditional updates prove too weak for concurrent lifecycle calls (v0 uses a `generation` compare-and-set). - **Desktop** holds only a short-lived Orca JWT (`aud=orca-sandbox`, minted by Orca Cloud auth like the billing token; until that endpoint exists the token provider is injected). It never sees a Vercel credential. - **Integration with existing VM code**: the hosted driver returns the same `orca-server` recipe result (`pairingCode`, `projectRoot`, `userData`) a shell recipe prints, so write-ahead records (#39241), timeouts (#39238), pairing reuse on resume (#39225), host health and sleep/wake work unchanged. The adapter lives in `src/main/environment-providers/environment-provider-recipe-adapter.ts`; recipe catalog/UI wiring is follow-up. ## Desktop interface (`src/main/environment-providers/`) `EnvironmentProviderDriver`: `create(spec)`, `resume(handle)`, `suspend(handle)`, `destroy(handle)`, `status(handle)`, optional `renewLease(handle, ms)` and `rollover(handle)`; every call takes `{signal, onProgress(phase)}`. Handles are `{driverId, environmentId, driverState}` (opaque JSON persisted in the runtime record's `userData`). Capability flags drive UX and schedulers: | flag | Vercel hosted | |---|---| | `memoryPause` | false (stop = reboot, PTYs lost; measured) | | `maxSessionMs` | 24h (Pro). Scheduler forces rollover at 23h50m | | `fsPersistOnStop` | true (auto snapshot on stop, keepLastSnapshots 2) | | `egressInjection` | true | | `idleStopMs` | 20 min default (Orca-owned, not Vercel's timeout) | Errors are `EnvironmentProviderError{code}` with a closed code set (`entitlement_denied:`, `not_found`, `conflict`, `unavailable`, `timeout`, `unauthorized`, `invalid_response`). A driver contract kit (`environment-provider-driver-contract.ts`) runs the lifecycle against any driver; the hosted driver runs it against a fake control plane. ## Control-plane API (Worker) All desktop routes require the Orca JWT; the account is `org:` or `user:` (same rule as billing). | route | does | |---|---| | `POST /v1/sandboxes {workspaceId, machineSize, region?, repo?}` | entitlement check → write-ahead row → create from golden snapshot → start orcad → `{sandbox, connection, session}` | | `GET /v1/sandboxes[/:id]` | status from D1 + provider reconcile | | `POST /v1/sandboxes/:id/suspend` | drain orcad → stop session (auto snapshot) → settle usage | | `POST /v1/sandboxes/:id/resume` | entitlement check → resume → start orcad → connection (same pairing; identity on disk) | | `POST /v1/sandboxes/:id/lease {extendMs}` | `extend-timeout`, clamped to the session deadline | | `POST /v1/sandboxes/:id/rollover` | drain → stop → resume → start orcad (24h cap path) | | `DELETE /v1/sandboxes/:id` | delete with `deleteOrphanSnapshots`, then verify every recorded snapshot is gone | | `POST /v1/egress/proxy` | Vercel `forwardURL` target (see egress) | | cron (5 min) | settle usage, stop sandboxes past idle/deadline or whose account lost entitlement, sweep stuck `creating` rows | Vercel resource naming: `orca-` where `sandboxId` is our random id (never user text). Tags: `orcaSandbox`, `orcaEnv` (prod/dev). Ownership is checked against D1 + the name; nothing is ever deleted by prefix. Create params: `source: {type:'snapshot', snapshotId: GOLDEN_SNAPSHOT_ID}`, `persistent: true`, `ports: [6768]`, `resources.vcpus` from machine size (standard 4, large 8; xlarge 16 needs Enterprise), `timeout` = Orca lease (30 min, renewed), `keepLastSnapshots: {count: 2}`, `snapshotExpiration: 0` while live (finite TTL after archive), `region` default `iad1`, `networkPolicy` from the policy compiler. **Entitlements**: `POST billing /v1/internal/entitlements/check {accountId, machineSize, runningSandboxes}` before create/resume. If billing is unreachable or unconfigured the Worker refuses (fail closed); a stub client is used only when `ENTITLEMENTS_MODE=stub` in local dev. **Usage**: per-session segments `[lastSettledAt, now]` (≤24h) sent to `/v1/internal/usage` with `usageId = ::` (idempotent). If the answer says new starts are no longer allowed for `included_hours_exhausted`/`overage_cap_reached`, running sandboxes are drained and stopped; `payment_past_due` keeps running (grace is an open billing decision). **Pairing material**: the Worker runs `orca-sandbox-start` in the VM and returns orcad's ready JSON (`pairingCode`, `projectRoot`). Pairing codes are never logged or stored in D1. v1 hardening: the desktop sends a device public key and the VM pre-authorizes it, so the control plane never carries a usable pairing secret. Endpoint branding: v0 dials the `*.vercel.run` route internally (not shown in UI); v1 fronts it with `*.sandbox.onorca.dev` if white-label needs to extend to network inspection. ## Golden image (`cloud/apps/sandbox-control/image/`) Operator-only script (`build-golden-image.ts`, dry-run by default). Steps: create build sandbox (`node24`, 4 vCPU, non-persistent, 40 min) → upload pinned orcad linux-x64 bundle (sha256-checked) → `provision-golden.sh`: apt basics, `gh`, pinned agent CLIs (Claude Code, Codex, OpenCode, Gemini), git credential helper + `orca-sandbox-start`, `orca-sandbox-drain`, `orca-egress-probe` → smoke (orcad reaches ready, CLIs `--version`) → **wipe orcad identity and any auth homes** (clone-safe, risk #1) → snapshot with `expiration: 0` → delete build sandbox → print manifest (`snapshotId`, versions, sha256) for `GOLDEN_SNAPSHOT_ID`. Snapshot → orcad ready measured at 3.3–4.3s. ## Network policy (fail closed) Compiled per sandbox by `compileNetworkPolicy()`: - Allowlist record form, **no `"*"` entry** (unlisted domains don't resolve). Default list: package registries (npm, pypi, crates, go proxy, rubygems), github.com + codeload + objects, agent APIs (Anthropic, OpenAI/ChatGPT, Google), and Orca endpoints. Workspaces can opt into `"open"` (adds `"*": []`), never silently. - Credential hosts get `[...inject rules, {response: 403 orca-egress-denied}]`: a request without our placeholder is refused, so the real token can't be paired with an attacker-chosen request shape. - GitHub: VM sets `http.https://github.com/.extraheader = Authorization: Basic b64(x-access-token:)` so every git request carries the placeholder (git otherwise sends an unauthenticated probe first, which the trailing deny would 403). The control plane mints a 1h GitHub App installation token and rewrites the policy before expiry. - **Node fetch hang**: with a `transform` rule, the Vercel proxy answers HTTP/1.1 with no `content-length` or `transfer-encoding`; undici waits forever. curl/git (libcurl) and Go (`gh`) are fine. Handling: (1) `transform` is used only for hosts whose clients are curl/Go (github.com git traffic); (2) hosts Node clients use (api.github.com) get a `forwardURL` rule to `POST /v1/egress/proxy` on the control plane instead, which verifies Vercel's sandbox OIDC token (`oidc.vercel.com`, aud = forward URL), maps `sandbox_name` → our row, injects the header and returns a normally framed response — this also keeps users' GitHub tokens out of Vercel's policy store; (3) the image ships `orca-egress-probe` that does a bounded Node `fetch` per credential host at boot and reports `egress_framing_hang` instead of letting agents hang. File the framing bug with Vercel. Needs a live test. - Agent sign-in is native inside the VM (unmodified CLI, Anthropic-compliant); model hosts are allowlisted, not injected. Auth homes live on the sandbox disk and persist across stop/resume. ## Lifecycle, idle stop and the 24h cap - One persistent sandbox per workspace. Orca owns the timer: Vercel `timeout` is a 30 min lease that the desktop renews while orcad reports busy; if the desktop disappears the lease lapses and Vercel stops the session (files kept). - Idle stop: 20 min without clients, agents or PTY input → suspend. - Opportunistic rollover after ~12h when all agents idle and PTYs quiet ≥2 min. Forced rollover at 23h50m: warn at T−60 and T−15, stop admitting turns at T−10, drain (orcad SIGTERM → durable restart offers, scrollback/cwd hints) up to 5 min, stop, resume, start orcad. One state machine covers idle stop, cap, crash and "Restart". - After reconnect the desktop auto-continues the durable offers (`agentSession.restartResumable` → `agentSession.restartContinue`) for sessions the rollover interrupted, so agents carry on without user action. ## Failure / leak handling - Write-ahead D1 row before any Vercel call; the deterministic name makes create retries idempotent (`GET` by name first). Cron reconciles rows stuck in `creating`/`destroying`. - Destroy deletes the sandbox with `deleteOrphanSnapshots=true`, then lists snapshots by name and deletes any left (bake-off: ~325 MB auto snapshots survive delete otherwise). - Vercel errors map to catalogued codes; bodies are never echoed to the desktop. ## Security - Secrets via `wrangler secret put` only: `VERCEL_TOKEN` (team-scoped, Sandbox only), `CONTROL_PLANE_TOKEN`, `GITHUB_APP_PRIVATE_KEY`. Worker refuses to start lifecycle routes if `VERCEL_TEAM_ID`/`VERCEL_PROJECT_ID` are missing. No routes / `workers_dev:false` in the checked-in wrangler file. - Per-account authorization on every route (row `account_id` must equal the caller's); ids are random. - Strict zod bodies, 16 KiB limit, no redirects on provider calls, provider error bodies dropped. GitHub App + billing dev (2026-11-09): app "Orca Cloud (dev)" (stablyai, id 5677658, https://github.com/apps/orca-cloud-dev; contents/pull_requests write, metadata read, no webhook) created by the manifest flow; key only as a Worker secret. From a real sandbox via the dev Worker: private repo cloned at create (6.6s total), real `git push` of a new branch in 1.0s with the transform-injected repo token, `gh` and Node fetch to api.github.com through the forwardURL proxy with a minted token (200, ~1s), no token on the VM disk. Billing dev is reached over a service binding (same-account workers.dev fetches fail with 1042): an account without a subscription gets 403 `no_subscription`; the cron's suspensions poll runs clean. `dev:*` operator accounts bypass billing outside prod. Desktop dogfood (2026-11-09, dev app → dev Worker → Vercel Pro): "Orca Cloud" is a built-in provider in Run on (reserved id `cloud`); create → orcad ready in the app 8–12s with the private repo cloned via the GitHub App; shell work + real `git push` from the cloud workspace; 15-min cap warning toast; forced rollover at the (shortened) cap with reconnect and files kept; idle sleep through the ordinary Sleep flow after a (shortened) 2-min idle; wake on click; destroy through runtime cleanup leaves no sandbox or snapshot. Found live and fixed: agent hosts must be wildcard-allowlisted (platform.claude.com), the runtime store dropped `provider` (now in the feature store), remote keystrokes are not 'driving' input (verdict reads all input), Vercel answers 400 when deleting an already-deleted snapshot. Not run: agent auto-resume with a real chat (needs a Claude/Codex sign-in). ## Built (mock-tested) and next wiring - (b) `src/main/environment-providers/*`, `src/main/hosted-sandbox/*` — driver, client, contract kit, recipe adapter. - (c) `cloud/apps/sandbox-control` — Worker, D1 schema, Vercel REST client, entitlements/usage, egress proxy, cron. - (d) `cloud/apps/sandbox-control/image` — golden image builder + VM entry points. - (e) `hosted-session-lease-policy.ts` / `-scheduler.ts` / `hosted-agent-auto-resume.ts` — lease renew, T−60/T−15 warnings, idle suspend, opportunistic + forced rollover, then `restartResumable` → `restartContinue` for offers recorded since the drain (older offers stay with the user). The cron only force-stops 5 min after the deadline. - Next: a built-in "Orca Cloud" recipe source whose create/resume/suspend/destroy call the driver through the recipe adapter; start one scheduler per ready hosted runtime (stop on suspend/destroy); an orcad RPC exposing the idle-probe verdict + last PTY input for `readActivity`; header warning + mobile push for `warn`. ## Live results (2026-11-09, Vercel Pro team `lovecast`, project `orca-sandbox-control`, iad1) Driven by the real Worker handler in-process (real Vercel REST, node:sqlite D1, stub billing) and the real desktop driver/scheduler. Base OS is Amazon Linux 2023 (dnf), not Ubuntu. - Golden image: orcad built from public stablyai/orca@7c14832 in a 8 vCPU build sandbox in 98s (67 MB tgz); provision 14.3s (gh 2.103.3, Claude Code 2.1.319, Codex 0.166.0; orcad loopback smoke passed); wipe ok; snapshot 8.2s. Bug found and fixed: provision downloaded the bundle over its own source path. - Snapshot → orcad ready (`POST /v1/sandboxes`, incl. Vercel create + orca-sandbox-start): 5.4 / 4.1 / 4.9 s; WS open. - Suspend (drain + stop) 7.6s; resume → ready 6.0s; same endpoint and same orcad host key (no re-pair). - Shortened cap (6 min): scheduler warned T−15, then forced rollover at the deadline in 17.3s (drain, stop, boot, orcad ready), reconnect WS open, new deadline set. Destroy 0.4–0.7s; zero sandboxes/snapshots left. - Egress: allowlist resolves npm (200), `example.com` does not resolve (curl rc 6). Policy update 0.25s. Placeholder → injected fake GitHub credential reaches GitHub (curl/gh `token` scheme: 401 Bad credentials); attacker token and placeholder-less git → 403 `orca-egress-denied`. Node fetch under a transform rule still hangs on Pro; `orca-egress-probe` reports `egress_framing_hang` (npm, untransformed: ok). No real GitHub token was used, so a successful authenticated push is still unverified. - Not covered live: agent auto-resume with a real chat (needs a real Claude/Codex sign-in). Dev deployment (2026-11-09): Worker https://orca-sandbox-control-dev.lovecast.workers.dev, D1 `orca-sandbox-control-dev`, config `wrangler.dev.jsonc`, billing stubbed (no billing dev Worker yet). Through it: create 5.1s, rollover 12.2s (same host key), destroy 1.0s, no snapshots left. Egress proxy end to end from a real sandbox: Bearer and gh `token` placeholders forwarded and re-injected (GitHub answers the injected fake: 401), Node fetch completes in 450 ms with chunked framing (the transform-rule hang is gone), attacker token / no auth → 403. ## Secret store and injection (org / personal / repo) Product decision: "API-key injection" in the Conductor parity matrix (form 2 = this store; form 3 = egress swap). - **Store** (`src/secrets.ts`, migration 0003): D1 `secrets` keyed (scope, owner_id, name). Envelope encryption (`src/secrets-crypto.ts`): per-secret random AES-256-GCM DEK; DEK wrapped by the KEK (`SECRETS_KEK`, base64 32 bytes, Worker secret only; id `SECRETS_KEK_ID`, default `k1`). Both layers take `["orca-secret-v1", layer, kekId, scope, ownerId, name]` as AAD, so a row copied onto another owner/name fails to open (skipped at injection, logged by name). Rotation: set the new KEK + id, move the old one into `SECRETS_KEK_RETIRED` (`{"k1":"…"}`); rows re-seal on their next write. - **API** (desktop JWT): `GET /v1/secrets?scope=org|personal|repo[&repo=owner/name]`, `PUT /v1/secrets/:scope/:NAME` `{value}`, `DELETE …`. Write-only values: responses carry name, scope, kind, updatedAt, updatedBy and a last-4 hint for values ≥16 chars. Owners: org = the org account (`org:`), personal = `user:`, repo = `#github.com//`. Org secrets and an org account's repo secrets need `orgRole` owner/admin on the token (any member lists names); personal secrets are the user's alone; dev tokens stay under `dev:`. - **Validation**: `^[A-Z_][A-Z0-9_]*$` ≤128; reserved names (PATH, HOME, NODE_OPTIONS, proxy/CA vars, GIT_SSH*, GH_TOKEN/GITHUB_TOKEN, …) and prefixes (LD_, DYLD_, ORCA_, VERCEL_, BASH_FUNC_, GIT_CONFIG). Values ≤8 KiB, no NUL; 100 secrets per owner; 128 KiB injected per start. **Never stored**: `CLAUDE_CODE_OAUTH_TOKEN`, any value starting `sk-ant-oat`, JWT-shaped `ANTHROPIC_API_KEY`/`OPENAI_API_KEY` (ChatGPT/Claude.ai sessions). - **Precedence**: env secrets repo > personal > org; agent keys (ANTHROPIC/OPENAI/GEMINI/GOOGLE/CURSOR keys, AWS Bedrock and Vertex vars) org-shared > repo > personal. Per name, so a partial org Bedrock set mixes with personal. - **Injection**: every boot (create, resume, rollover) resolves secrets and passes them to `orca-sandbox-start` as `ORCA_SECRET_ENV` (`NAME=` lines). The script decodes them into orcad's `env -i` launch env (agents inherit it) through a pipe, unsets the variable, and writes nothing to disk, so the stop snapshot never holds them and `orca-image-wipe` has nothing to wipe. Edits apply on the next start/resume, no image rebuild. Values transit Vercel's command API (as the VM's env would anyway); logs carry counts and names only. - **Phase 2 egress swap** (`src/secret-egress.ts`, `SECRETS_EGRESS_PLACEHOLDERS=on`, off by default): VM env gets `orca-ph-anthropic-` / `orca-ph-openai-`; boot records the hosts on the row (`egress_secret_hosts`) and the policy gets forwardURL claims (`api.anthropic.com`: `x-api-key: ` and `Authorization: Bearer `; `api.openai.com`: `Authorization: Bearer `) followed by the 403 deny. The egress proxy (OIDC-verified, as for api.github.com) swaps in the real key, cached per session for 60 s. Transform rules are not used: they hang Node clients (Claude Code, Codex), the forwardURL path is the one proven live for api.github.com. Not on by default because (1) SSE model streams through Vercel forwardURL → Worker are not yet tested live, and (2) with a key routed, api.anthropic.com denies everything else, including a subscription OAuth Bearer or a plain `ANTHROPIC_AUTH_TOKEN` in the same VM. Enable on dev after a live streaming test with the rebuilt image. - Needs: golden image rebuild (new `orca-sandbox-start`), and Orca Cloud auth minting `orgRole` on sandbox tokens (stablyai/orca-cloud#695). ## GitHub installation binding, guardrails, retention (2026-11-09) - Repo tokens need a binding: `github_installations(installation_id, account_id)`. `tokenForRepo(url, accountId)` looks up the repo's installation, refuses unless the caller's account holds a live binding (re-checked on cached tokens too), mints for that one repo (contents/pull_requests write, metadata read) and rejects a wider response. Binding: desktop `POST /v1/github/connect` → signed (HMAC), single-use, 10-min state carrying the Orca account → GitHub install / OAuth → `/v1/github/setup` + `/v1/github/callback` exchange the code for a user-to-server token and bind only installations on the GitHub user's own account or on an org where they are an active admin (`/user/memberships/orgs`, needs the app's Members:read). Installation ids in URLs are never trusted. Operator binding for support/dev: `POST /v1/internal/github/bindings {installationId|repoUrl, accountId}`. - Guardrails before billing: kill switch (D1 flag via `POST /v1/internal/kill-switch`, or `SANDBOX_CREATES_DISABLED`) refuses create/resume/rollover with 503 `maintenance` and drains running sandboxes (suspend, snapshot kept; cron keeps draining while on); global `SANDBOX_MAX_RUNNING` (50, 503 `capacity`); per-account 3 × seats backstop (403 `concurrency_limit`); `CREATE_RATE_PER_MINUTE` (10, 429). - Hourly housekeeping: provider sandboxes tagged with this env and no live row are stopped on first sight and deleted after `ORPHAN_GRACE_HOURS` (24); automatic snapshots whose sandbox no longer exists anywhere in the project are deleted after the same grace (manual/golden never; partial listings delete nothing). Canceled/unpaid accounts (suspensions list + daily standing probe; outages never start a clock) get a warning hook at T−7 days and are destroyed `DATA_RETENTION_DAYS` (30) after; suspended sandboxes idle `IDLE_RETENTION_DAYS` (90) likewise. New sandboxes set `snapshotExpiration` to 104 days (backstop). Destroyed rows drop `repo_url`. - Live on dev: unbound account → private clone refused (no token minted); after operator binding → clone + push + gh/Node via proxy OK; kill switch drill drained 1, refused create/resume, resume OK after lifting. ## What needs live access (not done) Vercel Pro team id + project id + a Sandbox-scoped token (as Worker secrets), wrangler auth for a dev D1, a billing Worker dev deployment + `CONTROL_PLANE_TOKEN`, and an Orca Cloud auth change to mint `aud=orca-sandbox` tokens. Live checks then: golden image build, snapshot→ready timing on Pro, forwardURL + OIDC proxy, Basic-auth inject for git, 24h rollover rehearsal with a shortened cap.