{"repo":"NousResearch/hermes-agent","public_id":"5807","display_label":"Credential pool not checked across CLI auth flows","summary":"A cluster of bugs and fixes around Hermes CLI tools (debug, doctor, model wizard, status, fallback chain) failing to consult the credential_pool or auth.json when checking for API keys, OAuth tokens, and provider configuration. Multiple commands incorrectly reported credentials as missing or providers as unconfigured when valid pooled credentials existed.","subsystem":"agent","counts":{"members":37,"open_issues":4,"open_issues_distinct":4,"open_prs":3,"closed_issues":7,"merged_prs":4,"attempt_prs":22},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":16101},"resolution":{"state":"partially_resolved","by":"pr-16101,pr-73509,pr-73883"},"freshness":{"state":"verified","last_verified_at":"2026-07-29T05:26:20+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.04894894894894894},"fix_edges":[{"issue_number":15914,"pr_number":16101,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":15932,"pr_number":16101,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":26436,"pr_number":27561,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":65977,"pr_number":73883,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":68003,"pr_number":73509,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":5807,"title":"fix: hermes doctor reports Nous Portal as 'not logged in' when credentials exist in pool","url":null,"state":"closed"},{"kind":"issue","number":15914,"title":"[Bug]: Fallback chain collapses when API key unavailable in os.environ but present in credential_pool","url":null,"state":"closed"},{"kind":"issue","number":15932,"title":"bug(auth): API keys in ~/.hermes/.env not resolved by credential_pool._seed_from_env","url":null,"state":"closed"},{"kind":"issue","number":20675,"title":"hermes debug: credential check should include credential_pool OAuth tokens, not just env vars","url":null,"state":"open"},{"kind":"issue","number":26428,"title":"I need to reshare this because I ran into an issue with the Hermes update. [Bug]: hermes doctor does not flag missing credentials for an active openrouter provider","url":null,"state":"closed"},{"kind":"issue","number":26436,"title":"[Bug]: hermes doctor does not flag missing credentials for an active openrouter provider","url":null,"state":"closed"},{"kind":"issue","number":42269,"title":"hermes auth add deepseek: credential stored to pool but provider resolution ignores it at runtime","url":null,"state":"open"},{"kind":"issue","number":50737,"title":"bug(auth/profile): OpenRouter pool state can override or leak into selected profile readiness","url":null,"state":"open"},{"kind":"issue","number":60106,"title":"hermes status reports OpenRouter not set when credential exists only in auth pool","url":null,"state":"open"},{"kind":"issue","number":65977,"title":"[Bug]: `hermes model` wizard says \"not configured\" for providers that already have a working credential in `auth.json`'s pool","url":null,"state":"closed"},{"kind":"issue","number":68003,"title":"[Bug]: STT/TTS tools bypass credential pool — get_env_value() ignores auth store","url":null,"state":"closed"},{"kind":"pr","number":5808,"title":"fix: check credential pool in get_nous_auth_status() fallback","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#5860","number":5860},{"kind":"pr","value":"#5860","number":5860}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":5860,"title":"fix(auth): check credential pool in get_nous_auth_status","url":null,"state":"closed","close_reason":{"category":"volume_quota","implication":"active"},"merged":false},{"kind":"pr","number":15920,"title":"fix(auth): resolve API keys from ~/.hermes/.env and credential_pool","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#16101","number":16101}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":15944,"title":"fix(auth): add credential_pool fallback when env var is absent","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#16101","number":16101},{"kind":"pr","value":"#15920","number":15920},{"kind":"pr","value":"#15914","number":15914}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":16101,"title":"fix(auth): resolve API keys from ~/.hermes/.env and credential_pool","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/credential_pool.py","hermes_cli/auth.py","tests/tools/test_credential_pool_env_fallback.py"],"fix_hunks":[{"file":"agent/credential_pool.py","hunks":["@@ -14,6 +14,7 @@\n from typing import Any, Dict, List, Optional, Set, Tuple\n \n from hermes_constants import OPENROUTER_BASE_URL\n+from hermes_cli.config import get_env_value\n import hermes_cli.auth as auth_mod\n from hermes_cli.auth import (\n     CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,","@@ -1273,7 +1274,8 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool\n         def _is_source_suppressed(_p, _s):  # type: ignore[misc]\n             return False\n     if provider == \"openrouter\":\n-        token = [redacted:secret-assignment]\"OPENROUTER_API_KEY\", \"\").strip()\n+        # Check both os.environ and ~/.hermes/.env file\n+        token = [redacted:secret-assignment]\"OPENROUTER_API_KEY\") or \"\").strip()\n         if token:\n             source = \"env:OPENROUTER_API_KEY\"\n             if _is_source_suppressed(provider, source):","@@ -1299,7 +1301,7 @@ def _is_source_suppressed(_p, _s):  # type: ignore[misc]\n \n     env_url = \"\"\n     if pconfig.base_url_env_var:\n-        env_url = os.getenv(pconfig.base_url_env_var, \"\").strip().rstrip(\"/\")\n+        env_url = (get_env_value(pconfig.base_url_env_var) or \"\").strip().rstrip(\"/\")\n \n     env_vars = list(pconfig.api_key_env_vars)\n     if provider == \"anthropic\":","@@ -1310,7 +1312,8 @@ def _is_source_suppressed(_p, _s):  # type: ignore[misc]\n         ]\n \n     for env_var in env_vars:\n-        token = [redacted:secret-assignment] \"\").strip()\n+        # Check both os.environ and ~/.hermes/.env file\n+        token = [redacted:secret-assignment] or \"\").strip()\n         if not token:\n             continue\n         source = f\"env:{env_var}\""]},{"file":"hermes_cli/auth.py","hunks":["@@ -467,11 +467,27 @@ def _resolve_api_key_provider_secret(\n             pass\n         return \"\", \"\"\n \n+    from hermes_cli.config import get_env_value\n     for env_var in pconfig.api_key_env_vars:\n-        val = os.getenv(env_var, \"\").strip()\n+        # Check both os.environ and ~/.hermes/.env file\n+        val = (get_env_value(env_var) or \"\").strip()\n         if has_usable_secret(val):\n             return val, env_var\n \n+    # Fallback: try credential pool (e.g. zai key stored via auth.json)\n+    try:\n+        from agent.credential_pool import load_pool\n+        pool = load_pool(provider_id)\n+        if pool and pool.has_credentials():\n+            entry = pool.peek()\n+            if entry:\n+                key = getattr(entry, \"access_token\", \"\") or getattr(entry, \"runtime_api_key\", \"\")\n+                key = str(key).strip()\n+                if has_usable_secret(key):\n+                    return key, f\"credential_pool:[redacted:secret-assignment]\"\n+    except Exception:\n+        pass\n+\n     return \"\", \"\"\n \n "]},{"file":"tests/tools/test_credential_pool_env_fallback.py","hunks":["@@ -0,0 +1,210 @@\n+\"\"\"Tests for credential_pool .env fallback and auth credential_pool lookup.\n+\n+Covers the fix from #15914 / PR #15920:\n+- _seed_from_env reads API keys from ~/.hermes/.env when not in os.environ\n+- _resolve_api_key_provider_secret falls back to credential_pool when env vars are empty\n+- env vars take priority over .env file (handled by get_env_value itself)\n+- env vars take priority over credential pool (fallback only kicks in when env is empty)\n+\"\"\"\n+\n+import os\n+from pathlib import Path\n+from unittest.mock import MagicMock, patch\n+\n+import pytest\n+\n+\n+def _make_pconfig(provider_id=\"deepseek\", env_vars=None):\n+    \"\"\"Create a minimal ProviderConfig for testing.\n+\n+    Default provider_id is 'deepseek' because it's a real api_key provider\n+    in PROVIDER_REGISTRY (needed for _seed_from_env's generic path).\n+    \"\"\"\n+    from hermes_cli.auth import ProviderConfig\n+    return ProviderConfig(\n+        id=provider_id,\n+        name=provider_id.title(),\n+        auth_type=\"api_key\",\n+        api_key_env_vars=[redacted:secret-assignment] or [f\"{provider_id.upper()}_API_KEY\"]),\n+    )\n+\n+\n[redacted:email]\n+def isolated_hermes_home(tmp_path, monkeypatch):\n+    \"\"\"Point HERMES_HOME at a temp dir and clear known API key env vars.\n+\n+    Also invalidates any cached get_env_value state by patching Path.home().\n+    \"\"\"\n+    home = tmp_path / \".hermes\"\n+    home.mkdir()\n+    monkeypatch.setattr(Path, \"home\", lambda: tmp_path)\n+    monkeypatch.setenv(\"HERMES_HOME\", str(home))\n+\n+    # Clear all known API key env vars so get_env_value falls through to .env\n+    for key in [\n+        \"OPENAI_API_KEY\", \"ANTHROPIC_API_KEY\", \"OPENROUTER_API_KEY\",\n+        \"ZAI_API_KEY\", \"DEEPSEEK_API_KEY\", \"ANTHROPIC_TOKEN\",\n+        \"CLAUDE_CODE_OAUTH_TOKEN\", \"OPENAI_BASE_URL\",\n+    ]:\n+        monkeypatch.delenv(key, raising=False)\n+\n+    return home\n+\n+\n+def _write_env_file(home: Path, **kwargs) -> None:\n+    \"\"\"Write key=value pairs to ~/.hermes/.env.\"\"\"\n+    lines = [f\"{k}={v}\" for k, v in kwargs.items()]\n+    (home / \".env\").write_text(\"\\n\".join(lines) + \"\\n\")\n+\n+\n+class TestCredentialPoolSeedsFromDotEnv:\n+    \"\"\"_seed_from_env must read keys from ~/.hermes/.env, not just os.environ.\n+\n+    This is the load-bearing behaviour for the fix: when a user adds a key to\n+    .env mid-session or via a non-CLI entry point that doesn't run\n+    load_hermes_dotenv, the credential pool must still discover it.\n+    \"\"\"\n+\n+    def test_deepseek_key_from_dotenv_only(self, isolated_hermes_home):\n+        \"\"\"Key in .env but not os.environ → _seed_from_env adds a pool entry.\"\"\"\n+        _write_env_file(isolated_hermes_home, DEEPSEEK_API_KEY=\"[redacted:secret-assignment]\")\n+        assert \"DEEPSEEK_API_KEY\" not in os.environ\n+\n+        from agent.credential_pool import _seed_from_env\n+        entries = []\n+        changed, active_sources = _seed_from_env(\"deepseek\", entries)\n+\n+        assert changed is True\n+        assert \"env:DEEPSEEK_API_KEY\" in active_sources\n+        assert any(\n+            e.access_token == \"sk-dotenv-only-12345\"\n+            and e.source == \"env:DEEPSEEK_API_KEY\"\n+            for e in entries\n+        ), f\"Expected seeded entry with dotenv key, got: {[(e.source, e.access_token) for e in entries]}\"\n+\n+    def test_openrouter_key_from_dotenv_only(self, isolated_hermes_home):\n+        \"\"\"OpenRouter path has its own branch — verify it also reads .env.\"\"\"\n+        _write_env_file(isolated_hermes_home, OPENROUTER_API_KEY=\"[redacted:secret-assignment]\")\n+        assert \"OPENROUTER_API_KEY\" not in os.environ\n+\n+        from agent.credential_pool import _seed_from_env\n+        entries = []\n+        changed, active_sources = _seed_from_env(\"openrouter\", entries)\n+\n+        assert changed is True\n+        assert \"env:OPENROUTER_API_KEY\" in active_sources\n+        assert any(\n+            e.access_token == \"sk-or-dotenv-abc\" for e in entries\n+        )\n+\n+    def test_empty_dotenv_no_entries(self, isolated_hermes_home):\n+        \"\"\"No .env file, no env vars → no entries seeded (and no crash).\"\"\"\n+        from agent.credential_pool import _seed_from_env\n+        entries = []\n+        changed, active_sources = _seed_from_env(\"deepseek\", entries)\n+        assert changed is False\n+        assert active_sources == set()\n+        assert entries == []\n+\n+    def test_os_environ_still_wins_over_dotenv(self, isolated_hermes_home, monkeypatch):\n+        \"\"\"get_env_value checks os.environ first — verify seeding picks that up.\"\"\"\n+        _write_env_file(isolated_hermes_home, DEEPSEEK_API_KEY=\"[redacted:secret-assignment]\")\n+        monkeypatch.setenv(\"DEEPSEEK_API_KEY\", \"sk-env-fresh-xyz\")\n+\n+        from agent.credential_pool import _seed_from_env\n+        entries = []\n+        changed, _ = _seed_from_env(\"deepseek\", entries)\n+\n+        assert changed is True\n+        seeded = [e for e in entries if e.source == \"env:DEEPSEEK_API_KEY\"]\n+        assert len(seeded) == 1\n+        assert seeded[0].access_token == \"sk-env-fresh-xyz\"\n+\n+\n+class TestAuthResolvesFromDotEnv:\n+    \"\"\"_resolve_api_key_provider_secret must also read from ~/.hermes/.env.\"\"\"\n+\n+    def test_key_from_dotenv_only(self, isolated_hermes_home):\n+        \"\"\"Key in .env but not os.environ → _resolve returns it with the env var source.\"\"\"\n+        _write_env_file(isolated_hermes_home, DEEPSEEK_API_KEY=\"[redacted:secret-assignment]\")\n+        assert \"DEEPSEEK_API_KEY\" not in os.environ\n+\n+        from hermes_cli.auth import _resolve_api_key_provider_secret\n+        key, source = _resolve_api_key_provider_secret(\n+            provider_id=\"deepseek\",\n+            pconfig=_make_pconfig(),\n+        )\n+        assert key == \"[redacted:openai-key]\"\n+        assert source == \"DEEPSEEK_API_KEY\"\n+\n+\n+class TestAuthCredentialPoolFallback:\n+    \"\"\"_resolve_api_key_provider_secret falls back to credential pool when env + dotenv are empty.\"\"\"\n+\n+    def test_credential_pool_fallback_structure(self, isolated_hermes_home):\n+        \"\"\"Empty env + empty .env → auth falls back to credential pool.\"\"\"\n+        mock_entry = MagicMock()\n+        mock_entry.access_token = \"[redacted:secret-assignment]\"\n+        mock_entry.runtime_api_key = \"\"\n+\n+        mock_pool = MagicMock()\n+        mock_pool.has_credentials.return_value = True\n+        mock_pool.peek.return_value = mock_entry\n+\n+        from hermes_cli.auth import _resolve_api_key_provider_secret\n+        with patch(\"agent.credential_pool.load_pool\", return_value=mock_pool):\n+            key, source = _resolve_api_key_provider_secret(\n+                provider_id=\"deepseek\",\n+                pconfig=_make_pconfig(),\n+            )\n+        assert \"test-pool-key-12345\" in key\n+        assert \"credential_pool\" in source\n+\n+    def test_credential_pool_empty_returns_empty(self, isolated_hermes_home):\n+        \"\"\"Empty env + empty .env + empty pool → empty string.\"\"\"\n+        mock_pool = MagicMock()\n+        mock_pool.has_credentials.return_value = False\n+\n+        from hermes_cli.auth import _resolve_api_key_provider_secret\n+        with patch(\"agent.credential_pool.load_pool\", return_value=mock_pool):\n+            key, source = _resolve_api_key_provider_secret(\n+                provider_id=\"deepseek\",\n+                pconfig=_make_pconfig(),\n+            )\n+        assert key == \"\"\n+\n+    def test_env_var_takes_priority_over_pool(self, isolated_hermes_home, monkeypatch):\n+        \"\"\"os.environ key wins — credential pool is NEVER consulted.\"\"\"\n+        monkeypatch.setenv(\"DEEPSEEK_API_KEY\", \"[redacted:openai-key]\")\n+\n+        mock_pool = MagicMock()\n+        mock_pool.has_credentials.return_value = True\n+\n+        from hermes_cli.auth import _resolve_api_key_provider_secret\n+        with patch(\"agent.credential_pool.load_pool\", return_value=mock_pool) as mp:\n+            key, source = _resolve_api_key_provider_secret(\n+                provider_id=\"deepseek\",\n+                pconfig=_make_pconfig(),\n+            )\n+        assert key == \"[redacted:openai-key]\"\n+        assert source == \"DEEPSEEK_API_KEY\"\n+        # Pool should not even have been loaded — env var satisfied the request first\n+        mp.assert_not_called()\n+\n+    def test_dotenv_takes_priority_over_pool(self, isolated_hermes_home):\n+        \"\"\"Key in .env beats credential pool — pool only fires when both env sources are empty.\"\"\"\n+        _write_env_file(isolated_hermes_home, DEEPSEEK_API_KEY=\"[redacted:secret-assignment]\")\n+        assert \"DEEPSEEK_API_KEY\" not in os.environ\n+\n+        mock_pool = MagicMock()\n+        mock_pool.has_credentials.return_value = True\n+\n+        from hermes_cli.auth import _resolve_api_key_provider_secret\n+        with patch(\"agent.credential_pool.load_pool\", return_value=mock_pool) as mp:\n+            key, source = _resolve_api_key_provider_secret(\n+                provider_id=\"deepseek\",\n+                pconfig=_make_pconfig(),\n+            )\n+        assert key == \"[redacted:openai-key]\"\n+        assert source == \"DEEPSEEK_API_KEY\"\n+        mp.assert_not_called()\n"]}]},{"kind":"pr","number":20698,"title":"fix(cli): hermes debug now sees credential_pool OAuth tokens","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":20761,"title":"fix(cli): hermes debug reports OAuth providers as 'not set' when logged in","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#20698","number":20698}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":26448,"title":"fix(doctor): flag missing OpenRouter credentials","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":26453,"title":"Fix doctor summary for active OpenRouter without credentials","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"73df32921","number":0},{"kind":"pr","value":"#27561","number":27561},{"kind":"pr","value":"#26436","number":26436}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":26776,"title":"fix(doctor): flag missing OpenRouter credentials","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"73df329214a89eddbd45b0fa84ee99aefa8aea30","number":0},{"kind":"pr","value":"#26448","number":26448},{"kind":"pr","value":"#26453","number":26453},{"kind":"pr","value":"#26436","number":26436}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":27561,"title":"fix(doctor): flag missing credentials for active openrouter provider (salvage #26453)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["Dockerfile","hermes_cli/doctor.py","scripts/release.py","tests/hermes_cli/test_doctor.py","tests/tools/test_dockerfile_pid1_reaping.py"],"fix_hunks":[{"file":"Dockerfile","hunks":["@@ -66,9 +66,11 @@ RUN npm install --prefer-offline --no-audit && \\\n # frontend stats the readme path during dep resolution, so we `touch` an\n # empty placeholder — the real README is restored by `COPY . .` below.\n #\n-# `uv sync --frozen --no-install-project --extra all` installs only the\n-# deps reachable through the composite `[all]` extra (handpicked set\n-# intended for the production image).  We do NOT use `--all-extras`:\n+# `uv sync --frozen --no-install-project --extra all --extra messaging`\n+# installs the deps reachable through the composite `[all]` extra\n+# (handpicked set intended for the production image), plus gateway\n+# messaging adapters that should work in the published image without a\n+# first-boot lazy install.  We do NOT use `--all-extras`:\n # that would pull in `[rl]` (atroposlib + tinker + torch + wandb from\n # git), `[yc-bench]` (another git dep), and `[termux-all]` (Android\n # redundancy), none of which belong in the published container.","@@ -76,7 +78,7 @@ RUN npm install --prefer-offline --no-audit && \\\n # The editable link is created after the source copy below.\n COPY pyproject.toml uv.lock ./\n RUN touch ./README.md\n-RUN uv sync --frozen --no-install-project --extra all\n+RUN uv sync --frozen --no-install-project --extra all --extra messaging\n \n # ---------- Source code ----------\n # .dockerignore excludes node_modules, so the installs above survive.","@@ -94,10 +96,10 @@ RUN cd web && npm run build && \\\n # hermes_cli/main.py succeeds (see #18800). /opt/hermes/web is build-time\n # only (HERMES_WEB_DIST points at hermes_cli/web_dist) and is intentionally\n # not chowned here.\n-# The .venv MUST be hermes-writable so lazy_deps.py can install platform\n-# packages (discord.py, telegram, slack, etc.) at first gateway boot.\n-# Without this, `uv pip install` fails with EACCES and all messaging\n-# adapters silently fail to load.  See tools/lazy_deps.py.\n+# The .venv MUST remain hermes-writable so lazy_deps.py can install\n+# remaining optional platform packages and future pin bumps at first use.\n+# Without this, `uv pip install` fails with EACCES and adapters silently\n+# fail to load.  See tools/lazy_deps.py.\n USER root\n RUN chmod -R a+rX /opt/hermes && \\\n     chown -R hermes:hermes /opt/hermes/.venv /opt/hermes/ui-tui /opt/hermes/node_modules"]},{"file":"hermes_cli/doctor.py","hunks":["@@ -651,31 +651,41 @@ def run_doctor(args):\n \n             # Check credentials for the configured provider.\n             # Limit to API-key providers in PROVIDER_REGISTRY — other provider\n-            # types (OAuth, SDK, openrouter/anthropic/custom/auto) have their\n-            # own env-var checks elsewhere in doctor, and get_auth_status()\n-            # returns a bare {logged_in: False} for anything it doesn't\n-            # explicitly dispatch, which would produce false positives.\n-            if runtime_provider and runtime_provider not in {\"auto\", \"custom\", \"openrouter\"}:\n+            # types (OAuth, SDK, anthropic/custom/auto) have their own env-var\n+            # checks elsewhere in doctor, and get_auth_status() returns a bare\n+            # {logged_in: False} for anything it doesn't explicitly dispatch,\n+            # which would produce false positives.\n+            if runtime_provider and runtime_provider not in (\"auto\", \"custom\"):\n                 try:\n-                    from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n-                    pconfig = PROVIDER_REGISTRY.get(runtime_provider)\n-                    if pconfig and getattr(pconfig, \"auth_type\", \"\") == \"api_key\":\n-                        status = get_auth_status(runtime_provider) or {}\n+                    if runtime_provider == \"openrouter\":\n+                        from hermes_cli.config import get_env_value\n+\n                         configured = bool(\n-                            status.get(\"configured\")\n-                            or status.get(\"logged_in\")\n-                            or status.get(\"api_key\")\n+                            str(get_env_value(\"OPENROUTER_API_KEY\") or \"\").strip()\n+                            or str(get_env_value(\"OPENAI_API_KEY\") or \"\").strip()\n                         )\n-                        if not configured:\n-                            check_fail(\n-                                f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n-                                \"(check ~/.hermes/.env or run 'hermes setup')\",\n-                            )\n-                            issues.append(\n-                                f\"No credentials found for provider '{runtime_provider}'. \"\n-                                f\"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, \"\n-                                f\"or switch providers with 'hermes config set model.provider <name>'\"\n+                    else:\n+                        from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n+\n+                        pconfig = PROVIDER_REGISTRY.get(runtime_provider)\n+                        configured = True\n+                        if pconfig and getattr(pconfig, \"auth_type\", \"\") == \"api_key\":\n+                            status = get_auth_status(runtime_provider) or {}\n+                            configured = bool(\n+                                status.get(\"configured\")\n+                                or status.get(\"logged_in\")\n+                                or status.get(\"api_key\")\n                             )\n+                    if not configured:\n+                        check_fail(\n+                            f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n+                            \"(check ~/.hermes/.env or run 'hermes setup')\",\n+                        )\n+                        issues.append(\n+                            f\"No credentials found for provider '{runtime_provider}'. \"\n+                            f\"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, \"\n+                            f\"or switch providers with 'hermes config set model.provider <name>'\"\n+                        )\n                 except Exception:\n                     pass\n "]},{"file":"scripts/release.py","hunks":["@@ -1161,6 +1161,7 @@\n     \"[redacted:email]\": \"davidcampbelldc\",\n     \"[redacted:email]\": \"hehehe0803\",  # PR #26212 salvage (codex kanban writable root)\n     \"[redacted:email]\": \"hehehe0803\",\n+    \"[redacted:email]\": \"vaddisrinivas\",  # PR #26394 salvage (Docker messaging extra)\n }\n \n "]},{"file":"tests/hermes_cli/test_doctor.py","hunks":["@@ -477,6 +477,48 @@ def test_run_doctor_accepts_bare_custom_provider(monkeypatch, tmp_path):\n     assert \"model.provider 'custom' is not a recognised provider\" not in out\n \n \n+def test_run_doctor_flags_missing_credentials_for_active_openrouter_provider(monkeypatch, tmp_path):\n+    home = tmp_path / \".hermes\"\n+    home.mkdir(parents=True, exist_ok=True)\n+    (home / \"config.yaml\").write_text(\n+        \"model:\\n\"\n+        \"  provider: openrouter\\n\"\n+        \"  default: openai/gpt-4.1-mini\\n\",\n+        encoding=\"utf-8\",\n+    )\n+\n+    monkeypatch.setattr(doctor_mod, \"HERMES_HOME\", home)\n+    monkeypatch.setattr(doctor_mod, \"PROJECT_ROOT\", tmp_path / \"project\")\n+    monkeypatch.setattr(doctor_mod, \"_DHH\", str(home))\n+    (tmp_path / \"project\").mkdir(exist_ok=True)\n+\n+    fake_model_tools = types.SimpleNamespace(\n+        check_tool_availability=lambda *a, **kw: ([], []),\n+        TOOLSET_REQUIREMENTS={},\n+    )\n+    monkeypatch.setitem(sys.modules, \"model_tools\", fake_model_tools)\n+    monkeypatch.delenv(\"OPENROUTER_API_KEY\", raising=False)\n+    monkeypatch.delenv(\"OPENAI_API_KEY\", raising=False)\n+\n+    try:\n+        from hermes_cli import auth as _auth_mod\n+\n+        monkeypatch.setattr(_auth_mod, \"get_nous_auth_status\", lambda: {})\n+        monkeypatch.setattr(_auth_mod, \"get_codex_auth_status\", lambda: {})\n+        monkeypatch.setattr(_auth_mod, \"get_gemini_oauth_auth_status\", lambda: {})\n+        monkeypatch.setattr(_auth_mod, \"get_minimax_oauth_auth_status\", lambda: {})\n+    except Exception:\n+        pass\n+\n+    buf = io.StringIO()\n+    with contextlib.redirect_stdout(buf):\n+        doctor_mod.run_doctor(Namespace(fix=False))\n+\n+    out = buf.getvalue()\n+    assert \"model.provider 'openrouter' is set but no API key is configured\" in out\n+    assert \"No credentials found for provider 'openrouter'.\" in out\n+\n+\n @pytest.mark.parametrize(\n     (\"provider\", \"default_model\"),\n     ["]},{"file":"tests/tools/test_dockerfile_pid1_reaping.py","hunks":["@@ -121,6 +121,20 @@ def test_dockerfile_installs_tui_dependencies(dockerfile_text):\n     )\n \n \n+def test_dockerfile_preinstalls_gateway_messaging_dependencies(dockerfile_text):\n+    sync_steps = [\n+        step for step in _run_steps(dockerfile_text)\n+        if \"uv sync\" in step and \"--no-install-project\" in step\n+    ]\n+\n+    assert sync_steps, \"Dockerfile must install Python dependencies with uv sync\"\n+    assert any(\"--extra messaging\" in step for step in sync_steps), (\n+        \"Published Docker images must preload the [messaging] extra so \"\n+        \"Telegram/Discord gateway adapters do not depend on first-boot \"\n+        \"lazy installation (#24698).\"\n+    )\n+\n+\n def test_dockerfile_builds_tui_assets(dockerfile_text):\n     assert any(\n         \"ui-tui\" in step and \"npm\" in step and \"run build\" in step\n"]}]},{"kind":"pr","number":42386,"title":"fix(auth): surface pool base_url and log errors in _resolve_api_key_p…","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":42425,"title":"fix(auth): honour pool entry base_url in provider credential resolution (#42269)","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":60135,"title":"fix: hermes status checks credential pool when env var is absent (#60106)","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#60214","number":60214}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":60214,"title":"fix(cli): check credential pool for OpenRouter in hermes status (#60106)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#60135","number":60135},{"kind":"pr","value":"#60106","number":60106},{"kind":"pr","value":"#60135","number":60135}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":60392,"title":"feat(debug): check credential_pool OAuth tokens in API key status","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":60401,"title":"fix(debug): hermes debug shows OAuth providers as 'not set' when authenticated (Fixes #20675)","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":65988,"title":"fix(cli): honor pooled credentials in model wizard 🩷","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#73883","number":73883}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":66007,"title":"fix(cli): model wizard recognises auth.json credential-pool keys (#65977)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#65988","number":65988}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":66041,"title":"fix: hermes model wizard recognises keys from credential pool (#65977)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#73883","number":73883}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":68011,"title":"fix(tools): resolve TTS/STT provider keys through credential pool (fixes #68003)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#68509","number":68509}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":68509,"title":"fix(tools): resolve TTS/STT provider keys through credential pool","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"c136400c9e","number":0},{"kind":"commit","value":"8c7ba05437","number":0},{"kind":"pr","value":"#73509","number":73509}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":68518,"title":"fix(config): check credential pool in get_env_value (#68003)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#68509","number":68509},{"kind":"pr","value":"#69469","number":69469},{"kind":"pr","value":"#68509","number":68509},{"kind":"pr","value":"#69469","number":69469},{"kind":"pr","value":"#68003","number":68003}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":73509,"title":"fix(tools): STT/TTS provider keys resolve through the credential pool via one scoped resolver","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_tool_backend_helpers.py","tests/tools/test_voice_credential_pool_resolution.py","tools/tool_backend_helpers.py","tools/transcription_tools.py","tools/tts_tool.py","tools/xai_http.py"]},{"kind":"pr","number":73883,"title":"fix(cli): honor pooled credentials in model wizard (#65977)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["contributors/emails/[redacted:email]","hermes_cli/main.py","hermes_cli/model_setup_flows.py","tests/hermes_cli/test_model_flow_pooled_credentials.py","tests/hermes_cli/test_prompt_api_key.py"]},{"kind":"pr","number":108333,"title":"fix(cli): count credential-pool entries as a configured API key in doctor, status and config show","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#60135","number":60135},{"kind":"pr","value":"#60106","number":60106},{"kind":"pr","value":"#62467","number":62467}],"category":"duplicate_of","implication":"duplicate"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}