From 61906e27ff416df867dce829f443b22e749bffe0 Mon Sep 17 00:00:00 2001 From: briandevans <252620095+briandevans@users.noreply.github.com> Date: Fri, 15 May 2026 06:22:33 -0700 Subject: [PATCH] fix(anthropic): consult credential_pool in resolve_anthropic_token (#26344) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hermes auth add anthropic --type oauth` (PKCE) stores its token only in `~/.hermes/auth.json::credential_pool["anthropic"]` — not in env vars and not in `~/.claude/.credentials.json`. `resolve_anthropic_token()` walked four sources (ANTHROPIC_TOKEN, CLAUDE_CODE_OAUTH_TOKEN, Claude Code creds file, ANTHROPIC_API_KEY) but never consulted the pool, so cron jobs that route through `resolve_runtime_provider` raised `AuthError: No Anthropic credentials found` even after `hermes auth status anthropic` reported `logged in`. Add a 4th step that reads the pool via `read_credential_pool("anthropic")` and returns the first entry's `access_token`, skipping entries currently in exhaustion cooldown (so the resolver doesn't hand back a token the pool already knows is rate-limited). Rotation/refresh/heartbeat semantics still belong to `CredentialPool.select()` — this is a lightweight read-only fallback for callers that only need a single token string (auxiliary client fallback, cron, account-usage probe, model picker). The new step is purely additive: env-var and Claude Code paths still win in their original priority order. Pool lookup wraps the read in try/except so the test seat belt in `_auth_file_path` and any malformed-JSON / missing-file case degrades cleanly to "no token" rather than crashing the caller. Co-Authored-By: Claude Opus 4.7 (1M context) --- agent/anthropic_adapter.py | 60 +++++++++++++- tests/agent/test_anthropic_adapter.py | 109 ++++++++++++++++++++++++++ 2 files changed, 167 insertions(+), 2 deletions(-) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 4b1134a4c0bc..273119fc5122 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -950,6 +950,51 @@ def _prefer_refreshable_claude_code_token(env_token: str, creds: Optional[Dict[s return None +def _resolve_anthropic_token_from_pool() -> Optional[str]: + """Pull the first usable Anthropic access_token from the credential pool. + + The credential pool (``~/.hermes/auth.json::credential_pool["anthropic"]``) + is populated by ``hermes auth add anthropic`` — including the PKCE OAuth + flow (``--type oauth``) and manual API keys (``--type api_key``). Without + consulting the pool, code paths that don't go through ``CredentialPool`` + explicitly (cron jobs, the auxiliary client fallback, ``hermes debug``) + cannot see tokens stored only via Hermes-native auth. + + Reads the pool raw and skips entries currently in exhaustion cooldown. + Rotation/refresh/heartbeat semantics still belong to + ``CredentialPool.select()`` — this is a lightweight read-only fallback + for callers that only need a single token string. Returns None on any + error (pool unavailable, malformed entries, test seat belt tripped). + """ + try: + from hermes_cli.auth import read_credential_pool + entries = read_credential_pool("anthropic") + except Exception as e: + logger.debug("Credential pool lookup failed: %s", e) + return None + + if not isinstance(entries, list): + return None + + import time as _time + now_s = _time.time() + for entry in entries: + if not isinstance(entry, dict): + continue + token = (entry.get("access_token") or "").strip() + if not token: + continue + # Skip entries currently in exhaustion cooldown — pool selection + # would also skip them, and returning an exhausted token would + # produce a misleading 401 rather than a clean "no creds" error. + if entry.get("last_status") == "exhausted": + reset_at = entry.get("last_error_reset_at") + if isinstance(reset_at, (int, float)) and reset_at > now_s: + continue + return token + return None + + def resolve_anthropic_token() -> Optional[str]: """Resolve an Anthropic token from all available sources. @@ -958,7 +1003,9 @@ def resolve_anthropic_token() -> Optional[str]: 2. CLAUDE_CODE_OAUTH_TOKEN env var 3. Claude Code credentials (~/.claude.json or ~/.claude/.credentials.json) — with automatic refresh if expired and a refresh token is available - 4. ANTHROPIC_API_KEY env var (regular API key, or legacy fallback) + 4. Hermes credential_pool (``~/.hermes/auth.json``) — populated by + ``hermes auth add anthropic`` for both PKCE OAuth and manual API keys. + 5. ANTHROPIC_API_KEY env var (regular API key, or legacy fallback) Returns the token string or None. """ @@ -985,7 +1032,16 @@ def resolve_anthropic_token() -> Optional[str]: if resolved_claude_token: return resolved_claude_token - # 4. Regular API key, or a legacy OAuth token saved in ANTHROPIC_API_KEY. + # 4. Hermes credential_pool — `hermes auth add anthropic --type oauth` + # (PKCE) stores its token only here, not in any env var or Claude Code + # file. Without this lookup, cron jobs that route through + # ``resolve_runtime_provider`` raise AuthError even when + # ``hermes auth status anthropic`` reports ``logged in``. See #26344. + pool_token = _resolve_anthropic_token_from_pool() + if pool_token: + return pool_token + + # 5. Regular API key, or a legacy OAuth token saved in ANTHROPIC_API_KEY. # This remains as a compatibility fallback for pre-migration Hermes configs. api_key = os.getenv("ANTHROPIC_API_KEY", "").strip() if api_key: diff --git a/tests/agent/test_anthropic_adapter.py b/tests/agent/test_anthropic_adapter.py index 0ba2ba29f51b..153ab1ec85df 100644 --- a/tests/agent/test_anthropic_adapter.py +++ b/tests/agent/test_anthropic_adapter.py @@ -305,6 +305,115 @@ def test_keeps_static_anthropic_token_when_only_non_refreshable_claude_key_exist assert resolve_anthropic_token() == "sk-ant-oat01-static-token" + def test_falls_back_to_credential_pool_when_only_hermes_pkce_exists(self, monkeypatch, tmp_path): + """`hermes auth add anthropic --type oauth` (PKCE) stores its token + only in the credential_pool — not in env vars or Claude Code files. + Cron jobs in this state must still resolve the token. Regression + for #26344. + """ + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False) + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + # Empty HOME so the Claude Code keychain + ~/.claude paths return + # nothing. HERMES_HOME points to a separate subpath so the auth.json + # seat belt in _auth_file_path doesn't refuse the test read (it + # triggers when the resolved path equals Path.home() / .hermes / auth.json). + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: fake_home) + + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + (hermes_home / "auth.json").write_text(json.dumps({ + "version": 1, + "credential_pool": { + "anthropic": [{ + "id": "pkce-1", + "label": "claude-pro", + "auth_type": "oauth", + "priority": 0, + "source": "manual:hermes_pkce", + "access_token": "sk-ant-oat01-pkce-token", + "refresh_token": "rt-1", + "expires_at_ms": int(time.time() * 1000) + 3600_000, + }], + }, + })) + + assert resolve_anthropic_token() == "sk-ant-oat01-pkce-token" + + def test_pool_lookup_skips_exhausted_entries(self, monkeypatch, tmp_path): + """Entries in exhaustion cooldown are skipped so the resolver + doesn't hand back a token the pool already knows is rate-limited.""" + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False) + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: fake_home) + + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + (hermes_home / "auth.json").write_text(json.dumps({ + "version": 1, + "credential_pool": { + "anthropic": [ + { + "id": "exhausted", + "label": "rate-limited", + "auth_type": "oauth", + "priority": 0, + "source": "manual:hermes_pkce", + "access_token": "sk-ant-oat01-exhausted", + "last_status": "exhausted", + "last_error_reset_at": time.time() + 3600, + }, + { + "id": "fresh", + "label": "fresh", + "auth_type": "oauth", + "priority": 1, + "source": "manual:hermes_pkce", + "access_token": "sk-ant-oat01-fresh", + }, + ], + }, + })) + + assert resolve_anthropic_token() == "sk-ant-oat01-fresh" + + def test_env_anthropic_token_still_wins_over_pool(self, monkeypatch, tmp_path): + """The env-var path is unchanged: it must win over the pool so + users can override the resolved credential without editing auth.json. + """ + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-env") + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: fake_home) + + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + (hermes_home / "auth.json").write_text(json.dumps({ + "version": 1, + "credential_pool": { + "anthropic": [{ + "id": "pkce-1", + "label": "pool-cred", + "auth_type": "oauth", + "priority": 0, + "source": "manual:hermes_pkce", + "access_token": "sk-ant-oat01-pool", + }], + }, + })) + + assert resolve_anthropic_token() == "sk-ant-oat01-env" + class TestRefreshOauthToken: def test_returns_none_without_refresh_token(self):