{"repo":"NousResearch/hermes-agent","public_id":"9331","display_label":"load_pool pruning env-seeded credentials bug","summary":"A bug where load_pool() destructively removes env-seeded credentials when the corresponding environment variable is absent from the current process. Multiple fixes and reports address making load_pool non-destructive for these entries.","subsystem":"agent/secret_sources","counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":50201},"resolution":{"state":"partially_resolved","by":"pr-50201"},"freshness":{"state":"verified","last_verified_at":"2026-06-21T15:26:38+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.33599999999999997},"fix_edges":[{"issue_number":9331,"pr_number":50201,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":9331,"title":"[Bug]: load_pool() destructively prunes env-seeded credentials when the env var is missing from the current process","url":null,"state":"closed"},{"kind":"issue","number":12636,"title":"bug: opencode-go env-seeded credentials are pruned on load","url":null,"state":"closed"},{"kind":"pr","number":9353,"title":"fix: prevent load_pool from pruning env-seeded credentials when env var is absent","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":9390,"title":"Make load_pool preserve env-seeded entries","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#50201","number":50201}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":50201,"title":"fix(auth): make load_pool() non-destructive for env-seeded credentials","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","tests/agent/test_credential_pool.py"],"fix_hunks":[{"file":"agent/credential_pool.py","hunks":["@@ -2062,19 +2062,34 @@ def _env_payload(\n     return changed, active_sources\n \n \n-def _prune_stale_seeded_entries(entries: List[PooledCredential], active_sources: Set[str]) -> bool:\n+def _prune_stale_seeded_entries(\n+    entries: List[PooledCredential],\n+    active_sources: Set[str],\n+    *,\n+    prune_env_sources: bool = True,\n+) -> bool:\n+    def _is_prunable(entry: PooledCredential) -> bool:\n+        # ``env:*`` entries are persisted references that get re-hydrated from\n+        # the environment on every load. A process that merely lacks the env\n+        # var this call must NOT delete the on-disk entry for every other\n+        # process — that destructive read is the bug behind #9331. Only prune\n+        # an env source when ``prune_env_sources`` is explicitly requested\n+        # (e.g. an `hermes auth` command that confirmed the source is gone).\n+        if entry.source.startswith(\"env:\"):\n+            return prune_env_sources\n+        # File-backed singletons (device-code OAuth, claude_code) and Hermes\n+        # PKCE should disappear from the pool when their backing file is gone.\n+        return (\n+            is_borrowed_credential_source(entry.source, entry.provider)\n+            or entry.source == \"hermes_pkce\"\n+        )\n+\n     retained = [\n         entry\n         for entry in entries\n         if _is_manual_source(entry.source)\n         or entry.source in active_sources\n-        or not (\n-            is_borrowed_credential_source(entry.source, entry.provider)\n-            # Hermes PKCE is Hermes-owned/persistable while present, but it is\n-            # still a file-backed singleton and should disappear from the pool\n-            # when the backing OAuth file is gone.\n-            or entry.source == \"hermes_pkce\"\n-        )\n+        or not _is_prunable(entry)\n     ]\n     if len(retained) == len(entries):\n         return False","@@ -2174,7 +2189,15 @@ def load_pool(provider: str) -> CredentialPool:\n         [redacted:secret-assignment] singleton_sources = _seed_from_singletons(provider, entries)\n         env_changed, env_sources = _seed_from_env(provider, entries)\n         changed = raw_needs_sanitization or singleton_changed or env_changed\n-        changed |= _prune_stale_seeded_entries(entries, singleton_sources | env_sources)\n+        # ``load_pool()`` is a non-destructive read for env-seeded entries: a\n+        # process missing a provider env var must not delete the persisted\n+        # pool entry for every other process (#9331). File-backed singletons\n+        # still prune when their backing file is gone.\n+        changed |= _prune_stale_seeded_entries(\n+            entries,\n+            singleton_sources | env_sources,\n+            prune_env_sources=False,\n+        )\n         changed |= _normalize_pool_priorities(provider, entries)\n \n     if changed:"]},{"file":"tests/agent/test_credential_pool.py","hunks":["@@ -1179,7 +1179,10 @@ def test_load_pool_falls_back_to_os_environ_when_dotenv_empty(tmp_path, monkeypa\n     assert entry.access_token == \"[redacted:openai-key]\"\n \n \n-def test_load_pool_removes_stale_seeded_env_entry(tmp_path, monkeypatch):\n+def test_load_pool_preserves_env_seeded_entry_when_env_is_missing(tmp_path, monkeypatch):\n+    # Regression for #9331: load_pool() is a non-destructive read. A process\n+    # that lacks the seeding env var must NOT delete the persisted pool entry\n+    # that another process correctly seeded.\n     monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path / \"hermes\"))\n     monkeypatch.delenv(\"OPENROUTER_API_KEY\", raising=False)\n     _write_auth_store(","@@ -1206,10 +1209,54 @@ def test_load_pool_removes_stale_seeded_env_entry(tmp_path, monkeypatch):\n \n     pool = load_pool(\"openrouter\")\n \n-    assert pool.entries() == []\n+    entries = pool.entries()\n+    assert len(entries) == 1\n+    assert entries[0].source == \"env:OPENROUTER_API_KEY\"\n+\n+    auth_payload = json.loads((tmp_path / \"hermes\" / \"auth.json\").read_text())\n+    persisted = auth_payload[\"credential_pool\"][\"openrouter\"]\n+    assert len(persisted) == 1\n+    assert persisted[0][\"source\"] == \"env:OPENROUTER_API_KEY\"\n+\n+\n+def test_load_pool_missing_env_does_not_overwrite_other_process_seed(tmp_path, monkeypatch):\n+    # The exact cross-process oscillation described in #9331: a process without\n+    # MINIMAX_API_KEY must leave the on-disk entry intact for processes that\n+    # do have it.\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path / \"hermes\"))\n+    monkeypatch.delenv(\"MINIMAX_API_KEY\", raising=False)\n+    _write_auth_store(\n+        tmp_path,\n+        {\n+            \"version\": 1,\n+            \"credential_pool\": {\n+                \"minimax\": [\n+                    {\n+                        \"id\": \"minimax-env\",\n+                        \"label\": \"MINIMAX_API_KEY\",\n+                        \"auth_type\": \"api_key\",\n+                        \"priority\": 0,\n+                        \"source\": \"env:MINIMAX_API_KEY\",\n+                        \"access_token\": \"seeded-by-other-process\",\n+                        \"base_url\": \"https://api.minimaxi.chat/v1\",\n+                    }\n+                ]\n+            },\n+        },\n+    )\n+\n+    from agent.credential_pool import load_pool\n+\n+    pool = load_pool(\"minimax\")\n+\n+    assert pool.has_credentials()\n+    assert len(pool.entries()) == 1\n+    assert pool.entries()[0].source == \"env:MINIMAX_API_KEY\"\n \n     auth_payload = json.loads((tmp_path / \"hermes\" / \"auth.json\").read_text())\n-    assert auth_payload[\"credential_pool\"][\"openrouter\"] == []\n+    persisted = auth_payload[\"credential_pool\"][\"minimax\"]\n+    assert len(persisted) == 1\n+    assert persisted[0][\"source\"] == \"env:MINIMAX_API_KEY\"\n \n \n def test_load_pool_migrates_nous_provider_state(tmp_path, monkeypatch):\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}