Skip to content

fix(anthropic): serialize Claude Code OAuth refresh under the auth-store lock - #60639

Open
loop2zero wants to merge 2 commits into
NousResearch:mainfrom
loop2zero:fix/anthropic-oauth-refresh-lock
Open

fix(anthropic): serialize Claude Code OAuth refresh under the auth-store lock#60639
loop2zero wants to merge 2 commits into
NousResearch:mainfrom
loop2zero:fix/anthropic-oauth-refresh-lock

Conversation

@loop2zero

Copy link
Copy Markdown

Summary

Native Anthropic (model.provider: anthropic, Claude Code OAuth) silently falls back to another provider under concurrent load, even when the Claude subscription is healthy and claude -p --model ... works directly. Every gateway/Discord turn logs:

Primary provider auth failed: No Anthropic credentials found ... — trying fallback
Fallback provider resolved: openai-codex model=gpt-5.5
Runtime provider supplied explicit model override: claude-opus-4-8 -> gpt-5.5

and hermes auth list anthropic shows the sole claude_code entry as rate-limited (429).

Root cause

Claude Code OAuth refresh tokens are single-use. Multiple concurrent Hermes turns (several gateway sessions, delegation subagents, cron jobs) each POST the same refresh token to Anthropic's token endpoint, invalidate one another, and drive the endpoint into 429s. With a single Anthropic credential in the pool, the entry is then marked exhausted and every live turn routes to the fallback provider — silently, for up to the cooldown TTL.

This is the same single-use-refresh-token concurrency race that #56233 fixed for Codex:

"...two concurrent Hermes processes could both adopt the same on-disk token and both POST it; the loser got refresh_token_reused / invalid_grant."

That fix wrapped the Codex branch of _refresh_entry in the shared _auth_store_lock but was deliberately scoped narrow ("this ships the narrow lock-serialization fix") and never generalized. Anthropic Claude Code OAuth has two refresh paths with the same shape, neither serialized:

