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:
Teknium 2026-05-06 09:07:32 -07:00
parent d514dd4055
commit 33bf5f6292
2 changed files with 483 additions and 5 deletions

View file

@ -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 <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()
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]: