{"repo":"NousResearch/hermes-agent","public_id":"18359","display_label":"Auth sync feature for detecting credential drift","summary":"These members concern a proposed or in-progress 'hermes auth sync' feature aimed at detecting and fixing credential drift across .env files, auth.json, and a credential pool. There is also a related fix for preferring ~/.hermes/.env over stale environment variables when seeding the credential pool.","subsystem":"hermes_cli","counts":{"members":4,"open_issues":1,"open_issues_distinct":1,"open_prs":1,"closed_issues":0,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":18755,"merged_at":"2026-05-02T09:00:32+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-05-02T09:00:32+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":18254,"pr_number":18755,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-02T09:00:32+00:00"},{"issue_number":18359,"pr_number":18755,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-05-02T09:00:32+00:00"}],"members":[{"kind":"issue","number":18359,"title":"[Feature]: hermes auth sync — detect and fix credential drift across .env files and credential pool","url":null,"state":"open"},{"kind":"pr","number":18361,"title":"feat(auth): hermes auth sync — detect and fix credential drift across .env files and auth.json","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#18755","number":18755},{"kind":"pr","value":"#18254","number":18254}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"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"},"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 = _get_env_prefer_dotenv(\"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:openai-key]\\n\"\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":52300,"title":"feat(auth): hermes auth sync — detect and fix credential drift across profile .env files","url":null,"state":"open","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}