# Orca Per Workspace Env

> Set up, review, debug, or validate Orca per-workspace environment recipes — on-demand, disposable runtimes (cloud sandboxes, VMs, or local) created fresh for each workspace. Covers first-time setup (provider prerequisites, the reusable base snapshot, the coding-agent auth snapshot, credentials, and state), not just the per-workspace lifecycle scripts. Use to stand up per-workspace environments, fix an `environmentRecipes` entry in `orca.yaml`, scaffold provider lifecycle scripts, or resolve an `orca vm recipe doctor` failure.

- Skill: `peteedoo/orca-per-workspace-env` (Agent Skill)
- Install (CLI): `npx skillmds add peteedoo/orca-per-workspace-env`
- Raw SKILL.md: https://api.skillmd.com/api/skills/peteedoo/orca-per-workspace-env/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: peteedoo (https://skillmd.com/u/peteedoo)
- Updated: 2026-09-08
- Page: https://skillmd.com/skills/peteedoo/orca-per-workspace-env

---


# Per-Workspace Environments

Help a user stand up and maintain a repo-owned per-workspace environment recipe end to end. Each
workspace gets its own on-demand, disposable runtime (a cloud sandbox, a VM, or a local one),
created fresh and torn down after.

Orca is a **thin wrapper**: you guide, detect, and scaffold; you never own the user's cloud account,
billing, images, or credentials.

- **You DO:** sequence the setup, detect what's detectable (provider CLI present/logged-in? recipe
  present? `doctor` passing?), scaffold provider-templated scripts the user fills in, drive the slow
  snapshot/auth phases with the user, and always show the next action.
- **You DO NOT:** create accounts, choose plans/regions, invent org/project/scope ids, store or print
  secrets, or run anything that spends money without an explicit user OK.

First-time setup has **four phases before the per-workspace recipe runs** — easy to miss, so walk
them in order:

1. **Prerequisites** — cloud account, provider CLI, scope/project, plan limits, git token (§2).
2. **Base snapshot** — reusable image: tools + repo + headless build, snapshotted once (§3).
3. **Agent-auth snapshot** — boot the base, run interactive device-auth, re-snapshot (§4).
4. **State** — thread snapshot id / scope / project / port between phases via a state file (§6).

Then the **per-workspace contract** (create/suspend/resume/destroy) runs fast (§8).

**The one branch that shapes everything — connection mode:** **Orca-server** (`create` runs `orca serve`
in the env and emits a `pairingCode`; §7c/§7f) vs **SSH** (`create` runs no server and emits a
`connection.type:"ssh"` block Orca dials into; §7g/§7h). Settle this first — it changes the `create`
output shape and half the templates.

**Quick-start (happy path):** interview the user (connection mode Orca-server vs SSH, provider, agent CLI,
git auth — §1.2) + read the provider's CLI docs → scaffold `scripts/orca-vm/` from §7 → run the
base-snapshot script, then the auth script (you invoke these by hand; not via `orca.yaml`) → wire
`environmentRecipes` in `orca.yaml` → `orca vm recipe doctor <id> --json` (free) → then the `--provision`
self-test loop (§9) until it passes.

---

## 1. Setup workflow

Drive these with the user. **[CHECKPOINT]** steps need explicit confirmation — they spend money, take
a long time, or need the user at the keyboard. Never create an Orca workspace or commit unless asked.

1. **Inspect the repo** for an existing `environmentRecipes` entry, `scripts/orca-vm/`, a state file, or setup
   notes. If a working recipe exists, jump to Doctor (§9) instead of rebuilding.
2. **Interview the user up front** — gather these choices and confirm them back before scaffolding
   anything. Don't pick for them (§11); don't guess.
   - **Connection mode:** how Orca attaches to the environment — an **Orca server** (the VM runs
     `orca serve` and Orca pairs over its pairing URL; worked example §7f) or **SSH** (Orca connects to
     the host over SSH; §7g). This decides the recipe's connection shape, so settle it first.
   - **Provider:** Vercel Sandbox, Fly, Modal, an existing SSH host, … For non-obvious providers, also
     ask scope/project/region and plan limits (§2). Then **read that provider's CLI/SDK docs** (or
     `<cli> --help`) before scaffolding — you need its exact create/exec/snapshot/remove verbs.
     If a provider advertises `ssh`, verify whether it exposes a real dialable SSH target
     (host/port/user/key or proxy command) or only a provider-mediated interactive shell; Orca SSH mode
     needs the former.
   - **Coding-agent CLI + account:** which agent runs in the VM (`codex`, `claude`, …) and that the user
     has an account for it — it gets logged in during the Phase-3 auth snapshot (§4).
   - **Git auth:** the token source for cloning a private repo (`GH_TOKEN`/`GITHUB_TOKEN` or `gh auth
     token`; §5).
3. **Check prerequisites (§2)** — detect the provider CLI + auth and confirm the items above are in
   place before any paid step.
4. **Scaffold scripts + state file** from §7 (worked Vercel example: §7f; SSH host: §7g; Docker SSH:
   §7h; Windows: §7i), filling in the provider's real commands. Make them executable.
