# Agent seats on buildboxes (crash-plan Phase 2)

Audience: AI coding agents first. Status: PLAN — approved direction; implementation through Cursor `ca.sh`; main thread documents, reviews, tests, lands, deploys, and verifies.

This plan moves interactive Claude Code processes and their tool trees to debian1/debian2. It is NOT `docs/plans/2026-07-29-harness-v2-phase2.md`.

## Done condition

`claudex --seat --model <gpt-model>` from laptop opens or reattaches one durable remote seat bound to exactly one GPT-backed model (`gpt-*` or alias `sol|terra|luna`). Claude, tmux server, shell tools, and project mutations run on buildbox only under a fail-closed autonomous contract: no hybrid/default model, no native Anthropic seat models, no workflow/orchestration surface, no nested `claude`/`claudex`/proxy escape. User can query and stop seat through `claudex`. Kernel-enforced aggregate and per-seat limits contain CPU, memory, swap, PIDs, OOM, and stop to seat cgroups. Laptop keeps terminal attachment and existing account selection; tray UI does not claim remote-seat status.

## Non-negotiable safety rules

- NEVER run a model request during build or verification.
- NEVER sign in interactively during verification.
- Use buildbox SSH identity, port, user, and options from `modules/workstation/claude/lib/remote-build.mjs` / `~/.claude/build-remote.json`. NEVER duplicate host transport defaults.
- NEVER put seats under `~/builds`; use `~/seats`. Remote-build GC MUST remain scoped to configured `remote_root` and doctor MUST fail if `remote_root` contains `~/seats`.
- NEVER rsync a whole account home, follow arbitrary profile symlinks, or overwrite remote mutable account state on reattach.
- NEVER create a second checkout or second host copy for one seat identity.
- No default tmux server. One dedicated socket/server per seat.
- No custom daemon, polling loop, or cron. tmux server and systemd scope are seat runtime, not a control daemon.
- Fail closed on ambiguous host, identity, git, credential, cgroup, stale-runtime, model, or orchestration-surface state.
- Seat launch MUST bind exactly one explicit GPT-backed model (`gpt-*` or alias `sol|terra|luna`); native Claude models (`claude-*`, aliases `fable|opus|sonnet`) are rejected before SSH with `unsupported-security-boundary`. Hybrid/default/duplicate/forwarded model overrides are rejected before any remote process starts.
- Seat runtime MUST enforce the model and orchestration boundary mechanically; prompt guidance is not a control.

## Measured baseline (2026-08-05)

- debian1/debian2: 31 GiB RAM, 6.2 GiB swap, 8 CPUs.
- Both boxes: `claude` missing, `tmux` missing, user systemd available, `sudo -n` available.
- Both boxes: `agent.slice` has no memory/swap ceiling.
- Both boxes: `git` and `rsync` present; GitHub CLI/auth absent; GitHub SSH host trust absent.
- Laptop authoritative unit: `modules/monitor/systemd/user/agent.slice` (`MemoryMax=32G`, `MemorySwapMax=16G`). Those laptop values MUST NOT be copied to 31 GiB buildboxes.
- Claude Code **2.1.222** (target parity): `claude --version` accepts `--settings`, `--setting-sources ""`, `--strict-mcp-config` with empty `--mcp-config`, `--agents {}`, and `--disallowedTools Agent Workflow` with no model request.

## Seat identity

Derive before host selection:

1. Resolve canonical project root with `git rev-parse --show-toplevel`.
2. Require one `origin` URL. Normalize HTTPS/SSH GitHub forms to lowercase `host/owner/repo` without credentials, query, fragment, or `.git` suffix.
3. Require attached local branch and configured upstream. Record branch full name and upstream full name.
4. Identity input: `<normalized-origin>\0<account-slug>\0<branch-full-name>`.
5. Seat ID: readable sanitized repo/account prefix plus first 16 hex chars of SHA-256 identity input. Use same ID for state dir, checkout, unit, and tmux socket. Never use basename alone.

Worktrees on different branches get different seats. Two worktrees on same origin/account/branch intentionally resolve to one seat and MUST NOT create divergent copies.

## Host discovery and lifecycle

Every `launch`, including explicit host, probes both configured hosts first for:

- SSH reachability and health eligibility.
- `~/seats/<seat-id>/seat.json` identity match.
- dedicated tmux socket liveness.
- `agent-seat-<seat-id>.scope` active state and cgroup membership.

Decision table:

- Exactly one valid existing seat: attach there. Explicit different host returns precise mismatch; never migrate implicitly.
- No existing seat and both hosts reachable: `auto` chooses existing remote-build health/load preference; explicit host uses requested healthy host.
- Both hosts contain seat, one host unreachable, any identity mismatch, or runtime state ambiguous: fail closed.
- One host unreachable and no seat found on reachable host: fail closed because hidden duplicate cannot be excluded.

Commands in launcher contract:

```text
seat-remote launch --host auto|debian1|debian2 --account <slug> --project <path> --model <exact-id> -- <claude-argv...>
seat-remote status --host auto|debian1|debian2 --account <slug> --project <path>
seat-remote stop   --host auto|debian1|debian2 --account <slug> --project <path>
```

`status` reports host, identity, checkout, branch, bound model, scope state, tmux state, Claude PID, cgroup, and limits. `stop` executes remote `systemctl --user stop`, verifies no descendants, terminates only dedicated tmux server if needed, removes stale socket, and preserves checkout/account/session data. User never constructs systemctl/tmux commands.

State reconciliation:

- scope + socket + verified Claude descendant = attach.
- neither scope nor socket = create.
- socket without scope, scope without socket, wrong cgroup, multiple Claude roots, or identity mismatch = `status` marks corrupt and `launch` refuses. Only `stop` may clean corrupt runtime; it MUST preserve persistent state.

## Dedicated tmux/cgroup runtime

Paths:

```text
state:  ~/.local/state/overdeck/seats/<seat-id>/
socket: ~/.local/state/overdeck/seats/<seat-id>/tmux.sock
unit:   agent-seat-<seat-id>.scope
server: tmux -S <socket> new-session -d -s main -- <seat-launcher> <claude-command>
attach: tmux -S <socket> attach-session -t main
```

Creation order:

1. Ensure socket parent mode 0700 and absent socket.
2. Start dedicated tmux server inside transient scope with `systemd-run --user --scope --collect`.
3. Pass `--slice=agent-seat.slice`, `OOMPolicy=kill`, `MemoryMax=12G`, `MemorySwapMax=1G`, `CPUQuota=400%`, `CPUWeight=1`, and `TasksMax=256`.
4. Attach through a separate SSH TTY only after verification. Operator attach stays outside seat netns and outside `agent-seat-*.scope`; uses `sudo -n overdeck-seat-tmux-mediator` (never direct cross-UID `tmux attach`). Only `ods-<seat-id>` processes run inside the scope.
5. Find actual Claude PID as descendant of dedicated tmux server. Every implementer descendant MUST retain per-seat effective cgroup limits (`memory.max=12G`, `cpu.max=400%`) — sibling `tmux-spawn-*.scope` units outside `agent-seat-<seat-id>.scope` with weaker limits are forbidden and fail closed at launch verification.

