hermes-agent/tests/hermes_cli/test_auth_profile_fallback.py
Teknium 33bf5f6292 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.
2026-05-06 13:29:54 -07:00

360 lines
13 KiB
Python

"""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"]