5. **[CHECKPOINT] Build the base snapshot (§3)** — paid, slow.
6. **[CHECKPOINT] Authenticate the agent (§4)** — interactive; the user follows a URL/code. **You cannot
   drive this step** — you run commands non-interactively, so there's no TTY for `docker exec -it` /
   `ssh -t` to prompt against. The **user** runs the Phase-3 login in their own terminal (or via the
   Claude Code harness bang-prefix — `! <cmd>`, with the required space after `!`); you scaffold and drive
   the non-interactive phases around it. After kicking it off, **ask the user to report back once the login
   finishes** — you can't observe it completing, and you need that confirmation before resuming the
   non-interactive steps (base/auth commit, doctor, provision).
7. **Wire the recipe** so `orca.yaml` points create/suspend/resume/destroy at the scripts (§8). The
   workspace composer reads `environmentRecipes` from the project's primary checkout of `orca.yaml`, **not** from
   a feature branch or worktree. So a recipe added only on a branch won't appear as a "Run on" option
   until that `orca.yaml` change is committed and merged to the project's primary branch. Tell the user
   this up front: `doctor`/`--provision` validate the scripts from the working copy on any branch, but
   creating a workspace from the recipe in the picker needs it on primary.
8. **Dry-run doctor** — `orca vm recipe doctor <recipe-id> --repo-path <repo> --json` (free, static; §9).
   Fix every failure before going live.
9. **[CHECKPOINT] Live self-test** — get the user's OK once, then run
   `orca vm recipe doctor <recipe-id> --provision --json` as a loop: it runs create → validates →
   destroys, and on failure returns a full transcript. Read it, fix the scripts, and re-run yourself until
   it passes (§9). Spends cloud money; the one approval covers the loop.
10. **[CHECKPOINT] Optional workspace test** — only if asked: create a workspace via the picker, then
    verify sleep/wake/delete.

---

## 2. Phase 1 — Prerequisites

The user's responsibility; verify what's verifiable, ask for the rest, invent nothing. State which
items you verified vs. which the user asserted.

- **Connection mode** (Orca server vs SSH) confirmed with the user — see §1 step 2; it shapes the recipe.
- **Cloud account + plan** that allows sandboxes/VMs. Ask.
- **Provider CLI installed + authenticated** — detect (`command -v <cli>`), check auth (e.g.
  `vercel whoami`). If missing, point at the provider's docs; don't log them in.
- **Scope / project / region** the sandboxes live under. Ask; flows into every script via state.
- **Plan / timeout / RAM caps.** Record them — e.g. Vercel Hobby caps sandbox timeout at **45m**,
  which limits both the base build and per-workspace runtime (see §10).
- **Git token for private repos** (`GH_TOKEN`/`GITHUB_TOKEN`, or the provider's git auth; can fall back
  to `gh auth token`). See §5.
- **Coding-agent CLI choice** (`codex`, `claude`…) and that the user has an account — it gets
  authenticated into the VM in Phase 3.

---

## 3. Phase 2 — Base snapshot (the reusable image)

Build **once**, snapshot, and every workspace boots from it in seconds instead of rebuilding.
Provisioning + building takes a while (often ~20–30 min), so it runs behind a checkpoint. The script
shape is §7a; key points:

- Build the **headless Electron main only** (not the renderer) so it fits in plan RAM.
- Use the VM image's package manager (`apt`/`dnf`/`apk`, per the base distro — not the provider brand).
- Clone with the git token via `GIT_ASKPASS` (§5).
- **Trap errors and remove the half-built sandbox** so a crash doesn't leave a paid resource running.
- Snapshot the stopped sandbox, parse the snapshot id, and write it + scope/project/port/repo to state.

---

## 4. Phase 3 — Agent-auth snapshot (interactive)

The base snapshot has the agent CLI installed but **not logged in**, and per-workspace VMs are
ephemeral — so authenticate once and bake it into a second snapshot layer. Script shape is §7b:

1. Boot a sandbox from the base `snapshotId` (from state).
2. Run the agent's login **interactively** (`--interactive --tty`); the user completes the URL/code in
   their browser. On a **headless VM this must be the device-auth flow** (e.g. `codex login --device-auth`),
   **not** plain `codex login`: the default OAuth login starts a loopback callback server on a container
   port the host browser can't reach, so it hangs. Device-auth instead prints a URL + code the user opens
   on the **host**.
3. Verify login; **refuse to snapshot an unauthenticated VM.** Prefer the status command's **exit code**
   (most agent CLIs exit non-zero when unauthenticated). If you grep instead, agent status often goes to
   **stderr** (e.g. `codex login status` prints "Logged in using ChatGPT" there), so **fold stderr first**
   (`... 2>&1 | grep …`) and match the agent's **exact success line** — never `grep -qi 'logged in'`, which
   also matches "**not** logged in" and would commit an unauthenticated image.
4. Re-snapshot, parse the new id, and overwrite `snapshotId` in state to the authenticated image
   (recording `authSourceSnapshotId`). Remove the auth sandbox.

**You can't drive step 2 yourself** (you run commands non-interactively — no TTY). The **user** runs it in
their own terminal, or via the Claude Code harness bang-prefix (`! <cmd>`, with the required space after
`!`). You scaffold/boot the sandbox and run steps 3–4, but **you cannot observe the interactive login
finishing** — so **ask the user to tell you when it's done** before you verify and re-snapshot.

If the agent's credentials are short-lived, warn that the snapshot may need periodic re-auth (§10).

For disposable runtimes, do **not** treat a host agent config directory (for example `~/.codex`) as the
auth snapshot by bind-mounting or copying it wholesale. Agent homes often contain sqlite state, hook
approval state, caches, logs, and host-specific env/config. Instead, authenticate/configure the agent
inside the disposable runtime and snapshot/commit that runtime layer.

---

## 5. Credentials

- **Never** commit secrets or put them in `userData`, recipe JSON, comments, docs, or the state file.
- **Git token:** read from env (`GH_TOKEN`/`GITHUB_TOKEN`), falling back to `gh auth token`. Pass to the
  VM only via the provider's ephemeral `--env`. Inside the VM, use a `GIT_ASKPASS` helper with
  `x-access-token` (not the token in the clone URL) and `GIT_TERMINAL_PROMPT=0` so a missing token fails
  fast instead of hanging. When you write the helper from inside `bash -lc` under `set -u`, escape the
  positional arg and the token (`\$1`, `\$GH_TOKEN`) so they land **literally** and resolve at git-runtime
  — an unescaped `$1` aborts with "unbound variable", and a literal `$GH_TOKEN` keeps the real token out of
  the written file. `rm -f` the helper after the clone/fetch.
- **Provider auth:** rely on the provider CLI's logged-in session, not checked-in keys.
- **Agent auth:** lives in the authenticated snapshot (Phase 3) — never a file you write or commit.
- State holds only **non-secret** wiring (snapshot ids, scope, project, port, repo url/ref).

---

## 6. State file

A repo-local JSON file (e.g. `scripts/orca-vm/<provider>-state.json`) threads non-secret values between
phases. Each script resolves values as **env var → state → built-in fallback**, and merges its outputs
back. Phase 2 writes the base `snapshotId`; Phase 3 overwrites it with the authenticated snapshot;
per-workspace `create` boots from `snapshotId`.

```json
{
  "baseName": "orca-base",
  "snapshotId": "snap_authenticated_image_id",
  "authSourceSnapshotId": "snap_base_image_id",
  "scope": "<provider-scope>",
  "project": "<provider-project>",
  "port": 7331,
  "repoUrl": "https://host/org/repo.git",
  "repoRef": "main",
  "projectRoot": "/abs/path/on/remote/repo"
}
```

---

## 7. Script templates (provider-agnostic shapes)

Scaffold under `scripts/orca-vm/`. These are **shapes** — fill in the provider's real commands. All
reserve stdout for the final JSON and log progress to stderr. Include a shared `json_value <key>` /
`env_value <NAME>` reader (env → state → fallback) in each.

**Where each script runs:**

- **Local-side** (`create`/`suspend`/`resume`/`destroy` + the base-snapshot/auth scripts the user
  invokes) runs **on the user's desktop**, so it must run on their OS. macOS/Linux: `#!/usr/bin/env
  bash`, `set -euo pipefail`, quoted paths. **Windows:** a bare `.sh` won't run — scaffold `.ps1`/`.cmd`
  or require WSL/Git-Bash and point `orca.yaml` at the right launcher.
- **Remote-side** (commands you `exec` *inside* the Linux VM) always runs in the VM's Linux shell, so
  bash is fine there regardless of the user's OS.

### 7a. Base-snapshot (`<provider>-base-snapshot.sh`) — Phase 2

```bash
#!/usr/bin/env bash
set -euo pipefail
# resolve base_name/repo_url/repo_ref/project_root/port/scope/project/timeout (env→state→fallback)
# resolve gh token: GH_TOKEN | GITHUB_TOKEN | `gh auth token`
# 1. provision a sandbox (timeout/vcpus/published port/snapshot retention); trap: remove on error
# 2. remote exec (long timeout): install pkgs + gh + corepack/pnpm + agent CLI;
#    clone with GIT_ASKPASS(token); write headless main-only build config;
#    dev setup; pnpm install; build CLI; build headless electron main; smoke-check tools
# 3. snapshot stopped sandbox; parse snapshot id (fail if unparseable)
# 4. merge { baseName, snapshotId, projectRoot, repoUrl, repoRef, port, scope, project } into state
# print only the state JSON to stdout
```

Worked Vercel commands for this phase are in §7f. You run this script by hand (not via `orca.yaml`),
after exporting the first-run inputs the state file doesn't have yet — e.g. provider scope/project, the
repo URL/ref, and a git token (`GH_TOKEN`); later runs read them back from state.

### 7b. Auth (`<provider>-base-auth.sh`) — Phase 3

```bash
#!/usr/bin/env bash
set -euo pipefail
# read source snapshot from state.snapshotId (fail if absent); auth_name="${base_name}-auth"
# 1. boot sandbox from source snapshot; trap: remove on error
# 2. INTERACTIVE/TTY remote exec: agent login — user completes URL/code. Headless VM: MUST use the
#    device-auth flow (e.g. `codex login --device-auth`) — plain OAuth login binds a loopback callback
#    port the host can't reach and hangs. User runs this themselves (you have no interactive TTY); ask
#    them to report back when it's done before continuing.
# 3. verify login, then refuse to snapshot if not logged in. Prefer the status command's EXIT CODE (most
#    agent CLIs exit non-zero when unauthenticated) over string-matching. If you must grep, fold stderr
#    first (`status 2>&1 | grep …` — many agents print the success line there) and match the agent's exact
#    success line; never `grep -qi 'logged in'`, which also matches "not logged in". Codex example: §7f.
# 4. snapshot; parse new id
# 5. merge { snapshotId:<new>, authSourceSnapshotId:<source> } into state; remove auth sandbox
# print only the state JSON to stdout
```

### 7c. Create (`<provider>-create.sh`) — per workspace

```bash
#!/usr/bin/env bash
set -euo pipefail
# read authenticated snapshotId/scope/project/port/repo*/project_root (env→state→fallback)
# fail clearly if snapshotId is missing (point back to Phases 2–3)
# name = orca-${ORCA_VM_RECIPE_ID}-${ORCA_VM_INSTANCE_ID} (sanitized, length-capped)
# 1. boot sandbox from snapshotId with a published port; capture the public URL → pairing address
#    (an externally reachable wss:// URL); trap: remove sandbox on error
# 2. remote exec: ensure repo at desired commit; rebuild only if commit changed (cache marker)
# 3. remote exec: start orca serve in the background and read the recipe JSON it writes (see below)
# 4. print serve's JSON to stdout, optionally enriched with userData:
#    { schemaVersion:1, pairingCode, projectRoot, userData:{ provider, resourceId:name, snapshotId } }
```

**The exact `orca serve` invocation and its output (verified — do not improvise the flags).** Inside the
VM, run:

```bash
orca serve \
  --port "$PORT" \
  --project-root "$ABS_REPO_PATH_ON_REMOTE" \
  --pairing-address "$EXTERNAL_WSS_URL" \
  --recipe-json
