diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 03e8b58e16c..762f551c5b8 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -1159,6 +1159,56 @@ def _prefer_refreshable_claude_code_token(env_token: str, creds: Optional[Dict[s return None +def _resolve_anthropic_pool_token() -> Optional[str]: + """Return the first available Anthropic OAuth token from credential_pool. + + Read-only: enumerates with ``clear_expired=False, refresh=False`` so a bare + token *resolve* (which runs from diagnostic/read-only call sites such as + ``account_usage`` and ``hermes models``) never mutates ``~/.hermes/auth.json`` + or makes a network refresh call. Refresh-on-expiry is owned by the API call + path's pool recovery, not the resolver. + """ + try: + from agent.credential_pool import AUTH_TYPE_OAUTH, load_pool + except Exception: + return None + + try: + pool = load_pool("anthropic") + except Exception: + logger.debug("Failed to load Anthropic credential_pool", exc_info=True) + return None + + available_entries = getattr(pool, "_available_entries", None) + if callable(available_entries): + try: + entries = available_entries(clear_expired=False, refresh=False) + except Exception: + logger.debug("Failed to enumerate Anthropic credential_pool entries", exc_info=True) + entries = [] + else: + try: + selected = pool.select() + except Exception: + logger.debug("Failed to select Anthropic credential_pool entry", exc_info=True) + selected = None + entries = [selected] if selected is not None else [] + + for entry in entries: + if getattr(entry, "auth_type", None) != AUTH_TYPE_OAUTH: + continue + # access_token is a declared field but a persisted entry can carry an + # explicit null (or a partially-written OAuth entry), so coerce before + # strip — a bare None.strip() here would escape the try/excepts above + # and crash the whole resolver, taking down the source #5 fallback too. + # Matches the aux-client analog (auxiliary_client.py: str(key or "")). + token = (getattr(entry, "access_token", None) or "").strip() + if token: + return token + + return None + + def resolve_anthropic_token() -> Optional[str]: """Resolve an Anthropic token from all available sources. @@ -1167,7 +1217,8 @@ 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. Anthropic credential_pool OAuth entry (~/.hermes/auth.json) + 5. ANTHROPIC_API_KEY env var (regular API key, or legacy fallback) Returns the token string or None. """ @@ -1194,7 +1245,12 @@ 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 OAuth entry. + resolved_pool_token = _resolve_anthropic_pool_token() + if resolved_pool_token: + return resolved_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 2a2f236b9a3..1d1e4a5b670 100644 --- a/tests/agent/test_anthropic_adapter.py +++ b/tests/agent/test_anthropic_adapter.py @@ -331,6 +331,135 @@ class TestResolveAnthropicToken: monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) assert resolve_anthropic_token() == "cc-auto-token" + def test_falls_back_to_anthropic_credential_pool_oauth(self, monkeypatch, tmp_path): + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False) + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + # Isolate source #4 (credential_pool): ensure source #3 (Claude Code + # creds, incl. the macOS keychain read which Path.home does not cover) + # returns nothing, mirroring a Hermes-PKCE-only setup. + monkeypatch.setattr("agent.anthropic_adapter.read_claude_code_credentials", lambda: None) + + pool_entry = SimpleNamespace( + auth_type="oauth", + access_token="pool-oauth-token", + ) + pool = SimpleNamespace( + _available_entries=lambda **_kwargs: [pool_entry], + select=lambda: pool_entry, + ) + monkeypatch.setattr("agent.credential_pool.load_pool", lambda provider: pool) + + assert resolve_anthropic_token() == "pool-oauth-token" + + def test_prefers_anthropic_credential_pool_oauth_over_api_key(self, monkeypatch, tmp_path): + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant...ykey") + monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False) + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + # Pool (source #4) must win over ANTHROPIC_API_KEY (source #5); also + # isolate source #3 so a machine-local Claude Code creds / keychain + # entry can't short-circuit before the pool. + monkeypatch.setattr("agent.anthropic_adapter.read_claude_code_credentials", lambda: None) + + pool_entry = SimpleNamespace( + auth_type="oauth", + access_token="pool-oauth-token", + ) + pool = SimpleNamespace( + _available_entries=lambda **_kwargs: [pool_entry], + select=lambda: pool_entry, + ) + monkeypatch.setattr("agent.credential_pool.load_pool", lambda provider: pool) + + assert resolve_anthropic_token() == "pool-oauth-token" + + def test_pool_entry_with_null_access_token_does_not_crash(self, monkeypatch, tmp_path): + """A persisted OAuth entry with access_token=None must not crash the + resolver (None.strip() would escape the helper's try/excepts and take + down the whole resolver incl. the ANTHROPIC_API_KEY fallback). It should + be skipped and the api-key fallback (source #5) should win.""" + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant...ykey") + monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False) + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + monkeypatch.setattr("agent.anthropic_adapter.read_claude_code_credentials", lambda: None) + + broken_entry = SimpleNamespace(auth_type="oauth", access_token=None) + pool = SimpleNamespace( + _available_entries=lambda **_kwargs: [broken_entry], + select=lambda: broken_entry, + ) + monkeypatch.setattr("agent.credential_pool.load_pool", lambda provider: pool) + + # Must fall through to source #5 (ANTHROPIC_API_KEY), not raise. + assert resolve_anthropic_token() == "sk-ant...ykey" + + def test_pool_api_key_only_entry_is_not_returned_as_token(self, monkeypatch, tmp_path): + """resolve_anthropic_token() returns an OAuth bearer token; a pool entry + whose auth_type is api_key (not oauth) must NOT be returned from the pool + path — those are consumed via the aux client's _pool_runtime_api_key + lane, a different resolution concern.""" + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False) + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + monkeypatch.setattr("agent.anthropic_adapter.read_claude_code_credentials", lambda: None) + + api_key_entry = SimpleNamespace(auth_type="api_key", access_token="sk-pool-apikey") + pool = SimpleNamespace( + _available_entries=lambda **_kwargs: [api_key_entry], + select=lambda: api_key_entry, + ) + monkeypatch.setattr("agent.credential_pool.load_pool", lambda provider: pool) + + # No OAuth entry and no other source → None (the api_key entry is ignored here). + assert resolve_anthropic_token() is None + + def test_pool_is_not_consulted_when_env_token_present(self, monkeypatch, tmp_path): + """Source #1 (ANTHROPIC_TOKEN) must short-circuit before the pool: when + it is set, load_pool must never be called (ordering contract #1 → #4).""" + monkeypatch.setenv("ANTHROPIC_TOKEN", "env-token") + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + monkeypatch.setattr("agent.anthropic_adapter.read_claude_code_credentials", lambda: None) + + pool_calls = [] + + def _tracking_load_pool(provider): + pool_calls.append(provider) + raise AssertionError("load_pool must not be called when source #1 wins") + + monkeypatch.setattr("agent.credential_pool.load_pool", _tracking_load_pool) + + assert resolve_anthropic_token() == "env-token" + assert pool_calls == [] + + def test_pool_resolution_is_read_only(self, monkeypatch, tmp_path): + """The resolver must enumerate the pool read-only — clear_expired and + refresh must both be False so a bare resolve never writes auth.json or + triggers a network refresh from diagnostic call sites (#50108 MED).""" + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False) + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + monkeypatch.setattr("agent.anthropic_adapter.read_claude_code_credentials", lambda: None) + + captured = {} + pool_entry = SimpleNamespace(auth_type="oauth", access_token="pool-oauth-token") + + def _available_entries(**kwargs): + captured.update(kwargs) + return [pool_entry] + + pool = SimpleNamespace(_available_entries=_available_entries, select=lambda: pool_entry) + monkeypatch.setattr("agent.credential_pool.load_pool", lambda provider: pool) + + assert resolve_anthropic_token() == "pool-oauth-token" + assert captured == {"clear_expired": False, "refresh": False} + def test_prefers_refreshable_claude_code_credentials_over_static_anthropic_token(self, monkeypatch, tmp_path): monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-static-token")