fix(auth): honor anthropic credential pool oauth

Co-authored-by: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
This commit is contained in:
LeonSGP43 2026-05-15 21:11:42 +08:00
parent f57ff7aef1
commit 3463188512
2 changed files with 187 additions and 2 deletions

View file

@ -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:

View file

@ -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")