Never use `tmux new-session -A`, `tmux -L` default temp sockets, or default/shared tmux server. Later panes inherit scope from dedicated server.

## Aggregate buildbox policy

Add repo-owned `modules/workstation/claude/systemd/user/agent-seat.slice`:

```ini
[Unit]
Description=Remote interactive AI agent seats

[Slice]
MemoryMax=16G
MemorySwapMax=2G
CPUQuota=700%
CPUWeight=1
TasksMax=512
```

Install as a symlink into each box `~/.config/systemd/user/agent-seat.slice`, daemon-reload, and verify exact readback. Per-seat limits above remain stricter. `OOMPolicy=kill` is required because this systemd version does not accept `MemoryOOMGroup=`; verify `memory.oom.group` behavior through the OOM test rather than assuming it.

## Project checkout contract

Persistent checkout: `~/seats/<seat-id>/repo`. Metadata `seat.json` mode 0600, atomically written, contains schema version, normalized origin, account slug, branch, upstream, checkout, host, and bound `model` (exact ID) per **Seat metadata**.

Existing valid seat:

- Reattach without fetch, pull, reset, checkout, rebase, or laptop sync.
- Dirty/unpushed remote state is valid and MUST remain untouched.

First creation only:

- Require laptop worktree clean, no untracked files, branch attached, upstream present, and `HEAD == @{upstream}`. Precise failure names dirty, untracked, unpushed, or behind/diverged state.
- Clone normalized origin, fetch exact upstream, and check out exact branch tracking upstream.
- Verify remote `HEAD`, branch, upstream, and origin match metadata before launching.
- Remote GitHub credential must already pass read-only `git ls-remote`; never copy a working tree.

## Remote Git and tool provisioning

Provision both boxes idempotently:

- Install distro `tmux`, `curl`, `bubblewrap`, and `socat` using noninteractive `sudo -n`; install `gh` from Debian when an apt candidate exists, otherwise configure the official signed GitHub CLI apt repository (`stable` + keyring) and install `gh` from there; fail closed if root path unavailable; verify `tmux`, `curl`, `bubblewrap`, and `socat` on PATH after install.
- Install exact laptop Claude Code version through official native installer version target. Verify real binary outside repo shim and exact `claude --version` parity.
- Pin and install exact laptop `claude-code-proxy` version (**0.1.22** measured); host baseline doctor fails on version skew. Task 1 provisions the proxy system account, base directory `/var/lib/overdeck/seat-proxy/` (`root:overdeck-seat-proxy` mode `0710`), exact proxy binary, `overdeck-seat-proxy@.service` unit **template**, and verifies **`bubblewrap` + `socat`** sandbox prerequisites. Per-seat Codex credential transfer/shape, `CCP_CODEX_MODEL` exact value, port, network namespace, and live proxy health belong to Task 3 seat creation and Task 5 verification — Task 1 MUST NOT validate nonexistent per-seat auth or placeholder doctor-auth stubs. NEVER copy Codex or Anthropic provider credentials into `~/.claudex-accounts/<account>/<seat-id>/` allowlist paths. NEVER silently fall back to a different GPT model or to native Anthropic when proxy/Codex auth is missing or invalid.
- Transfer existing laptop GitHub CLI credential through stdin over buildbox SSH; never put token in argv, files in repo, logs, or shell trace. Store using `gh auth login --with-token`; verify `gh auth status` and read-only `git ls-remote` only.
- Deploy repo-managed Claude runtime (hooks, skills, agents, commands, bin, lib, workflows, settings, MCP config, statusline dependencies) from overdeck into versioned remote runtime directory and atomically repoint remote `~/.claude` managed links. Doctor resolves every required symlink target and executable.
- Run `install-headless-guard` on boxes after runtime deployment; verify status.
- Run `install-seat-guard` on boxes after runtime deployment; verify versioned wrapper release, atomic `current` symlink, and block-only wrapper executability (see **Nested execution guard**). Task 1 does NOT prepend seat-guard to any PATH — that is Task 3 seat launch only.

## Account/session state contract

First seat creation performs one atomic bootstrap into `~/.claudex-accounts/<account>/<seat-id>/` with `umask 077`. Explicit allowlist only:

- profile identity: `account_identity.json` when present; `.claude.json` only when present and stripped of provider OAuth/API-key fields (doctor rejects credential material in seat profile);
- resumable state: `projects/`, `tasks/`, `plans/`, `todos/`, `session-env/`, `shell-snapshots/`, `history.jsonl` when present.

Reject symlinks inside allowlisted mutable trees; copy regular files/directories through staging, validate owner/modes, then atomic rename. Exclude caches, logs, temp files, backups, plugins, binaries, hooks, skills, MCP config, provider credential files (Anthropic OAuth and Codex auth), `.git`, daemon state, and every unlisted path. Seat profile MUST NOT hold any provider credential that would allow a second upstream model outside the loopback proxy; Codex credentials are provisioned only in proxy-owned config (see **Credential and privilege boundary**).

Remote launch sets:

```text
CLAUDE_CONFIG_DIR=<remote-profile>
CLAUDE_SECURESTORAGE_CONFIG_DIR=<remote-profile>/secure-storage
SYSTRAY_CLAUDE_ACCOUNT_HOME=<remote-profile>
SYSTRAY_CLAUDE_ACCOUNT_SLUG=<account>
OVERDECK_SEAT_HOST=<actual-host>
OVERDECK_SEAT_ID=<seat-id>
OVERDECK_SEAT_MODEL=<exact-model-id>
```

After bootstrap, remote profile is authoritative. Reattach MUST NOT rsync laptop state over it. Laptop stores atomic mode-0600 account binding `{account, seat-id, host, credential-fingerprint, model}`. While binding exists, local `claudex` for that account fails with `use claudex --seat`; this enforces one writer for refreshable credentials. `stop` does not delete binding or persistent profile. Reattach MUST refuse when requested model differs from bound `seat.json` model.

## Build-wrapper behavior inside a seat

Existing non-goal claiming no wrapper changes is removed. Seat environment MUST prevent recursive SSH to same box:

- `local-gate`: when `OVERDECK_SEAT_HOST` equals local hostname, run existing local admission/execution path and do not call remote-build SSH.
- `e2e-remote`: on a seat host, run coupled server+browser locally through its existing wrapper contract while setting `E2E_REMOTE_OK=1`; never SSH to self. Preserve artifact paths and fail closed.
- A mismatched/unknown `OVERDECK_SEAT_HOST` is an error, not local fallback.
- Tests cover buildbox detection, no SSH call, headless guard compatibility, and normal laptop behavior unchanged.

## Remote autonomous claudex contract

Audience: implementers of `claudex --seat`, `seat-remote`, seat doctor, and seat runtime guards. This section is canonical for seat launches. Non-seat `claudex` behavior remains governed by `modules/systray/docs/specs/2026-07-22-claudex-design.md`.

### Scope

A remote seat is one autonomous implementer session: exactly one model, no workflow/orchestration surface, no nested model-routing children. The contract applies to first launch, reattach, doctor, and every process inside the seat cgroup after attach.

### Model selection — fail closed

1. `claudex --seat` MUST require exactly one `--model` on the wrapper. Bare `claudex --seat`, hybrid default, and Codex-only implicit default are rejected with exit `1` naming the missing/ambiguous model.
2. Accept only one GPT-backed model token. Map seat aliases `sol|terra|luna` to exact `gpt-*` IDs once; store and bind the resolved exact ID in `seat.json`, laptop binding, and runtime env. Reject unknown aliases. Reject native Claude models (`claude-*`) and native aliases (`fable|opus|sonnet`) **before SSH** with exit `1` and message `unsupported-security-boundary: remote seats accept only GPT-backed models (sol, terra, luna, gpt-*)`. Non-seat `claudex` keeps full alias/model support per `2026-07-22-claudex-design.md`.
3. Reject duplicate model declarations: a second `--model` on the wrapper, a forwarded `--model` in remaining Claude argv, or both wrapper and forwarded forms present.
4. Reject any other model override channel at launch: `--fallback-model`, `--settings` paths that alter `availableModels` or model bindings, and inherited env vars listed in **Effective model surface** below when their value differs from the bound model.
5. Reattach MUST use the same resolved model as `seat.json`. A different `--model` on reattach is rejected; never silently retarget an existing seat.

### Effective model surface

After resolution, the seat's effective configuration MUST satisfy all of the following before Claude starts:

| Surface | Required value |
|---------|----------------|
| `availableModels` (injected seat settings) | `[<bound-model>]` exactly one element |
| `ANTHROPIC_MODEL` | absent in seat cgroup (loopback proxy only) |
| `ANTHROPIC_SMALL_FAST_MODEL` | absent or `<bound-model>` |
| `ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU,FABLE}_MODEL` (+ `_NAME`/`_DESCRIPTION`/`_SUPPORTED_CAPABILITIES`) | absent or `<bound-model>` |
| `CLAUDE_CODE_SUBAGENT_MODEL` | absent or `<bound-model>` |
| `advisorModel`, `--fallback-model`, gateway picker slots | absent or `<bound-model>` |
| `cache/gateway-models.json` | absent, empty, or exactly one entry with `id == <bound-model>` |
| `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY` | absent or `0` |

Hybrid router mode is forbidden for seats. Every seat bound model is `gpt-*` and MUST use account-scoped `claude-code-proxy` through a **per-seat network namespace** front endpoint (see **Per-seat proxy network boundary**) — NOT a shared host `127.0.0.1` listener.

Pin `claude-code-proxy` **0.1.22** (measured baseline). Launcher starts proxy under the dedicated proxy service account (see **Credential and privilege boundary**) inside the seat netns with `CCP_CODEX_MODEL=<bound-model>` (or `codex.model` in proxy config) so every Codex upstream request uses that model. Measured 0.1.22 documents model forcing, not client-body reject/allowlist — do NOT claim the proxy returns `400`/`403` on mismatched client `model`; acceptance tests assert outbound upstream model only via mock upstream, no spend. Missing/invalid Codex credentials or proxy health failure MUST fail launch closed; NEVER fall back to Anthropic or a different GPT model. Seat cgroup processes MUST NOT receive Codex credential paths, `CCP_CONFIG_DIR`, Anthropic OAuth material, or direct provider API env — netns-local `ANTHROPIC_BASE_URL` only.

### Orchestration surface — fail closed

Seat implementer settings are authoritative and MUST NOT be reopenable from user, project, local, plugin, MCP, agent-definition, or model-catalog sources.

Inject a seat-only settings file through `--settings` on every launch and reattach:

```json
{
  "enableWorkflows": false,
  "availableModels": ["<bound-model>"],
  "permissions": {
    "deny": ["Agent", "Workflow"]
  },
  "enabledPlugins": {},
  "extraKnownMarketplaces": {},
  "sandbox": {
    "enabled": true,
    "allowUnsandboxedCommands": false,
    "failIfUnavailable": true,
    "autoAllowBashIfSandboxed": true,
    "excludedCommands": [],
    "network": {
      "strictAllowlist": true,
      "allowedDomains": [],
      "allowLocalBinding": true,
      "allowUnixSockets": []
    }
  }
}
```

Claude Code uses `permissions.deny` for tool denial — do NOT use a top-level `disallowedTools` key in settings JSON.

Additionally, every seat Claude argv MUST include (measured on Claude Code **2.1.222**: all flags accepted via `claude --version` with no model request):

1. `--disallowedTools Agent Workflow` (CLI flag; complements `permissions.deny`).
2. `--setting-sources` followed by the empty string (`--setting-sources ""`) so `user`, `project`, and `local` never merge back in — only injected `--settings` (and unavoidable admin policy) apply. Doctor dry-run MUST assert project `.claude/settings.json`, `.claude/settings.local.json`, and seat-profile user settings do not widen orchestration, model surface, or sandbox settings. Reject any forwarded argv, env, or config surface that weakens sandbox (`--sandbox`, `--no-sandbox`, sandbox-disable env vars).
3. `--agents '{}'` — no custom agent definitions.
4. `--strict-mcp-config` with an explicit empty MCP config (`--mcp-config` path to `{"mcpServers":{}}` or equivalent empty JSON). Do NOT rely on `enabledPlugins: {}` alone to suppress MCP reopen.
5. Strip inherited orchestration env before Claude starts: `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY`, workflow/run-plan hooks, and any env that would load alternate agent catalogs.
6. Seat profile bootstrap MUST NOT copy laptop plugins, MCP config, agent definitions, or workflow trees into the seat allowlist. Runtime deployment may symlink repo-managed hooks/skills for the implementer, but seat launch MUST NOT expose Workflow/Agent tools or `enableWorkflows: true` from any merged settings source.

### Credential and privilege boundary

