fix(anthropic): scope the Claude Code keychain lookup by account — and correct the billing docs it produced - #75146
Conversation
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`.
|
Thanks for the focused credential-resolution regression. Current The scoped-first lookup with the preserved unscoped fallback in This is an automated hermes-sweeper review. |
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.
|
Additional context — this bug decides which account gets billed, which I think makes it more than a macOS papercut.
So when the Keychain lookup wrongly returns Measured on macOS against a real multi-item Keychain (Claude Code stores its MCP-server OAuth state under the same 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 |
…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.
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-passwordreturns 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 sameClaude Code-credentialsservice, as a separate item whose account isunknownand whose payload contains only anmcpOAuthkey — noclaudeAiOauthat 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 noclaudeAiOauth, and returnNone. Nothing errors. The lookup succeeds and returns the wrong item.Observed on a real machine — four items share that service:
Why it is more than a papercut: it changes which account is billed
resolve_anthropic_token()ranks the Claude Code credential (source 3) aboveANTHROPIC_API_KEY(source 5), and every source is a plainif token: return token— a miss falls through, it never raises._is_oauth_token()rejects thesk-ant-apiprefix before the OAuth branch, so a Console key is classified as an API key.An OAuth token ships as
Authorization: Bearerand draws on the subscription plan allowance. A Console key ships asx-api-keyand draws on that organisation's prepaid API credits. Different money.Driving the real resolver against the real Keychain, offline:
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_KEYset, 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:
and
GET /api/oauth/usage→spend.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
:::warningdescribing the silentANTHROPIC_API_KEYfall-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 caveated170f433(2026-04-29), 4.9 days later.The change
-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.claudeAiOauthno longer ends the search.Verification
test_anthropic_keychain_account_scoping.py,test_anthropic_adapter.py,test_anthropic_oauth_ua_prefix.py,test_credential_pool.py.ruff check .clean ·python scripts/check-windows-footguns.py --allclean (893 files).providers.mdadmonition balance is unchanged frommain(a pre-existing off-by-one, not introduced here).Honest caveats
429on direct calls and400 "You're out of extra usage"via the CLI, while no plan window was near exhaustion (five_hour10%,seven_day33%) and the token was valid for another ~7 hours. I cannot explain that and would rather flag it. One lead:/api/oauth/usageexposes a top-levelseven_day_oauth_appskey (nullon 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.