# NousResearch/hermes-agent · Anthropic credential pool ignored in token resolution

## Open now

- 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.

## TL;DR

- 4 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #50108. Verify it addresses your case.
- **Recommended:** read PR #50108 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-50108`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.50`: the share of member 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.

## Closed / duplicate issues

- #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)

## Prior & competing PRs

- **#26351: fix(auth): honor anthropic credential pool oauth**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#26356: fix(anthropic): consult credential_pool in resolve_anthropic_token (#26344)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#26351`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#50108: fix(auth): consult credential_pool in resolve_anthropic_token (#26344)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `agent/anthropic_adapter.py`, `tests/agent/test_anthropic_adapter.py`
  - fix hunks:
  - `agent/anthropic_adapter.py`

    ```diff
    @@ -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.
     
    ```

    ```diff
    @@ -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.
         """
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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]")
    ```

## Fix status

- Merged to main in PR #50108 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-22`.
- Subsystem: `agent/secret_sources`