Required guarantee: arbitrary Bash inside the implementer seat cgroup cannot obtain a second model or reopen Agent/Workflow. Mechanical controls are: (1) authoritative seat settings with Claude Code official Bash sandbox (`sandbox.enabled=true`, `allowUnsandboxedCommands=false`, `failIfUnavailable=true`, `network.strictAllowlist=true`, empty `allowedDomains`/`allowUnixSockets`, `allowLocalBinding=true` for dev servers only); (2) per-seat netns proxy front bound to deterministic veth `nsIp` (not host or netns loopback) with `ANTHROPIC_BASE_URL` pointing at that address; (3) namespace nft output policy allowing implementer UID egress only to exact `nsIp:<port>` plus loopback, proxy UID full upstream egress, everything else dropped; (4) absent Codex/Anthropic credentials in seat cgroup. PATH-only seat-guard wrappers are defense in depth only — not the primary boundary.

| Surface | Mechanism |
|---------|-----------|
| **Orchestration** | `--setting-sources ""` + injected `--settings` with `permissions.deny` + CLI `--disallowedTools` + `--agents '{}'` + `--strict-mcp-config` with empty MCP |
| **Bash sandbox (primary nested escape control)** | Authoritative seat settings `sandbox.*` per injected settings above; `failIfUnavailable=true` with Task 1 `bubblewrap`+`socat` prerequisites; `network.strictAllowlist=true` with empty domains/Unix sockets; localhost exempt so proxy MUST NOT bind loopback |
| **Claude model picker** | `availableModels == [<bound-model>]` + stripped model env per **Effective model surface** |
| **GPT upstream model (one-model boundary)** | Per-seat netns proxy front at `deriveNetnsLayout(seatId).nsIp:<port>` + absent Codex/Anthropic credentials in seat cgroup + proxy-owned credentials with `CCP_CODEX_MODEL=<bound-model>` on pinned proxy 0.1.22; mock-upstream tests assert outbound Codex model, no spend |
| **Cross-seat proxy reachability** | Per-seat netns isolates proxy front; sibling seat cgroup cannot connect to another seat proxy or host-published proxy port (see **Per-seat proxy network boundary**) |
| **Direct upstream egress** | Kernel nftables on seat netns output: loopback accept; implementer UID accept only `tcp dport <port> ip daddr <nsIp>`; `overdeck-seat-proxy` skuid accept all; DROP default. Host veth NAT unchanged for proxy upstream only |
| **Nested claude/claudex/proxy** | Seat-scope PATH prepend block-only wrappers (defense in depth); Bash sandbox denies non-loopback model endpoint even via absolute `claude` or raw curl |

**NOT controls (explicit):** same-UID `0700` files alone, hidden/high ports, mutable env flags (`OVERDECK_SEAT_*`), obscurity, or host-loopback bind without per-seat netns isolation.

**Proxy ownership (required):** Same-UID file permissions are insufficient. Seat implementer processes run as a deterministic per-seat system user `ods-<seat-id>` (non-login, no user systemd, owner-marked, collision-checked); operator SSH user never enters `agent-seat-*.scope`. Proxy config and Codex auth MUST NOT be readable or traversable by seat implementer UIDs. Operator credential files under `~/.claude/` (including `.credentials.json`) MUST be unreadable by every `ods-*` implementer UID. Base `/var/lib/overdeck/seat-proxy/` is `root:overdeck-seat-proxy` mode `0710` so only `overdeck-seat-proxy` (group traverse) can reach known per-seat instance dirs (`overdeck-seat-proxy:overdeck-seat-proxy` mode `0700`); `seat-proxy.env` stays root-owned mode `0600`.

### Per-seat proxy network boundary

Required guarantee: seat A cgroup cannot (1) reach Codex/CCP upstream directly, (2) reach seat B proxy front endpoint, or (3) read seat B authorization material. Host `127.0.0.1` shared by every root-owned proxy is forbidden — it exposes every seat proxy to every other seat on the box.

**Mechanism:** one persistent network namespace per seat, shared only by that seat's implementer cgroup and that seat's `overdeck-seat-proxy@<seat-id>` unit. Proxy front binds `deriveNetnsLayout(seatId).nsIp:<port>` inside the netns — NOT `127.0.0.1`. Claude Code Bash sandbox exempts localhost from `strictAllowlist`, so loopback bind would let nested Bash reach CCP; veth `nsIp` is outside that exemption and blocked by sandbox for Bash descendants while root Claude retains the one allowed egress nft rule to `nsIp:<port>`.

Concrete layout:

```text
netns:          /var/run/netns/overdeck-seat-<seat-id>   (launcher creates; persists for seat lifetime)
veth pair:      od-seat-<seat-id> (inside netns) ↔ od-seat-<seat-id>-host (host ns, no seat traffic by default)
proxy user:     overdeck-seat-proxy (system account, no login shell)
proxy unit:     overdeck-seat-proxy@<seat-id>.service (systemd system unit)
proxy netns:    NetworkNamespacePath=/var/run/netns/overdeck-seat-<seat-id>
proxy base:     /var/lib/overdeck/seat-proxy/   owner root:overdeck-seat-proxy mode 0710 (proxy group may traverse known instance; operator SSH user and `ods-<seat-id>` implementer may not)
proxy config:   /var/lib/overdeck/seat-proxy/<seat-id>/   owner overdeck-seat-proxy:overdeck-seat-proxy mode 0700
per-seat env:   /var/lib/overdeck/seat-proxy/<seat-id>/seat-proxy.env   (root-owned mode 0600; created atomically by Task 3; exact allowlisted keys only — see **Proxy unit template**)
unit template:  EnvironmentFile=/var/lib/overdeck/seat-proxy/%i/seat-proxy.env — MUST fail closed if file missing or invalid; `ExecStartPre` validator parses env as inert data (never source/eval as root); NEVER ship `Environment=CCP_CODEX_MODEL=` empty in the installed template
proxy front:    <nsIp>:<port>/v1 INSIDE seat netns (CCP_BIND_ADDRESS=<nsIp>; NEVER 127.0.0.1)
seat netns:     agent-seat-<seat-id>.scope joins same netns (systemd NetworkNamespacePath or seat-launcher enters netns before tmux)
seat env:       ANTHROPIC_BASE_URL=http://<nsIp>:<port>/v1   (deterministic veth address; not loopback)
namespace nft:  output policy drop; accept 127.0.0.0/8; accept implementer UID → tcp dport <port> ip daddr <nsIp>; accept skuid overdeck-seat-proxy; proxy UID retains upstream egress via host NAT
tmux socket:    ~/.local/state/overdeck/seats/<seat-id>/tmux.sock on shared mount ns (no PrivateMounts hiding socket); operator SSH attach stays outside seat netns
```

Task 3 launcher provisions netns + veth + nftables rules via `sudo -n`, atomically writes per-seat proxy config + `seat-proxy.env`, transfers Codex credentials into proxy-owned config, starts/stops the system unit, and tears down netns on `stop` when no scope remains. Task 1 host baseline doctor verifies only: proxy system account, base directory `root:overdeck-seat-proxy` mode `0710`, pinned proxy binary, unit template contract including `ExecStartPre` validator, **`bubblewrap` and `socat` on PATH**, and seat-guard `current` (no per-seat env file, no live unit). Per-seat doctor checks (proxy unit in correct netns; proxy front listening on `<nsIp>:<port>` inside netns and NOT on host or netns `127.0.0.1`; seat scope in same netns; sandbox prerequisites present; `seat-proxy.env` includes `CCP_BIND_ADDRESS=<nsIp>`; …) run at Task 3 creation and Task 5 verification only. Seat cgroup env carries `ANTHROPIC_BASE_URL` to `nsIp` only — no `CCP_CONFIG_DIR`, no Codex API env, no Anthropic OAuth, no readable provider auth under `~/seats/` or `~/.claudex-accounts/<account>/<seat-id>/`.

### Host control vs seat implementer process

| Class | Examples | Contract |
|-------|----------|----------|
| **Host / operator control** | `seat-remote`, provisioning, doctor, `systemctl --user`, dedicated tmux server creation via root helper `overdeck-seat-scope-entry`, operator attach via root helper `overdeck-seat-tmux-mediator`, `sudo -n` proxy unit lifecycle (holds Codex creds as `overdeck-seat-proxy`), SSH attach client | MAY invoke `claude --version`, `auth status`, run `install-seat-guard` / `install-headless-guard`, start/stop scopes; runs outside `agent-seat-*.scope` with unmodified operator PATH and operator UID |
| **Seat implementer** | Claude root and every descendant inside `agent-seat-<seat-id>.scope` | Runs as per-seat `ods-<seat-id>` via `sudo -n overdeck-seat-implementer-exec` root transition + privilege drop (operator invokes inside scope); no operator credential paths, no user-systemd bus, no `DBUS_SESSION_BUS_ADDRESS`; **local git only** inside checkout (status/commit); remote fetch/push absent/blocked in seat netns; subject to settings isolation and credential boundary above |

Task 1 installs seat-guard block-only wrappers and publishes them through atomic `current` symlink; Task 3 prepends `~/.local/share/overdeck/seat-guard/current/bin` to seat-scope PATH only and starts tmux via root-only `overdeck-seat-scope-entry` → operator `systemd-run --user --scope` → `sudo -n overdeck-seat-implementer-exec` root transition + privilege drop into per-seat `ods-<seat-id>`. Operator attach (`sudo -n overdeck-seat-tmux-mediator`) stays outside the implementer class and never uses cross-UID `tmux attach`.

### Per-seat UID lifecycle, control path, ACL, tmux mediator, and local-only Git

**Identity:** Each seat gets deterministic Linux user from `implementer_username_for_seat(seat_id)` in `seat_implementer_identity.py` (JS parity via `seat-implementer-identity.mjs` only — uppercase/dots/length-invalid seat IDs hash to `ods-<sha256[:16]>`). System account: `/usr/sbin/nologin`, primary group only (no supplementary `/etc/group` memberships), no user systemd, no `/run/user/<uid>` bus.

**Control path (root-owned, traversable for known config only):** `/var/lib/overdeck/seat-control/` mode `0711` root:root (base MUST be traversable — `0700` blocks dropped-UID reads). Per-seat `/var/lib/overdeck/seat-control/<seat-id>/` mode `0711` root:root holds owner marker (`owner` mode `0600`) and tmux config (`tmux-seat.conf` mode `0644`). Seat implementer UID can traverse base + per-seat dir to read the known tmux config path after privilege drop but cannot list, write, or replace directory contents and cannot read the owner marker. All root metadata writes use dirfd + `O_NOFOLLOW` + atomic replace inside this tree. Provision/install MUST enforce base `0711` with explicit `chmod` after `makedirs`/`install -d` so restrictive umask cannot weaken modes; `--install-check` and `ensure_control_base()` fail closed on base mode/owner/symlink drift.

**Runtime home:** `/var/lib/overdeck/seat-runtime/<seat-id>/home` mode `0700` owned by seat UID (implementer HOME only).

**ACL contract (canonical `seat_implementer_identity.py` only — no shell duplicate):**
- Operator ancestors through profile/state/checkout parents: **traverse-only `x`** — never `rwX` on `$HOME`, `.claude`, `.local`, `.claudex-accounts`, or credential parents.
- Operator credential paths (`~/.ssh`, `~/.config/gh`, `~/.claude/.credentials.json`, `~/.git-credentials`, `~/.gitconfig`): named-user `---` ACL where path exists; verify implementer cannot read even world-readable files.
- Seat root `~/seats/<seat-id>`: **traverse-only `x`** for implementer — never `rwX` on the seat directory itself. Operator-owned `seat.json` (mode `0600`) MUST be unreadable, unwritable, undeletable, and unreplaceable by any `ods-*` UID. Write `seat.json` before scope launch and access grants.
- Exact rw roots only: `~/seats/<seat-id>/repo`, `~/.claudex-accounts/<account>/<seat-id>`, `~/.local/state/overdeck/seats/<seat-id>` (socket/tmux writable only) — `rwX` + default ACL for **both** operator and implementer on descendants created by either identity.
- Launch authority files live under `~/.local/state/overdeck/seats/<seat-id>/trusted-config/` (`seat-settings.json`, `empty-mcp.json`, `claude-forwarded.json`, `seat-env.json`): operator-writable; implementer read-only (readable for launch, not writable/deletable/replaceable). Writable seat state (tmux socket) stays in the state dir root.
- Managed executables: resolve allowed one-hop symlinks only (`seat-guard/current`, `.local/bin/claude`); validate operator ownership; grant minimum `rx`/`x` on link ancestors + targets; reject chained/cyclic/escaping links.
- Implementer identity MUST exist before netns nft rules resolve `id -u` for `ods-*`. Root helper `overdeck-seat-scope-entry --ensure-implementer-only` provisions identity; `buildNetnsProvisionScript` runs only after that succeeds.

**Scope launch (root transition inside operator scope):**
1. Operator SSH runs `sudo -n overdeck-seat-scope-entry --ensure-implementer-only` during seat create/restart before netns nft provisioning.
2. Operator SSH runs `sudo -n overdeck-seat-scope-entry` (root joins seat netns, provisions identity + ACL).
2. Root calls `systemd-run --user --scope` **as operator** inside netns; scope command is `sudo -n overdeck-seat-implementer-exec` (mode `0755`, dedicated sudoers rule).
3. Implementer-exec validates `SUDO_UID` operator, seat args, root-owned tmux conf path, drops permanently to `ods-<seat-id>`, execs tmux inside scope.
4. Launch verification: pane PID retains seat netns inode, `agent-seat-<seat-id>.scope` ancestry, effective cgroup limits (`memory.max=12G`, `memory.swap.max=1G`, `cpu.max=400%`, `cpu.weight=1`, `pids.max=256`), `OOMPolicy=kill`, implementer UID, no `DBUS_SESSION_BUS_ADDRESS`/`XDG_RUNTIME_DIR`.

