mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-21 21:15:09 +00:00
60930f0ba4
* fix(tui): pass CLAUDE_CODE_OAUTH_TOKEN to spawned claude + reap defunct sessions
Root cause (PI231 incident): tmux does not forward the parent's env to the
pane, so the TUI claude never saw CLAUDE_CODE_OAUTH_TOKEN and fell back to
~/.claude/.credentials.json, whose single-use refresh token got corrupted to an
empty string by the per-request spawn + kill-session teardown racing claude's
token rotation -> permanent "Please run /login" 401 (re-login re-corrupted on
the next spawn). Connected leak: the pane's claude is a child of the tmux server
(not node), so kill-session left <defunct> zombies the server never reaped (25
over 30 days; tmux kill-server dropped it 25->3).
Fix 1: buildTuiCmd now adds CLAUDE_CODE_OAUTH_TOKEN=<shq-escaped> to the pane
env prefix when the env is set, so claude authenticates via the long-lived token
and never touches the credentials.json refresh path (matching stable hosts).
Unset -> no token added (credentials.json-only hosts unaffected).
Fix 2: reapStaleTuiSessions kill-servers after clearing our own sessions ONLY
when no foreign tmux session remains (never disrupts a co-hosted olp-tui-*).
kill-server is the only node-reachable action that ACTUALLY reaps -- server exit
reparents survivors to init, which waitpids them; a per-session kill cannot,
since node is not the zombies' parent. Added a 15-min periodic reap (server.mjs)
gated on TUI_MODE and on the TUI path being idle. Residual: a request whose pane
is created in the idle-check/kill-server window fails cleanly via the existing
honesty gates (documented).
ALIGNMENT: Class B (OCP-owned TUI spawn). cli.js does NOT perform either
operation -- there is no cli.js analogue for "how the TUI pane authenticates" or
"reaping tmux-server-owned zombies"; authorized by ADR 0007 (PR-C amendment) per
ALIGNMENT.md's Class B citation requirement. No Class A wire surface, no endpoint
shape, no alignment.yml token, and no models.json entry touched.
Tests: +6 in test-features.mjs (buildTuiCmd token set/unset/shq-injection;
reaper kill-server ours-only / foreign-present / no-server). 241 passed, 0 failed.
Co-Authored-By: Claude <claude-opus> <noreply@anthropic.com>
* fix(tui): isolate TUI auth to env-token-only home (no credentials.json shadowing)
Passing CLAUDE_CODE_OAUTH_TOKEN to the spawned interactive `claude` (commit
6394ca3) is necessary but INSUFFICIENT to fix the PI231 401: interactive `claude`
PREFERS ~/.claude/.credentials.json over the env var (unlike `-p`, where the env
token wins), so a stale/corrupt credentials.json SHADOWS the env token. Decisive
live evidence on PI231 (claude 2.1.104):
- env token passed + a broken ~/.claude/.credentials.json present → 401
("Please run /login · API Error: 401").
- env token passed + credentials.json moved aside → real answer.
Fix: when CLAUDE_CODE_OAUTH_TOKEN is set (and OCP_TUI_HOME is unset), run the TUI
`claude` in a CREDENTIAL-FREE scratch home (<HOME>/.ocp-tui/home) that has NO
credentials.json — no symlink, no copy. The env token is then the only credential
and is authoritative because nothing shadows it. This ALSO ends the original
refresh-corruption incident (25-zombie / empty-refresh-token) at the ROOT: with no
credentials file, claude never runs the token-refresh path, so the single-use
refresh token can never be rotated/corrupted by the per-request spawn+kill cycle.
This RESOLVES — not reintroduces — the ADR 0007 scratch-home concern. The old
caveat was about a SYMLINKED credentials.json being forked on token refresh; in
env-token mode there is no credentials file to fork and no refresh ever happens.
Mechanism: scratch HOME (not CLAUDE_CONFIG_DIR). The claude binary supports
CLAUDE_CONFIG_DIR, but it relocates transcripts to <CONFIG_DIR>/projects/ rather
than <HOME>/.claude/projects/, forking the transcript-resolution rule across modes
for no benefit. Scratch-HOME reuses the existing, tested prepareTuiHome/ehome
plumbing; readTuiTranscript reads from the same home claude runs under, so
transcripts land under the scratch home and findTranscriptPath globs them there.
Backward compatible: when CLAUDE_CODE_OAUTH_TOKEN is unset, behaviour is byte-for-
byte unchanged (real home + credentials.json) so hosts that intentionally rely on
credentials.json are unaffected. Explicit OCP_TUI_HOME still wins. Onboarding +
cwd-trust are seeded in the scratch .claude.json (hasCompletedOnboarding=true +
trust ONLY the scratch cwd) so no interactive trust/onboarding dialog can hang the
turn.
Changes:
- lib/tui/session.mjs: add resolveTuiHome() (pure) + DEFAULT_TUI_SCRATCH_HOME;
prepareTuiHome() gains { envTokenMode } — skips the credentials symlink and seeds
a minimal .claude.json; runTuiTurn derives envTokenMode = token set && ehome!==rhome.
- server.mjs: TUI_HOME computed via resolveTuiHome(); boot log surfaces the auth mode.
- test-features.mjs: env-token credential-free prepareTuiHome test (asserts NO
credentials.json created/symlinked, .claude.json seeded with onboarding + cwd
trust) + 3 resolveTuiHome decision tests; existing buildTuiCmd-token + reaper +
legacy/real-home tests stay green (245 passed, 0 failed).
- docs/adr/0007: PR-D amendment (corrects the PR-C rationale + the original
scratch-home caveat); README Troubleshooting #401 + env-var table + TUI section.
ALIGNMENT: Class B (OCP-owned TUI spawn). cli.js has no analogue for the TUI pane's
auth/home strategy — authorized by ADR 0007 (PR-D amendment) per ALIGNMENT.md's
Class B citation requirement. No Class A wire path, no alignment.yml blacklist
token, no models.json touched. server.mjs is touched only to wire TUI_HOME via
resolveTuiHome() and surface auth mode in the boot log.
Co-Authored-By: Claude <claude-opus> <noreply@anthropic.com>
---------
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus> <noreply@anthropic.com>
339 lines
26 KiB
Markdown
339 lines
26 KiB
Markdown
# ADR 0007 — TUI Interactive Mode (subscription-pool bridge)
|
||
|
||
**Date:** 2026-05-31
|
||
**Status:** Accepted — amended by PR-4 (entrypoint hardening), PR-B (observability + concurrency), PR-C (env-token auth + defunct-reaping), PR-D (credential-isolated home — corrects PR-C)
|
||
**Deciders:** project maintainer
|
||
**Authority:** claude CLI v2.1.158 interactive mode — verified live on the test host that sessions launched without `-p` / `--output-format` carry `cc_entrypoint=cli` (subscription pool), not `cc_entrypoint=sdk-cli` (Agent SDK credit pool). Mechanism verified on cli.js v2.1.104; live-confirmed on v2.1.158.
|
||
|
||
---
|
||
|
||
## Context
|
||
|
||
On 2026-05-14 Anthropic announced (effective 2026-06-15) a billing split that routes requests by `cc_entrypoint`:
|
||
|
||
| `cc_entrypoint` value | Billing pool |
|
||
|-----------------------|-------------|
|
||
| `cli` | Pro/Max subscription pool |
|
||
| `sdk-cli` | Agent SDK credit pool (~$20/mo on Pro = easily exhausted) |
|
||
|
||
OCP's existing path (`claude --output-format stream-json -p`) sets `cc_entrypoint=sdk-cli`. After 2026-06-15 every OCP request will draw from the Agent SDK pool rather than the subscription.
|
||
|
||
The structural response: add an opt-in mode that drives a real **interactive** `claude` session (no `-p`, no `--output-format`), which carries `cc_entrypoint=cli` and therefore bills against the subscription. The response text is read from claude's native JSONL transcript instead of from `stdout`.
|
||
|
||
This is a personal-use A-path feature (single-user, single-subscription host). It is **not** a multi-tenant isolation layer.
|
||
|
||
### Source-verified entrypoint mechanism (PR-4 amendment)
|
||
|
||
Claude CLI's `main()` calls a startup function (`t$A` in the compiled bundle) that sets
|
||
`process.env.CLAUDE_CODE_ENTRYPOINT` **only if unset** to:
|
||
|
||
```
|
||
(argv has -p/--print/--init-only/--sdk-url OR !process.stdout.isTTY) ? "sdk-cli" : "cli"
|
||
```
|
||
|
||
The billing header reads `cc_entrypoint = process.env.CLAUDE_CODE_ENTRYPOINT ?? "unknown"`.
|
||
The `"unknown"` branch is dead code for any real `main()` spawn — the startup function always
|
||
sets a value on unset env. The **real risk** is not `"unknown"`: it is a **lost TTY** (e.g. stdout
|
||
redirected or a non-PTY spawn) silently flipping the self-classification to `"sdk-cli"` and
|
||
drawing from the metered pool.
|
||
|
||
`cc_entrypoint` is one of ~6 upstream run-mode signals. The **dominant discriminator** is the
|
||
system-prompt identity block ("official CLI" vs "Claude Agent SDK"), which is driven by genuine
|
||
interactivity (no `-p`, no `--output-format`, real PTY) and is overridable by no env var. This
|
||
is the real reason the tmux/no-`-p` approach works: the spawn is genuinely interactive, not just
|
||
labelled as such.
|
||
|
||
---
|
||
|
||
## Decision
|
||
|
||
Add `CLAUDE_TUI_MODE=true` as an opt-in flag in `server.mjs`.
|
||
|
||
### How it works
|
||
|
||
1. Each request spawns a fresh tmux session running `claude --model <M> --session-id <UUID> --strict-mcp-config --disallowedTools 'mcp__*'` (no `-p`, no `--output-format`).
|
||
2. The spawn result is checked immediately: if `tmux new-session` returns a non-zero exit status (or a falsy result), the request is aborted with `tui_spawn_failed: tmux session not created` **before** the boot sleep. This is the spawn/PTY gate — OCP must not issue a billing request without a verified interactive session.
|
||
3. The serialized prompt (from `messagesToPrompt`) is pasted via `tmux send-keys … "$(cat file)"` + a separate `Enter` key event.
|
||
4. The answer is read from claude's native JSONL transcript at `<HOME>/.claude/projects/<encoded-cwd>/<session-id>.jsonl`, polling until a `turn_duration` system event or the wall-clock cap (`CLAUDE_TUI_WALLCLOCK_MS`, default 120 s).
|
||
5. The string answer is returned to OCP's existing downstream (singleflight → cache write-back → `completionResponse` / `streamStringAsSSE`) — **same contract as `callClaude`**.
|
||
6. Streaming requests are buffered then replayed as chunked SSE (no real token streaming — deliberate; "don't build fragile features").
|
||
|
||
### Billing-classifier labeling (`OCP_TUI_ENTRYPOINT`, PR-4)
|
||
|
||
`CLAUDE_CODE_ENTRYPOINT` on the spawn env is managed by `resolveTuiEntrypointEnv(env, mode)`
|
||
(exported from `lib/tui/session.mjs`, pure, testable). The function **always deletes any
|
||
inherited value first** so a stray env var from OCP's own parent process can never leak in and
|
||
mislabel the billing header. Then:
|
||
|
||
| `OCP_TUI_ENTRYPOINT` | Behaviour |
|
||
|----------------------|-----------|
|
||
| `cli` (default) | Sets `CLAUDE_CODE_ENTRYPOINT=cli` deterministically — subscription-pool classification. **Honest only because the spawn is a genuine interactive PTY** (tmux pane, no `-p`, stdout not redirected, `new-session` verified). |
|
||
| `auto` | Deletes the key → claude self-classifies via `t$A` (TTY → `cli`). Use to observe/diagnose the real TTY-derived value. |
|
||
| `off` | Leaves the env exactly as inherited — diagnostics / honesty audit only. |
|
||
|
||
**Governing rule (verbatim):** *OCP may make a true value deterministic; it may never assert a
|
||
value the spawn's real state contradicts. When it cannot make the claim true (e.g. cannot
|
||
guarantee a PTY), it fails/drops the request — it does not force the signal.*
|
||
|
||
This is why the spawn/PTY gate (step 2 above) is load-bearing for `mode="cli"`: if `new-session`
|
||
fails, there is no PTY, so asserting `cli` would be dishonest. Abort rather than lie.
|
||
|
||
OCP never suppresses the billing header (anti-fingerprinting: we do not mask the spawn).
|
||
|
||
### 2026-06-15 verification protocol
|
||
|
||
Run one quiesced canary request in TUI-mode and watch the **Agent SDK credit balance** (not the
|
||
request header). If the balance drops, the subscription pool is unreachable via spawn. Per the
|
||
constitution (`ALIGNMENT.md`), the response is to **drop the Anthropic provider** rather than
|
||
escalate spoofing.
|
||
|
||
Version caveat: mechanism verified on cli.js v2.1.104 + live on v2.1.158. Re-verify after any
|
||
major cli.js upgrade.
|
||
|
||
### Default behaviour is unchanged
|
||
|
||
When `CLAUDE_TUI_MODE` is unset (the default), no code path touches `callClaudeTui` or `runTuiTurn`. `upstreamCall === callClaude` and streaming uses `callClaudeStreaming` — byte-for-byte identical to the pre-TUI code path.
|
||
|
||
### Kill-switch
|
||
|
||
Unset `CLAUDE_TUI_MODE` (or set it to any value other than `"true"`) → stream-json path restored immediately on next restart.
|
||
|
||
### Home strategy
|
||
|
||
> **Superseded by the PR-D amendment below for the env-token case.** As of PR-D, `TUI_HOME`
|
||
> is computed by `resolveTuiHome()`: when `CLAUDE_CODE_OAUTH_TOKEN` is set (and `OCP_TUI_HOME`
|
||
> is unset) the default is a **credential-free scratch home**, not the real home. The
|
||
> descriptions below remain accurate for the **no-env-token** case and the **explicit
|
||
> `OCP_TUI_HOME` override** case.
|
||
|
||
- **Real-home (default when NO env token, `OCP_TUI_HOME` unset):** claude runs with the operator's own `~/.claude/` — shared credentials, existing onboarding, no OAuth fork risk. `ensureTuiCwdTrusted` seeds the trust record for the scratch cwd in the real `~/.claude.json` (atomic write).
|
||
- **Scratch-home opt-in (`OCP_TUI_HOME=<path>`, no env token):** a dedicated `HOME` that symlinks `~/.claude/.credentials.json` from the real home (token is never copied) and seeds a stripped `~/.claude.json` (no project history, trusts only the scratch cwd). **Caveat:** claude rewrites `.credentials.json` on OAuth token refresh, replacing the symlink with a regular file — this forks the credentials. Use this legacy symlink mode only with a dedicated OAuth or for ephemeral testing. (The PR-D env-token mode avoids this caveat entirely — no credentials file to fork.)
|
||
|
||
### Working directory
|
||
|
||
`TUI_CWD = OCP_TUI_CWD || $HOME/.ocp-tui/work` (dedicated scratch cwd). Transcripts land under `<HOME>/.claude/projects/<encoded-cwd>/` — a stable, single location separate from the operator's real project histories. The directory is created automatically on first request.
|
||
|
||
### MCP hard-disable
|
||
|
||
`--strict-mcp-config` (no `--mcp-config` argument) prevents account-attached managed MCP servers from connecting. Belt-and-braces: `--disallowedTools 'mcp__*'` blocks any MCP tool invocation even if a server were somehow loaded. Built-in tools (Bash, Read, etc.) are left enabled on the A-path (single-user, acceptable).
|
||
|
||
### Session namespace
|
||
|
||
All tmux sessions use the prefix `ocp-tui-`. The prefix-scoped reaper (`reapStaleTuiSessions`) kills only `ocp-tui-*` sessions, never `olp-tui-*` or any other prefix. A stale-session cleanup runs once at OCP boot when `TUI_MODE` is on.
|
||
|
||
---
|
||
|
||
## SECURITY — PROMINENT WARNING
|
||
|
||
**TUI-mode is SINGLE-USER / SINGLE-OPERATOR ONLY.**
|
||
|
||
`claude` runs as the OCP process owner with full filesystem access regardless of `HOME` setting. Home selection is **not** user isolation. If OCP is serving multiple users or guest API keys:
|
||
|
||
- A guest prompt would run `claude` with the **operator's** filesystem access.
|
||
- An adversarial prompt could exfiltrate files, run shell commands, or exhaust the subscription.
|
||
|
||
**Never enable `CLAUDE_TUI_MODE=true` on an OCP instance that serves untrusted callers or multiple users.**
|
||
|
||
The B-path (multi-tenant isolation) requires:
|
||
1. `--tools ""` (no built-in tools)
|
||
2. Per-key ephemeral `HOME` (isolated credentials + no cross-key project pollution)
|
||
3. Sandbox runtime (e.g. `@anthropic-ai/sandbox-runtime`)
|
||
|
||
B-path is **deferred** and is not implemented in this ADR. Until B-path lands, TUI-mode must only be enabled on a personal single-user OCP.
|
||
|
||
---
|
||
|
||
## Observability and concurrency (PR-B amendment)
|
||
|
||
**Date:** 2026-06-10
|
||
**Status:** Accepted — amends ADR 0007.
|
||
**Motivation:** the post-PR-A code audit, findings C-4 (P1) and C-5 (P1).
|
||
|
||
### C-4 — independent concurrency bound for the TUI path
|
||
|
||
The global `MAX_CONCURRENT` gate lives in `spawnClaudeProcess()` (the `-p` / stream-json
|
||
path). `callClaudeTui()` never calls `spawnClaudeProcess` — it calls `runTuiTurn()`, which
|
||
cold-boots a full interactive `claude` inside a fresh tmux session. So the TUI path had **no**
|
||
concurrency bound: N concurrent TUI requests spawned N simultaneous cold-boot tmux+claude
|
||
processes. On a small host (e.g. a Pi 4 serving a family) a burst of ~5 is an OOM risk and
|
||
also multiplies subscription rate-limit pressure.
|
||
|
||
PR-B adds an **independent** limiter for the TUI path (`lib/tui/semaphore.mjs`,
|
||
`TuiSemaphore`):
|
||
|
||
- **`OCP_TUI_MAX_CONCURRENT`, default `2`.** Rationale: a TUI turn is heavy — a per-request
|
||
cold-boot of tmux+claude plus up to `CLAUDE_TUI_WALLCLOCK_MS` (120 s) of wallclock — so a
|
||
small host cannot run many at once. `2` is the conservative default that keeps a Pi-class
|
||
host alive under a family burst while still allowing some overlap. It is deliberately **not**
|
||
the same knob as `MAX_CONCURRENT` (default 8): the two pools have different shapes (a
|
||
stream-json spawn is cheap and fast; a TUI turn is a heavy cold-boot + long wallclock), so
|
||
coupling them would mis-size one of the two paths.
|
||
- **Queue, don't reject.** The limiter **queues** (awaits a slot), mirroring the spirit of
|
||
`MAX_CONCURRENT` — requests are not dropped on contention. To bound memory against a runaway
|
||
client, the wait queue itself is capped (`maxQueue`, default 32× the limit); when the queue
|
||
is full `run()` rejects with `tui_queue_full`, surfaced as a 503 — deterministic backpressure
|
||
rather than silent OOM.
|
||
- **Slot released in a `finally`.** `TuiSemaphore.run(fn)` releases the slot in a `finally`, so
|
||
any throw — PR-A's honesty gates (`tui_wallclock_truncated`, `tui_upstream_error`), a
|
||
`tui_paste_not_landed`, or a `tui_spawn_failed` — can never leak a slot.
|
||
|
||
This limiter has **zero effect when `TUI_MODE` is off**: `callClaudeTui` is never reached, so
|
||
the semaphore is never entered. The default stream-json path is untouched.
|
||
|
||
### C-5 — operator-visible drift surface on `/health` (additive)
|
||
|
||
The `tui_entrypoint_mismatch` warning only reached journald. After the 2026-06-15 flip, a
|
||
silent `sdk-cli` drift (the documented top risk in this ADR — a lost TTY flipping the
|
||
self-classification to the metered Agent SDK pool) would drain metered credits **invisibly**.
|
||
PR-B adds a `tui` block to the `/health` JSON response so an operator can poll it:
|
||
|
||
```
|
||
tui: {
|
||
enabled: <TUI_MODE>,
|
||
entrypointMode: <OCP_TUI_ENTRYPOINT>, // cli | auto | off
|
||
lastEntrypoint: <last observed cc_entrypoint, e.g. "cli", or null>,
|
||
entrypointMismatches: <count of cli-expected-but-got-other turns>,
|
||
inflight: <current concurrent TUI turns>,
|
||
queued: <turns waiting for a slot>,
|
||
maxConcurrent: <OCP_TUI_MAX_CONCURRENT>
|
||
}
|
||
```
|
||
|
||
`lastEntrypoint` is recorded and `entrypointMismatches` incremented inside `callClaudeTui` in
|
||
the same mismatch branch that already emits the journald warning (via `recordTuiEntrypoint`).
|
||
`inflight` / `queued` / `maxConcurrent` come from the C-4 semaphore. When `TUI_MODE` is off the
|
||
block still appears with `enabled:false` (cheap, harmless) so the response shape is stable for
|
||
consumers regardless of mode.
|
||
|
||
### ALIGNMENT authorization for the `/health` change
|
||
|
||
`/health` is a **grandfathered B.2 endpoint** under ADR 0006, frozen at its v3.16.4 behaviour.
|
||
`ALIGNMENT.md`'s grandfather provision states: *"Any change to the contract (request shape,
|
||
response shape, semantics) of a grandfathered B.2 endpoint is treated as a new authorization
|
||
request and requires either a behaviour-preserving refactor PR or its own ADR."*
|
||
|
||
This amendment **is** that authorization. The argument:
|
||
|
||
- The change is **additive**: it adds one new top-level field (`tui`) containing only new
|
||
sub-fields. **No existing `/health` field is changed, renamed, removed, or re-typed**, and no
|
||
existing semantics change. Existing `/health` consumers (the dashboard, `ocp-connect`,
|
||
monitoring) read the fields they already read and are unaffected — the change is
|
||
**behaviour-preserving** for them, which is exactly the bar the grandfather provision sets for
|
||
a non-ADR contract change.
|
||
- The TUI observability surface is an **intrinsic part of the TUI feature** whose authorizing
|
||
authority is **this ADR (0007)**, not a brand-new B.2 endpoint. We are not adding a new B.2
|
||
endpoint or a new method (which would each require their own fresh ADR under the New Class B
|
||
endpoint procedure) — we are extending the response of an existing grandfathered endpoint with
|
||
fields that report state owned by an ADR-0007 feature. ADR 0007 is the natural home for that
|
||
authority, and this amendment records it explicitly.
|
||
- `cli.js` does not perform this operation — `/health` is OCP-owned (Class B), so no `cli.js`
|
||
citation applies; the citation is this ADR + ADR 0006 (grandfathered B.2) per
|
||
`ALIGNMENT.md`'s Class B citation requirement.
|
||
|
||
### `OCP_TUI_MAX_CONCURRENT` summary
|
||
|
||
| Env var | Default | Meaning |
|
||
|---|---|---|
|
||
| `OCP_TUI_MAX_CONCURRENT` | `2` | Max concurrent interactive TUI turns. Independent of `CLAUDE_MAX_CONCURRENT` (the stream-json path). Excess turns queue (bounded); a full queue yields a 503. |
|
||
|
||
---
|
||
|
||
## Authentication + defunct-reaping (PR-C amendment)
|
||
|
||
**Date:** 2026-06-13
|
||
**Status:** Accepted — amends ADR 0007.
|
||
**Motivation:** the PI231 production incident — TUI-mode returned `Please run /login · API Error: 401` for days; re-login never stuck.
|
||
|
||
### How the TUI `claude` authenticates
|
||
|
||
The spawned interactive `claude` obtains its OAuth bearer in one of two ways, in this order of preference:
|
||
|
||
1. **`CLAUDE_CODE_OAUTH_TOKEN` in env (PREFERRED).** If the env var is set on the OCP process, `buildTuiCmd` adds `CLAUDE_CODE_OAUTH_TOKEN=<shq-escaped token>` to the pane command's `env` prefix. claude then authenticates via this long-lived token and **never touches the credentials-refresh path**. This is the stable mode — it is exactly how the oracle and Mac-mini hosts already run (and how `server.mjs`'s own `getOAuthCredentials()` takes the same env at highest precedence). cli.js is **not** the authority here: this is a Class B, OCP-owned TUI spawn — see the Class B citation below.
|
||
2. **`<HOME>/.claude/.credentials.json` (FALLBACK).** When the env var is unset, claude falls back to the credentials file and its short-lived access token, renewing via the single-use refresh token.
|
||
|
||
The token MUST be set explicitly in `buildTuiCmd` because **tmux does not forward the parent process's environment to the pane** (verified live 2026-06-01 — the same reason the whole env is delivered as an `env` prefix). A token sitting in the OCP process env is invisible to the pane unless `buildTuiCmd` re-emits it.
|
||
|
||
### Why the fallback path corrupts (the PI231 incident)
|
||
|
||
When the env token is absent, every per-request spawn drives claude through the credentials.json refresh path. OAuth refresh tokens are **single-use / rotating**: a refresh consumes the old refresh token and writes a new one. The per-request `kill-session` teardown can race / interrupt claude mid-rotation, and over many spawn+kill cycles the refresh token ended up an **empty string** — at which point renewal is impossible and the host returns a permanent 401. Re-login writes a fresh token, but the next spawn re-corrupts it. **Proof the env-token fix works:** on the broken PI231 host, `CLAUDE_CODE_OAUTH_TOKEN=<oat01 token> claude -p ...` returned a real answer *despite* the corrupt credentials.json (control without the env token = 401).
|
||
|
||
**Operator guidance:** set `CLAUDE_CODE_OAUTH_TOKEN` on any TUI-mode host. The credentials.json fallback is retained only for hosts that intentionally rely on it; it is not recommended for a long-running TUI deployment.
|
||
|
||
**Security note:** with the token in the pane command, it is visible in `ps`. This is acceptable for the **single-user A-path** (it mirrors the existing plaintext-token practice for `server.mjs`), and the **multi-user B-path is already refused at boot** (`CLAUDE_TUI_MODE=true` + `AUTH_MODE=multi` is a hard FATAL), so a guest can never reach this spawn.
|
||
|
||
### Defunct `<claude>` reaping
|
||
|
||
The connected leak: the pane's `claude` process is a child of the long-lived **tmux server** daemon, not of the OCP node process (`tmux new-session -d` returns the instant the server forks the pane). Node can therefore never `waitpid()`/reap it — a SIGKILL still needs the *parent* (the tmux server) to reap. `kill-session` destroys the session but leaves the pane's `claude` (and its grandchildren) as `<defunct>` zombies that only the server reaps; over 30 days on PI231 this accumulated to **25 defunct `<claude>`** (a live `tmux kill-server` dropped it 25→3).
|
||
|
||
The node-reachable action that *actually reaps* — rather than merely re-signalling — is to stop the tmux server: on server exit the kernel reparents survivors to init (PID 1), which reaps them. `reapStaleTuiSessions` therefore, after killing our own `ocp-tui-*` sessions, issues `kill-server` **only when no foreign session of any prefix remains** (coexistence: never disrupt a co-hosted `olp-tui-*` instance). This runs at boot (existing) and now on a 15-min periodic interval gated on TUI-mode and on the TUI path being idle (`inflight === 0 && queued === 0`) so a live turn's pane is never torn down. Residual: a request whose pane is created in the narrow window between the idle-check and `kill-server` would fail cleanly via the existing honesty gates (rare; documented in the server comment).
|
||
|
||
### ALIGNMENT authorization (Class B)
|
||
|
||
Both changes are **Class B** (OCP-owned TUI spawn). `cli.js` does not perform either operation — there is no `cli.js` analogue for "how the TUI pane authenticates" or "reaping tmux-server-owned zombies"; this surface is authorized by **this ADR (0007)** per `ALIGNMENT.md`'s Class B citation requirement. No Class A wire surface, no endpoint shape, no `alignment.yml` blacklist token, and no `models.json` entry is touched.
|
||
|
||
---
|
||
|
||
## Credential-isolated home for env-token auth (PR-D amendment)
|
||
|
||
**Date:** 2026-06-13
|
||
**Status:** Accepted — amends ADR 0007. **Corrects** the PR-C rationale and the original "Home strategy" section's scratch-home caveat.
|
||
**Motivation:** PR-C's env-token passing alone did **not** fix the PI231 401. Decisive live evidence (claude 2.1.104, PI231):
|
||
|
||
| Condition | Result |
|
||
|---|---|
|
||
| env token passed + a broken `~/.claude/.credentials.json` present | **401** (`Please run /login · API Error: 401`) |
|
||
| env token passed + `credentials.json` moved aside | **works** (real answer) |
|
||
|
||
### Corrected root cause
|
||
|
||
**Interactive `claude` PREFERS `~/.claude/.credentials.json` over the `CLAUDE_CODE_OAUTH_TOKEN` env var.** A stale/corrupt `credentials.json` therefore **shadows** the env token. (This is *unlike* `-p` mode, where the env token wins — which is why `server.mjs`'s own `getOAuthCredentials()` is unaffected and why PR-C's premise looked sufficient.) So passing the token (PR-C, `buildTuiCmd`) is **necessary but insufficient**: the TUI `claude` must additionally run in a HOME that has **no `credentials.json`**, so the env token is the only credential and is authoritative.
|
||
|
||
This also fixes the original incident at the **root**, more completely than PR-C claimed: with no `credentials.json` in the home, claude never runs the token-refresh path at all, so the single-use refresh token can never be rotated — and therefore never corrupted — by the spawn+`kill-session` cycle. The 25-zombie / empty-refresh-token failure mode becomes structurally impossible, not merely avoided.
|
||
|
||
### Decision
|
||
|
||
When `CLAUDE_CODE_OAUTH_TOKEN` is set, the TUI `claude` runs in a **credential-free scratch home** by default:
|
||
|
||
- `resolveTuiHome({ realHome, configuredHome, envTokenSet })` (exported from `lib/tui/session.mjs`, pure) decides the home:
|
||
- **`OCP_TUI_HOME` set** → that path (explicit override, back-compat — an operator who configured it keeps exactly that home).
|
||
- **else env token set** → `<realHome>/.ocp-tui/home` — a dedicated scratch home seeded with a minimal `.claude.json` (`hasCompletedOnboarding=true` + trust **only** the scratch cwd) and its own `projects/` dir, and **deliberately NO `.credentials.json`** (no symlink, no copy).
|
||
- **else (no env token)** → the operator's real home — **byte-for-byte the pre-fix behaviour** for hosts that intentionally rely on `credentials.json`.
|
||
- `prepareTuiHome(realHome, tuiHome, cwd, { envTokenMode })` gates the credential handling: in `envTokenMode` it creates the scratch `projects/` dir and seeds the minimal trusted `.claude.json` but **never** creates the credentials symlink. `runTuiTurn` sets `envTokenMode = !!CLAUDE_CODE_OAUTH_TOKEN && ehome !== realHome`.
|
||
- `readTuiTranscript` reads from the **same** home claude runs under (`ehome`), so transcripts land under `<scratch home>/.claude/projects/` and `findTranscriptPath` globs them there — the home is threaded through consistently. (We chose scratch-`HOME` over `CLAUDE_CONFIG_DIR`: the binary supports `CLAUDE_CONFIG_DIR`, but it relocates the transcript root to `<CONFIG_DIR>/projects/` rather than `<HOME>/.claude/projects/`, which would fork the transcript-resolution rule across modes for no benefit. The scratch-HOME lever reuses the existing, tested `prepareTuiHome`/`ehome` plumbing.)
|
||
|
||
### This RESOLVES — not reintroduces — the scratch-home caveat
|
||
|
||
The original "Home strategy" section and PR-C's `prepareTuiHome` comment warned that scratch-home is unsafe because *claude rewrites a **symlinked** `.credentials.json` on token refresh → forks/corrupts the OAuth credentials*. **That caveat does not apply to env-token mode**: there is no `credentials.json` in the home to fork, and claude never refreshes (it uses the long-lived env token), so there is no rotation and no corruption. The fork risk was inherent to the *symlink* approach; removing the credentials file entirely removes the risk. The legacy symlink mode is retained **only** for an operator who explicitly sets `OCP_TUI_HOME` without an env token, and its caveat is preserved for exactly that path.
|
||
|
||
### ALIGNMENT authorization (Class B)
|
||
|
||
**Class B** (OCP-owned TUI spawn). `cli.js` has no analogue for the TUI pane's auth/home strategy; authorized by **this ADR (0007)** per `ALIGNMENT.md`'s Class B citation requirement. `server.mjs` is touched only to compute `TUI_HOME` via `resolveTuiHome()` (TUI wiring) and to surface the auth mode in the boot log — no Class A wire surface, no endpoint shape, no `alignment.yml` blacklist token, and no `models.json` entry is touched.
|
||
|
||
---
|
||
|
||
## Consequences
|
||
|
||
### Positive
|
||
|
||
- After 2026-06-15, requests in TUI-mode bill against the Pro/Max subscription pool (`cc_entrypoint=cli`) rather than the Agent SDK credit pool.
|
||
- Kill-switch is immediate (unset env var + restart); zero code change required.
|
||
- Default stream-json path is untouched — no regression risk for existing deployments.
|
||
|
||
### Negative / trade-offs
|
||
|
||
- **No token streaming:** responses are buffered then replayed as chunked SSE. Clients see a delay then the full response arrives; real-time token streaming is not available in TUI-mode.
|
||
- **Billing unmeasurable until 2026-06-15:** the `cc_entrypoint=cli` signal is verified, but the credit deduction from the correct pool cannot be confirmed until the billing split activates.
|
||
- **tmux dependency:** the host must have `tmux` installed. CI / Docker images that lack tmux cannot use TUI-mode (the default stream-json path is unaffected).
|
||
- **Wall-clock cap:** long Opus thinking turns may hit the 120 s cap. Increase `CLAUDE_TUI_WALLCLOCK_MS` if needed (no quiescence heuristic — the reader polls until terminal marker or cap).
|
||
- **Grey-area usage:** running an interactive `claude` session headlessly to serve HTTP requests is not an officially documented use case. If Anthropic policy changes to block this pattern, OCP must fall back to the stream-json path (unset `CLAUDE_TUI_MODE`).
|
||
|
||
### Coexistence
|
||
|
||
- tmux prefix `ocp-tui-` is registered. Any co-hosted OLP test instance must use `olp-tui-`. Never run two TUI proxies on the same OAuth concurrently — stop one instance during integration testing.
|
||
|
||
---
|
||
|
||
## Provenance
|
||
|
||
TUI-mode originated in a prototype contributed via PR #101 (see the PR for author attribution). The productionization design is in `docs/superpowers/specs/2026-05-30-tui-mode-production-design.md`. Spikes S1–S6 / T1–T6 were validated live on the test host against `claude v2.1.158`.
|