credential pool env file vs os.environ precedence

NousResearch/hermes-agent · cluster 18254 · agent

Data as of

Nothing open on GitHub. Fix merged in #18755. Check it covers your case.

The short answer

0open issues
0open PRs
8items grouped
Known?
Yes. 8 related items grouped here, 2 issues already closed.
Fix event
A fix was merged to main in PR #18755. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PRs #18755, #55528. That is separate from the fix event.
Still open
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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #18755 fix: prefer ~/.hermes/.env over stale os.environ when seeding credential pool (salvage #18256)

    merged to main

    files: agent/credential_pool.py, scripts/release.py, tests/agent/test_credential_pool.py

    Show the diff
    • agent/credential_pool.py
      @@ -3,6 +3,7 @@
       from __future__ import annotations
       
       import logging
      +import os
       import random
       import threading
       import time
      @@ -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,
      @@ -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.
      @@ -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):
      @@ -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":
      @@ -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
      @@ -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
      @@ -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

    files: hermes_cli/auth.py, hermes_cli/config.py, tests/tools/test_credential_pool_env_fallback.py

    Show the diff
    • hermes_cli/auth.py
      @@ -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 ""
      @@ -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
       
      @@ -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 = {}
      @@ -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
      @@ -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
      @@ -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
      @@ -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:
      @@ -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."""

4 PRs closed without merging

From the close comments: merged via another change 2 · done elsewhere 1 · superseded by another PR 1

  • #18256 fix: prefer ~/.hermes/.env over os.environ when seeding credential pool · merged via another change → #18755
  • #18298 fix(config): make ~/.hermes/.env authoritative over os.environ in get_env_value (#18254) · superseded by another PR → #18256
  • #19021 test(credential_pool): align with .env-first seed precedence · done elsewhere → 66320de, 0a6865b
  • #20602 fix(credentials): prefer ~/.hermes/.env over stale os.environ on key rotation · merged via another change → 26f39f7, #20591, #55528
Closed issues · 2
  • Closed#18254[Bug]: auth.json credential cache ignores .env changes — stale key persists
  • Closed#20591bug: credential pool reads stale os.environ instead of fresh .env — test documents correct behavior but code doesn't match
How this cluster was grouped
  • Link evidence (partly structural links): 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 item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#18755, #55528
Closed issues
2
Attempts
4 PRs closed without merging
Fix link checked
30 Jun 2026

For your agent

The same facts, typed.