```

**Binary name:** in a VM built from source (the Phase-2 flow), run it as `pnpm exec orca-dev serve …`
from the repo root — `orca-dev` is the in-repo entrypoint and is what the §7f example uses. Plain
`orca serve …` is the same command when the built CLI is installed on the VM's PATH. The flags/output
are identical either way.

There is **no `--host` flag**. `--project-root` must be an absolute directory on the remote. With
`--recipe-json` the server **stays running** and prints exactly this single object to **stdout**, then
keeps serving:

```json
{ "schemaVersion": 1, "pairingCode": "<orca pairing URL>", "projectRoot": "<the --project-root you passed>" }
```

`pairingCode` is the pairing URL, already pointing at whatever you passed as `--pairing-address` — so set
`--pairing-address` to the externally reachable address and **pass `pairingCode` through unchanged; never
hand-rewrite it**. Because serve runs in the foreground and doesn't exit, redirect its stdout to a file
and poll until that file parses as JSON (and bail if the process dies — dump its stderr log). Your
`create` script then prints that JSON (optionally merging `userData`). Concrete pattern: §7f.

### 7d. Suspend / resume / destroy — per workspace

```bash
#!/usr/bin/env bash
set -euo pipefail
payload="$(cat)"                       # Orca passes lifecycle JSON on stdin
resource_id="$(node -e 'const d=JSON.parse(process.argv[1]); process.stdout.write(d.recipeResult?.userData?.resourceId ?? "")' "$payload")"
[ -n "$resource_id" ] || { echo "No resource id in lifecycle payload" >&2; exit 1; }
# suspend: provider suspend "$resource_id"
# resume:  provider resume "$resource_id"; then RE-EMIT fresh recipe JSON (pairing may change)
# destroy: provider remove "$resource_id"   (or set destroy: none in orca.yaml)
```

### 7e. State file — scaffold with scope/project/repo filled in and snapshot ids empty (§6).

### 7f. Worked example — Vercel Sandbox (all three phases)

A real, working shape (the Vercel surface is a CLI: `vercel sandbox create|exec|snapshot|remove`). Adapt
names; verify flags against `vercel sandbox --help` for the user's CLI version before relying on them.
These ground §7a (base snapshot) and §7b (auth), which are otherwise generic skeletons.

**Phase 2 — base snapshot (§7a):** provision → install tools + clone + headless build → snapshot.

```bash
# provision a fresh build sandbox (retain a couple of snapshots); trap-remove on error
vercel sandbox create --name "$base" --runtime node24 --timeout 30m --vcpus 4 --publish-port "$port" \
  --snapshot-expiration 30d --keep-last-snapshots 2 "${vercel_args[@]}" >&2
# remote build (long timeout): install pkgs+gh+pnpm+agent CLI, clone with GIT_ASKPASS (write the helper
# with LITERAL \$1/\$GH_TOKEN so they resolve at git-runtime, not write-time — see §5/§7f create — then
# `rm -f /tmp/askpass.sh`), write the headless main-only build config (drop the renderer), dev setup,
# build CLI + headless main, smoke-check
vercel sandbox exec "$base" "${vercel_args[@]}" --timeout 25m --env "GH_TOKEN=$gh_token" … -- bash -lc '…build…' >&2
# snapshot the STOPPED sandbox and parse the id from CLI output (fail if unparseable)
out="$(vercel sandbox snapshot "$base" --stop --expiration 30d "${vercel_args[@]}" 2>&1)"; printf '%s\n' "$out" >&2
snapshot_id="$(printf '%s\n' "$out" | sed -nE 's/.*(snap_[A-Za-z0-9]+).*/\1/p' | tail -1)"
# merge { baseName, snapshotId, scope, project, port, repoUrl, repoRef, projectRoot } into state; print state JSON
```

**Phase 3 — agent-auth snapshot (§7b):** boot the base, log the agent in interactively, re-snapshot.
(`codex` below is an example — substitute the user's chosen agent's login/status verbs, e.g. `claude`.)

```bash
vercel sandbox create --name "$auth" --snapshot "$snapshot_id" --timeout 30m --publish-port "$port" "${vercel_args[@]}" >&2
# INTERACTIVE — the USER runs this in their own terminal (you have no interactive TTY) and completes the
# URL/code on the HOST. --device-auth is MANDATORY on a headless VM: plain `codex login` binds a loopback
# callback port the host browser can't reach and hangs. Ask the user to report back when login finishes.
vercel sandbox exec --interactive --tty "$auth" "${vercel_args[@]}" -- bash -lc 'codex login --device-auth'
# refuse to snapshot an unauthenticated VM — fold stderr, match codex's exact success line (§4)
vercel sandbox exec "$auth" "${vercel_args[@]}" --timeout 30s -- bash -lc 'codex login status 2>&1' | grep -Eqi 'Logged in using ChatGPT|Logged in via device' \
  || { echo "agent not logged in; not snapshotting" >&2; exit 1; }
out="$(vercel sandbox snapshot "$auth" --stop --expiration 30d "${vercel_args[@]}" 2>&1)"; printf '%s\n' "$out" >&2
new_id="$(printf '%s\n' "$out" | sed -nE 's/.*(snap_[A-Za-z0-9]+).*/\1/p' | tail -1)"
# overwrite state.snapshotId = new_id, record authSourceSnapshotId = snapshot_id; remove the auth sandbox
```

**Per-workspace `create`** (the fast path):

```bash
#!/usr/bin/env bash
set -euo pipefail
# resolve from env→state→fallback: snapshot_id, scope, project, port, repo_url, repo_ref, project_root
vercel_args=(); [ -n "$scope" ] && vercel_args+=(--scope "$scope"); [ -n "$project" ] && vercel_args+=(--project "$project")
[ -n "$snapshot_id" ] || { echo "snapshotId missing — run Phases 2–3 first" >&2; exit 1; }
gh_token="${GH_TOKEN:-${GITHUB_TOKEN:-$(command -v gh >/dev/null 2>&1 && gh auth token 2>/dev/null || true)}}"
name="orca-${ORCA_VM_RECIPE_ID:-vercel-sandbox}-${ORCA_VM_INSTANCE_ID:-$(date +%s)}"  # sanitize+cap to 63 chars

# Arm cleanup BEFORE create so a failing create can't leak a half-built paid sandbox.
cleanup_on_error() { [ "$?" -ne 0 ] && vercel sandbox remove "$name" "${vercel_args[@]}" >/dev/null 2>&1 || true; }
trap cleanup_on_error EXIT

# 1. boot from the authenticated snapshot, publish the serve port
create_output="$(vercel sandbox create --name "$name" --snapshot "$snapshot_id" \
  --timeout 30m --publish-port "$port" "${vercel_args[@]}" 2>&1)"; printf '%s\n' "$create_output" >&2
# Vercel prints the published https URL; derive the external wss:// pairing address from it
public_url="$(printf '%s\n' "$create_output" | sed -nE 's#.*(https://[^[:space:]]+\.vercel\.run).*#\1#p' | head -1)"
[ -n "$public_url" ] || { echo "no published URL in create output" >&2; exit 1; }
pairing_ws="${public_url/https:\/\//wss://}"

# 2. (remote) ensure the repo is at the right commit; rebuild only if the commit changed (cache marker)
vercel sandbox exec "$name" "${vercel_args[@]}" --timeout 20m \
  --env "GH_TOKEN=$gh_token" --env "ORCA_PROJECT_ROOT=$project_root" \
  --env "ORCA_REPO_URL=$repo_url" --env "ORCA_REPO_REF=$repo_ref" \
  -- bash -lc 'set -euo pipefail; cd "$ORCA_PROJECT_ROOT"; \
    # Re-establish git auth for the private-repo fetch (why + full rationale: §5); else it hangs on a prompt.
    # Load-bearing escaping: \$1 and \$GH_TOKEN must land LITERALLY and resolve at git-runtime. Test after
    # any edit here — reformatting the nested printf/node quoting silently breaks the fetch or leaks the token.
    if [ -n "${GH_TOKEN:-}" ]; then \
      printf "%s\n" "#!/usr/bin/env bash" "case \"\$1\" in *Username*) echo x-access-token;; *Password*) echo \"\$GH_TOKEN\";; esac" > /tmp/askpass.sh; \
      chmod 700 /tmp/askpass.sh; export GIT_ASKPASS=/tmp/askpass.sh GIT_TERMINAL_PROMPT=0; fi; \
    git fetch origin "$ORCA_REPO_REF"; \
    git checkout -B "$ORCA_REPO_REF" FETCH_HEAD; \
    rm -f /tmp/askpass.sh; \
    c="$(git rev-parse HEAD)"; [ -f .orca-built ] && [ "$(cat .orca-built)" = "$c" ] || { \
      pnpm install --prefer-offline && pnpm run build:cli && \
      node config/scripts/run-electron-vite-build.mjs --config config/electron-vite.vm-serve.config.ts && \
      printf "%s" "$c" > .orca-built; }' >&2

# 3. (remote) start orca serve in the background, writing recipe JSON to a file; poll until it parses
recipe_json="$(vercel sandbox exec "$name" "${vercel_args[@]}" --timeout 60s \
  --env "ORCA_PORT=$port" --env "ORCA_PROJECT_ROOT=$project_root" --env "ORCA_PAIRING_ADDRESS=$pairing_ws" \
  -- bash -lc 'set -euo pipefail; cd "$ORCA_PROJECT_ROOT"; rm -f /tmp/orca-recipe.json /tmp/orca-serve.log; \
    nohup pnpm exec orca-dev serve --port "$ORCA_PORT" --project-root "$ORCA_PROJECT_ROOT" \
      --pairing-address "$ORCA_PAIRING_ADDRESS" --recipe-json >/tmp/orca-recipe.json 2>/tmp/orca-serve.log </dev/null & \
    pid=$!; for _ in $(seq 1 80); do \
      node -e "JSON.parse(require(\"node:fs\").readFileSync(\"/tmp/orca-recipe.json\",\"utf8\"))" >/dev/null 2>&1 && { cat /tmp/orca-recipe.json; exit 0; }; \
      kill -0 "$pid" 2>/dev/null || { cat /tmp/orca-serve.log >&2; exit 1; }; sleep 0.25; \
    done; cat /tmp/orca-serve.log >&2; echo "serve recipe JSON timed out" >&2; exit 1')"

# 4. print serve's JSON enriched with userData (single object on stdout)
node -e 'const p=JSON.parse(process.argv[1]); console.log(JSON.stringify({...p, schemaVersion:1,
  userData:{...p.userData, provider:"vercel-sandbox", resourceId:process.argv[2], snapshotId:process.argv[3]}}))' \
  "$recipe_json" "$name" "$snapshot_id"
trap - EXIT
```

`suspend`/`resume`/`destroy` use `vercel sandbox stop|...|remove "$resource_id"` reading
`userData.resourceId` from stdin (§7d). This is the **Orca-server** connection mode (the recipe emits a
pairing URL). If the user chose **SSH** in the §1 interview, use §7g instead.

### 7g. Worked example — existing SSH host (SSH connection mode)

SSH mode is **fundamentally different from §7c/§7f**, not a relabeling of them:

- **`create` does NOT run `orca serve` and does NOT emit a `pairingCode`.** Orca itself connects to the
  host over its SSH relay, brings up the git + filesystem providers, and imports the repo. The script's
  only job is to make the host ready and **print SSH connection details** Orca will dial.
- The result uses a `connection` block with `type: "ssh"` and a `target`, **not** the flat
  `pairingCode`/`projectRoot` shape. Exact shape (Orca rejects anything else):

```json
{
  "schemaVersion": 1,
  "connection": {
    "type": "ssh",
    "projectRoot": "/abs/path/to/repo/on/host",
    "target": {
      "label": "my-box",
      "host": "192.0.2.10",
      "port": 22,
      "username": "ubuntu",
      "identityFile": "~/.ssh/id_ed25519",
      "jumpHost": "bastion.example.com",
      "proxyCommand": "cloudflared access ssh --hostname %h",
      "relayGracePeriodSeconds": 0,
      "portForwards": []
    }
  }
}
```

`label`, `host`, `port`, `username` are required; the rest are optional — omit any you don't need.

**Networking → which `target` fields to set** (how *your desktop* reaches the box — there is no
`orca serve` URL in SSH mode):

- Public IP / DNS, or a Tailscale/VPN address → `host`; SSH port → `port` (usually 22).
- Key auth → `identityFile` (add `identitiesOnly: true` if the agent has many keys).
- Through a bastion → `jumpHost` (a `user@host` ProxyJump) **or** a full `proxyCommand` (e.g. an access
  proxy). Use one, not both.
- A service port the workspace needs → add entries to `portForwards`.
- `relayGracePeriodSeconds` (optional): how long Orca keeps the SSH relay alive after the workspace
  detaches before tearing it down; `0` = tear down immediately. Leave it off unless the user wants a
  reconnect grace window.

**Toolchain & agent auth on a persistent (no-snapshot) host — do this ONCE, by hand, before wiring the
recipe** (there's no base image to bake; the host *is* the base). Run the §7f Phase-2 install steps and
the §7f Phase-3 `<agent> login --device-auth` **directly over SSH on the host** (interactive, e.g.
`ssh -t user@host '<agent> login --device-auth'`). After that the host stays ready across workspaces.

```bash
#!/usr/bin/env bash
set -euo pipefail
# resolve from env→state→fallback (default unset optionals to ""): ssh_username, host,
#   ssh_port (default 22), identity_file, jump_host, proxy_command, project_root, repo_url, repo_ref
: "${identity_file:=}"; : "${jump_host:=}"; : "${proxy_command:=}"   # avoid set -u aborts on optionals
gh_token="${GH_TOKEN:-${GITHUB_TOKEN:-$(command -v gh >/dev/null 2>&1 && gh auth token 2>/dev/null || true)}}"
ssh_target="${ssh_username}@${host}"
ssh_opts=(-p "$ssh_port"); [ -n "$identity_file" ] && ssh_opts+=(-i "$identity_file")
# Why: a fresh host's key isn't in known_hosts; a StrictHostKeyChecking prompt would HANG a
# non-interactive create. Pre-add the key (or set the option) so it can't block.
ssh-keyscan -p "$ssh_port" "$host" >> "$HOME/.ssh/known_hosts" 2>/dev/null || true

