# NousResearch/hermes-agent · credential pool env file vs os.environ precedence

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 8 member(s): 0 open issue(s), 2 closed issue(s), 0 open PR(s), 2 merged PR(s), 4 PR(s) closed without merging.
- A fix was merged to main in PR #18755. Verify it addresses your case.
- **Recommended:** read PR #18755 (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-18755,pr-55528`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.23`: 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

- #18254: \[Bug\]: auth.json credential cache ignores .env changes — stale key persists
- #20591: bug: credential pool reads stale os.environ instead of fresh .env — test documents correct behavior but code doesn't match

## Prior & competing PRs

- **#18298: fix(config): make ~/.hermes/.env authoritative over os.environ in get_env_value (#18254)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#18256`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#18755: fix: prefer ~/.hermes/.env over stale os.environ when seeding credential pool (salvage #18256)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `agent/credential_pool.py`, `scripts/release.py`, `tests/agent/test_credential_pool.py`
  - fix hunks:
  - `agent/credential_pool.py`

    ```diff
    @@ -3,6 +3,7 @@
     from __future__ import annotations
     
     import logging
    +import os
     import random
     import threading
     import time
    ```

    ```diff
    @@ -13,7 +14,7 @@
     from typing import Any, Dict, List, Optional, Set, Tuple
     
     from hermes_constants import OPENROUTER_BASE_URL
    -from hermes_cli.config import get_env_value
    +from hermes_cli.config import get_env_value, load_env
     import hermes_cli.auth as auth_mod
     from hermes_cli.auth import (
         CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
    ```

    ```diff
    @@ -1380,6 +1381,16 @@ def _is_suppressed(_p, _s):  # type: ignore[misc]
     def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool, Set[str]]:
         changed = False
         active_sources: Set[str] = set()
    +
    +    # Prefer ~/.hermes/.env over os.environ — the user's config file is the
    +    # authoritative source for Hermes credentials. Stale env vars from parent
    +    # processes (Codex CLI, test scripts, etc.) should not override deliberate
    +    # changes to the .env file.
    +    def _get_env_prefer_dotenv(key: str) -> str:
    +        env_file = load_env()
    +        val = env_file.get(key) or os.environ.get(key) or ""
    +        return val.strip()
    +
         # Honour user suppression — `hermes auth remove <provider> <N>` for an
         # env-seeded credential marks the env:<VAR> source as suppressed so it
         # won't be re-seeded from the user's shell environment or ~/.hermes/.env.
    ```

    ```diff
    @@ -1391,8 +1402,8 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
             def _is_source_suppressed(_p, _s):  # type: ignore[misc]
                 return False
         if provider == "openrouter":
    -        # Check both os.environ and ~/.hermes/.env file
    -        token = [redacted:secret-assignment]"OPENROUTER_API_KEY") or "").strip()
    +        # Prefer ~/.hermes/.env over os.environ
    +        token = [redacted:secret-assignment]"OPENROUTER_API_KEY")
             if token:
                 source = "env:OPENROUTER_API_KEY"
                 if _is_source_suppressed(provider, source):
    ```

    ```diff
    @@ -1418,7 +1429,7 @@ def _is_source_suppressed(_p, _s):  # type: ignore[misc]
     
         env_url = ""
         if pconfig.base_url_env_var:
    -        env_url = (get_env_value(pconfig.base_url_env_var) or "").strip().rstrip("/")
    +        env_url = _get_env_prefer_dotenv(pconfig.base_url_env_var).rstrip("/")
     
         env_vars = list(pconfig.api_key_env_vars)
         if provider == "anthropic":
    ```

    ```diff
    @@ -1429,8 +1440,8 @@ def _is_source_suppressed(_p, _s):  # type: ignore[misc]
             ]
     
         for env_var in env_vars:
    -        # Check both os.environ and ~/.hermes/.env file
    -        token = [redacted:secret-assignment] or "").strip()
    +        # Prefer ~/.hermes/.env over os.environ
    +        token = [redacted:secret-assignment]
             if not token:
                 continue
             source = f"env:{env_var}"
    ```
  - `scripts/release.py`

    ```diff
    @@ -371,6 +371,7 @@
         "[redacted:email]": "zons-zhaozhy",
         "[redacted:email]": "Es1la",
         "[redacted:email]": "CoreyNoDream",
    +    "[redacted:email]": "franksong2702",
         # ── bulk addition: 75 emails resolved via API, PR salvage bodies, noreply
         #    crossref, and GH contributor list matching (April 2026 audit) ──
         "[redacted:email]": "aaronagent",
    ```
  - `tests/agent/test_credential_pool.py`

    ```diff
    @@ -348,6 +348,64 @@ def test_load_pool_seeds_env_api_key(tmp_path, monkeypatch):
         assert entry.access_token == "sk-or-seeded"
     
     
    +
    +def test_load_pool_prefers_dotenv_over_stale_os_environ(tmp_path, monkeypatch):
    +    """Regression for #18254: stale OPENROUTER_API_KEY in os.environ (inherited
    +    from a parent shell) must NOT shadow the fresh key in ~/.hermes/.env when
    +    seeding the credential pool. Before the fix, `get_env_value()` preferred
    +    os.environ and silently wrote the stale value into auth.json, causing
    +    persistent 401 errors after key rotation.
    +    """
    +    hermes_home = tmp_path / "hermes"
    +    hermes_home.mkdir()
    +    monkeypatch.setenv("HERMES_HOME", str(hermes_home))
    +
    +    # Simulate the bug: parent shell exported a stale test key
    +    monkeypatch.setenv("OPENROUTER_API_KEY", "[redacted:openai-key]")
    +
    +    # User edited ~/.hermes/.env with the fresh key
    +    (hermes_home / ".env").write_text(
    +        "OPENROUTER_API_KEY=[redacted:secret-assignment]"
    +    )
    +
    +    _write_auth_store(tmp_path, {"version": 1, "providers": {}})
    +
    +    from agent.credential_pool import load_pool
    +    pool = load_pool("openrouter")
    +    entry = pool.select()
    +
    +    assert entry is not None
    +    assert entry.source == "env:OPENROUTER_API_KEY"
    +    # The fresh key from .env must win over the stale shell export
    +    assert entry.access_token == "[redacted:openai-key]", (
    +        f"Expected .env to win, got {entry.access_token!r}"
    +    )
    +
    +
    +def test_load_pool_falls_back_to_os_environ_when_dotenv_empty(tmp_path, monkeypatch):
    +    """When ~/.hermes/.env does not define OPENROUTER_API_KEY (typical Docker /
    +    K8s / systemd deployment), seeding must still pick up the key from
    +    os.environ. Guards against regressions that would break production
    +    deployments relying on runtime-injected env vars.
    +    """
    +    hermes_home = tmp_path / "hermes"
    +    hermes_home.mkdir()
    +    monkeypatch.setenv("HERMES_HOME", str(hermes_home))
    +    monkeypatch.setenv("OPENROUTER_API_KEY", "[redacted:openai-key]")
    +
    +    # .env exists but does not define OPENROUTER_API_KEY
    +    (hermes_home / ".env").write_text("SOME_OTHER_VAR=unrelated\n")
    +
    +    _write_auth_store(tmp_path, {"version": 1, "providers": {}})
    +
    +    from agent.credential_pool import load_pool
    +    pool = load_pool("openrouter")
    +    entry = pool.select()
    +
    +    assert entry is not None
    +    assert entry.access_token == "[redacted:openai-key]"
    +
    +
     def test_load_pool_removes_stale_seeded_env_entry(tmp_path, monkeypatch):
         monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
         monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
    ```
- **#55528: fix(credentials): prefer ~/.hermes/.env over stale os.environ on key rotation (#20591)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `hermes_cli/auth.py`, `hermes_cli/config.py`, `tests/tools/test_credential_pool_env_fallback.py`
  - fix hunks:
  - `hermes_cli/auth.py`

    ```diff
    @@ -479,16 +479,18 @@ class ProviderConfig:
     def get_anthropic_key() -> str:
         """Return the first usable Anthropic credential, or ``""``.
     
    -    Checks both the ``.env`` file (via ``get_env_value``) and the process
    -    environment (``os.getenv``).  The fallback order mirrors the
    -    ``PROVIDER_REGISTRY["anthropic"].api_key_env_vars`` tuple:
    +    Checks both the ``.env`` file and the process environment, preferring
    +    ``~/.hermes/.env`` so a deliberate key rotation isn't shadowed by a stale
    +    shell export (matches the api-key resolution path — see #20591).  The
    +    order mirrors the ``PROVIDER_REGISTRY["anthropic"].api_key_env_vars``
    +    tuple:
     
             ANTHROPIC_API_KEY -> ANTHROPIC_TOKEN -> CLAUDE_CODE_OAUTH_TOKEN
         """
    -    from hermes_cli.config import get_env_value
    +    from hermes_cli.config import get_env_value_prefer_dotenv
     
         for var in PROVIDER_REGISTRY["anthropic"].api_key_env_vars:
    -        value = get_env_value(var) or os.getenv(var, "")
    +        value = get_env_value_prefer_dotenv(var) or ""
             if value:
                 return value
         return ""
    ```

    ```diff
    @@ -573,10 +575,12 @@ def _resolve_api_key_provider_secret(
                 pass
             return "", ""
     
    -    from hermes_cli.config import get_env_value
    +    from hermes_cli.config import get_env_value_prefer_dotenv
         for env_var in pconfig.api_key_env_vars:
    -        # Check both os.environ and ~/.hermes/.env file
    -        val = (get_env_value(env_var) or "").strip()
    +        # Prefer ~/.hermes/.env over os.environ so a deliberate key rotation
    +        # in the user's .env file isn't shadowed by a stale shell export
    +        # inherited from a parent process (Codex CLI, test runners, etc.).
    +        val = (get_env_value_prefer_dotenv(env_var) or "").strip()
             if has_usable_secret(val):
                 return val, env_var
     
    ```

    ```diff
    @@ -6260,7 +6264,7 @@ def _get_azure_foundry_auth_status() -> Dict[str, Any]:
         """
         info: Dict[str, Any] = {"provider": "azure-foundry"}
         try:
    -        from hermes_cli.config import load_config, get_env_value
    +        from hermes_cli.config import load_config, get_env_value_prefer_dotenv
             cfg = load_config()
         except Exception:
             cfg = {}
    ```

    ```diff
    @@ -6313,7 +6317,7 @@ def _get_azure_foundry_auth_status() -> Dict[str, Any]:
     
         # api_key mode (default)
         try:
    -        api_key = [redacted:secret-assignment]"AZURE_FOUNDRY_API_KEY") or os.getenv("AZURE_FOUNDRY_API_KEY", "")
    +        api_key = [redacted:secret-assignment]"AZURE_FOUNDRY_API_KEY") or ""
         except Exception:
             api_key = [redacted:secret-assignment]"AZURE_FOUNDRY_API_KEY", "")
         info["logged_in"] = has_usable_secret(api_key)
    ```
  - `hermes_cli/config.py`

    ```diff
    @@ -7046,12 +7046,38 @@ def get_env_value(key: str) -> Optional[str]:
         # Check environment first
         if key in os.environ:
             return os.environ[key]
    -    
    +
         # Then check .env file
         env_vars = load_env()
         return env_vars.get(key)
     
     
    +def get_env_value_prefer_dotenv(key: str) -> Optional[str]:
    +    """Resolve a credential env value, preferring ``~/.hermes/.env`` over ``os.environ``.
    +
    +    Used for Hermes-managed credentials where a deliberate edit to ``.env``
    +    must take precedence over a stale value inherited from the parent shell
    +    (Codex CLI, test scripts, login profile exports). Without this, rotating
    +    a key in ``.env`` mid-session leaves callers serving the stale shell
    +    value and produces persistent 401s.
    +
    +    The ``os.environ`` fallback routes through ``secret_scope.get_secret`` so
    +    that, under an active profile scope (multiplexed gateway turn), this read
    +    is scope-checked rather than leaking another profile's raw ``os.environ``
    +    value — matching the credential-pool seeding path's behaviour.
    +    """
    +    env_vars = load_env()
    +    val = env_vars.get(key)
    +    if val:
    +        return val
    +    try:
    +        from agent.secret_scope import get_secret as _get_secret
    +
    +        return _get_secret(key)
    +    except Exception:
    +        return os.environ.get(key)
    +
    +
     # =============================================================================
     # Config display
     # =============================================================================
    ```
  - `tests/tools/test_credential_pool_env_fallback.py`

    ```diff
    @@ -1,10 +1,11 @@
     """Tests for credential_pool .env fallback and auth credential_pool lookup.
     
    -Covers the fix from #15914 / PR #15920:
    +Covers the fix from #15914 / PR #15920 and the rotation fix from #20591:
     - _seed_from_env reads API keys from ~/.hermes/.env when not in os.environ
     - _resolve_api_key_provider_secret falls back to credential_pool when env vars are empty
    -- env vars take priority over .env file (handled by get_env_value itself)
    -- env vars take priority over credential pool (fallback only kicks in when env is empty)
    +- ~/.hermes/.env takes priority over os.environ for Hermes-managed credentials
    +  (so a deliberate rotation in .env wins over a stale shell export)
    +- env / dotenv values take priority over credential pool (pool fires only when both are empty)
     """
     
     import os
    ```

    ```diff
    @@ -106,6 +107,23 @@ def test_empty_dotenv_no_entries(self, isolated_hermes_home):
             assert active_sources == set()
             assert entries == []
     
    +    def test_dotenv_wins_over_stale_os_environ(self, isolated_hermes_home, monkeypatch):
    +        """Regression for #20591: a fresh key rotated into ~/.hermes/.env must
    +        win over a stale value inherited from os.environ (parent shell export
    +        from Codex CLI, test runner, login profile, etc.). Without this, key
    +        rotation produces persistent 401s.
    +        """
    +        _write_env_file(isolated_hermes_home, DEEPSEEK_API_KEY="[redacted:secret-assignment]")
    +        monkeypatch.setenv("DEEPSEEK_API_KEY", "sk-env-stale-xyz")
    +
    +        from agent.credential_pool import _seed_from_env
    +        entries = []
    +        changed, _ = _seed_from_env("deepseek", entries)
    +
    +        assert changed is True
    +        seeded = [e for e in entries if e.source == "env:DEEPSEEK_API_KEY"]
    +        assert len(seeded) == 1
    +        assert seeded[0].access_token == "sk-dotenv-fresh"
     
     
     class TestAuthResolvesFromDotEnv:
    ```

    ```diff
    @@ -124,6 +142,41 @@ def test_key_from_dotenv_only(self, isolated_hermes_home):
             assert key == "[redacted:openai-key]"
             assert source == "DEEPSEEK_API_KEY"
     
    +    def test_dotenv_wins_over_stale_os_environ_on_resolve(
    +        self, isolated_hermes_home, monkeypatch
    +    ):
    +        """Regression for #20591: when both ~/.hermes/.env and os.environ define
    +        the key, the .env value wins. Symmetric with the pool seeding rule —
    +        without this, the pool gets re-seeded with the fresh .env key while the
    +        live request path keeps returning the stale shell export, producing
    +        persistent 401s after rotation.
    +        """
    +        _write_env_file(isolated_hermes_home, DEEPSEEK_API_KEY="[redacted:secret-assignment]")
    +        monkeypatch.setenv("DEEPSEEK_API_KEY", "stale-shell-deepseek")
    +
    +        from hermes_cli.auth import _resolve_api_key_provider_secret
    +        key, source = _resolve_api_key_provider_secret(
    +            provider_id="deepseek",
    +            pconfig=_make_pconfig(),
    +        )
    +        assert key == "dotenv-fresh-deepseek"
    +        assert source == "DEEPSEEK_API_KEY"
    +
    +    def test_get_anthropic_key_prefers_dotenv_over_stale_os_environ(
    +        self, isolated_hermes_home, monkeypatch
    +    ):
    +        """Regression for #20591 (sibling site): get_anthropic_key() must also
    +        prefer ~/.hermes/.env over a stale shell export. This path resolves
    +        ANTHROPIC_API_KEY/ANTHROPIC_TOKEN/CLAUDE_CODE_OAUTH_TOKEN and had the
    +        identical os.environ-first rotation bug that the api-key resolution
    +        path did, just for Anthropic.
    +        """
    +        _write_env_file(isolated_hermes_home, ANTHROPIC_API_KEY="[redacted:secret-assignment]")
    +        monkeypatch.setenv("ANTHROPIC_API_KEY", "stale-shell-anthropic")
    +
    +        from hermes_cli.auth import get_anthropic_key
    +        assert get_anthropic_key() == "dotenv-fresh-anthropic"
    +
     
     class TestAuthCredentialPoolFallback:
         """_resolve_api_key_provider_secret falls back to credential pool when env + dotenv are empty."""
    ```
- **#18256: fix: prefer ~/.hermes/.env over os.environ when seeding credential pool**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#18755`
- **#19021: test(credential_pool): align with .env-first seed precedence**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `66320de52e9d77c5afc9767a350447011c8577f1`, `0a6865b328ee6057eb59ee4a150c4886aa72d48c`
- **#20602: fix(credentials): prefer ~/.hermes/.env over stale os.environ on key rotation**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `26f39f7b9`, `#20591`, `#55528`

## Fix status

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