Files
orca/cloud/docs/vercel-driver-design.md
T
Neil ba7db6d219 fix(sandbox-control): read the orgRole claim that Orca Cloud auth mints (#29)
Auth mints the live org role as orgRole on sandbox tokens (same claim as billing
tokens, stablyai/orca-cloud#695); the verifier read org_role, so admins would
never have been recognized.
2026-11-09 04:07:04 -08:00

271 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:<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 `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:<id>`), personal = `user:<sub>`, repo =
`<account>#github.com/<owner>/<repo>`. 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=<base64>` 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-<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>` and `Authorization: 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 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.