# 1. ensure the repo is present and at the right commit on the host (NO orca serve here)
ssh "${ssh_opts[@]}" "$ssh_target" \
  "GH_TOKEN='$gh_token' GIT_TERMINAL_PROMPT=0 bash -lc '
     set -euo pipefail
     [ -d \"$project_root/.git\" ] || git clone \"$repo_url\" \"$project_root\"
     cd \"$project_root\" && git fetch origin \"$repo_ref\" && git checkout -B \"$repo_ref\" FETCH_HEAD
   '" >&2

# 2. print the SSH connection block (NO pairingCode, NO orca serve). host/port/username tell Orca's
#    relay how to dial in; identityFile/jumpHost/proxyCommand/portForwards are emitted when set.
node -e 'const [host,port,user,idf,jh,pc,root]=process.argv.slice(1);
  const target={ label:"per-workspace-host", host, port:Number(port), username:user };
  if(idf) target.identityFile=idf; if(jh) target.jumpHost=jh; if(pc) target.proxyCommand=pc;
  // add target.portForwards=[...] here if the workspace needs forwarded service ports
  console.log(JSON.stringify({ schemaVersion:1, connection:{ type:"ssh", projectRoot:root, target } }))' \
  "$host" "$ssh_port" "$ssh_username" "$identity_file" "$jump_host" "$proxy_command" "$project_root"
```

`suspend`/`resume`/`destroy`: on a persistent host there's usually nothing to tear down — set
`destroy: none` and omit suspend/resume. (Orca still disconnects/reconnects its own SSH relay on
sleep/wake/delete — that's separate from these scripts.)

If the SSH host is instead an **ephemeral/snapshot-capable VM** (your hypervisor, or a cloud VM with
image support), keep the §7f Phase-2/3 base-image model for provisioning, but still emit the
`connection.type:"ssh"` block above instead of starting `orca serve`.

### 7h. Worked example — local Docker SSH (SSH connection mode)

Local Docker can model an ephemeral SSH VM without cloud cost: build a base image with `sshd`, tools,
repo prerequisites, and the agent CLI; run an **interactive auth container** once; then `docker commit`
that container as the authenticated image used by per-workspace `create`.

Key points:

- Publish container SSH to a random localhost port (`-p 127.0.0.1::22`) and emit
  `connection.type:"ssh"` with `host:"127.0.0.1"`, that port, `username`, `identityFile`, and
  `identitiesOnly:true`.
- Generate a repo-local SSH key if needed, but gitignore the private/public key files.
- **Bake SSH host keys into the base image** (`ssh-keygen -A` at **build** time; at runtime only generate
  if absent). Ephemeral containers all present the **same** host key, so `known_hosts` on `127.0.0.1`
  doesn't churn as the published port rotates across workspaces (otherwise every container's freshly
  generated key collides on `localhost` and trips host-key-changed warnings).
- The auth image is the Docker equivalent of Phase 3: the **user** runs the agent login **inside** the
  container (you can't drive it — you have no interactive TTY), configures proxy env/config, approves
  hooks, and you commit once they report it's done. On a headless container use the **device-auth** flow
  (§4). Verify login before committing — exit code, or fold stderr and match the exact success line (§4).
- Do not bind-mount or copy the host's full agent home into the image. Let each container have writable
  agent state; only the committed auth image should carry reusable authenticated state.
- If committing from an interactive shell, force the runtime entrypoint back to `sshd`:
  `docker commit --change='ENTRYPOINT ["/usr/local/bin/orca-docker-ssh-entrypoint"]' …`.
- `destroy` should read `recipeResult.userData.resourceId` and run `docker rm -f "$resource_id"`.

Validation before wiring/live use:

```bash
docker image inspect "$auth_image" --format '{{json .Config.Entrypoint}}'
docker run -d --name "$name" -p 127.0.0.1::22 -e "ORCA_SSH_PUBLIC_KEY=$pubkey" "$auth_image"
docker ps -a --filter "name=$name"
docker logs "$name"
ssh -i "$key" -p "$port" -o IdentitiesOnly=yes user@127.0.0.1 'codex --version'
```

If the container exits immediately, inspect logs before the cleanup trap removes it; a committed
interactive image with `ENTRYPOINT ["bash"]` is a common cause.

Also confirm the **host key is stable** across containers: the SSH `ssh -i … 127.0.0.1` dial should not
trigger a host-key-changed warning when a second container reuses the port. If it does, the host keys
weren't baked into the base image (see the `ssh-keygen -A` point above).

### 7i. Windows local-side scripts

The local-side scripts run on the user's desktop. On **Windows**, a bare `.sh` won't execute. Either
require WSL/Git-Bash (and point `orca.yaml` at e.g. `bash ./scripts/orca-vm/<name>.sh` via a `.cmd`
launcher), or scaffold PowerShell equivalents. Minimal PowerShell shape:

```powershell
#requires -Version 5
$ErrorActionPreference = 'Stop'
# resolve env→state→fallback; run the provider CLI / ssh the same way;
# capture provider output; build the result object for the chosen mode and write ONE line of JSON to stdout.
# Orca-server mode: @{ schemaVersion=1; pairingCode=$pairingCode; projectRoot=$projectRoot; userData=@{...} }
# SSH mode:        @{ schemaVersion=1; connection=@{ type="ssh"; projectRoot=$projectRoot;
#                     target=@{ label=$label; host=$host; port=$port; username=$user } } }  (see §7g/§7h)
($result | ConvertTo-Json -Compress -Depth 6)
# progress/errors → Write-Error / the error stream, never stdout.
```

The remote-side commands you run *inside* the Linux VM stay bash regardless of the desktop OS.

---

## 8. Per-workspace recipe contract (the fast path)

Once the authenticated snapshot exists, this runs on every workspace create. Define recipes in
`orca.yaml`:

```yaml
environmentRecipes:
  - id: cloud-sandbox
    name: Cloud Sandbox
    create: ./scripts/orca-vm/cloud-sandbox-create.sh
    suspend: ./scripts/orca-vm/cloud-sandbox-suspend.sh
    resume: ./scripts/orca-vm/cloud-sandbox-resume.sh
    destroy: ./scripts/orca-vm/cloud-sandbox-destroy.sh
```

`create` runs **locally from the repo root** and prints **one** JSON object to stdout. Its shape depends
on the connection mode chosen in §1:

**Orca-server mode** — boot the env, start `orca serve` in it, and print serve's result:

```json
{
  "schemaVersion": 1,
  "pairingCode": "orca-pairing-code-or-url",
  "projectRoot": "/absolute/path/to/repo/on/remote",
  "userData": { "provider": "example", "resourceId": "provider-resource-id" }
}
```

Here `pairingCode` (from `orca serve --recipe-json`) and `projectRoot` are required; `schemaVersion` (`1`)
and `userData` are optional.

**SSH mode** — do **not** run `orca serve`; print the `connection.type:"ssh"` block instead (full shape +
worked script in §7g). `pairingCode` is **not** used in SSH mode.

Lifecycle hooks (all run locally):

- `create`: required. Prints recipe result JSON.
- `suspend`: optional. Sleep; reads lifecycle payload on stdin.
- `resume`: optional. Wake; reads payload on stdin and **prints fresh recipe JSON** (pairing may change).
- `destroy`: optional unless `destroy: none`. Delete/cleanup; reads payload on stdin.

Start Orca remotely with `orca serve --port "$PORT" --project-root "$ABS_ROOT" --pairing-address
"$EXTERNAL_WSS_URL" --recipe-json` (exact flags + output in §7c). Set `--pairing-address` to the
externally reachable address so the emitted `pairingCode` is reachable; tunneling/port mapping is the
script's job.

Backward compatibility: `command`→`create`, `cleanup`→`destroy`, `cleanup: none`→`destroy: none`.
Prefer the lifecycle names.

---

## 9. Doctor and validation

Validate in two stages — the cheap dry run first, then the live self-test.

### Dry run (free, non-destructive) — always do this first

`orca vm recipe doctor <recipe-id> --repo-path <repo> --json` validates **static wiring only** — it does
**not** boot anything. It checks: local-host execution (v1), repo path, recipe id exists,
create/destroy/suspend/resume command paths resolve, suspend/resume are paired, and each script is
executable (POSIX exec bit; skipped on Windows). Fix every failure here before spending any cloud money.

### Live self-test (`--provision`) — diagnose and iterate yourself

`orca vm recipe doctor <recipe-id> --repo-path <repo> --provision --json` actually runs the recipe end
to end: it executes `create`, validates the returned recipe JSON, then runs `destroy` to **tear the
environment back down** (so the test leaves nothing running, as long as `destroy` works). It spends real
cloud money, so get the user's OK **once** before starting — that one approval covers the whole loop
below; do not re-ask before each run.

On failure, the JSON result includes a `provisionTranscript` with the **complete** captured output of
each stage so you can self-diagnose without asking the user to relay logs:

```json
{
  "ok": false,
  "checks": [ { "id": "recipe.provision", "status": "fail", "message": "…" } ],
  "provisionTranscript": {
    "provision": { "exitCode": 0, "signal": null, "stdout": "…", "stderr": "…", "parseError": "…" },
    "destroy":   { "exitCode": 0, "signal": null, "stdout": "…", "stderr": "…" }
  }
}
```

**Run it as a loop:** read `provisionTranscript.provision.stderr` / `.stdout` / `.parseError` (and
`destroy.*`), fix the script, and re-run `--provision` until `ok` is `true` — iterating on your own
rather than waiting for the user to paste errors. Common reads: a non-empty `stderr` with `exitCode 0`
plus a `parseError` means `create` ran but printed something other than the single recipe-result JSON on
stdout (often a stray `echo` — route it to stderr, see §10); a non-zero `exitCode` is a provider/script
failure described in `stderr`. Each stream is redacted and capped (head+tail) — large logs keep both the
setup context and the failure.

The self-test cannot see provider-side truth beyond what the scripts print, so still confirm: state has a
populated **authenticated** `snapshotId` (Phases 2–3 done), and `destroy` is implemented/tested (or
explicitly `none` — in which case the self-test won't tear down, so clean up manually).

For SSH recipes, also smoke-test the exact emitted target before declaring success: dial the host/port
with the identity/proxy settings, run `pwd`, verify the repo path, check the agent binary, and confirm
`destroy` removes the provider resource/container. For Docker, inspect the auth image entrypoint and do a
startup-only `docker run` before the full clone/install path.

---

## 10. Failure modes

- **Build exceeds plan timeout (e.g. Hobby 45m).** Use enough vCPUs and a timeout covering the build;
  else split work or use a higher plan. The cap also limits per-workspace runtime — surface it.
- **Build exceeds plan RAM.** Build the **headless main only** (drop the renderer) — the biggest fitter.
- **Private-repo clone hangs/fails.** Wrong/missing token. Use `GIT_ASKPASS` + `GIT_TERMINAL_PROMPT=0`
  so it fails fast instead of prompting.
- **`GIT_ASKPASS` helper aborts the clone with "`$1: unbound variable`".** The `printf`/heredoc that writes
  the helper inside `bash -lc` under `set -u` expanded `$1`/`$GH_TOKEN` at **write** time. Escape th

…(truncated)
