---
name: od-auth
description: How authentication works across the overdeck fleet — agent CLI account routing (cdx/claudex/ca), buildbox seat credentials, and workstation root. Read BEFORE touching any auth path. Triggers on auth/login/token/credential/account failures, "Authentication required", 401, "not logged in", account switching or rotation, exit 97 / 84 / 81 / 80 / 75, sudo/root/password prompts, .secrets files, seat dispatch refused, "credential absent on debian1/2/3", copying or seeding a token onto a buildbox.
---

# overdeck auth — how it actually works

audience: AI coding agents first. BLUF. Read the whole file before editing any auth path.

Three INDEPENDENT planes. Diagnose which one you are in FIRST — the same word "auth" means a different mechanism in each, and mixing them is the single most common failure.

| Plane | Question it answers | Source of truth |
|---|---|---|
| **Account routing** | which vendor account does this CLI run as? | systray runtime accounts + symlinks |
| **Seat credentials** | does the buildbox hold the token the wrapper needs? | `modules/harness/seat/credentials.json` |
| **Workstation root** | may this process become root? | `deck-sudo` + the owner's password file |

## Rule 0 — never move a token by hand

NEVER `cp`/`scp`/`cat >` a credential, NEVER paste one into a file, NEVER hardcode an account slug, NEVER print a token to stdout/stderr/a log/a commit. Every plane below already has an automatic path. If you find yourself typing a token, you are in the wrong plane.

Secrets under `/home/user/Projects/overdeck/.secrets/` are owner-only. `secret-file-gate` refuses to read them — including via a glob. Do not name the password file in any tool call.

---

## Plane 1 — account routing (which account a CLI runs as)

`cdx` (codex) and `claudex` (claude) route to an account through systray. Accounts live under `~/.overdeck/systray/runtime/accounts/<slug>/` (codex) and `~/.systray-ai/claude-accounts/<slug>/` (claude). The active default is `~/.overdeck/systray/runtime/default_slug`.

**The live credential path is a SYMLINK systray repoints on account switch:**

```
~/.codex/auth.json -> ~/.overdeck/systray/runtime/accounts/<active>/CODEX_HOME/auth.json
```

Follow the symlink to get the active account. NEVER read the account name from a config, a memory, or this file — it changes.

**Pin an account explicitly** (first arg, before the subcommand): `cdx --account=<slug> exec …` or `--profile=<slug>`.

**Non-mutating probes — use these to diagnose, they start no agent:**

```bash
cdx route              # {"account":…,"chain":[…],"source":…,"project":…}  which account WOULD run
cdx route --canonical  # the canonical profile for this cwd
cdx --status           # live usage/health for the routed account
```

`--profile <slug>` on `codex.sh` requires a `[profiles."<slug>"]` section in the box's `~/.codex/config.toml`. Missing section → the run silently routes to the wrong account, so `seat-run.sh` refuses it (exit 3) instead.

**Claude sessions are account-scoped.** `CLAUDE_CONFIG_DIR` selects the account's `CLAUDE_HOME`, and `--resume` only scans that account's project dir. A session recorded under a different account is NOT lost — it is invisible to the current `CLAUDE_CONFIG_DIR`. Look under the other account's home before concluding data loss.

## Plane 2 — seat credentials (buildbox side)

A seat runs the wrapper inside a rootless podman container on debian1/2/3. That container needs the vendor token, and the box needs it first.

**It is already automatic. Do not seed anything by hand.**

- `modules/harness/seat/credentials.json` declares the set ONCE — id, `home` (path under `$HOME`), `seed` (mount point in the container), `required_by` (which wrappers need it). All three sides read this one file: the workstation converger, the launcher's mounts, the container entrypoint.
- `seat-creds.mjs` runs on EVERY dispatch. It resolves each credential through its symlink (so the source is whatever account systray has active), compares digests, pushes only on drift, and verifies the digest after every write. A host already current costs nothing — a per-host stamp skips it without opening a connection.
- `seat-run.sh` is the fail-closed half: a box missing a credential the wrapper needs is refused with **exit 3 before podman is touched**. Nothing is dispatched, nothing is burned.

Adding a wrapper that needs a token → add an item to `credentials.json` with the right `required_by`. That is the whole change. NEVER hardcode a credential path in a launcher — `seat/test/seat-creds.test.sh` fails you for it.

