hermes - 💡(How to fix) Fix Anthropic auth fails when macOS Keychain and ~/.claude/.credentials.json hold different OAuth tokens

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

agent.anthropic_adapter.read_claude_code_credentials() returns the macOS Keychain entry as soon as one exists, without checking whether its OAuth token is still valid. When the Keychain holds an expired token but ~/.claude/.credentials.json holds a fresh one (or vice versa), the downstream is_claude_code_token_valid() check rejects the returned creds and resolve_anthropic_token() returns None. The user then sees:

No Anthropic credentials found. Set ANTHROPIC_TOKEN or ANTHROPIC_API_KEY,
run 'claude setup-token', or authenticate with 'claude /login'.

…even though Claude Code is fully authenticated and a valid refreshable token is sitting in the JSON file the function would consult next, if it knew to fall back.

Root Cause

agent/anthropic_adapter.py:706-743 (current main):

def read_claude_code_credentials() -> Optional[Dict[str, Any]]:
    # Try macOS Keychain first (covers Claude Code >=2.1.114)
    kc_creds = _read_claude_code_credentials_from_keychain()
    if kc_creds:
        return kc_creds                  # ← returned even if expired

    # Fall back to JSON file
    cred_path = Path.home() / ".claude" / ".credentials.json"
    ...                                  # ← only reached when keychain MISSING

The function treats the two sources as a strict priority chain (Keychain > file) and only consults the file when the Keychain entry is absent. Whether the Keychain entry is valid never enters the decision — that check happens later, in is_claude_code_token_valid(), by which point the file has already been ignored.

resolve_anthropic_token() then (agent/anthropic_adapter.py:973-974):

resolved_claude_token = _resolve_claude_code_token_from_credentials(creds)
if resolved_claude_token:
    return resolved_claude_token

…receives None because the expired Keychain creds fail validity, and falls through to ANTHROPIC_API_KEY, which is normally unset for OAuth users. Result: AuthError.

Fix Action

Fix / Workaround

Branch / patch

Patch is on a local branch ready to push as a PR:

Code Example

No Anthropic credentials found. Set ANTHROPIC_TOKEN or ANTHROPIC_API_KEY,
run 'claude setup-token', or authenticate with 'claude /login'.

---

# Keep the fresh file token aside
   cat ~/.claude/.credentials.json    # has fresh expiresAt

   # Replace the keychain entry with an expired one
   security add-generic-password -s "Claude Code-credentials" -a "$USER" -U \
     -w '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-EXPIRED","refreshToken":"x","expiresAt":1}}'

---

hermes -z "ping" -m claude-haiku-4-5 --provider anthropic

---

def read_claude_code_credentials() -> Optional[Dict[str, Any]]:
    # Try macOS Keychain first (covers Claude Code >=2.1.114)
    kc_creds = _read_claude_code_credentials_from_keychain()
    if kc_creds:
        return kc_creds                  # ← returned even if expired

    # Fall back to JSON file
    cred_path = Path.home() / ".claude" / ".credentials.json"
    ...                                  # ← only reached when keychain MISSING

---

resolved_claude_token = _resolve_claude_code_token_from_credentials(creds)
if resolved_claude_token:
    return resolved_claude_token

---

def read_claude_code_credentials() -> Optional[Dict[str, Any]]:
    kc_creds = _read_claude_code_credentials_from_keychain()
    file_creds = _read_claude_code_credentials_from_file()

    if kc_creds and file_creds:
        kc_valid = is_claude_code_token_valid(kc_creds)
        file_valid = is_claude_code_token_valid(file_creds)
        if kc_valid and not file_valid:
            return kc_creds
        if file_valid and not kc_valid:
            return file_creds
        kc_exp = kc_creds.get("expiresAt", 0) or 0
        file_exp = file_creds.get("expiresAt", 0) or 0
        return kc_creds if kc_exp >= file_exp else file_creds

    return kc_creds or file_creds

---

tests/agent/test_anthropic_keychain.py::... 16 passed in 1.36s
RAW_BUFFERClick to expand / collapse

Summary

agent.anthropic_adapter.read_claude_code_credentials() returns the macOS Keychain entry as soon as one exists, without checking whether its OAuth token is still valid. When the Keychain holds an expired token but ~/.claude/.credentials.json holds a fresh one (or vice versa), the downstream is_claude_code_token_valid() check rejects the returned creds and resolve_anthropic_token() returns None. The user then sees:

No Anthropic credentials found. Set ANTHROPIC_TOKEN or ANTHROPIC_API_KEY,
run 'claude setup-token', or authenticate with 'claude /login'.

…even though Claude Code is fully authenticated and a valid refreshable token is sitting in the JSON file the function would consult next, if it knew to fall back.

Environment

  • Hermes Agent v0.12.0 (commit 49c3c2e0d at time of investigation)
  • Claude Code 2.1.123 on macOS Darwin 25.4.0
  • Python 3.11.15

Repro

The simplest deterministic repro doesn't need to trigger the underlying desync — it just exercises the missing fallback:

  1. Be on macOS with Claude Code logged in (so both Keychain entry Claude Code-credentials and ~/.claude/.credentials.json exist).
  2. Manually overwrite the Keychain entry with an expired token while leaving the file fresh:
    # Keep the fresh file token aside
    cat ~/.claude/.credentials.json    # has fresh expiresAt
    
    # Replace the keychain entry with an expired one
    security add-generic-password -s "Claude Code-credentials" -a "$USER" -U \
      -w '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-EXPIRED","refreshToken":"x","expiresAt":1}}'
  3. Run any Hermes call that needs Anthropic:
    hermes -z "ping" -m claude-haiku-4-5 --provider anthropic
  4. Observe AuthError: No Anthropic credentials found. ..., despite the JSON file still being valid.