**tmux:** Require tmux ≥ 3.7 (parse `tmux -V`). Generated config sets only `set -g exit-empty on` — NEVER `set -g systemd off` (not a tmux 3.7b runtime option; startup config errors are forbidden). Verify `exit-empty` on an isolated probe server via `show-options -s exit-empty` (no default-server dependency, no spend). Pane cgroup escape prevention is structural: implementer launch strips `DBUS_SESSION_BUS_ADDRESS` and `XDG_RUNTIME_DIR`; dedicated non-login `ods-<seat-id>` has no user manager/bus; tmux 3.7b cgroup path uses `sd_bus_default_user` and fails without that bus (no fallback). Live acceptance MUST prove no `tmux-spawn-*.scope` and every descendant stays in `agent-seat-<seat-id>.scope`.

**tmux mediator:** Operator attach uses `sudo -n overdeck-seat-tmux-mediator` only; mediator validates socket path + owner marker, runs allowed subcommands as seat UID.

**Git boundary:** Seat netns blocks upstream egress. **No** seat-readable GitHub token, **no** `overdeck-seat-git-credential`, **no** operator `~/.ssh`/`gh` exposure. Each checkout gets local-only Git author `Overdeck Seat <seat-id>` + `<seat-id>@overdeck.invalid` (never operator config/credentials). Implementer may run **local** `git status`/`git commit` in checkout as seat UID; `git fetch`/`git push`/`ls-remote` fail or are absent inside seat. Operator lifecycle (outside netns) owns clone/fetch/landing.

**Install attestation:** Manifest + `--install-check` verify exact modes/owners/hashes for scope-entry, implementer-exec (`0755`), tmux-mediator, `seat_common.py`, identity module; exact three-line sudoers (`scope-entry *`, `tmux-mediator *`, `implementer-exec *`); no legacy wrapper-path fallback.

### Proxy unit template

Task 1 installs `overdeck-seat-proxy@.service` as a system unit template only. The template MUST:

1. Run as `overdeck-seat-proxy` with `NetworkNamespacePath=/var/run/netns/overdeck-seat-%i`.
2. Load runtime env exclusively from `EnvironmentFile=/var/lib/overdeck/seat-proxy/%i/seat-proxy.env` (no `-` prefix — missing file is a hard unit start failure).
3. NEVER embed `Environment=CCP_CODEX_MODEL=` or other empty per-seat keys in the shipped template.
4. `ExecStartPre` runs `overdeck-seat-proxy-validate` (or equivalent) as **root** to parse `seat-proxy.env` as **inert data** — line-oriented `KEY=VALUE` only. NEVER `source`, `.`, or `eval` the file. Reject duplicate keys, unknown keys, malformed lines, loopback `CCP_BIND_ADDRESS`, and values that do not match the seat's bound contract. Exact allowlist only (no optional extras): `CCP_CODEX_MODEL`, `CCP_CONFIG_DIR`, `PORT`, `CCP_BIND_ADDRESS` (must equal `deriveNetnsLayout(<seat-id>).nsIp`, never `127.0.0.1`). Enforce exact `CCP_CODEX_MODEL=<bound-model>`, exact `CCP_CONFIG_DIR=/var/lib/overdeck/seat-proxy/<seat-id>/`, exact listen port, and exact bind address for that seat. Validator exit non-zero aborts unit start before proxy exec.
5. `ExecStart` points at the pinned operator-installed `claude-code-proxy` absolute path recorded at install time.

Task 3 atomically creates `/var/lib/overdeck/seat-proxy/<seat-id>/seat-proxy.env` (mode `0600`, root-owned) with only the allowlisted keys and exact values above before `systemctl start overdeck-seat-proxy@<seat-id>`. Invalid, partial, or drifted env MUST fail launch closed before the unit starts (launcher pre-check + `ExecStartPre` validator).

### Nested execution guard — defense in depth, cgroup-checked

Dedicated seat-only PATH wrappers for `claude`, `claudex`, and `claude-code-proxy` (modeled on `install-headless-guard`) are **block-only** defense in depth — NOT the primary security boundary. They exist solely for processes that already have guard PATH prepended inside `agent-seat-*.scope`. Operator control (`seat-remote`, doctor, provisioning, SSH attach) NEVER prepends guard PATH and invokes real operator binaries directly from the unmodified operator PATH. Operator-installed binaries under `~/.local/bin` (or elsewhere on operator PATH) are NEVER moved, renamed, or shadowed with `.real` siblings. No `targets.json` or operator-binary forwarding — wrappers do not `exec` real binaries. Bypass remains via absolute path to operator binaries, `/proc/self/exe` copy, or other non-PATH invocation — the guard does NOT claim absolute-path blocking. Mutable env (`OVERDECK_SEAT_PROCESS`, unset env) is NOT the gate. Including `claudex` in the guard set blocks nested seat `claudex` even when no remote operator `claudex` binary is installed.

Layout:

```text
releases:  ~/.local/share/overdeck/seat-guard/releases/<release-id>/
wrappers:  ~/.local/share/overdeck/seat-guard/releases/<release-id>/bin/{claude,claudex,claude-code-proxy}
current:   ~/.local/share/overdeck/seat-guard/current -> releases/<release-id>/   (atomic symlink; seat PATH uses current/bin)
installer: install-seat-guard   (idempotent; writes versioned release, atomically repoints current)
```

1. Task 1 runs `install-seat-guard`: write block-only cgroup-checked wrapper scripts for all three names into a new versioned release directory, then atomically repoint `current`.
2. Each wrapper reads `/proc/self/cgroup`. If cgroup path matches `agent-seat-*.scope`, exit `97` with message to use the seat launcher — regardless of env. Wrappers MUST NOT forward to operator binaries (operator processes never hit guard PATH).
3. Task 3 prepends `~/.local/share/overdeck/seat-guard/current/bin` to PATH **only** for processes inside `agent-seat-<seat-id>.scope`. Operator attach, doctor, and provisioning keep the unmodified operator PATH and real binaries.
4. **Initial Claude spawn:** `seat-launcher` MUST `exec` the absolute operator `claude` path — never PATH `claude` — so the first Claude tree starts while seat-scope PATH-based nested invocations remain blocked.
5. Re-running `install-seat-guard` atomically publishes a new release and repoints `current`; doctor verifies `current` symlink, all three wrappers exist under `current/bin`, are executable, and are block-only (no `exec`/`targets.json`).
6. Prompt/skill text MUST NOT be relied on for this boundary.

### Startup and doctor — no-spend verification

Two doctor tiers. Task 1 implements host baseline only; per-seat checks require an existing seat ID and run in Task 3 creation and Task 5 verification.

**Host baseline (Task 1 — no seat ID required):**

1. Tool/runtime parity: `tmux`, `curl`, `bubblewrap`, `socat`, `gh`, pinned `claude-code-proxy --version` (**0.1.22**), Claude Code version parity, runtime symlinks, headless guard.
2. Proxy prerequisites: `overdeck-seat-proxy` system account, `/var/lib/overdeck/seat-proxy/` base directory `root:overdeck-seat-proxy` mode `0710`, installed unit template with `EnvironmentFile=` contract and `ExecStartPre` validator (no empty `CCP_CODEX_MODEL` in template), pinned proxy binary path.
3. Seat guard prerequisites: `~/.local/share/overdeck/seat-guard/current` symlink targets a release with block-only wrappers for all three names under `current/bin/`, operator PATH binaries untouched (no `.real` siblings, no `targets.json`).
4. Auth/read-only Git, disk, and `~/seats`-outside-GC checks.
5. Run only `claude --version` and `claude auth status` (no model request).
6. MUST NOT require per-seat Codex auth shape, `CCP_CODEX_MODEL` value, netns/port layout, live `overdeck-seat-proxy@<seat-id>` health, or seat launch dry-run — no seat exists yet.

**Per-seat (Task 3 creation, Task 5 verification, reattach):**

1. Resolve bound model from `seat.json` or launch args.
2. Dry-run the seat launch planner: assemble exact Claude argv (`--settings`, `--setting-sources ""`, `--disallowedTools`, `--agents`, `--strict-mcp-config`, `--mcp-config`), stripped env, seat PATH prepend (`~/.local/share/overdeck/seat-guard/current/bin`), and per-seat proxy config; fail on any violation of **Model selection**, **Effective model surface**, **Orchestration surface**, and **Credential and privilege boundary**.
3. Verify seat-guard `current/bin` prepended on seat PATH only; operator PATH outside scope unchanged and uses real binaries directly.
4. Verify `seat-proxy.env` exists with exact allowlisted keys and values (`CCP_CODEX_MODEL=<bound-model>`, `CCP_CONFIG_DIR`, listen port), `overdeck-seat-proxy@<seat-id>` unit active in correct netns, netns-local loopback bind, proxy-owned Codex auth presence/shape under `/var/lib/overdeck/seat-proxy/<seat-id>/` (`overdeck-seat-proxy:overdeck-seat-proxy` mode `0700`), `overdeck-seat-proxy` CAN traverse base + instance paths, operator SSH user AND `ods-<seat-id>` implementer CANNOT traverse or read under `/var/lib/overdeck/seat-proxy/`, seat env lacks provider credential paths, and proxy health endpoint with no upstream model call.
5. On any mismatch, fail closed with a precise line naming the offending surface (model binding, picker row, workflow flag, `permissions.deny`, setting-source merge, agent/MCP reopen, guard wrapper/`current` symlink drift, proxy pin, `seat-proxy.env` allowlist/`ExecStartPre` validation, `CCP_CODEX_MODEL`, credential boundary, netns isolation, proxy filesystem traverse).

Reattach doctor MUST re-verify per-seat surfaces against persisted `seat.json` model and on-disk seat settings; a drifted profile refuses attach until `stop` + explicit relaunch.

### Seat metadata

Extend `seat.json` (mode `0600`) with:

```json
{
  "model": "<exact-model-id>",
  "modelSource": "claudex-seat-launch",
  "proxy": {
    "required": true,
    "pinnedVersion": "0.1.22",
    "ccpCodexModel": "<exact-model-id>",
    "bindAddress": "<nsIp>",
    "baseUrl": "http://<nsIp>:<port>/v1",
    "unit": "overdeck-seat-proxy@<seat-id>.service",
    "configDir": "/var/lib/overdeck/seat-proxy/<seat-id>/",
    "envFile": "/var/lib/overdeck/seat-proxy/<seat-id>/seat-proxy.env"
  }
}
```

`proxy.required` is always `true` (all seat models are `gpt-*`). `proxy.ccpCodexModel` MUST equal `model`.

## Claudex interface

Add mutually exclusive flags before model parsing:

```text
--seat[=auto|debian1|debian2]   # REQUIRES exactly one --model
--seat-status[=auto|debian1|debian2]
--seat-stop[=auto|debian1|debian2]
```

Bare host flag defaults to `auto`. `--seat` MUST require exactly one `--model` on the wrapper (seat aliases `sol|terra|luna` only). Resolve Claude account exactly through existing `resolve_claude_account()`. Resolve model to exact `gpt-*` ID; reject native Claude models and aliases before SSH per **Remote autonomous claudex contract**; reject hybrid/default/duplicate/forwarded overrides; then call:

```text
seat-remote launch --host … --account … --project … --model <exact-id> -- <claude-argv-without-model>
```

`seat-remote` MUST strip any forwarded `--model`, `--fallback-model`, and conflicting `--settings` from `<claude-argv…>` before remote launch. Local paths never appear in remote argv except project used for local identity derivation. Existing non-seat behavior remains unchanged except bound accounts fail closed to remote-seat instruction.

## Implementation tasks (one Cursor dispatch each)

### Task 1 — provisioning and doctor

Files: `remote-build.mjs`, provisioning script under `modules/workstation/claude/bin/`, `install-seat-guard`, `overdeck-seat-proxy-validate`, `systemd/system/overdeck-seat-proxy@.service`, tests under `modules/workstation/claude/tests/`.

Implement exact Claude/tmux/gh/runtime prerequisites; pin/install `claude-code-proxy` 0.1.22; provision `overdeck-seat-proxy` system account, `/var/lib/overdeck/seat-proxy/` base directory (`root:overdeck-seat-proxy` mode `0710`), and `overdeck-seat-proxy@.service` unit **template** per **Proxy unit template** including `ExecStartPre` env validator (no per-seat env, no empty `CCP_CODEX_MODEL`); install seat-guard versioned release + atomic `current` symlink per **Nested execution guard** without mutating operator binaries. Extend `remoteDoctor` with **host baseline** checks only: per-host parity, auth/read-only Git, symlink targets, headless guard, seat-guard `current` symlink + block-only wrappers, pinned proxy binary, unit template + validator contract, disk, and `~/seats`-outside-GC. MUST NOT validate per-seat Codex auth shape, `CCP_CODEX_MODEL`, netns/port, live proxy units, proxy traverse matrix, or seat launch dry-run. Unit tests inject SSH/installer dependencies and cover every host-baseline failure line. Deploy and verify both hosts without model calls.

### Task 2 — aggregate seat slice

Files: repo-owned unit above, idempotent installer/apply path, tests.

Install on both boxes and assert exact `systemctl --user show agent-seat.slice` values. Test second apply and stale-value correction.

