Skip to content

Fix false 'login expired' crash loop from stale auth-error text in the TUI pane - #39

Open
aroc wants to merge 9 commits into
mainfrom
fix/stale-auth-pane-health-check
Open

Fix false 'login expired' crash loop from stale auth-error text in the TUI pane#39
aroc wants to merge 9 commits into
mainfrom
fix/stale-auth-pane-health-check

Conversation

@aroc

@aroc aroc commented Jul 21, 2026

Copy link
Copy Markdown

Problem

The Claude TUI health check greps the last 20 rendered pane lines for auth-error strings (API Error: 401, authentication_error, OAuth token has expired, Please run /login). That text is transcript history, not current state: after an auth incident resolves (token refreshed), the old error output stays on screen indefinitely.

This caused a real production outage: GetHealth kept reporting a non-recoverable "login expired" from stale pane text, runtime.Validate treats that as fatal, and the daemon crash-looped for hours — while the on-disk OAuth token was valid the whole time and the operator's /login had long since succeeded. The gateway also kept replying "login expired; run /login" to users for the same reason.

Fix

When auth-error text matches, the pane is no longer trusted on its own:

  1. Cross-check the on-disk credentials ($CLAUDE_CONFIG_DIR/.credentials.json, default ~/.claude/.credentials.json, claudeAiOauth.expiresAt). Missing, unreadable, or expired credentials (macOS Keychain setups, API-key auth) keep the exact pre-PR behavior — the check can only ever tighten the conditions for declaring login expired, never loosen them elsewhere.
  2. If the token is unexpired, settle it with a definitive headless probe — a minimal claude --dangerously-skip-permissions -p request (same invocation conventions as internal/subagent). A token can be revoked server-side while still looking valid on disk, so the expiry timestamp alone can't be trusted either:
    • Probe succeeds → the pane text is provably stale. Report healthy; normal traffic scrolls the text away. No restart churn.
    • Probe hits an auth error → login really is required. Original non-recoverable escalation (daemon validation failure, gateway manual-intervention message) is preserved.
    • Probe inconclusive (timeout, network, binary missing) → recoverable failure, retried later. Notably this means a timed-out probe inside the daemon's 45s validation window cannot fatal the startup.

The probe is hang-proof by construction: it runs claude in its own process group, kills the whole group on cancellation (claude's children inherit the output pipes — killing only the direct process would leave CombinedOutput blocked on pipe EOF indefinitely), and caps post-kill pipe drain with cmd.WaitDelay. Probe execution is serialized on its own mutex; the verdict cache has a separate mutex that is never held across a probe, so cache readers stay wait-free even mid-probe (pinned by a liveness test).

Probe verdicts are cached (15 min positive, 1 min negative, inconclusive never) and invalidated on RestartSession/ResetConversation. GetSessionState runs in 2-second polling loops, so it never probes — it only reads the cached verdict, and suppresses BlockedAuth solely when the token is unexpired and a recent probe passed. Two mechanisms keep that safe for long dispatches without ever sliding expiry on reads (a verdict is at most 15 minutes old, keeping revoked-token exposure bounded): GetHealth renews a positive verdict when under 8 minutes of life remain, and WaitForAwaitingInput re-verifies an apparent auth block (confirmBlockedAuth) before surfacing it — so even a dispatch that chains multiple retry windows past the whole TTL re-arms the cache instead of emitting a false "login expired".

Known trade-off: the positive cache means a token revoked within 15 minutes of a passing probe, while auth text is still on-screen, could swallow messages until the cache expires or the session restarts.

Tests

internal/claudetui previously had one test (context-output parsing). This PR adds coverage for:

  • credentials-file parsing (missing/malformed/no-oauth/zero/unexpired/expired) and CLAUDE_CONFIG_DIR resolution
  • the full health classification matrix, including the exact pane text from the production incident
  • probe verdict caching, invalidation, and the never-cache-inconclusive rule
  • wiring tests via healthFromSnapshot/classifySessionState seams, driving the real credentials lookup through CLAUDE_CONFIG_DIR. These were added after adversarial review demonstrated by mutation that hardcoding the credentials state at the GetHealth call site — an exact revert of the incident fix — passed the entire suite. All seven review-surfaced mutations (hardcoded credentialsUnknown; inverted BlockedAuth gate; deleted cache-invalidation calls; GetHealth bypassing the shared cache; shrunken positive TTL; dropped negative-verdict caching; removed renewal) now fail the tests.
  • cache-reader liveness while a probe is in flight, and TTL/renewal timing rules via a fake-clock seam

gofmt clean, go build ./..., go vet ./..., go test -race ./internal/claudetui/, go test ./... all pass.

Scope / follow-ups

  • claudetui only. internal/claude (headless) greps fresh stderr from the last run — a different mechanism without the staleness bug. codextui has its own auth detection against codex's auth store; giving it the same treatment is a possible follow-up.
  • A second wedge class from the same outage — new interactive claude dialogs (e.g. the 2.1.216 "resume large session" prompt) blocking the unattended TUI — is not addressed here.

Review process

Reviewed pre-submission by four rounds of a multi-agent panel (4 review lenses × 3 adversarial judges per finding). Round 1 confirmed the revoked-token dispatch regression and two mutation-proven test gaps; round 2 confirmed the probe-hang/mutex-wedge deadlock and the TTL/polling-window collision; round 3 confirmed the TTL fix was incomplete (no renewal on cache hits). Each round's findings were fixed in a dedicated commit before the next round ran against the full updated diff; the final round ran against the complete branch.

aroc added 7 commits July 20, 2026 22:15
The TUI health check greps the last 20 rendered pane lines for auth-error
strings. That text is transcript history, not current state: after an auth
incident resolves (token refreshed), the old 401/"Please run /login" output
stays on screen indefinitely, so GetHealth kept reporting a non-recoverable
"login expired" — crash-looping the daemon via runtime validation even
though credentials were valid the whole time.

Now, when auth-error text matches, GetHealth and GetSessionState consult
the on-disk OAuth token ($CLAUDE_CONFIG_DIR/.credentials.json expiresAt).
An unexpired token refutes the pane text: the failure is downgraded to
recoverable so callers restart the session — clearing the stale pane while
--resume preserves the conversation — instead of demanding a manual /login.
Missing, unreadable, or expired credentials (macOS Keychain, API-key auth)
leave behavior exactly as before.
Review found two gaps in the credentials-file cross-check: a token can be
revoked server-side while still unexpired on disk, in which case (a) the
recoverable downgrade caused indefinite restart churn — a freshly restarted
pane is clean until traffic hits it, so ensureHealthySession always declared
recovery after one restart and the admin escalation never fired — and (b)
GetSessionState fell through to dispatch, losing user messages silently
since the retry detector only matches 5xx errors.

Auth-error text plus an unexpired token now triggers a definitive check: a
minimal headless claude -p request (same invocation conventions as
internal/subagent). Probe success proves the pane text is stale — report
healthy and let normal traffic scroll it away; no restart needed. Probe
auth failure restores the original non-recoverable escalation. Verdicts
are cached (5 min positive, 1 min negative, inconclusive never) and
invalidated on session restart; GetSessionState only ever reads the cache,
since it runs in 2-second polling loops.
Review demonstrated by mutation that the credentials and probe wiring was
untested: hardcoding credentialsUnknown at GetHealth's call site — an exact
revert of the incident fix — passed the entire suite, as did inverting the
BlockedAuth gate in GetSessionState. Extract healthFromSnapshot and
classifySessionState so both classifications are testable without a live
tmux server, driving the real on-disk credentials lookup via
CLAUDE_CONFIG_DIR. Both mutations now fail the new tests.
Round-2 review found the probe could wedge the whole daemon: claude spawns
children that inherit the output pipes, so after the 25s context kill of
the direct process, CombinedOutput blocked indefinitely on pipe EOF — and
probeMu was held across the probe, so GetHealth, GetSessionState polling,
RestartSession, and ResetConversation all queued behind the hang with no
escape. The probe now runs in its own process group, kills the group on
cancellation, and caps pipe drain with WaitDelay; probe execution is
serialized on a dedicated probeRunMu while the verdict cache keeps its own
mutex that is never held across a probe, keeping cachedAuthState and
invalidateAuthProbe wait-free (covered by a liveness test).

Also raise the positive verdict TTL to 15 minutes: at 5 it exactly matched
the gateway's post-send polling window, so a verdict could expire mid-wait
of a long-running task with stale auth text still on screen, flipping
classifySessionState to a false BlockedAuth mid-dispatch.

New mutation-verified tests pin the two remaining wiring contracts: both
session-recycle paths must invalidate the cache, and GetHealth's probe must
land in the same cache GetSessionState reads.
Round-3 review showed the TTL raise alone did not establish the
no-expiry-mid-dispatch invariant: verdict lifetime runs from probe time and
cache hits never extend it, so a dispatch admitted at minute 11 of a 15-min
verdict could still watch it lapse inside the gateway's 5-min post-send
polling window — reproducing the false BlockedAuth this branch exists to
prevent. GetHealth's probing path now treats a positive verdict with under
8 minutes of life as a miss and re-probes, so any admitted dispatch holds a
verdict that outlives the polling window plus dispatch overheads. Expiry is
never slid on reads — a verdict stays at most 15 minutes old, keeping the
revoked-token window bounded. Negative verdicts renew naturally via their
1-minute TTL.

A fake-clock seam (nowFn) pins all three timing rules; mutations shrinking
the positive TTL, dropping negative-verdict caching, or removing the
renewal all fail the new test.
Round-4 review showed the renewal window only covers one gateway polling
window, but sendWithRetry chains up to three 5-minute windows after a
single GetHealth admission — and the worst case exceeds the whole verdict
TTL, so no renewal constant can guarantee a verdict outlives a retried
dispatch. Close it at the consumption point instead: when
WaitForAwaitingInput sees BlockedAuth, it re-checks via confirmBlockedAuth
— unexpired on-disk token plus a passing probe (cached or fresh) refutes
the block and re-arms the cache, so the next poll classifies normally; a
failed or inconclusive verification lets the block stand. This bounds the
false-escalation window regardless of dispatch length while keeping
classifySessionState itself probe-free.
Round-5 review found the safety branch untested by mutation: weakening the
comparison so an inconclusive probe refutes the block survived the suite —
under which a genuine outage whose re-verification probe times out would be
swallowed instead of surfacing login-expired. New subtests cover the
inconclusive-probe and unknown-credentials cases; the mutation now fails.
@alan-botts

Copy link
Copy Markdown
Contributor

Thank you—this passed my focused review and is included in the reviewed 0.2.0 integration release: #40. I could not execute the final org merge because the available GitHub/deploy-key identity has no Endgame-Labs merge permission; #40 is ready for an authorized maintainer to merge.

dorkitude added a commit to alan-botts/goated that referenced this pull request Aug 23, 2026
dorkitude added a commit to alan-botts/goated that referenced this pull request Aug 23, 2026
@dorkitude

Copy link
Copy Markdown
Contributor

Follow-ups pushed in 51770a2 and c8674a1: the live auth probe is now authoritative across OAuth refresh, Keychain, and API-key auth; local credential-file state is diagnostic only; and only a confirmed auth-probe failure may surface BlockedAuth. Passing probes refute stale pane text, while inconclusive probes resolve to an ambiguous non-auth-blocking state. Full tests, claudetui race tests, vet, and build pass.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants