# NousResearch/hermes-agent · Auth sync feature for detecting credential drift

## Open now

- 1 issue, 1 PR open on GitHub.
- #18359: \[Feature\]: hermes auth sync — detect and fix credential drift across .env files and credential pool
- PR #52300: feat(auth): hermes auth sync — detect and fix credential drift across profile .env files

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.

## TL;DR

- 4 member(s): 1 open issue(s), 0 closed issue(s), 1 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #18755. Verify it addresses your case.
- **Recommended:** read PR #18755 (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: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.50`: 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.

## Open related issues

- #18359: \[Feature\]: hermes auth sync — detect and fix credential drift across .env files and credential pool

## Prior & competing PRs

- **#18755: fix: prefer ~/.hermes/.env over stale os.environ when seeding credential pool (salvage #18256)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `agent/credential_pool.py`, `scripts/release.py`, `tests/agent/test_credential_pool.py`
  - fix hunks:
  - `agent/credential_pool.py`

    ```diff
    @@ -3,6 +3,7 @@
     from __future__ import annotations
     
     import logging
    +import os
     import random
     import threading
     import time
    ```

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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.
    ```

    ```diff
    @@ -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 = _get_env_prefer_dotenv("OPENROUTER_API_KEY")
             if token:
                 source = "env:OPENROUTER_API_KEY"
                 if _is_source_suppressed(provider, source):
    ```

    ```diff
    @@ -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":
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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:openai-key]\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.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)
    ```
- **#18361: feat(auth): hermes auth sync — detect and fix credential drift across .env files and auth.json**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#18755`, `#18254`
- **#52300: feat(auth): hermes auth sync — detect and fix credential drift across profile .env files**. open
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`
  - files: `hermes_cli/auth_commands.py`, `hermes_cli/subcommands/auth.py`, `tests/hermes_cli/test_auth_sync.py`

## Fix status

- Merged to main in PR #18755 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-02`.
- Subsystem: `hermes_cli`
