From 33bf5f6292f49f109f11fb9c035afae6dcd356e3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 6 May 2026 09:07:32 -0700 Subject: [PATCH] fix(auth): fall back to global-root auth.json for providers missing in profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Profile processes (kanban workers, cron subprocesses, delegated subagents) read the profile's auth.json only. If a provider was authenticated at the global root but not inside the profile, the profile's credential_pool comes back empty and the process fails with 'No LLM provider configured' — even though the credentials are sitting in ~/.hermes/auth.json. #18594 propagated HERMES_HOME correctly, which is what surfaced this: workers now land in the right profile, and the profile turns out to shadow global with no fallback. Semantics (read-only, per-provider shadowing): * Profile has any entries for provider X → use profile only (global ignored). * Profile has zero entries for provider X → fall back to global. * Writes (write_credential_pool, _save_auth_store) still target the profile. * Classic mode (HERMES_HOME == global root) skips the fallback entirely — _global_auth_file_path() returns None. Also mirrors the fallback in get_provider_auth_state so OAuth singletons (nous, minimax-oauth, openai-codex, spotify) inherit cleanly — the Nous shared-token store (PR #19712) remains the authoritative path for Nous OAuth rotation, this just makes the read side consistent with it. Seat belt: _load_global_auth_store() refuses to read the real user's ~/.hermes/auth.json under PYTEST_CURRENT_TEST even when HERMES_HOME points to a profile-shaped path. Guard uses $HOME (stable across fixtures) rather than Path.home() (which fixtures often monkeypatch to a tmp root). Reported by @SeedsForbidden on Twitter as the credential_pool shadowing follow-up to the #18594 fix. --- hermes_cli/auth.py | 128 ++++++- .../hermes_cli/test_auth_profile_fallback.py | 360 ++++++++++++++++++ 2 files changed, 483 insertions(+), 5 deletions(-) create mode 100644 tests/hermes_cli/test_auth_profile_fallback.py diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 48abb1fa12..5ff5638b91 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -780,6 +780,73 @@ def _auth_file_path() -> Path: return path +def _global_auth_file_path() -> Optional[Path]: + """Return the global-root auth.json when the process is in profile mode. + + Returns ``None`` when the profile and global root resolve to the same + directory (classic mode, or custom HERMES_HOME that is not a profile). + Used by read-only fallback paths so providers authed at the root are + visible to profile processes that haven't configured them locally. + + See issue #18594 follow-up (credential_pool shadowing). + """ + try: + from hermes_constants import get_default_hermes_root + global_root = get_default_hermes_root() + except Exception: + return None + profile_home = get_hermes_home() + try: + if profile_home.resolve(strict=False) == global_root.resolve(strict=False): + return None + except Exception: + if profile_home == global_root: + return None + # No pytest seat belt here: this is a pure read-only path, and + # ``_load_global_auth_store()`` wraps the read in a try/except so an + # unreadable global file can never break the profile process. The + # write-side seat belt still lives on ``_auth_file_path()`` where it + # belongs (that's what protects the real user's auth store from being + # corrupted by a mis-configured test). + return global_root / "auth.json" + + +def _load_global_auth_store() -> Dict[str, Any]: + """Load the global-root auth store (read-only fallback). + + Returns an empty dict when no global fallback exists (classic mode, + or the global auth.json is absent). Never raises on missing file. + + Seat belt: under pytest, refuses to read the real user's + ``~/.hermes/auth.json`` even when HERMES_HOME is set to a profile + path. The hermetic conftest does not redirect ``HOME``, so + ``get_default_hermes_root()`` for a profile-shaped HERMES_HOME can + still resolve to the real user's home on a dev machine. That would + leak real credentials into tests. This guard uses the unmodified + ``HOME`` env var (what ``os.path.expanduser('~')`` would resolve to), + not ``Path.home()``, because ``Path.home`` is sometimes monkeypatched + by fixtures that want to relocate the global root to a tmp path. + """ + global_path = _global_auth_file_path() + if global_path is None or not global_path.exists(): + return {} + if os.environ.get("PYTEST_CURRENT_TEST"): + real_home_env = os.environ.get("HOME", "") + if real_home_env: + real_root = Path(real_home_env) / ".hermes" / "auth.json" + try: + if global_path.resolve(strict=False) == real_root.resolve(strict=False): + return {} + except Exception: + pass + try: + return _load_auth_store(global_path) + except Exception: + # A malformed global store must not break profile reads. The + # profile's own auth store is still authoritative. + return {} + + def _auth_lock_path() -> Path: return _auth_file_path().with_suffix(".lock") @@ -966,15 +1033,50 @@ def get_auth_provider_display_name(provider_id: str) -> str: def read_credential_pool(provider_id: Optional[str] = None) -> Dict[str, Any]: - """Return the persisted credential pool, or one provider slice.""" + """Return the persisted credential pool, or one provider slice. + + In profile mode, the profile's credential pool is authoritative. If a + provider has no entries in the profile, entries from the global-root + ``auth.json`` are used as a read-only fallback — so workers spawned in a + profile can see providers that were only authenticated at global scope. + + Profile entries always win: the global fallback only applies per-provider + when the profile has zero entries for that provider. Once the user runs + ``hermes auth add `` inside the profile, profile entries + fully shadow global for that provider on the next read. + + Writes always go to the profile (``write_credential_pool`` is unchanged). + See issue #18594 follow-up. + """ auth_store = _load_auth_store() pool = auth_store.get("credential_pool") if not isinstance(pool, dict): pool = {} + + global_pool: Dict[str, Any] = {} + global_store = _load_global_auth_store() + maybe_global_pool = global_store.get("credential_pool") if global_store else None + if isinstance(maybe_global_pool, dict): + global_pool = maybe_global_pool + if provider_id is None: - return dict(pool) + merged = dict(pool) + for gp_key, gp_entries in global_pool.items(): + if not isinstance(gp_entries, list) or not gp_entries: + continue + # Per-provider shadowing: profile wins whenever it has ANY entries. + existing = merged.get(gp_key) + if isinstance(existing, list) and existing: + continue + merged[gp_key] = list(gp_entries) + return merged + provider_entries = pool.get(provider_id) - return list(provider_entries) if isinstance(provider_entries, list) else [] + if isinstance(provider_entries, list) and provider_entries: + return list(provider_entries) + # Profile has no entries for this provider — fall back to global. + global_entries = global_pool.get(provider_id) + return list(global_entries) if isinstance(global_entries, list) else [] def write_credential_pool(provider_id: str, entries: List[Dict[str, Any]]) -> Path: @@ -1033,9 +1135,25 @@ def unsuppress_credential_source(provider_id: str, source: str) -> bool: def get_provider_auth_state(provider_id: str) -> Optional[Dict[str, Any]]: - """Return persisted auth state for a provider, or None.""" + """Return persisted auth state for a provider, or None. + + In profile mode, falls back to the global-root ``auth.json`` when the + profile has no state for this provider. Profile state always wins when + present. Writes (``_save_auth_store`` / ``persist_*_credentials``) are + unchanged — they still target the profile only. This mirrors + ``read_credential_pool``'s per-provider shadowing semantics so that + ``_seed_from_singletons`` can reseed a profile's credential pool from + global-scope provider state (e.g. a globally-authenticated Anthropic + OAuth or Nous device-code session). See issue #18594 follow-up. + """ auth_store = _load_auth_store() - return _load_provider_state(auth_store, provider_id) + state = _load_provider_state(auth_store, provider_id) + if state is not None: + return state + global_store = _load_global_auth_store() + if not global_store: + return None + return _load_provider_state(global_store, provider_id) def get_active_provider() -> Optional[str]: diff --git a/tests/hermes_cli/test_auth_profile_fallback.py b/tests/hermes_cli/test_auth_profile_fallback.py new file mode 100644 index 0000000000..2063517d28 --- /dev/null +++ b/tests/hermes_cli/test_auth_profile_fallback.py @@ -0,0 +1,360 @@ +"""Tests for cross-profile auth fallback. + +When ``HERMES_HOME`` points to a named profile, ``read_credential_pool()`` +and ``get_provider_auth_state()`` fall back to the global-root +``auth.json`` per-provider when the profile has no entries for that +provider. Writes still target the profile only. + +See the #18594 follow-up report: profile workers couldn't see providers +authenticated only at the global root. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + + +def _make_auth_store(pool: dict | None = None, providers: dict | None = None) -> dict: + store: dict = {"version": 1} + if pool is not None: + store["credential_pool"] = pool + if providers is not None: + store["providers"] = providers + return store + + +@pytest.fixture() +def profile_env(tmp_path, monkeypatch): + """Set up a global root + an active profile under Path.home()/.hermes/profiles/coder. + + * Path.home() -> tmp_path + * Global root -> tmp_path/.hermes (has its own auth.json fixture) + * Profile -> tmp_path/.hermes/profiles/coder (active, HERMES_HOME points here) + + This mirrors the real "named profile mounted under the default root" + layout that profile users actually have on disk. + """ + monkeypatch.setattr(Path, "home", lambda: tmp_path) + global_root = tmp_path / ".hermes" + global_root.mkdir() + profile_dir = global_root / "profiles" / "coder" + profile_dir.mkdir(parents=True) + monkeypatch.setenv("HERMES_HOME", str(profile_dir)) + return {"global": global_root, "profile": profile_dir} + + +def _write(path: Path, payload: dict) -> None: + path.write_text(json.dumps(payload, indent=2)) + + +# --------------------------------------------------------------------------- +# read_credential_pool — provider-slice reads +# --------------------------------------------------------------------------- + + +def test_profile_with_zero_entries_falls_back_to_global(profile_env): + """Empty profile pool inherits the global-root entries for that provider.""" + from hermes_cli.auth import read_credential_pool + + _write(profile_env["global"] / "auth.json", _make_auth_store(pool={ + "openrouter": [{ + "id": "glob-1", + "label": "global-key", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "sk-or-global", + }], + })) + # Profile auth.json: exists but has no openrouter entries. + _write(profile_env["profile"] / "auth.json", _make_auth_store(pool={})) + + entries = read_credential_pool("openrouter") + assert len(entries) == 1 + assert entries[0]["id"] == "glob-1" + assert entries[0]["access_token"] == "sk-or-global" + + +def test_profile_with_entries_fully_shadows_global(profile_env): + """Once the profile has any entries for a provider, global is ignored.""" + from hermes_cli.auth import read_credential_pool + + _write(profile_env["global"] / "auth.json", _make_auth_store(pool={ + "openrouter": [{ + "id": "glob-1", + "label": "global-key", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "sk-or-global", + }], + })) + _write(profile_env["profile"] / "auth.json", _make_auth_store(pool={ + "openrouter": [{ + "id": "prof-1", + "label": "profile-key", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "sk-or-profile", + }], + })) + + entries = read_credential_pool("openrouter") + assert len(entries) == 1 + assert entries[0]["id"] == "prof-1" + assert entries[0]["access_token"] == "sk-or-profile" + + +def test_per_provider_shadowing_is_independent(profile_env): + """Profile can override one provider while inheriting another from global.""" + from hermes_cli.auth import read_credential_pool + + _write(profile_env["global"] / "auth.json", _make_auth_store(pool={ + "openrouter": [{ + "id": "glob-or", + "label": "global-or", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "sk-or-global", + }], + "anthropic": [{ + "id": "glob-ant", + "label": "global-ant", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "sk-ant-global", + }], + })) + _write(profile_env["profile"] / "auth.json", _make_auth_store(pool={ + # Profile has openrouter only — anthropic should still fall back. + "openrouter": [{ + "id": "prof-or", + "label": "profile-or", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "sk-or-profile", + }], + })) + + or_entries = read_credential_pool("openrouter") + ant_entries = read_credential_pool("anthropic") + assert [e["id"] for e in or_entries] == ["prof-or"] + assert [e["id"] for e in ant_entries] == ["glob-ant"] + + +def test_missing_global_auth_file_is_safe(profile_env): + """Profile processes that never had a global auth.json still work.""" + from hermes_cli.auth import read_credential_pool + + # No global auth.json written at all. + _write(profile_env["profile"] / "auth.json", _make_auth_store(pool={ + "openrouter": [{ + "id": "prof-1", + "label": "profile", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "sk-profile", + }], + })) + + assert read_credential_pool("openrouter")[0]["id"] == "prof-1" + assert read_credential_pool("anthropic") == [] + + +def test_malformed_global_auth_file_does_not_break_profile_read(profile_env): + (profile_env["global"] / "auth.json").write_text("{not valid json") + _write(profile_env["profile"] / "auth.json", _make_auth_store(pool={ + "openrouter": [{ + "id": "prof-1", + "label": "profile", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "sk-profile", + }], + })) + + from hermes_cli.auth import read_credential_pool + + # Profile reads still work; malformed global is silently ignored. + assert read_credential_pool("openrouter")[0]["id"] == "prof-1" + # And no fallback for anthropic since global is unreadable. + assert read_credential_pool("anthropic") == [] + + +# --------------------------------------------------------------------------- +# read_credential_pool — whole-pool reads (provider_id=None) +# --------------------------------------------------------------------------- + + +def test_whole_pool_merges_global_providers_when_missing_locally(profile_env): + from hermes_cli.auth import read_credential_pool + + _write(profile_env["global"] / "auth.json", _make_auth_store(pool={ + "openrouter": [{ + "id": "glob-or", + "label": "global-or", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "sk-or-global", + }], + "anthropic": [{ + "id": "glob-ant", + "label": "global-ant", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "sk-ant-global", + }], + })) + _write(profile_env["profile"] / "auth.json", _make_auth_store(pool={ + "openrouter": [{ + "id": "prof-or", + "label": "profile-or", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "sk-or-profile", + }], + })) + + pool = read_credential_pool(None) + # Profile wins for openrouter, global fills in anthropic. + assert [e["id"] for e in pool["openrouter"]] == ["prof-or"] + assert [e["id"] for e in pool["anthropic"]] == ["glob-ant"] + + +# --------------------------------------------------------------------------- +# get_provider_auth_state — singleton fallback +# --------------------------------------------------------------------------- + + +def test_provider_auth_state_falls_back_to_global_when_profile_has_none(profile_env): + from hermes_cli.auth import get_provider_auth_state + + _write(profile_env["global"] / "auth.json", _make_auth_store(providers={ + "nous": {"access_token": "nous-global", "refresh_token": "rt-global"}, + })) + _write(profile_env["profile"] / "auth.json", _make_auth_store(providers={})) + + state = get_provider_auth_state("nous") + assert state is not None + assert state["access_token"] == "nous-global" + + +def test_provider_auth_state_profile_wins_when_present(profile_env): + from hermes_cli.auth import get_provider_auth_state + + _write(profile_env["global"] / "auth.json", _make_auth_store(providers={ + "nous": {"access_token": "nous-global"}, + })) + _write(profile_env["profile"] / "auth.json", _make_auth_store(providers={ + "nous": {"access_token": "nous-profile"}, + })) + + state = get_provider_auth_state("nous") + assert state is not None + assert state["access_token"] == "nous-profile" + + +def test_provider_auth_state_returns_none_when_neither_has_it(profile_env): + from hermes_cli.auth import get_provider_auth_state + + _write(profile_env["global"] / "auth.json", _make_auth_store(providers={})) + _write(profile_env["profile"] / "auth.json", _make_auth_store(providers={})) + + assert get_provider_auth_state("nous") is None + + +# --------------------------------------------------------------------------- +# Classic mode — no fallback path should ever trigger +# --------------------------------------------------------------------------- + + +def test_classic_mode_does_not_double_read_same_file(tmp_path, monkeypatch): + """In classic mode (HERMES_HOME == global root), no fallback path runs. + + This guards against the merge accidentally duplicating entries when the + profile and global resolve to the same directory. + """ + # Put Path.home() under a subdir so the seat belt in _auth_file_path() + # sees tmp_path/home/.hermes as the "real home" — which is NOT equal + # to the HERMES_HOME we set (tmp_path/classic), so the guard passes. + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setattr(Path, "home", lambda: fake_home) + hermes_home = tmp_path / "classic" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + _write(hermes_home / "auth.json", _make_auth_store(pool={ + "openrouter": [{ + "id": "only", + "label": "classic", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "sk-classic", + }], + })) + + from hermes_cli.auth import read_credential_pool, _global_auth_file_path + + # Classic mode: HERMES_HOME is set to a custom path that is NOT under + # ~/.hermes/profiles/ — get_default_hermes_root() returns HERMES_HOME + # itself, so the profile root and global root are the same directory, + # and the helper correctly returns None (no fallback). + assert _global_auth_file_path() is None + # And the read should return exactly one entry (not two). + entries = read_credential_pool("openrouter") + assert len(entries) == 1 + assert entries[0]["id"] == "only" + + +# --------------------------------------------------------------------------- +# Writes stay scoped to the profile +# --------------------------------------------------------------------------- + + +def test_write_credential_pool_targets_profile_not_global(profile_env): + from hermes_cli.auth import read_credential_pool, write_credential_pool + + _write(profile_env["global"] / "auth.json", _make_auth_store(pool={ + "openrouter": [{ + "id": "glob-1", + "label": "global", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "sk-global", + }], + })) + + write_credential_pool("openrouter", [{ + "id": "prof-new", + "label": "profile-new", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "sk-profile-new", + }]) + + # Global auth.json unchanged. + global_data = json.loads((profile_env["global"] / "auth.json").read_text()) + assert global_data["credential_pool"]["openrouter"][0]["id"] == "glob-1" + + # Profile auth.json holds the new entry. + profile_data = json.loads((profile_env["profile"] / "auth.json").read_text()) + assert profile_data["credential_pool"]["openrouter"][0]["id"] == "prof-new" + + # Subsequent read returns profile (shadows global). + assert [e["id"] for e in read_credential_pool("openrouter")] == ["prof-new"]