NousResearch/hermes-agent

Anthropic credential pool ignored in token resolution

Cluster 26344 · agent/secret sources. Data as of

Nothing open on GitHub. Fix merged in #50108 on 22 Jun 2026. Check it covers your case.

The short answer

0open issues
0open PRs
4items grouped
Known?
Yes. 4 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #50108. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #50108. That is separate from the fix event.
Still open
Nothing open on GitHub.

A bug where resolve_anthropic_token does not consult the credential_pool, causing failures for users authenticated via OAuth through the credential pool. Multiple fixes address this lookup gap.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #50108 fix(auth): consult credential_pool in resolve_anthropic_token (#26344)

    merged to main

    files: agent/anthropic_adapter.py, tests/agent/test_anthropic_adapter.py

    Show the diff
    • agent/anthropic_adapter.py
      @@ -1159,6 +1159,46 @@ 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")
      +        # Enumerate read-only (clear_expired=False, refresh=False): never persist
      +        # to auth.json or trigger a network refresh from a bare resolve. select()
      +        # is deliberately NOT used — it runs clear_expired=True, refresh=True,
      +        # which would violate this read-only contract.
      +        entries = pool._available_entries(clear_expired=False, refresh=False)
      +    except Exception:
      +        logger.debug("Failed to read Anthropic credential_pool", exc_info=True)
      +        return None
      +
      +    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 = [redacted:secret-assignment] "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 +1207,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 +1235,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:
    • tests/agent/test_anthropic_adapter.py
      @@ -331,6 +331,131 @@ def test_falls_back_to_claude_code_credentials(self, monkeypatch, tmp_path):
               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="[redacted:secret-assignment]",
      +        )
      +        pool = SimpleNamespace(
      +            _available_entries=lambda **_kwargs: [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="[redacted:secret-assignment]",
      +        )
      +        pool = SimpleNamespace(
      +            _available_entries=lambda **_kwargs: [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],
      +        )
      +        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 = [redacted:secret-assignment]"api_key", access_token="[redacted:secret-assignment]")
      +        pool = SimpleNamespace(
      +            _available_entries=lambda **_kwargs: [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="[redacted:secret-assignment]")
      +
      +        def _available_entries(**kwargs):
      +            captured.update(kwargs)
      +            return [pool_entry]
      +
      +        pool = SimpleNamespace(_available_entries=_available_entries)
      +        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", "[redacted:openai-key]")

2 PRs closed without merging

From the close comments: superseded by another PR 1 · other 1

  • #26351 fix(auth): honor anthropic credential pool oauth · other
  • #26356 fix(anthropic): consult credential_pool in resolve_anthropic_token (#26344) · superseded by another PR → #26351
Closed issues · 1
  • Closed#26344[Bug]: cron jobs fail with 'No Anthropic credentials found' when authenticated via 'hermes auth add anthropic --type oauth' (credential_pool ignored by resolve_anthropic_token)
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.50: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent/secret sources
Merged PRs
#50108
Closed issues
1
Attempts
2 PRs closed without merging
Fix link checked
22 Jun 2026

For your agent

The same facts, typed.