Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 58 additions & 2 deletions agent/anthropic_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +966 to +973
return None

if not isinstance(entries, list):
return None

import time as _time
now_s = _time.time()
Comment on lines +979 to +980
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
Comment on lines +981 to +994
return None


def resolve_anthropic_token() -> Optional[str]:
"""Resolve an Anthropic token from all available sources.

Expand All @@ -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.
"""
Expand All @@ -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:
Expand Down
109 changes: 109 additions & 0 deletions tests/agent/test_anthropic_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading