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
- 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
Open PRs0
Fixes on record
-
#18755 fix: prefer ~/.hermes/.env over stale os.environ when seeding credential pool (salvage #18256)
files:
agent/credential_pool.py,scripts/release.py,tests/agent/test_credential_pool.pyShow 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)
files:
hermes_cli/auth.py,hermes_cli/config.py,tests/tools/test_credential_pool_env_fallback.pyShow 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
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.00means 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