# NousResearch/hermes-agent · load_pool pruning env-seeded credentials bug

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 5 member(s): 0 open issue(s), 2 closed issue(s), 0 open PR(s), 1 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #50201. Verify it addresses your case.
- **Recommended:** read PR #50201 (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: `partially_resolved` · deciding PR(s): `pr-50201`. 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.34`: 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

- #9331: \[Bug\]: load_pool() destructively prunes env-seeded credentials when the env var is missing from the current process
- #12636: bug: opencode-go env-seeded credentials are pruned on load

## Prior & competing PRs

- **#50201: fix(auth): make load_pool() non-destructive for env-seeded credentials**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `agent/credential_pool.py`, `tests/agent/test_credential_pool.py`
  - fix hunks:
  - `agent/credential_pool.py`

    ```diff
    @@ -2062,19 +2062,34 @@ def _env_payload(
         return changed, active_sources
     
     
    -def _prune_stale_seeded_entries(entries: List[PooledCredential], active_sources: Set[str]) -> bool:
    +def _prune_stale_seeded_entries(
    +    entries: List[PooledCredential],
    +    active_sources: Set[str],
    +    *,
    +    prune_env_sources: bool = True,
    +) -> bool:
    +    def _is_prunable(entry: PooledCredential) -> bool:
    +        # ``env:*`` entries are persisted references that get re-hydrated from
    +        # the environment on every load. A process that merely lacks the env
    +        # var this call must NOT delete the on-disk entry for every other
    +        # process — that destructive read is the bug behind #9331. Only prune
    +        # an env source when ``prune_env_sources`` is explicitly requested
    +        # (e.g. an `hermes auth` command that confirmed the source is gone).
    +        if entry.source.startswith("env:"):
    +            return prune_env_sources
    +        # File-backed singletons (device-code OAuth, claude_code) and Hermes
    +        # PKCE should disappear from the pool when their backing file is gone.
    +        return (
    +            is_borrowed_credential_source(entry.source, entry.provider)
    +            or entry.source == "hermes_pkce"
    +        )
    +
         retained = [
             entry
             for entry in entries
             if _is_manual_source(entry.source)
             or entry.source in active_sources
    -        or not (
    -            is_borrowed_credential_source(entry.source, entry.provider)
    -            # Hermes PKCE is Hermes-owned/persistable while present, but it is
    -            # still a file-backed singleton and should disappear from the pool
    -            # when the backing OAuth file is gone.
    -            or entry.source == "hermes_pkce"
    -        )
    +        or not _is_prunable(entry)
         ]
         if len(retained) == len(entries):
             return False
    ```

    ```diff
    @@ -2174,7 +2189,15 @@ def load_pool(provider: str) -> CredentialPool:
             [redacted:secret-assignment] singleton_sources = _seed_from_singletons(provider, entries)
             env_changed, env_sources = _seed_from_env(provider, entries)
             changed = raw_needs_sanitization or singleton_changed or env_changed
    -        changed |= _prune_stale_seeded_entries(entries, singleton_sources | env_sources)
    +        # ``load_pool()`` is a non-destructive read for env-seeded entries: a
    +        # process missing a provider env var must not delete the persisted
    +        # pool entry for every other process (#9331). File-backed singletons
    +        # still prune when their backing file is gone.
    +        changed |= _prune_stale_seeded_entries(
    +            entries,
    +            singleton_sources | env_sources,
    +            prune_env_sources=False,
    +        )
             changed |= _normalize_pool_priorities(provider, entries)
     
         if changed:
    ```
  - `tests/agent/test_credential_pool.py`

    ```diff
    @@ -1179,7 +1179,10 @@ def test_load_pool_falls_back_to_os_environ_when_dotenv_empty(tmp_path, monkeypa
         assert entry.access_token == "[redacted:openai-key]"
     
     
    -def test_load_pool_removes_stale_seeded_env_entry(tmp_path, monkeypatch):
    +def test_load_pool_preserves_env_seeded_entry_when_env_is_missing(tmp_path, monkeypatch):
    +    # Regression for #9331: load_pool() is a non-destructive read. A process
    +    # that lacks the seeding env var must NOT delete the persisted pool entry
    +    # that another process correctly seeded.
         monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
         monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
         _write_auth_store(
    ```

    ```diff
    @@ -1206,10 +1209,54 @@ def test_load_pool_removes_stale_seeded_env_entry(tmp_path, monkeypatch):
     
         pool = load_pool("openrouter")
     
    -    assert pool.entries() == []
    +    entries = pool.entries()
    +    assert len(entries) == 1
    +    assert entries[0].source == "env:OPENROUTER_API_KEY"
    +
    +    auth_payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
    +    persisted = auth_payload["credential_pool"]["openrouter"]
    +    assert len(persisted) == 1
    +    assert persisted[0]["source"] == "env:OPENROUTER_API_KEY"
    +
    +
    +def test_load_pool_missing_env_does_not_overwrite_other_process_seed(tmp_path, monkeypatch):
    +    # The exact cross-process oscillation described in #9331: a process without
    +    # MINIMAX_API_KEY must leave the on-disk entry intact for processes that
    +    # do have it.
    +    monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
    +    monkeypatch.delenv("MINIMAX_API_KEY", raising=False)
    +    _write_auth_store(
    +        tmp_path,
    +        {
    +            "version": 1,
    +            "credential_pool": {
    +                "minimax": [
    +                    {
    +                        "id": "minimax-env",
    +                        "label": "MINIMAX_API_KEY",
    +                        "auth_type": "api_key",
    +                        "priority": 0,
    +                        "source": "env:MINIMAX_API_KEY",
    +                        "access_token": "seeded-by-other-process",
    +                        "base_url": "https://api.minimaxi.chat/v1",
    +                    }
    +                ]
    +            },
    +        },
    +    )
    +
    +    from agent.credential_pool import load_pool
    +
    +    pool = load_pool("minimax")
    +
    +    assert pool.has_credentials()
    +    assert len(pool.entries()) == 1
    +    assert pool.entries()[0].source == "env:MINIMAX_API_KEY"
     
         auth_payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
    -    assert auth_payload["credential_pool"]["openrouter"] == []
    +    persisted = auth_payload["credential_pool"]["minimax"]
    +    assert len(persisted) == 1
    +    assert persisted[0]["source"] == "env:MINIMAX_API_KEY"
     
     
     def test_load_pool_migrates_nous_provider_state(tmp_path, monkeypatch):
    ```
- **#9353: fix: prevent load_pool from pruning env-seeded credentials when env var is absent**. closed without merge
  - maintainer direction: category `stale_abandoned`; implication `active`
- **#9390: Make load_pool preserve env-seeded entries**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#50201`

## Fix status

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