Skip to content

fix(anthropic): scope the Claude Code keychain lookup by account — and correct the billing docs it produced - #75146

Open
griffinwork40 wants to merge 5 commits into
NousResearch:mainfrom
griffinwork40:fix/anthropic-keychain-account-scoping
Open

fix(anthropic): scope the Claude Code keychain lookup by account — and correct the billing docs it produced#75146
griffinwork40 wants to merge 5 commits into
NousResearch:mainfrom
griffinwork40:fix/anthropic-keychain-account-scoping

Conversation

@griffinwork40

@griffinwork40 griffinwork40 commented Jul 31, 2026

Copy link
Copy Markdown

On macOS, Hermes can report "No Anthropic credentials found" while you are fully signed in to Claude Code — or, worse, quietly bill your requests to the wrong account. Both come from one lookup.

This PR fixes that lookup and corrects the documentation the bug produced. They are together because the second is only true once the first lands; more on that below.

The bug

security find-generic-password returns only the first matching item, and macOS allows many items to share one service name. Claude Code does exactly that: alongside your login credential it stores MCP-server OAuth state under the same Claude Code-credentials service, as a separate item whose account is unknown and whose payload contains only an mcpOAuth key — no claudeAiOauth at all.

_read_claude_code_credentials_from_keychain() queries with -s <service> and no -a <account>. On a machine with both items it can win the MCP one, parse it successfully, find no claudeAiOauth, and return None. Nothing errors. The lookup succeeds and returns the wrong item.

Observed on a real machine — four items share that service:

upstream UNSCOPED (-s only)  -> top-level keys: ['mcpOAuth']            claudeAiOauth? False
FIXED account-scoped         -> top-level keys: ['accessToken', ...]    usable login token

Why it is more than a papercut: it changes which account is billed

resolve_anthropic_token() ranks the Claude Code credential (source 3) above ANTHROPIC_API_KEY (source 5), and every source is a plain if token: return token — a miss falls through, it never raises. _is_oauth_token() rejects the sk-ant-api prefix before the OAuth branch, so a Console key is classified as an API key.

An OAuth token ships as Authorization: Bearer and draws on the subscription plan allowance. A Console key ships as x-api-key and draws on that organisation's prepaid API credits. Different money.

Driving the real resolver against the real Keychain, offline:

unscoped read + ANTHROPIC_API_KEY unset -> no credential   (hard failure, at least it is loud)
unscoped read + ANTHROPIC_API_KEY set   -> x-api-key       (prepaid API credits)
scoped read   + ANTHROPIC_API_KEY set   -> Bearer          (plan allowance)

Every log call on that path is logger.debug, so at default verbosity nothing surfaces. The plan allowance simply goes untouched.

Why the docs change is in this PR

That last point is also, I believe, the origin of the current caveat in website/docs/integrations/providers.md, which says OAuth "only works if you're on a Claude Max plan and have purchased extra usage credits" and that "the base Max plan allowance is not consumed by Hermes."

Before this fix, on macOS with ANTHROPIC_API_KEY set, that is an accurate description of what Hermes does: OAuth is silently never used, and the plan allowance genuinely is not consumed. After this fix, the OAuth token is actually used and Anthropic attributes the request to the plan window.

Measured on a Claude Team subscription with the overage lane explicitly disabled — nine served requests, Anthropic's own headers:

anthropic-ratelimit-unified-status:                  allowed
anthropic-ratelimit-unified-representative-claim:    five_hour        <- the PLAN window
anthropic-ratelimit-unified-overage-status:          rejected
anthropic-ratelimit-unified-overage-disabled-reason: out_of_credits   <- zero credits available

and GET /api/oauth/usagespend.used.amount_minor: 0, i.e. zero extra-usage credits consumed.

So the doc commits here are not an independent correction — they only become true once the lookup is fixed. Shipping them apart would have left the corrected page wrong for exactly the users the bug affects. The docs also gain a :::warning describing the silent ANTHROPIC_API_KEY fall-through, and a note that credits are the spillover once an allowance runs out ("usage credits cover you when you hit your plan limits" is Anthropic's own wording) so the page does not overpromise.

Timeline, for what it is worth: the Keychain read landed e1106772d (2026-04-24); the caveat ed170f433 (2026-04-29), 4.9 days later.

The change

  • Account-scoped lookup first (-a $(getpass.getuser())), unscoped read preserved as a fallback, so behaviour is unchanged where the old path already worked and where the username cannot be determined.
  • An item that parses but lacks claudeAiOauth no longer ends the search.
  • Non-Darwin short-circuits before any subprocess, exactly as before.
  • No new configuration, no new dependency, no change to any non-macOS path.

Verification

  • 149 tests pass across test_anthropic_keychain_account_scoping.py, test_anthropic_adapter.py, test_anthropic_oauth_ua_prefix.py, test_credential_pool.py.
  • The 7 new lookup tests are genuine regression tests: 4 of 7 fail against the unfixed reader.
  • 2 further tests pin the billing lane — the subscription credential must outrank the env API key, and a Console key must never classify as OAuth.
  • ruff check . clean · python scripts/check-windows-footguns.py --all clean (893 files).
  • providers.md admonition balance is unchanged from main (a pre-existing off-by-one, not introduced here).

Honest caveats

  • The billing measurement was taken on a Team plan. Max is expected to match; Pro is untested, and the docs say so rather than replacing one unverified claim with another.
  • I could not re-run the measurement later the same day: the same token began returning 429 on direct calls and 400 "You're out of extra usage" via the CLI, while no plan window was near exhaustion (five_hour 10%, seven_day 33%) and the token was valid for another ~7 hours. I cannot explain that and would rather flag it. One lead: /api/oauth/usage exposes a top-level seven_day_oauth_apps key (null on this account) — if third-party OAuth clients are metered separately, exhausting that would produce an extra-usage message while the plan sits mostly unused. That would be a ceiling on the plan-backed allowance, not evidence that credits are a precondition.

Supersedes #75147, which contained the documentation commits alone.

macOS allows several generic-password items to share one service name, and
Claude Code does exactly that: alongside the login credential it stores its
MCP-server OAuth state under the same `Claude Code-credentials` service, in a
separate item whose account is `unknown` and whose payload holds only
`mcpOAuth` — no `claudeAiOauth` key.

`security find-generic-password` returns the FIRST matching item. The lookup
passed only `-s <service>`, so it frequently won the MCP item, parsed a
perfectly valid JSON payload, found no `claudeAiOauth`, and returned None.
Hermes then reports "No Anthropic credentials found" while the user is fully
logged in to Claude Code, and every Anthropic OAuth path fails. Nothing raises
— the lookup succeeds and simply returns the wrong item, so there is no error
to notice.

Observed on a real machine: 4 items shared the service name; only the one under
`acct=<login user>` carried `claudeAiOauth`.

Try the account-scoped read first, then fall back to the historical unscoped
read so any setup whose item account differs from the login username behaves
exactly as before. A payload that parses but lacks `claudeAiOauth` no longer
aborts the search — it advances to the next candidate, which also stops a
corrupt or non-zero-exit item from masking a good one behind it.

Purely additive: no config, no new dependency, and the previous code path is
retained as the fallback.

Tests: 4 of the 7 new cases fail against the unfixed reader, including
`test_mcp_only_first_item_does_not_shadow_the_login_credential`.
@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 31, 2026
@teknium1

Copy link
Copy Markdown
Contributor

Thanks for the focused credential-resolution regression.

Current main still calls security find-generic-password with only the shared service name at agent/anthropic_adapter.py:959-967; a parsed payload lacking claudeAiOauth then reaches return None at agent/anthropic_adapter.py:986-997. The helper feeds the active Claude Code credential reconciliation path at agent/anthropic_adapter.py:1049-1065.

The scoped-first lookup with the preserved unscoped fallback in 9f54904663cf directly addresses that path without adding configuration or changing non-Darwin behavior. GitHub reports the commit is one ahead of current main and zero behind, so salvage should be mechanical.

This is an 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 31, 2026
The account-scoped lookup fixed in the previous commit is not only about
finding the credential — it decides which account gets billed.

resolve_anthropic_token ranks the Claude Code credential (source 3) above
ANTHROPIC_API_KEY (source 5). An OAuth token ships as Authorization: Bearer
and draws down the subscription plan allowance; a Console key ships as
x-api-key and draws down that organisation's prepaid API credits.

So a lookup that wrongly returns None does not merely fail. When
ANTHROPIC_API_KEY is also set it falls through to source 5 and silently
reroutes the billing lane — every log call on that path is logger.debug, so
nothing surfaces at default verbosity. Before the account-scoped read, an
mcpOAuth-only item sharing the service name was enough to trigger it.

Adds two guards: the subscription credential must outrank the env API key,
and a Console key must never be classified as OAuth. The second test
characterises the fall-through rather than endorsing it, so the ranking is
understood as load-bearing.
@griffinwork40

Copy link
Copy Markdown
Author

Additional context — this bug decides which account gets billed, which I think makes it more than a macOS papercut.

resolve_anthropic_token() ranks the Claude Code credential (source 3) above ANTHROPIC_API_KEY (source 5). An OAuth token ships as Authorization: Bearer and draws on the subscription plan allowance; a Console key ships as x-api-key and draws on that organisation's prepaid API credits.

So when the Keychain lookup wrongly returns None and ANTHROPIC_API_KEY is set, resolution doesn't fail — it silently falls through to the API key and reroutes the billing lane. Every log call on that path is logger.debug, so nothing surfaces at default verbosity.

Measured on macOS against a real multi-item Keychain (Claude Code stores its MCP-server OAuth state under the same Claude Code-credentials service):

unscoped read + ANTHROPIC_API_KEY unset -> no credential   (hard failure)
unscoped read + ANTHROPIC_API_KEY set   -> x-api-key       (prepaid API credits)
scoped read   + ANTHROPIC_API_KEY set   -> Bearer          (plan allowance)

Pushed a follow-up commit with two guards for exactly this: the subscription credential must outrank the env API key, and a Console key must never be classified as OAuth.

This may also explain the docs caveat I'm correcting in #75147 — "the base plan allowance is not consumed by Hermes, only the extra/overage credits are" is precisely what this fall-through produces. The Keychain read landed in e1106772d (2026-04-24), 4.9 days before the caveat (ed170f433, 2026-04-29). I can't prove that's what was observed, since the bug needs macOS and multiple items sharing the service name — but it is a confirmed sufficient cause of that exact symptom, so the two PRs are probably one story.

…age credits

The docs stated that Anthropic OAuth "only works if you're on a Claude Max plan
and have purchased extra usage credits", that "the base Max plan allowance is
not consumed by Hermes — only the extra/overage credits", and that "Claude Pro
subscribers cannot use this path."

Measured against Anthropic's own rate-limit response headers, that is not how
the OAuth path bills. On a Claude **Team** subscription with the overage lane
explicitly disabled, requests were served normally and attributed to the
subscription's included plan window:

    anthropic-ratelimit-unified-status:                  allowed
    anthropic-ratelimit-unified-representative-claim:    five_hour
    anthropic-ratelimit-unified-overage-status:          rejected
    anthropic-ratelimit-unified-overage-disabled-reason: out_of_credits

`overage-status: rejected` with `out_of_credits` means no extra-usage credits
were available to draw on, so an allowed request necessarily came out of the
plan allowance. Reproduced across a range of system-prompt sizes (200 to 40,000
chars, 65 to 9,178 input tokens); every request landed in the same lane.

The practical consequence is the opposite of what the docs implied, and worth
stating plainly: OAuth *does* spend the same budget as Claude Code and
claude.ai, so heavy Hermes use competes with your own interactive usage.

This corrects the claim in all five places it appeared (providers.md caveat and
summary table, quickstart table, environment-variables prose,
credential-pools example) and documents how a reader can confirm the lane on
their own account from the `anthropic-ratelimit-unified-*` headers.

Scope: verified on Team. Max is expected to match; Pro is untested, and the note
says so rather than replacing one unverified claim with another.
…esolution fails

Correcting the billing claim is not sufficient on its own: there is a real
path where the plan allowance genuinely is not consumed, and it looks
identical to the behaviour the old caveat described as policy.

resolve_anthropic_token ranks the Claude Code credential above
ANTHROPIC_API_KEY, so a working OAuth login always wins. But if OAuth
resolution returns nothing and ANTHROPIC_API_KEY is set, resolution falls
through to the API key and bills pay-per-token. Every log call on that path
is logger.debug, so nothing surfaces at default verbosity — the plan
allowance is just quietly untouched.

Documents how to tell the lanes apart from the response headers, and names
one known trigger: on macOS, Claude Code stores MCP-server OAuth state under
the same Keychain service as the login credential, so an unscoped lookup
could return the MCP item and conclude the user was not signed in.
…e runs out

The corrected text said credits are not required, which is what the headers
show for served requests, but it left the reader unprepared for the
"You're out of extra usage" message that appears once an allowance is
exhausted. Anthropic's own wording on the usage endpoint is "usage credits
cover you when you hit your plan limits" — credits are the spillover, not the
entry ticket.

States that distinction explicitly so the page does not overpromise.
@griffinwork40 griffinwork40 changed the title fix(anthropic): disambiguate Claude Code keychain lookup by account fix(anthropic): scope the Claude Code keychain lookup by account — and correct the billing docs it produced Jul 31, 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