In the wild we hit this without step 2 — the Keychain naturally drifted into an expired state while the file refreshed cleanly. See the How this happens in practice section below.

Root cause

agent/anthropic_adapter.py:706-743 (current main):

def read_claude_code_credentials() -> Optional[Dict[str, Any]]:
    # Try macOS Keychain first (covers Claude Code >=2.1.114)
    kc_creds = _read_claude_code_credentials_from_keychain()
    if kc_creds:
        return kc_creds                  # ← returned even if expired

    # Fall back to JSON file
    cred_path = Path.home() / ".claude" / ".credentials.json"
    ...                                  # ← only reached when keychain MISSING

The function treats the two sources as a strict priority chain (Keychain > file) and only consults the file when the Keychain entry is absent. Whether the Keychain entry is valid never enters the decision — that check happens later, in is_claude_code_token_valid(), by which point the file has already been ignored.

resolve_anthropic_token() then (agent/anthropic_adapter.py:973-974):

resolved_claude_token = _resolve_claude_code_token_from_credentials(creds)
if resolved_claude_token:
    return resolved_claude_token

…receives None because the expired Keychain creds fail validity, and falls through to ANTHROPIC_API_KEY, which is normally unset for OAuth users. Result: AuthError.

How this happens in practice (Claude Code-side desync)

Claude Code stores its OAuth credentials in two places on macOS:

  1. macOS Keychain entry Claude Code-credentials (introduced in Claude Code 2.1.114+)
  2. ~/.claude/.credentials.json

Both are managed by Claude Code itself. Hermes only reads them.

We have observed Claude Code refreshing one of these stores but not the other. Specifically: after a long-running Claude Code IDE session, ~/.claude/.credentials.json was rewritten with a fresh expiresAt while the macOS Keychain entry still held the previous (now-expired) token. We weren't able to pin down a single root cause for the desync — possible contributors include the IDE extension and the CLI taking different refresh paths, a partial write, or a Keychain access failure during the silent background refresh — but the upshot for Hermes is that we cannot assume the two sources are always in lockstep.

The Anthropic team may want to investigate that desync independently, but Hermes should be defensive against it regardless: if one store is expired and the other is fresh, we should use the fresh one rather than failing the whole auth path.

Proposed fix

Read both sources, then reconcile:

  • If exactly one is non-expired, use that one.
  • If both are valid (or both expired), prefer the source with the later expiresAt, so the freshest refresh_token is used by any subsequent refresh.
def read_claude_code_credentials() -> Optional[Dict[str, Any]]:
    kc_creds = _read_claude_code_credentials_from_keychain()
    file_creds = _read_claude_code_credentials_from_file()

    if kc_creds and file_creds:
        kc_valid = is_claude_code_token_valid(kc_creds)
        file_valid = is_claude_code_token_valid(file_creds)
        if kc_valid and not file_valid:
            return kc_creds
        if file_valid and not kc_valid:
            return file_creds
        kc_exp = kc_creds.get("expiresAt", 0) or 0
        file_exp = file_creds.get("expiresAt", 0) or 0
        return kc_creds if kc_exp >= file_exp else file_creds

    return kc_creds or file_creds

The file-reading logic is extracted into a sibling _read_claude_code_credentials_from_file() helper to mirror the existing _read_claude_code_credentials_from_keychain() shape.

The existing TestReadClaudeCodeCredentialsPriority::test_keychain_takes_priority_over_json_file test continues to pass because both fixtures share the same expiresAt and the tiebreaker uses >=.

New tests

Adds TestReadClaudeCodeCredentialsDesync to tests/agent/test_anthropic_keychain.py covering:

  • ✅ Keychain expired + file fresh → returns file (regression case for this bug)
  • ✅ Keychain fresh + file expired → returns Keychain
  • ✅ Both valid, file has later expiresAt → returns file
  • ✅ Both expired, file has later expiresAt → returns file (so refresh uses freshest token)

All 4 new + the 12 pre-existing tests in test_anthropic_keychain.py pass:

tests/agent/test_anthropic_keychain.py::... 16 passed in 1.36s

(Note: tests/agent/test_anthropic_adapter.py shows 19 pre-existing failures on this Mac — they assume an empty macOS Keychain and don't mock _read_claude_code_credentials_from_keychain(). These same 19 fail on unmodified origin/main as well, so they are out of scope for this fix but worth flagging as a separate cleanup.)

Branch / patch

Patch is on a local branch ready to push as a PR:

  • Branch: fix/anthropic-credentials-source-fallback
  • Commit: fix(anthropic): reconcile keychain/file credentials when one is expired
  • Diff: agent/anthropic_adapter.py (+47/-19), tests/agent/test_anthropic_keychain.py (+110/-0)

Happy to open a PR if the maintainers agree with the approach.

Related observation (not part of this fix)

~/.hermes/auth.json keeps a last_status: "exhausted" flag on the claude_code credential pool entry after a 401. Once the underlying OAuth token recovers, that flag is not auto-reset, so even after fixing the Keychain/file desync the user has to run hermes auth reset anthropic (or manually edit auth.json) before Hermes will re-attempt the credential. Worth tracking separately — should the credential pool re-probe exhausted entries on a TTL or on credential-source change?

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING