Skip to content

fix(auth): harden Anthropic OAuth refresh and tokenless pool hydration - #58096

Closed
mssteuer wants to merge 1 commit into
NousResearch:mainfrom
mssteuer:upstream-anthropic-auth-fixes
Closed

fix(auth): harden Anthropic OAuth refresh and tokenless pool hydration#58096
mssteuer wants to merge 1 commit into
NousResearch:mainfrom
mssteuer:upstream-anthropic-auth-fixes

Conversation

@mssteuer

@mssteuer mssteuer commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes two Anthropic OAuth/auth-pool failure modes:

  • make Anthropic OAuth token exchange/refresh avoid claude-code/ user-agent prefixes, which can trigger token-endpoint 429s
  • hydrate tokenless claude_code pool entries from ~/.claude/.credentials.json before force-refresh marks them exhausted

Why

A claude_code pool entry can be a lazy reference with no in-pool access_token / refresh_token. Under round_robin, if that entry is selected and _refresh_entry(force=True) sees no refresh token, it currently tombstones the entry immediately. Because pool state is persisted to shared auth.json, that synthetic exhaustion can poison all gateway/cron processes.

The new behavior attempts the existing Claude credentials-file sync first, then only marks the entry exhausted if no usable token material exists.

Test plan

  • python -m pytest tests/agent/test_credential_pool.py tests/agent/test_anthropic_adapter.py tests/agent/test_anthropic_oauth_ua_prefix.py -q
  • python -m py_compile agent/credential_pool.py agent/anthropic_adapter.py tests/agent/test_credential_pool.py tests/agent/test_anthropic_adapter.py tests/agent/test_anthropic_oauth_ua_prefix.py

Result from clean worktree: 264 passed in 9.53s.

Notes

I searched for existing PRs around tokenless claude_code round_robin auth.json and did not find a duplicate.

@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 area/auth Authentication, OAuth, credential pools provider/anthropic Anthropic native Messages API sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data P2 Medium — degraded but workaround exists labels Jul 4, 2026

@tonydwb tonydwb left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Summary

Verdict: Approved (read-only token - formal approval deferred)

Hardens Anthropic OAuth refresh and tokenless pool hydration. Key changes: (1) OAuth token endpoint now uses a non-claude-code User-Agent to avoid HTTP 429 from Anthropic; (2) token endpoint URL migrated from console.anthropic.com to claude.com (Anthropic platform migration); (3) tokenless claude_code entries in credential pool are now hydrated from ~/.claude/.credentials.json instead of being tombstamped as exhausted. Includes new test. No concerns.

@tonydwb tonydwb left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Summary

Verdict: Approved (LGTM)

Hardens Anthropic OAuth refresh and tokenless pool hydration. Security-relevant fix for auth flows.

What Looks Good

  • Security-relevant fix
  • No debug artifacts

Reviewed by Hermes Agent

@kshitijk4poor

Copy link
Copy Markdown
Collaborator

Heads up @mssteuer — the UA half of this PR just landed on main via #58178 (salvaged from your #57922): the OAuth token endpoint now uses a non-claude-code/ UA (axios/) at all three call sites, with the inference path kept on claude-code/. So that portion here is now redundant against main.

The tokenless claude_code pool hydration half is a separate, legitimate fix that did NOT land in #58178 and is still worth having: a claude_code pool entry that's a lazy reference with no in-pool access_token/refresh_token gets tombstoned immediately by _refresh_entry(force=True) under round_robin, and because pool state persists to shared auth.json, that synthetic exhaustion poisons all gateway/cron processes. Your fix (attempt the ~/.claude/.credentials.json sync first, only tombstone if no usable token material exists) addresses a real failure mode.

Could you rebase this onto current main and trim it to just the pool-hydration delta (dropping the now-redundant UA changes)? That leaves a clean, single-concern PR we can review and merge on its own. Alternatively, let me know and I can salvage that half separately with credit to you.

Reference: UA fix merged in #58178

@mssteuer
mssteuer force-pushed the upstream-anthropic-auth-fixes branch from 3846094 to 1df298b Compare July 5, 2026 09:05
@mssteuer

mssteuer commented Jul 5, 2026

Copy link
Copy Markdown
Contributor Author

@kshitijk4poor done — thanks for the clear read.

I force-pushed this PR down to the single remaining concern you called out:

What the remaining patch does: when an Anthropic pool entry is a tokenless source == "claude_code" lazy reference, _refresh_entry(force=True) now tries to hydrate it from the Claude Code credentials file first. It only tombstones/exhausts the entry if that sync produces no usable refresh token, so round-robin no longer persists a synthetic exhausted state into shared auth.json just because the pool entry itself was tokenless.

Verification I ran locally:

python3 -m pytest tests/agent/test_credential_pool.py -q -o 'addopts='
# 87 passed

python3 -m pytest tests/agent/test_anthropic_adapter.py tests/agent/test_anthropic_oauth_ua_prefix.py -q -o 'addopts='
# 177 passed

python3 -m ruff check agent/credential_pool.py tests/agent/test_credential_pool.py
# All checks passed

GitHub CI is also green now, including required checks and Docker builds.

@slackarea

Copy link
Copy Markdown

Hi, I ran into a related issue while testing multiple Claude Code accounts with Hermes.

Hermes currently reads only one CLAUDE_CONFIG_DIR, so I tested a small patch that lets each claude_code pool entry reference its own config directory and macOS Keychain credential. I tested it with four accounts in one Hermes profile: all credentials were loaded separately, three completed live requests, and the rate-limited account rotated correctly to the next one.

This work is for research and interoperability testing only. It is not intended to bypass usage limits, billing, licensing requirements, or the terms of use of either Hermes or Claude Code.

Patch and test notes: https://github.com/vcnngr/hermes-claude-config-dir-patch

This may be useful alongside the tokenless pool hydration work in this PR.

@teknium1

Copy link
Copy Markdown
Contributor

Thanks for the focused follow-up after the OAuth-UA portion was removed.

This automated hermes-sweeper review found that current main already provides the requested behavior:

  • agent/credential_pool.py:1903-1923 rehydrates the persisted claude_code reference from read_claude_code_credentials() before returning the runtime pool entry.
  • agent/credential_pool.py:2398-2461 performs that singleton hydration before constructing CredentialPool; when the backing source is unavailable, the file-backed row is pruned instead.
  • agent/credential_persistence.py:151-174 intentionally keeps borrowed-source secrets out of auth.json; this design shipped in d7c5d5dee and relies on the load-time rehydration above.

The proposed regression test reaches the force-refresh branch only by using private _replace_entry() to fabricate a tokenless in-memory entry after hydration. Production construction uses load_pool(), so the reported tombstone path is already prevented on main.

@teknium1 teknium1 closed this Jul 15, 2026
@teknium1 teknium1 added the sweeper:implemented-on-main Sweeper: behavior already present on current main label Jul 15, 2026
Grey0202 added a commit to Grey0202/hermes-agent that referenced this pull request Aug 4, 2026
…tombstoning

Our anthropic credential-pool entry is a tokenless claude_code lazy pointer
(source=claude_code, auth_type=oauth, refresh_token=""); it resolves the real
token from ~/.claude/.credentials.json at request time. A forced refresh
(try_refresh_current -> _refresh_entry(force=True)) hit the
`not entry.refresh_token` bail and called _mark_exhausted(None), writing
STATUS_EXHAUSTED (1h TTL) to the SHARED auth.json -- poisoning the anthropic
provider for every gateway + cron until the TTL expired or a full restart.

Fix: in the tokenless bail, for an anthropic claude_code entry, hydrate from
the credentials file first via _sync_anthropic_entry_from_credentials_file and
only fall through to exhaustion if that yields no usable refresh token.

Adapted from upstream PR NousResearch#58096. Two regression tests added
(hydrate-instead-of-exhaust, and still-exhaust-when-no-creds-file).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Grey0202 added a commit to Grey0202/hermes-agent that referenced this pull request Aug 4, 2026
…tombstoning

Our anthropic credential-pool entry is a tokenless claude_code lazy pointer
(source=claude_code, auth_type=oauth, refresh_token=""); it resolves the real
token from ~/.claude/.credentials.json at request time. A forced refresh
(try_refresh_current -> _refresh_entry(force=True)) hit the
`not entry.refresh_token` bail and called _mark_exhausted(None), writing
STATUS_EXHAUSTED (1h TTL) to the SHARED auth.json -- poisoning the anthropic
provider for every gateway + cron until the TTL expired or a full restart.

Fix: in the tokenless bail, for an anthropic claude_code entry, hydrate from
the credentials file first via _sync_anthropic_entry_from_credentials_file and
only fall through to exhaustion if that yields no usable refresh token.

Adapted from upstream PR NousResearch#58096. Two regression tests added
(hydrate-instead-of-exhaust, and still-exhaust-when-no-creds-file).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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:implemented-on-main Sweeper: behavior already present on current main 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.

6 participants