Operator routes GET /v1/internal/secrets/kek and POST /v1/internal/secrets/rewrap move every envelope-encrypted row (secrets, agent_credentials, github_user_tokens) from a retired KEK to the active one: idempotent, resumable, conditional writes, counts-only logs. A v2 row format behind SECRETS_SEAL_FORMAT=v2 (default v1) keeps the KEK id out of the value layer's AAD so rotation re-wraps data keys without decrypting values. Adds the KMS design doc.
23 KiB
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 ofVERCEL_TOKEN/team/project. Workers can't run@vercel/sandbox(undici, fs, xdg paths), so the Worker calls Vercel's REST API (/v2,/v3endpoints read from SDK 3.9.1's api-client) through aSandboxProviderApiinterface. Tests use an in-memory fake; the REST client is tested against a fakefetch. A Durable Object per sandbox is the upgrade path if D1 conditional updates prove too weak for concurrent lifecycle calls (v0 uses agenerationcompare-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-serverrecipe 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 insrc/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:<reason>,
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:<id> or user:<id> (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-<sandboxId> 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 = <sandboxId>:<sessionId>:<segmentStart> (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:<placeholder>)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
transformrule, the Vercel proxy answers HTTP/1.1 with nocontent-lengthortransfer-encoding; undici waits forever. curl/git (libcurl) and Go (gh) are fine. Handling: (1)transformis used only for hosts whose clients are curl/Go (github.com git traffic); (2) hosts Node clients use (api.github.com) get aforwardURLrule toPOST /v1/egress/proxyon the control plane instead, which verifies Vercel's sandbox OIDC token (oidc.vercel.com, aud = forward URL), mapssandbox_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 shipsorca-egress-probethat does a bounded Nodefetchper credential host at boot and reportsegress_framing_hanginstead 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
timeoutis 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 (
GETby name first). Cron reconciles rows stuck increating/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 putonly:VERCEL_TOKEN(team-scoped, Sandbox only),CONTROL_PLANE_TOKEN,GITHUB_APP_PRIVATE_KEY. Worker refuses to start lifecycle routes ifVERCEL_TEAM_ID/VERCEL_PROJECT_IDare missing. No routes /workers_dev:falsein the checked-in wrangler file. - Per-account authorization on every route (row
account_idmust 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, thenrestartResumable→restartContinuefor 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 forwarn.
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.comdoes not resolve (curl rc 6). Policy update 0.25s. Placeholder → injected fake GitHub credential reaches GitHub (curl/ghtokenscheme: 401 Bad credentials); attacker token and placeholder-less git → 403orca-egress-denied. Node fetch under a transform rule still hangs on Pro;orca-egress-probereportsegress_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): D1secretskeyed (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; idSECRETS_KEK_ID, defaultk1). 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 intoSECRETS_KEK_RETIRED({"k1":"…"}) in the same deploy, then run the operator sweepPOST /v1/internal/secrets/rewrapuntildone(src/secrets-rewrap.ts; it also coversagent_credentialsandgithub_user_tokens).GET /v1/internal/secrets/kekreports when the retired id is unreferenced and can be dropped. WithSECRETS_SEAL_FORMAT=v2rows drop the KEK id from the value layer's AAD, so the sweep re-wraps DEKs without decrypting values. KMS plan:cloud/docs/sec-kms-design.md. - 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:<id>), personal =user:<sub>, repo =<account>#github.com/<owner>/<repo>. Org secrets and an org account's repo secrets needorgRoleowner/admin on the token (any member lists names); personal secrets are the user's alone; dev tokens stay underdev:. - 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 startingsk-ant-oat, JWT-shapedANTHROPIC_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-startasORCA_SECRET_ENV(NAME=<base64>lines). The script decodes them into orcad'senv -ilaunch env (agents inherit it) through a pipe, unsets the variable, and writes nothing to disk, so the stop snapshot never holds them andorca-image-wipehas 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 getsorca-ph-anthropic-<sbx>/orca-ph-openai-<sbx>; boot records the hosts on the row (egress_secret_hosts) and the policy gets forwardURL claims (api.anthropic.com:x-api-key: <ph>andAuthorization: Bearer <ph>;api.openai.com:Authorization: Bearer <ph>) 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 plainANTHROPIC_AUTH_TOKENin 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 mintingorgRoleon 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: desktopPOST /v1/github/connect→ signed (HMAC), single-use, 10-min state carrying the Orca account → GitHub install / OAuth →/v1/github/setup+/v1/github/callbackexchange 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, orSANDBOX_CREATES_DISABLED) refuses create/resume/rollover with 503maintenanceand drains running sandboxes (suspend, snapshot kept; cron keeps draining while on); globalSANDBOX_MAX_RUNNING(50, 503capacity); per-account 3 × seats backstop (403concurrency_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 destroyedDATA_RETENTION_DAYS(30) after; suspended sandboxes idleIDLE_RETENTION_DAYS(90) likewise. New sandboxes setsnapshotExpirationto 104 days (backstop). Destroyed rows droprepo_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.