{"repo":"NousResearch/hermes-agent","public_id":"26344","display_label":"Anthropic credential pool ignored in token resolution","summary":"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.","subsystem":"agent/secret_sources","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":50108,"merged_at":"2026-06-22T10:28:01+00:00"},"resolution":{"state":"resolved","by":"pr-50108"},"freshness":{"state":"verified","last_verified_at":"2026-06-22T10:28:01+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":26344,"pr_number":50108,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-22T10:28:01+00:00"}],"members":[{"kind":"issue","number":26344,"title":"[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)","url":null,"state":"closed"},{"kind":"pr","number":26351,"title":"fix(auth): honor anthropic credential pool oauth","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":26356,"title":"fix(anthropic): consult credential_pool in resolve_anthropic_token (#26344)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#26351","number":26351}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":50108,"title":"fix(auth): consult credential_pool in resolve_anthropic_token (#26344)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/anthropic_adapter.py","tests/agent/test_anthropic_adapter.py"],"fix_hunks":[{"file":"agent/anthropic_adapter.py","hunks":["@@ -1159,6 +1159,46 @@ def _prefer_refreshable_claude_code_token(env_token: str, creds: Optional[Dict[s\n     return None\n \n \n+def _resolve_anthropic_pool_token() -> Optional[str]:\n+    \"\"\"Return the first available Anthropic OAuth token from credential_pool.\n+\n+    Read-only: enumerates with ``clear_expired=False, refresh=False`` so a bare\n+    token *resolve* (which runs from diagnostic/read-only call sites such as\n+    ``account_usage`` and ``hermes models``) never mutates ``~/.hermes/auth.json``\n+    or makes a network refresh call. Refresh-on-expiry is owned by the API call\n+    path's pool recovery, not the resolver.\n+    \"\"\"\n+    try:\n+        from agent.credential_pool import AUTH_TYPE_OAUTH, load_pool\n+    except Exception:\n+        return None\n+\n+    try:\n+        pool = load_pool(\"anthropic\")\n+        # Enumerate read-only (clear_expired=False, refresh=False): never persist\n+        # to auth.json or trigger a network refresh from a bare resolve. select()\n+        # is deliberately NOT used — it runs clear_expired=True, refresh=True,\n+        # which would violate this read-only contract.\n+        entries = pool._available_entries(clear_expired=False, refresh=False)\n+    except Exception:\n+        logger.debug(\"Failed to read Anthropic credential_pool\", exc_info=True)\n+        return None\n+\n+    for entry in entries:\n+        if getattr(entry, \"auth_type\", None) != AUTH_TYPE_OAUTH:\n+            continue\n+        # access_token is a declared field but a persisted entry can carry an\n+        # explicit null (or a partially-written OAuth entry), so coerce before\n+        # strip — a bare None.strip() here would escape the try/excepts above\n+        # and crash the whole resolver, taking down the source #5 fallback too.\n+        # Matches the aux-client analog (auxiliary_client.py: str(key or \"\")).\n+        token = [redacted:secret-assignment] \"access_token\", None) or \"\").strip()\n+        if token:\n+            return token\n+\n+    return None\n+\n+\n def resolve_anthropic_token() -> Optional[str]:\n     \"\"\"Resolve an Anthropic token from all available sources.\n ","@@ -1167,7 +1207,8 @@ def resolve_anthropic_token() -> Optional[str]:\n       2. CLAUDE_CODE_OAUTH_TOKEN env var\n       3. Claude Code credentials (~/.claude.json or ~/.claude/.credentials.json)\n          — with automatic refresh if expired and a refresh token is available\n-      4. ANTHROPIC_API_KEY env var (regular API key, or legacy fallback)\n+      4. Anthropic credential_pool OAuth entry (~/.hermes/auth.json)\n+      5. ANTHROPIC_API_KEY env var (regular API key, or legacy fallback)\n \n     Returns the token string or None.\n     \"\"\"","@@ -1194,7 +1235,12 @@ def resolve_anthropic_token() -> Optional[str]:\n     if resolved_claude_token:\n         return resolved_claude_token\n \n-    # 4. Regular API key, or a legacy OAuth token saved in ANTHROPIC_API_KEY.\n+    # 4. Hermes credential_pool OAuth entry.\n+    resolved_pool_token = _resolve_anthropic_pool_token()\n+    if resolved_pool_token:\n+        return resolved_pool_token\n+\n+    # 5. Regular API key, or a legacy OAuth token saved in ANTHROPIC_API_KEY.\n     # This remains as a compatibility fallback for pre-migration Hermes configs.\n     api_key = os.getenv(\"ANTHROPIC_API_KEY\", \"\").strip()\n     if api_key:"]},{"file":"tests/agent/test_anthropic_adapter.py","hunks":["@@ -331,6 +331,131 @@ def test_falls_back_to_claude_code_credentials(self, monkeypatch, tmp_path):\n         monkeypatch.setattr(\"agent.anthropic_adapter.Path.home\", lambda: tmp_path)\n         assert resolve_anthropic_token() == \"cc-auto-token\"\n \n+    def test_falls_back_to_anthropic_credential_pool_oauth(self, monkeypatch, tmp_path):\n+        monkeypatch.delenv(\"ANTHROPIC_API_KEY\", raising=False)\n+        monkeypatch.delenv(\"ANTHROPIC_TOKEN\", raising=False)\n+        monkeypatch.delenv(\"CLAUDE_CODE_OAUTH_TOKEN\", raising=False)\n+        monkeypatch.setattr(\"agent.anthropic_adapter.Path.home\", lambda: tmp_path)\n+        # Isolate source #4 (credential_pool): ensure source #3 (Claude Code\n+        # creds, incl. the macOS keychain read which Path.home does not cover)\n+        # returns nothing, mirroring a Hermes-PKCE-only setup.\n+        monkeypatch.setattr(\"agent.anthropic_adapter.read_claude_code_credentials\", lambda: None)\n+\n+        pool_entry = SimpleNamespace(\n+            auth_type=\"oauth\",\n+            access_token=\"[redacted:secret-assignment]\",\n+        )\n+        pool = SimpleNamespace(\n+            _available_entries=lambda **_kwargs: [pool_entry],\n+        )\n+        monkeypatch.setattr(\"agent.credential_pool.load_pool\", lambda provider: pool)\n+\n+        assert resolve_anthropic_token() == \"pool-oauth-token\"\n+\n+    def test_prefers_anthropic_credential_pool_oauth_over_api_key(self, monkeypatch, tmp_path):\n+        monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant...ykey\")\n+        monkeypatch.delenv(\"ANTHROPIC_TOKEN\", raising=False)\n+        monkeypatch.delenv(\"CLAUDE_CODE_OAUTH_TOKEN\", raising=False)\n+        monkeypatch.setattr(\"agent.anthropic_adapter.Path.home\", lambda: tmp_path)\n+        # Pool (source #4) must win over ANTHROPIC_API_KEY (source #5); also\n+        # isolate source #3 so a machine-local Claude Code creds / keychain\n+        # entry can't short-circuit before the pool.\n+        monkeypatch.setattr(\"agent.anthropic_adapter.read_claude_code_credentials\", lambda: None)\n+\n+        pool_entry = SimpleNamespace(\n+            auth_type=\"oauth\",\n+            access_token=\"[redacted:secret-assignment]\",\n+        )\n+        pool = SimpleNamespace(\n+            _available_entries=lambda **_kwargs: [pool_entry],\n+        )\n+        monkeypatch.setattr(\"agent.credential_pool.load_pool\", lambda provider: pool)\n+\n+        assert resolve_anthropic_token() == \"pool-oauth-token\"\n+\n+    def test_pool_entry_with_null_access_token_does_not_crash(self, monkeypatch, tmp_path):\n+        \"\"\"A persisted OAuth entry with access_token=None must not crash the\n+        resolver (None.strip() would escape the helper's try/excepts and take\n+        down the whole resolver incl. the ANTHROPIC_API_KEY fallback). It should\n+        be skipped and the api-key fallback (source #5) should win.\"\"\"\n+        monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"sk-ant...ykey\")\n+        monkeypatch.delenv(\"ANTHROPIC_TOKEN\", raising=False)\n+        monkeypatch.delenv(\"CLAUDE_CODE_OAUTH_TOKEN\", raising=False)\n+        monkeypatch.setattr(\"agent.anthropic_adapter.Path.home\", lambda: tmp_path)\n+        monkeypatch.setattr(\"agent.anthropic_adapter.read_claude_code_credentials\", lambda: None)\n+\n+        broken_entry = SimpleNamespace(auth_type=\"oauth\", access_token=None)\n+        pool = SimpleNamespace(\n+            _available_entries=lambda **_kwargs: [broken_entry],\n+        )\n+        monkeypatch.setattr(\"agent.credential_pool.load_pool\", lambda provider: pool)\n+\n+        # Must fall through to source #5 (ANTHROPIC_API_KEY), not raise.\n+        assert resolve_anthropic_token() == \"sk-ant...ykey\"\n+\n+    def test_pool_api_key_only_entry_is_not_returned_as_token(self, monkeypatch, tmp_path):\n+        \"\"\"resolve_anthropic_token() returns an OAuth bearer token; a pool entry\n+        whose auth_type is api_key (not oauth) must NOT be returned from the pool\n+        path — those are consumed via the aux client's _pool_runtime_api_key\n+        lane, a different resolution concern.\"\"\"\n+        monkeypatch.delenv(\"ANTHROPIC_API_KEY\", raising=False)\n+        monkeypatch.delenv(\"ANTHROPIC_TOKEN\", raising=False)\n+        monkeypatch.delenv(\"CLAUDE_CODE_OAUTH_TOKEN\", raising=False)\n+        monkeypatch.setattr(\"agent.anthropic_adapter.Path.home\", lambda: tmp_path)\n+        monkeypatch.setattr(\"agent.anthropic_adapter.read_claude_code_credentials\", lambda: None)\n+\n+        api_key_entry = [redacted:secret-assignment]\"api_key\", access_token=\"[redacted:secret-assignment]\")\n+        pool = SimpleNamespace(\n+            _available_entries=lambda **_kwargs: [api_key_entry],\n+        )\n+        monkeypatch.setattr(\"agent.credential_pool.load_pool\", lambda provider: pool)\n+\n+        # No OAuth entry and no other source → None (the api_key entry is ignored here).\n+        assert resolve_anthropic_token() is None\n+\n+    def test_pool_is_not_consulted_when_env_token_present(self, monkeypatch, tmp_path):\n+        \"\"\"Source #1 (ANTHROPIC_TOKEN) must short-circuit before the pool: when\n+        it is set, load_pool must never be called (ordering contract #1 → #4).\"\"\"\n+        monkeypatch.setenv(\"ANTHROPIC_TOKEN\", \"env-token\")\n+        monkeypatch.delenv(\"ANTHROPIC_API_KEY\", raising=False)\n+        monkeypatch.delenv(\"CLAUDE_CODE_OAUTH_TOKEN\", raising=False)\n+        monkeypatch.setattr(\"agent.anthropic_adapter.Path.home\", lambda: tmp_path)\n+        monkeypatch.setattr(\"agent.anthropic_adapter.read_claude_code_credentials\", lambda: None)\n+\n+        pool_calls = []\n+\n+        def _tracking_load_pool(provider):\n+            pool_calls.append(provider)\n+            raise AssertionError(\"load_pool must not be called when source #1 wins\")\n+\n+        monkeypatch.setattr(\"agent.credential_pool.load_pool\", _tracking_load_pool)\n+\n+        assert resolve_anthropic_token() == \"env-token\"\n+        assert pool_calls == []\n+\n+    def test_pool_resolution_is_read_only(self, monkeypatch, tmp_path):\n+        \"\"\"The resolver must enumerate the pool read-only — clear_expired and\n+        refresh must both be False so a bare resolve never writes auth.json or\n+        triggers a network refresh from diagnostic call sites (#50108 MED).\"\"\"\n+        monkeypatch.delenv(\"ANTHROPIC_API_KEY\", raising=False)\n+        monkeypatch.delenv(\"ANTHROPIC_TOKEN\", raising=False)\n+        monkeypatch.delenv(\"CLAUDE_CODE_OAUTH_TOKEN\", raising=False)\n+        monkeypatch.setattr(\"agent.anthropic_adapter.Path.home\", lambda: tmp_path)\n+        monkeypatch.setattr(\"agent.anthropic_adapter.read_claude_code_credentials\", lambda: None)\n+\n+        captured = {}\n+        pool_entry = SimpleNamespace(auth_type=\"oauth\", access_token=\"[redacted:secret-assignment]\")\n+\n+        def _available_entries(**kwargs):\n+            captured.update(kwargs)\n+            return [pool_entry]\n+\n+        pool = SimpleNamespace(_available_entries=_available_entries)\n+        monkeypatch.setattr(\"agent.credential_pool.load_pool\", lambda provider: pool)\n+\n+        assert resolve_anthropic_token() == \"pool-oauth-token\"\n+        assert captured == {\"clear_expired\": False, \"refresh\": False}\n+\n     def test_prefers_refreshable_claude_code_credentials_over_static_anthropic_token(self, monkeypatch, tmp_path):\n         monkeypatch.delenv(\"ANTHROPIC_API_KEY\", raising=False)\n         monkeypatch.setenv(\"ANTHROPIC_TOKEN\", \"[redacted:openai-key]\")\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}