Distributing copies is safe by design: a rotated-away refresh token stays valid, each node refreshes its own copy, and drift is re-derived from the workstation on the next dispatch.

**Verify (read-only), never assume:**

```bash
# the box's copy vs the workstation's — compare digests, never contents
ssh <host> 'cd ~ && sha256sum .codex/auth.json .claude/.credentials.json .config/cursor/auth.json 2>/dev/null'
sha256sum "$(readlink -f ~/.codex/auth.json)"
bash modules/harness/seat/test/seat-creds.test.sh   # the whole seam, 8 assertions
```

## Plane 3 — workstation root

Root on the laptop is `deck-sudo <command> [args…]` — on PATH, sourced from `modules/workstation/bin/deck-sudo`.

NEVER ask the owner for a password. NEVER trigger a GUI askpass. Raw `sudo`/`pkexec`/`run0` CANNOT work from an agent process: the kernel sets `no_new_privs`, so they refuse before authenticating. `deck-sudo` respawns via `systemd-run --user` (fresh process, flag not inherited) and reads the password inside that process — never printed, never in a transcript.

Password file absent ⇒ no root. That absence IS the owner's revocation. NEVER work around it.

---

## Exit codes — each means something different

| rc | Meaning | Correct response |
|---|---|---|
| **97** | headless agent dispatch on the workstation denied (`local-dispatch-guard`) | dispatch on a buildbox: `seat-remote launch --host auto --account <slug> --project <path>` |
| **84** | `EXIT_UNCONTAINABLE` — this invocation has no containerized path | the invocation starts agent compute with nowhere to contain it; route it through a seat |
| **81** | `EXIT_CREDENTIAL` — resolved credential file missing or unreadable | let convergence run (it is per-dispatch); check the symlink target exists |
| **80** | `EXIT_NO_NODE` — registry unreadable, or no reachable seat host / access door | check `~/.claude/buildbox-hosts.json`; a host not marked reachable MUST NOT be contacted |
| **75** | rate-limited — every account capped | back off to `resume_at`; do NOT switch to an unauthorized account |
| **3** | wrapper contract: engine down / precondition unmet, **NOTHING dispatched** | safe to retry after fixing the precondition |

A terminal on fd 0/1/2 means a human is driving, and 97 never fires for them. Your Bash subprocess has pipes on all three — so 97 lands exactly on you.

## Forbidden workarounds

These silence a guard instead of fixing the cause. Every one of them is a standing owner prohibition.

```bash
# DO NOT — fakes "I am already on a buildbox" to defeat the dispatch guard
export OD_LOCAL_DISPATCH_OK=1
export HARNESS_SEAT_CONTAINER=1

# DO NOT — hand-seeds a token the converger owns
scp ~/.codex/auth.json debian1:~/.codex/auth.json

# DO NOT — pins an account that may not be active
cdx --account=multideal exec …   # unless the task genuinely requires THAT account
```

`OD_LOCAL_DISPATCH_OK=1` is set by launchers that already placed the process on a buildbox, and by `~/.local/bin/cdx` (an owner decision releasing the codex CLI process itself — heavy work still leaves via cpu-guard/local-gate). It is NEVER yours to export.

## Diagnosis ladder

Stop at the first rung that explains the failure.

1. **Which plane?** Account routing / seat credential / root. Wrong plane ⇒ every step after is wasted.
2. **What is the exit code?** Read the table above. Do not infer from the message text.
3. **Which account would run?** `cdx route`. Compare to what the task requires.
4. **Does the symlink resolve?** `readlink -f ~/.codex/auth.json` — a dangling link and an absent file are the same fact.
5. **Does the box hold it?** Hash the remote path and compare to local. `ABSENT` ⇒ convergence has not run or could not reach the host; it re-runs on the next dispatch.
6. **Still stuck?** Report the plane, the rc, and the probe output. Do NOT reach for an env var.

## Related

- Seat dispatch mechanics, container caps: `modules/harness/seat/README.md`
- Buildbox identity, reachability, spill order: `~/.claude/buildbox-hosts.json` (never hardcode a host)
- Factory layout, config and tracing: `modules/harness/CLAUDE.md`
