Detect Claude auth expiry before it silently stops every agent - #42
Merged
Conversation
Agent servers authenticate Claude Code per-Unix-user via `claude /login`
(claude.ai OAuth, Max subscription). That login expires roughly every 30
days. When it lapses every agent task fails or stalls, and the only
symptom is that no work happens. There was no detection at all — the
operator found out by noticing the board had gone quiet.
Why the obvious check does not work
-----------------------------------
`claude auth status` cannot be trusted. It reads cached local config and
never contacts Anthropic, so it happily reports success on credentials
that have been dead for months. On a 95-day-dead login it still returned,
with exit 0:
{"loggedIn": true, "authMethod": "claude.ai",
"apiProvider": "firstParty", "subscriptionType": "max"}
`claude doctor`, `claude mcp list` and `claude auth status --text` are
equally offline and equally wrong. Any check built on them reports a
healthy server right up until someone asks why nothing has run in a week.
What is actually reliable
-------------------------
Only a real model request proves the login works:
claude -p "hi" --model haiku --max-turns 1 </dev/null >/dev/null 2>&1
Exit 0 = healthy (~3-6s). Exit 1 on expired auth, with "Failed to
authenticate: OAuth session expired and could not be refreshed". It costs
about 40 tokens, so it is gated behind a free offline read of
~/.claude/.credentials.json -> claudeAiOauth.refreshTokenExpiresAt. The
access token lasts ~8h and self-refreshes; the refresh token is the real
~30-day sliding clock.
Changes
-------
* templates/claude-auth-monitor.tmpl — new monitor. Free credentials
check, then the gated probe. Healthy: removes the .auth-failed flag and
warns via `auth_expiring` when <=5 days remain. Unhealthy: touches
.auth-failed and emits `auth_failed`. Idempotent, cron-safe, with alert
cooldowns (6h failures / 24h warnings) so a 30-minute cron cannot storm
Slack.
The .auth-failed flag path matches what modules/linear/linear-poll.mjs
already reads — join(__dirname, '.auth-failed'), i.e. ~/scripts. That
graceful-degradation path has been dead code since it was written
because nothing ever wrote the flag. Now something does.
A `claudeAiOauth` block with no refreshTokenExpiresAt is pre-2.1.x and
certainly dead. No `claudeAiOauth` block at all is *unknown*, not dead —
verified against a Mac where the credential lives in the Keychain yet
the probe succeeds — so the probe decides.
* setup.sh — install_auth_monitor(), wired into both the server and
exe.dev provisioning paths, with a */30 cron entry. Every path the
monitor writes (flag, state, log) lives under the invoking user's own
$HOME. Several GMs can share one box, and a /tmp file owned by another
user is exactly how a daemon start got broken in production; the script
also falls back to $HOME at runtime if the rendered home is not
writable.
* modules/slack/slack-bridge.mjs — formatNotification cases for
auth_failed / auth_expiring. These carry no task_id, so they route to
SLACK_NOTIFY_CHANNEL rather than a task thread, and would otherwise
have rendered as "Task #? (auth_failed)". Covered by new unit tests.
* .claude/commands/gm-doctor.md — Check 9 reports days until refresh-token
expiry, runs the probe once, and *rectifies existing installs*: GMs
provisioned before this change get the monitor and its cron entry
installed on the spot, and a stale .auth-failed flag left behind by a
fixed login gets cleared.
* .claude/commands/launch.md — stopped treating `loggedIn: true` as proof
that auth works; it now verifies with the probe.
* README.md — the expiry cycle, why the obvious check lies, the detection
method, and `claude setup-token` (1-year token) as an option for boxes
that do not need claude.ai MCP connectors or Remote Control, which it
disables. Deliberately not the default.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
TaskYou-OS agent servers authenticate Claude Code per-Unix-user via
claude /login(claude.ai OAuth against a Max subscription). That login expires roughly every 30 days. When it lapses, every agent task fails or stalls — and there was no detection at all. The operator found out by noticing the board had gone quiet.Why the obvious check fails
claude auth statuscannot be trusted for this. It reads cached local config and never contacts Anthropic, so it reports success on credentials that have been dead for months. On a 95-day-dead login it still returned, with exit 0:{ "loggedIn": true, "authMethod": "claude.ai", "apiProvider": "firstParty", "subscriptionType": "max" }claude doctor,claude mcp listandclaude auth status --textare equally offline and equally wrong. Any monitor built on them reports a healthy server right up until someone asks why nothing has run in a week.The detection method
1. Free offline pre-check. Read
~/.claude/.credentials.json→claudeAiOauth.refreshTokenExpiresAt(epoch ms). The access token lasts ~8h and self-refreshes; the refresh token is the real clock (~30 days, sliding). Three outcomes:refreshTokenExpiresAtin the futureok+ days remaining → gate the proberefreshTokenExpiresAtin the pastexpired→ skip the probe, spend no tokensclaudeAiOauthpresent, norefreshTokenExpiresAtclaudeAiOauthblock at all, or no fileunknown, not dead → let the probe decideThat last row matters: on macOS the credential lives in the Keychain, so the file has only an
mcpOAuthblock yet auth works fine. Treating "no block" as "dead" would false-alarm every such box. Verified against a real machine in that state.2. The only truthful probe — one real model request:
Exit 0 = healthy (measured 3–6s). Exit 1 on expired auth with "Failed to authenticate: OAuth session expired and could not be refreshed".
Token cost
~40 tokens per probe. At the default 30-minute cron that is ~48 probes/day ≈ 2k tokens/day per server — and less in practice, because the free offline check short-circuits the probe entirely once a credential is provably dead. Nothing is spent proving something already known.
What ships
A.
templates/claude-auth-monitor.tmpl(new)Free check → gated probe → act:
.auth-failed; if the refresh token expires within 5 days, append anauth_expiringevent carryingdays_remaining..auth-failed, append anauth_failedevent.Idempotent and cron-safe. Alerts are rate-limited (failures re-alert every 6h, warnings every 24h) so a 30-minute cron cannot storm Slack. A missing
claudebinary logs and exits 0 rather than raising a false auth alarm.This finally activates dead code.
modules/linear/linear-poll.mjshas always read a.auth-failedflag and degraded gracefully (create tasks, don't execute them) — but nothing in the repo ever wrote it. The monitor writes it at exactly the path the poller resolves:join(__dirname, '.auth-failed'), i.e.~/scripts/.auth-failed.B. Cron wiring in
setup.shNew
install_auth_monitor(), called from both the server and exe.dev provisioning paths, installing a*/30cron entry so new installs are covered automatically.Multi-tenancy: every path the monitor writes — flag, state, log — lives under the invoking user's own
$HOME, never a shared/tmp. Several GMs can share one box, and a/tmp/ty-daemon.logowned by another user is exactly how a daemon start got broken in production. The script also falls back to$HOMEat runtime if the rendered home is not writable, and the local staging file is PID-unique.C. Slack bridge cases
formatNotificationgainedauth_failedandauth_expiring. These carry notask_id, so they route toSLACK_NOTIFY_CHANNELrather than a task thread — previously they would have rendered asTask #? (auth_failed). Handles singular/plural days and a missing day count without printingNaN.D.
/doctorCheck 9 — and what it rectifiesReports days until refresh-token expiry, runs the probe once, and then fixes existing installs rather than just reporting on them:
.auth-failedflag — if the flag is present but the probe returnsAUTH_OK, the login was fixed but the flag was never cleared, andlinear-poll.mjsis still refusing to execute tasks. The monitor would clear this itself within 30 minutes; doctor does it immediately.claude /loginfix.E.
.claude/commands/launch.mdStopped treating
loggedIn: trueas proof that auth works. The credential-transfer step now verifies with the probe, and the section leads with whyclaude auth statusmust not be used.Docs
README.mdgains a Claude Auth Expiry section: the ~30-day cycle, the lying output above, the detection method, andclaude setup-token— which issues a 1-year token but disables claude.ai MCP connectors and Remote Control, so it is documented as an option for boxes that don't need those, explicitly not the default.Verification
Every piece was tested locally against a throwaway
$HOME. No real~/.claude/.credentials.jsonwas modified, and the live probe was run exactly twice (once to confirm exit code and timing, once via the existing QA path).Monitor script — rendered through the same substitution
setup.shuses, then driven through 9 scenarios with a stubclaudebinary that counts invocations:auth_expiringw/days_remaining, no flagrefreshTokenExpiresAtin the pastauth_failed, 0 probesauth_failed, 0 probesauth_failedclaudeAiOauthblock (Keychain)claudebinary absentSERVER_HOMEunwritable$HOMERendered output has zero unsubstituted
{{...}}placeholders and passesbash -n.Slack bridge —
node --testinmodules/slack: 14/14 pass, including new coverage for both event types. Additionally piped the monitor's literal emitted bytes through the bridge's realreadNewChunk→formatNotificationpath and confirmed both render correctly and route to the notify channel (notask_id).setup.sh —
bash -nclean.install_auth_monitor()was extracted and executed verbatim against stubbedssh/scp: correctmkdir/scp/chmodsequence, cron entry installed once, idempotent on re-run, and no state path under/tmp./doctorsnippet — the embedded days-remaining Python is quoted to survive anssh '...'wrapper; executed through that exact quoting against all five credential shapes, returningDAYS_LEFT=N,PRE_2_1_X_FORMAT_DEAD,NO_OAUTH_BLOCK, andNO_CREDENTIALS_FILEcorrectly.Repo QA harness —
qa/run-qa.sh: 29 passed, 0 failed.Rebased on
mainat0def18b(post-#41).🤖 Generated with Claude Code
https://claude.ai/code/session_019jCVFwrwj2ajbbcnBWpR8z