### Task 3 — `seat-remote` lifecycle and wrapper recursion

Files: new launcher/library/tests plus minimal `local-gate` and `e2e-remote` changes.

Implement identity, dual-host discovery, checkout, account bootstrap/binding, dedicated runtime, status, stop, corruption handling, seat launch planner (GPT-only model gate before SSH, single-model settings injection with `permissions.deny`, `--setting-sources ""`, `--agents '{}`, `--strict-mcp-config`, orchestration CLI flags), per-seat proxy lifecycle (netns + veth + nftables, atomic `seat-proxy.env` with exact allowlisted keys/values, Codex credential transfer into proxy-owned config, `overdeck-seat-proxy@<seat-id>` start/stop), prepend `~/.local/share/overdeck/seat-guard/current/bin` to seat-scope PATH only, and per-seat doctor at creation. Dependency-injected tests cover decision table, git gates, allowlist, modes, argv secrecy, cgroup verification, native-model rejection, model/orchestration/credential contract failures, guard PATH prepend + block-only exit `97` inside scope, proxy env atomic write + `ExecStartPre` allowlist rejection + fail-closed unit start, proxy traverse matrix, and no self-SSH.

### Task 4 — claudex integration

Files: `modules/systray/claudex.py`, `modules/systray/tests/test_claudex.py`.

Implement flags/delegation/bound-account fail-closed behavior; require `--model` for `--seat`; resolve seat aliases `sol|terra|luna` once; reject native Claude models/aliases before SSH with `unsupported-security-boundary`; reject hybrid/default/duplicate/forwarded model overrides; pass `--model <exact-gpt-id>` to `seat-remote`. Cover parsing, resolved account/model, argv stripping, exit propagation, missing launcher, mutual exclusion, unchanged local non-seat paths (including native model support), and every **Seat acceptance tests** row that runs on the laptop side.

### Task 5 — no-spend end-to-end verification

Repo-owned script, registered with workstation tests. Verify on both hosts:

1. CLI/tool/runtime/config parity and secure modes.
2. Dedicated tmux survives SSH detach.
3. Actual Claude executable is invoked only with `--version` or `auth status`; no conversation/model request.
4. Claude PID and every descendant belong to expected `agent-seat-<seat-id>.scope`; operator attach client does not. Seat env lacks provider credential paths; `overdeck-seat-proxy` can traverse `/var/lib/overdeck/seat-proxy/<seat-id>/`; operator SSH user and `ods-<seat-id>` implementer cannot traverse or read under `/var/lib/overdeck/seat-proxy/`.
5. Exact aggregate/per-seat property readbacks.
6. Reattach reaches same tmux server, pane PID, scope, checkout, and bound model.
7. Status reports same identity and model.
8. Separate 64 MiB probe scope triggers group OOM; sibling sentinel scope survives.
9. Stop removes all descendants and socket while preserving checkout/profile.
10. `~/seats` survives remote-build GC sweep.
11. Per-seat doctor dry-run reports exact effective model, `permissions.deny`, `--setting-sources ""`, empty agents/MCP, `seat-proxy.env` allowlist + exact `CCP_CODEX_MODEL`/`CCP_CONFIG_DIR`/port, proxy traverse matrix (proxy user yes; operator SSH user and `ods-<seat-id>` implementer no), seat-guard `current/bin` on seat PATH only, and cgroup block-only guard exit `97` inside scope; injected failure lines fail closed without spend. Host baseline doctor (Task 1) passes without any seat ID.
12. **Seat acceptance tests** (dependency-injected where possible; live no-spend assembly checks otherwise):

| Path | Setup | Expected |
|------|-------|----------|
| **direct** | `claudex --seat --model gpt-5.6-terra` | Launch succeeds; `availableModels == ["gpt-5.6-terra"]`; proxy unit active with `CCP_CODEX_MODEL=gpt-5.6-terra` |
| **alias** | `claudex --seat --model terra` | Resolves to `gpt-5.6-terra`; `seat.json.model` stores exact ID |
| **native-reject** | `claudex --seat --model sonnet` or `--model claude-sonnet-5` | Rejected before SSH with `unsupported-security-boundary`; no remote process started |
| **forwarded** | `claudex --seat --model terra -- --model gpt-5.6-sol` | Rejected before remote start; names duplicate/forwarded override |
| **environment** | Inherited `ANTHROPIC_MODEL=gpt-5.6-sol` with `--model terra` | Rejected or stripped so effective model remains terra only; never two models |
| **nested-process** | Inside running seat cgroup (seat PATH prepended), `bash -lc 'claude --version'` and `claudex --help` | Seat-guard block-only wrappers exit `97`; no nested child; operator `claude --version` outside scope (unmodified PATH, no guard prepend) still succeeds via real operator binary |
| **settings-source** | Project `.claude/settings.json` sets `enableWorkflows: true`, extra `availableModels`, or `permissions.allow` for Agent | Dry-run/effective launch plan keeps `enableWorkflows: false`, single-model picker, `permissions.deny` includes Agent/Workflow; `--setting-sources ""` blocks user/project/local merge |
| **agents-mcp** | Seat profile or project enables plugin/MCP/agent catalog entries | Dry-run fails closed naming reopen surface; argv includes `--agents '{}'`, `--strict-mcp-config`, empty `--mcp-config` |
| **proxy-upstream** | `--model gpt-5.6-terra`; mock Codex upstream captures outbound requests | `CCP_CODEX_MODEL=gpt-5.6-terra`; every captured upstream body uses `gpt-5.6-terra`; no spend; do NOT assert client-body rejection |
| **proxy-credential** | Seat launch plan | Seat env has netns-local `ANTHROPIC_BASE_URL` only; `seat-proxy.env` holds exact allowlisted keys; `overdeck-seat-proxy` can traverse instance dir; operator SSH user and `ods-<seat-id>` implementer cannot traverse or read `/var/lib/overdeck/seat-proxy/`; no provider auth in seat profile allowlist |
| **reattach** | Stop SSH attach and `claudex --seat --model <same-gpt-id>` again | Same socket/scope/checkout/model; doctor passes; different `--model` on reattach rejected |

## Sequence

Task 1 → Task 2 → Task 3 → Task 4 → Task 5. Review diff and run task tests before next dispatch. Deploy/verify each infrastructure prerequisite before downstream work.

## Non-goals

- No Managed Agents migration: it does not preserve claudex account/session semantics or self-hosted checkout control.
- No headless harness scheduling, queue, or multi-user seats.
- No implicit seat migration between boxes.
- No tray UI for seat state; `claudex --seat-status` is authoritative.
- No automatic return of a bound account to laptop in this phase.
- No hybrid or unpinned-model seat launches; no native Claude seat models; no seat workflow/orchestration escape hatch.
