mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-14 04:02:26 +00:00
fix(auth): fall back to global-root auth.json for providers missing in profile
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.
This commit is contained in:
parent
d514dd4055
commit
33bf5f6292
2 changed files with 483 additions and 5 deletions
|
|
@ -780,6 +780,73 @@ def _auth_file_path() -> Path:
|
||||||
return 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:
|
def _auth_lock_path() -> Path:
|
||||||
return _auth_file_path().with_suffix(".lock")
|
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]:
|
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 <provider>`` 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()
|
auth_store = _load_auth_store()
|
||||||
pool = auth_store.get("credential_pool")
|
pool = auth_store.get("credential_pool")
|
||||||
if not isinstance(pool, dict):
|
if not isinstance(pool, dict):
|
||||||
pool = {}
|
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:
|
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)
|
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:
|
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]]:
|
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()
|
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]:
|
def get_active_provider() -> Optional[str]:
|
||||||
|
|
|
||||||
360
tests/hermes_cli/test_auth_profile_fallback.py
Normal file
360
tests/hermes_cli/test_auth_profile_fallback.py
Normal file
|
|
@ -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"]
|
||||||
Loading…
Add table
Add a link
Reference in a new issue