Refresh path Codex Anthropic (before this PR)
Credential-pool _refresh_entry ✅ locked (#56233) ❌ unlocked
Direct _refresh_oauth_token (no independent path) ❌ unlocked since introduction

Why the asymmetry existed:

  1. anthropic_adapter._refresh_oauth_token predates the credential pool. The direct adapter refresh landed 2026-03-12; credential_pool.py (which owns _auth_store_lock) landed 2026-03-31. The direct path was written around Claude Code's own ~/.claude/.credentials.json file flow and never learned about the later auth-store lock.
  2. Codex and Anthropic use different backing stores. Codex tokens live in auth.json (providers.openai-codex), already under _auth_store_lock, so "wrap it in the existing lock" was the natural Codex fix. Anthropic claude_code tokens live in Claude Code's ~/.claude/.credentials.json, historically guarded only by a file-sync mechanism (_sync_anthropic_entry_from_credentials_file) — which narrows the race window but cannot eliminate it for a single-use token.
  3. fix(auth): serialize Codex OAuth pool refresh under the auth-store lock #56233 was intentionally Codex-only and the structurally-identical Anthropic pool path was not carried along.

Changes

Both commits reuse the exact pattern established by #56233 — the existing reentrant, cross-process _auth_store_lock, with an in-lock re-read so a waiter adopts the token the winner rotated instead of re-POSTing a consumed one.

Commit 1 — pool refresh path (agent/credential_pool.py)
Wrap the provider == "anthropic" and source == "claude_code" branch of _refresh_entry in _auth_store_lock, re-syncing from the credentials file inside the lock. Directly mirrors the Codex block added in #56233.

Commit 2 — direct refresh path (agent/anthropic_adapter.py)
Serialize _refresh_oauth_token's read→POST→write-back under the same reentrant lock, with an in-lock re-read that adopts a concurrently-rotated token and skips the POST. This path is hit on every anthropic_messages API call via _try_refresh_anthropic_client_credentials() and by auxiliary_client refresh, so it covers the main agent, delegation subagents, and cron jobs. Falls back to the unserialized path only if the lock helper is unavailable.

Together these cover all Claude Code OAuth refresh entry points.

Reproduction

  1. Configure native Anthropic via Claude Code OAuth (single pooled claude_code credential).
  2. Drive several concurrent turns (e.g. multiple Discord threads + a delegation batch + a cron job firing in the same tick).
  3. Observe: auth list anthropic flips to rate-limited (429), and every subsequent turn logs Primary provider auth failed ... Fallback provider resolved: openai-codex, running Claude-configured sessions on the fallback model instead.

Testing

  • pytest tests/agent/test_credential_pool.py — 87 passed (incl. 2 new regressions: refresh runs under the lock; a resync short-circuits the POST).
  • pytest tests/agent/test_anthropic_adapter.py — 175 passed with isolated HOME (incl. 2 new regressions: direct refresh runs under the lock; a concurrently-rotated token is adopted without re-POSTing).
  • Combined on a clean checkout of this branch off main: 262 passed.

Verified live on a running gateway after applying both commits: main agent, delegation subagents (platform=subagent), and an agent-mode cron job all log provider=anthropic with zero Fallback provider resolved / Runtime provider supplied explicit model override lines, and the pooled credential stays active instead of flipping to rate-limited.

Related

loop2zero added 2 commits July 8, 2026 10:18
…th-store lock

The credential-pool anthropic claude_code refresh path synced tokens
from ~/.claude/.credentials.json and then POSTed the refresh_token to
Anthropic without holding the cross-process auth-store lock across the
whole read->POST->write-back sequence. Because Claude Code OAuth refresh
tokens are single-use, concurrent Hermes turns (multiple gateway
sessions, delegation subagents, cron jobs) could each adopt the same
on-disk token and POST it; the losers 429 the refresh endpoint. With a
single Anthropic credential in the pool, the entry is then marked
exhausted and every live turn silently falls back to another provider
even though the Claude subscription is healthy.

Wrap the anthropic claude_code branch of _refresh_entry in the existing
shared _auth_store_lock (reentrant cross-process flock), mirroring the
Codex OAuth fix in NousResearch#56233. A waiter now blocks on the lock and, once
inside, the in-lock re-sync from the credentials file adopts the token
the winner rotated and skips its own POST.

Tests: tests/agent/test_credential_pool.py (87 passed) incl. two new
regressions asserting the lock is taken and that a resync short-circuits
the POST.
…auth-store lock

_refresh_oauth_token() is a second, older Claude Code OAuth refresh path
(predates credential_pool) that the NousResearch#56233 Codex fix and the pool-side
lock do not cover. It is invoked on every anthropic_messages API call
via _try_refresh_anthropic_client_credentials() (run_agent.py) and by
auxiliary_client refresh — for the main agent, delegation subagents and
cron jobs alike. It POSTed the single-use refresh token with no
cross-process serialization, so concurrent turns replayed the same
token, 429d Anthropic's refresh endpoint, exhausted the sole Anthropic
credential and forced a silent fallback to another provider.

Wrap the read->POST->write-back in the same reentrant shared
_auth_store_lock (so the pool refresh path that already holds it does
not deadlock) and re-read the live credential file inside the lock: a
waiter adopts the token the winner already rotated instead of
re-spending a consumed one. Falls back to the unserialized path only if
the lock helper is unavailable.

Together with the pool-side commit this covers all Claude Code OAuth
refresh entry points.

Tests: tests/agent/test_anthropic_adapter.py (175 passed, isolated HOME)
incl. two new regressions asserting the refresh runs under the lock and
that a concurrently-rotated token is adopted without re-POSTing.
@alt-glitch alt-glitch added type/bug Something isn't working comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint provider/anthropic Anthropic native Messages API area/auth Authentication, OAuth, credential pools P2 Medium — degraded but workaround exists labels Jul 8, 2026
@teknium1

Copy link
Copy Markdown
Contributor

Thanks for tracing both current refresh paths; the underlying race is present on current main (agent/anthropic_adapter.py:1097-1123, agent/credential_pool.py:962-1022).

Problems

  • The proposed lock is profile-local, not Claude-Code-store-local. _auth_store_lock() locks get_hermes_home()/auth.json (hermes_cli/auth.py:895, :983-984), but the token being refreshed is shared at Path.home()/.claude/.credentials.json (agent/anthropic_adapter.py:934, :1143). Two profiles can hold distinct auth-store locks and still POST the same shared refresh token. The new tests use one HERMES_HOME, so they do not cover this path.
  • The diff adds HERMES_ANTHROPIC_REFRESH_TIMEOUT_SECONDS; AGENTS.md prohibits new user-facing non-secret HERMES_* configuration. Use config.yaml if this needs to be configurable, or retain a fixed internal timeout.

Suggested changes

  • Use a dedicated cross-profile lock for the shared Claude Code credential store and add a two-profile/single-HOME concurrency regression proving one POST.

Automated hermes-sweeper review.

@teknium1 teknium1 added sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/auth Authentication, OAuth, credential pools comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P2 Medium — degraded but workaround exists provider/anthropic Anthropic native Messages API sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants