{"repo":"NousResearch/hermes-agent","public_id":"18254","display_label":"credential pool env file vs os.environ precedence","summary":"These issues and PRs concern a bug where the credential pool reads from stale os.environ values instead of the ~/.hermes/.env file, and proposed fixes to make the .env file authoritative during credential seeding and key rotation.","subsystem":"agent","counts":{"members":8,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":2,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":18755},"resolution":{"state":"resolved","by":"pr-18755,pr-55528"},"freshness":{"state":"verified","last_verified_at":"2026-06-30T09:49:53+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.22714285714285712},"fix_edges":[{"issue_number":18254,"pr_number":18755,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":18359,"pr_number":18755,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":20591,"pr_number":55528,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":18254,"title":"[Bug]: auth.json credential cache ignores .env changes — stale key persists","url":null,"state":"closed"},{"kind":"issue","number":20591,"title":"bug: credential pool reads stale os.environ instead of fresh .env — test documents correct behavior but code doesn't match","url":null,"state":"closed"},{"kind":"pr","number":18256,"title":"fix: prefer ~/.hermes/.env over os.environ when seeding credential pool","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#18755","number":18755}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":18298,"title":"fix(config): make ~/.hermes/.env authoritative over os.environ in get_env_value (#18254)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#18256","number":18256}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":18755,"title":"fix: prefer ~/.hermes/.env over stale os.environ when seeding credential pool (salvage #18256)","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","scripts/release.py","tests/agent/test_credential_pool.py"],"fix_hunks":[{"file":"agent/credential_pool.py","hunks":["@@ -3,6 +3,7 @@\n from __future__ import annotations\n \n import logging\n+import os\n import random\n import threading\n import time","@@ -13,7 +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+from hermes_cli.config import get_env_value, load_env\n import hermes_cli.auth as auth_mod\n from hermes_cli.auth import (\n     CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,","@@ -1380,6 +1381,16 @@ def _is_suppressed(_p, _s):  # type: ignore[misc]\n def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool, Set[str]]:\n     changed = False\n     active_sources: Set[str] = set()\n+\n+    # Prefer ~/.hermes/.env over os.environ — the user's config file is the\n+    # authoritative source for Hermes credentials. Stale env vars from parent\n+    # processes (Codex CLI, test scripts, etc.) should not override deliberate\n+    # changes to the .env file.\n+    def _get_env_prefer_dotenv(key: str) -> str:\n+        env_file = load_env()\n+        val = env_file.get(key) or os.environ.get(key) or \"\"\n+        return val.strip()\n+\n     # Honour user suppression — `hermes auth remove <provider> <N>` for an\n     # env-seeded credential marks the env:<VAR> source as suppressed so it\n     # won't be re-seeded from the user's shell environment or ~/.hermes/.env.","@@ -1391,8 +1402,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-        # Check both os.environ and ~/.hermes/.env file\n-        token = [redacted:secret-assignment]\"OPENROUTER_API_KEY\") or \"\").strip()\n+        # Prefer ~/.hermes/.env over os.environ\n+        token = [redacted:secret-assignment]\"OPENROUTER_API_KEY\")\n         if token:\n             source = \"env:OPENROUTER_API_KEY\"\n             if _is_source_suppressed(provider, source):","@@ -1418,7 +1429,7 @@ def _is_source_suppressed(_p, _s):  # type: ignore[misc]\n \n     env_url = \"\"\n     if pconfig.base_url_env_var:\n-        env_url = (get_env_value(pconfig.base_url_env_var) or \"\").strip().rstrip(\"/\")\n+        env_url = _get_env_prefer_dotenv(pconfig.base_url_env_var).rstrip(\"/\")\n \n     env_vars = list(pconfig.api_key_env_vars)\n     if provider == \"anthropic\":","@@ -1429,8 +1440,8 @@ def _is_source_suppressed(_p, _s):  # type: ignore[misc]\n         ]\n \n     for env_var in env_vars:\n-        # Check both os.environ and ~/.hermes/.env file\n-        token = [redacted:secret-assignment] or \"\").strip()\n+        # Prefer ~/.hermes/.env over os.environ\n+        token = [redacted:secret-assignment]\n         if not token:\n             continue\n         source = f\"env:{env_var}\""]},{"file":"scripts/release.py","hunks":["@@ -371,6 +371,7 @@\n     \"[redacted:email]\": \"zons-zhaozhy\",\n     \"[redacted:email]\": \"Es1la\",\n     \"[redacted:email]\": \"CoreyNoDream\",\n+    \"[redacted:email]\": \"franksong2702\",\n     # ── bulk addition: 75 emails resolved via API, PR salvage bodies, noreply\n     #    crossref, and GH contributor list matching (April 2026 audit) ──\n     \"[redacted:email]\": \"aaronagent\","]},{"file":"tests/agent/test_credential_pool.py","hunks":["@@ -348,6 +348,64 @@ def test_load_pool_seeds_env_api_key(tmp_path, monkeypatch):\n     assert entry.access_token == \"sk-or-seeded\"\n \n \n+\n+def test_load_pool_prefers_dotenv_over_stale_os_environ(tmp_path, monkeypatch):\n+    \"\"\"Regression for #18254: stale OPENROUTER_API_KEY in os.environ (inherited\n+    from a parent shell) must NOT shadow the fresh key in ~/.hermes/.env when\n+    seeding the credential pool. Before the fix, `get_env_value()` preferred\n+    os.environ and silently wrote the stale value into auth.json, causing\n+    persistent 401 errors after key rotation.\n+    \"\"\"\n+    hermes_home = tmp_path / \"hermes\"\n+    hermes_home.mkdir()\n+    monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+\n+    # Simulate the bug: parent shell exported a stale test key\n+    monkeypatch.setenv(\"OPENROUTER_API_KEY\", \"[redacted:openai-key]\")\n+\n+    # User edited ~/.hermes/.env with the fresh key\n+    (hermes_home / \".env\").write_text(\n+        \"OPENROUTER_API_KEY=[redacted:secret-assignment]\"\n+    )\n+\n+    _write_auth_store(tmp_path, {\"version\": 1, \"providers\": {}})\n+\n+    from agent.credential_pool import load_pool\n+    pool = load_pool(\"openrouter\")\n+    entry = pool.select()\n+\n+    assert entry is not None\n+    assert entry.source == \"env:OPENROUTER_API_KEY\"\n+    # The fresh key from .env must win over the stale shell export\n+    assert entry.access_token == \"[redacted:openai-key]\", (\n+        f\"Expected .env to win, got {entry.access_token!r}\"\n+    )\n+\n+\n+def test_load_pool_falls_back_to_os_environ_when_dotenv_empty(tmp_path, monkeypatch):\n+    \"\"\"When ~/.hermes/.env does not define OPENROUTER_API_KEY (typical Docker /\n+    K8s / systemd deployment), seeding must still pick up the key from\n+    os.environ. Guards against regressions that would break production\n+    deployments relying on runtime-injected env vars.\n+    \"\"\"\n+    hermes_home = tmp_path / \"hermes\"\n+    hermes_home.mkdir()\n+    monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+    monkeypatch.setenv(\"OPENROUTER_API_KEY\", \"[redacted:openai-key]\")\n+\n+    # .env exists but does not define OPENROUTER_API_KEY\n+    (hermes_home / \".env\").write_text(\"SOME_OTHER_VAR=unrelated\\n\")\n+\n+    _write_auth_store(tmp_path, {\"version\": 1, \"providers\": {}})\n+\n+    from agent.credential_pool import load_pool\n+    pool = load_pool(\"openrouter\")\n+    entry = pool.select()\n+\n+    assert entry is not None\n+    assert entry.access_token == \"[redacted:openai-key]\"\n+\n+\n def test_load_pool_removes_stale_seeded_env_entry(tmp_path, monkeypatch):\n     monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path / \"hermes\"))\n     monkeypatch.delenv(\"OPENROUTER_API_KEY\", raising=False)\n"]}]},{"kind":"pr","number":19021,"title":"test(credential_pool): align with .env-first seed precedence","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"66320de52e9d77c5afc9767a350447011c8577f1","number":0},{"kind":"commit","value":"0a6865b328ee6057eb59ee4a150c4886aa72d48c","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":20602,"title":"fix(credentials): prefer ~/.hermes/.env over stale os.environ on key rotation","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"26f39f7b9","number":0},{"kind":"pr","value":"#20591","number":20591},{"kind":"pr","value":"#55528","number":55528}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":55528,"title":"fix(credentials): prefer ~/.hermes/.env over stale os.environ on key rotation (#20591)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/auth.py","hermes_cli/config.py","tests/tools/test_credential_pool_env_fallback.py"],"fix_hunks":[{"file":"hermes_cli/auth.py","hunks":["@@ -479,16 +479,18 @@ class ProviderConfig:\n def get_anthropic_key() -> str:\n     \"\"\"Return the first usable Anthropic credential, or ``\"\"``.\n \n-    Checks both the ``.env`` file (via ``get_env_value``) and the process\n-    environment (``os.getenv``).  The fallback order mirrors the\n-    ``PROVIDER_REGISTRY[\"anthropic\"].api_key_env_vars`` tuple:\n+    Checks both the ``.env`` file and the process environment, preferring\n+    ``~/.hermes/.env`` so a deliberate key rotation isn't shadowed by a stale\n+    shell export (matches the api-key resolution path — see #20591).  The\n+    order mirrors the ``PROVIDER_REGISTRY[\"anthropic\"].api_key_env_vars``\n+    tuple:\n \n         ANTHROPIC_API_KEY -> ANTHROPIC_TOKEN -> CLAUDE_CODE_OAUTH_TOKEN\n     \"\"\"\n-    from hermes_cli.config import get_env_value\n+    from hermes_cli.config import get_env_value_prefer_dotenv\n \n     for var in PROVIDER_REGISTRY[\"anthropic\"].api_key_env_vars:\n-        value = get_env_value(var) or os.getenv(var, \"\")\n+        value = get_env_value_prefer_dotenv(var) or \"\"\n         if value:\n             return value\n     return \"\"","@@ -573,10 +575,12 @@ def _resolve_api_key_provider_secret(\n             pass\n         return \"\", \"\"\n \n-    from hermes_cli.config import get_env_value\n+    from hermes_cli.config import get_env_value_prefer_dotenv\n     for env_var in pconfig.api_key_env_vars:\n-        # Check both os.environ and ~/.hermes/.env file\n-        val = (get_env_value(env_var) or \"\").strip()\n+        # Prefer ~/.hermes/.env over os.environ so a deliberate key rotation\n+        # in the user's .env file isn't shadowed by a stale shell export\n+        # inherited from a parent process (Codex CLI, test runners, etc.).\n+        val = (get_env_value_prefer_dotenv(env_var) or \"\").strip()\n         if has_usable_secret(val):\n             return val, env_var\n ","@@ -6260,7 +6264,7 @@ def _get_azure_foundry_auth_status() -> Dict[str, Any]:\n     \"\"\"\n     info: Dict[str, Any] = {\"provider\": \"azure-foundry\"}\n     try:\n-        from hermes_cli.config import load_config, get_env_value\n+        from hermes_cli.config import load_config, get_env_value_prefer_dotenv\n         cfg = load_config()\n     except Exception:\n         cfg = {}","@@ -6313,7 +6317,7 @@ def _get_azure_foundry_auth_status() -> Dict[str, Any]:\n \n     # api_key mode (default)\n     try:\n-        api_key = [redacted:secret-assignment]\"AZURE_FOUNDRY_API_KEY\") or os.getenv(\"AZURE_FOUNDRY_API_KEY\", \"\")\n+        api_key = [redacted:secret-assignment]\"AZURE_FOUNDRY_API_KEY\") or \"\"\n     except Exception:\n         api_key = [redacted:secret-assignment]\"AZURE_FOUNDRY_API_KEY\", \"\")\n     info[\"logged_in\"] = has_usable_secret(api_key)"]},{"file":"hermes_cli/config.py","hunks":["@@ -7046,12 +7046,38 @@ def get_env_value(key: str) -> Optional[str]:\n     # Check environment first\n     if key in os.environ:\n         return os.environ[key]\n-    \n+\n     # Then check .env file\n     env_vars = load_env()\n     return env_vars.get(key)\n \n \n+def get_env_value_prefer_dotenv(key: str) -> Optional[str]:\n+    \"\"\"Resolve a credential env value, preferring ``~/.hermes/.env`` over ``os.environ``.\n+\n+    Used for Hermes-managed credentials where a deliberate edit to ``.env``\n+    must take precedence over a stale value inherited from the parent shell\n+    (Codex CLI, test scripts, login profile exports). Without this, rotating\n+    a key in ``.env`` mid-session leaves callers serving the stale shell\n+    value and produces persistent 401s.\n+\n+    The ``os.environ`` fallback routes through ``secret_scope.get_secret`` so\n+    that, under an active profile scope (multiplexed gateway turn), this read\n+    is scope-checked rather than leaking another profile's raw ``os.environ``\n+    value — matching the credential-pool seeding path's behaviour.\n+    \"\"\"\n+    env_vars = load_env()\n+    val = env_vars.get(key)\n+    if val:\n+        return val\n+    try:\n+        from agent.secret_scope import get_secret as _get_secret\n+\n+        return _get_secret(key)\n+    except Exception:\n+        return os.environ.get(key)\n+\n+\n # =============================================================================\n # Config display\n # ============================================================================="]},{"file":"tests/tools/test_credential_pool_env_fallback.py","hunks":["@@ -1,10 +1,11 @@\n \"\"\"Tests for credential_pool .env fallback and auth credential_pool lookup.\n \n-Covers the fix from #15914 / PR #15920:\n+Covers the fix from #15914 / PR #15920 and the rotation fix from #20591:\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+- ~/.hermes/.env takes priority over os.environ for Hermes-managed credentials\n+  (so a deliberate rotation in .env wins over a stale shell export)\n+- env / dotenv values take priority over credential pool (pool fires only when both are empty)\n \"\"\"\n \n import os","@@ -106,6 +107,23 @@ def test_empty_dotenv_no_entries(self, isolated_hermes_home):\n         assert active_sources == set()\n         assert entries == []\n \n+    def test_dotenv_wins_over_stale_os_environ(self, isolated_hermes_home, monkeypatch):\n+        \"\"\"Regression for #20591: a fresh key rotated into ~/.hermes/.env must\n+        win over a stale value inherited from os.environ (parent shell export\n+        from Codex CLI, test runner, login profile, etc.). Without this, key\n+        rotation produces persistent 401s.\n+        \"\"\"\n+        _write_env_file(isolated_hermes_home, DEEPSEEK_API_KEY=\"[redacted:secret-assignment]\")\n+        monkeypatch.setenv(\"DEEPSEEK_API_KEY\", \"sk-env-stale-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-dotenv-fresh\"\n \n \n class TestAuthResolvesFromDotEnv:","@@ -124,6 +142,41 @@ def test_key_from_dotenv_only(self, isolated_hermes_home):\n         assert key == \"[redacted:openai-key]\"\n         assert source == \"DEEPSEEK_API_KEY\"\n \n+    def test_dotenv_wins_over_stale_os_environ_on_resolve(\n+        self, isolated_hermes_home, monkeypatch\n+    ):\n+        \"\"\"Regression for #20591: when both ~/.hermes/.env and os.environ define\n+        the key, the .env value wins. Symmetric with the pool seeding rule —\n+        without this, the pool gets re-seeded with the fresh .env key while the\n+        live request path keeps returning the stale shell export, producing\n+        persistent 401s after rotation.\n+        \"\"\"\n+        _write_env_file(isolated_hermes_home, DEEPSEEK_API_KEY=\"[redacted:secret-assignment]\")\n+        monkeypatch.setenv(\"DEEPSEEK_API_KEY\", \"stale-shell-deepseek\")\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 == \"dotenv-fresh-deepseek\"\n+        assert source == \"DEEPSEEK_API_KEY\"\n+\n+    def test_get_anthropic_key_prefers_dotenv_over_stale_os_environ(\n+        self, isolated_hermes_home, monkeypatch\n+    ):\n+        \"\"\"Regression for #20591 (sibling site): get_anthropic_key() must also\n+        prefer ~/.hermes/.env over a stale shell export. This path resolves\n+        ANTHROPIC_API_KEY/ANTHROPIC_TOKEN/CLAUDE_CODE_OAUTH_TOKEN and had the\n+        identical os.environ-first rotation bug that the api-key resolution\n+        path did, just for Anthropic.\n+        \"\"\"\n+        _write_env_file(isolated_hermes_home, ANTHROPIC_API_KEY=\"[redacted:secret-assignment]\")\n+        monkeypatch.setenv(\"ANTHROPIC_API_KEY\", \"stale-shell-anthropic\")\n+\n+        from hermes_cli.auth import get_anthropic_key\n+        assert get_anthropic_key() == \"dotenv-fresh-anthropic\"\n+\n \n class TestAuthCredentialPoolFallback:\n     \"\"\"_resolve_api_key_provider_secret falls back to credential pool when env + dotenv are empty.\"\"\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}