Files
hermes-agent/tests/hermes_cli/test_auth_profile_fallback.py
kshitij 683afe45ba fix(auth): a global-root write-through invalidates the root store memo
_load_global_auth_store is memoised on (path, st_mtime_ns). A write-through
to the root (borrowed Codex cooldown clear, xAI/Anthropic root rotation)
followed by a fallback read in the same mtime tick — coarse-mtime
filesystems (exFAT, some network/overlay mounts) — kept serving the
pre-write store, so the resolve path could log "quota restored" and then
raise quota_exhausted from the stale memo. _save_auth_store(target_path=...)
now drops the memo.
2026-09-12 12:02:55 +05:30

384 lines
15 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
import time
from contextlib import contextmanager
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_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)
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# 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_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
# ---------------------------------------------------------------------------
# _load_provider_state — internal global fallback (issue #18594 follow-up)
#
# Several runtime helpers (notably ``resolve_nous_runtime_credentials`` and
# ``resolve_nous_access_token``) call ``_load_provider_state`` directly with
# a profile-loaded auth store rather than going through
# ``get_provider_auth_state``. Without the fallback wired into
# ``_load_provider_state`` itself, those helpers raise ``"Hermes is not
# logged into Nous Portal"`` even though the user has a valid global Nous
# login. These tests pin the per-provider shadowing into the helper.
# ---------------------------------------------------------------------------
def test_codex_runtime_uses_global_pool_when_profile_singleton_is_empty(profile_env):
"""Stale empty profile Codex state must not block the global credential pool."""
from hermes_cli.auth import resolve_codex_runtime_credentials
_write(profile_env["global"] / "auth.json", _make_auth_store(pool={
"openai-codex": [{
"id": "glob-codex",
"label": "global-codex",
"auth_type": "oauth",
"priority": 0,
"source": "manual:device_code",
"access_token": "global-codex-access-token",
"refresh_token": "global-codex-refresh-token",
}],
}))
_write(profile_env["profile"] / "auth.json", _make_auth_store(
providers={
"openai-codex": {
"auth_mode": "chatgpt",
"tokens": {"access_token": "", "refresh_token": ""},
},
},
pool={"openai-codex": []},
))
creds = resolve_codex_runtime_credentials(refresh_if_expiring=False)
assert creds["source"] == "credential_pool"
assert creds["api_key"] == "global-codex-access-token"
# Profile rows shadow the root the moment they exist (read_credential_pool precedence).
_write(profile_env["profile"] / "auth.json", _make_auth_store(pool={
"openai-codex": [{"id": "prof", "auth_type": "oauth", "priority": 0,
"access_token": "profile-codex-access-token", "refresh_token": "r"}],
}))
assert resolve_codex_runtime_credentials(refresh_if_expiring=False)["api_key"] == "profile-codex-access-token"
def test_codex_cooldown_clear_writes_to_the_store_that_owns_the_borrowed_pool(profile_env):
"""A restored quota must unfreeze the ROOT row a profile borrows; clearing the (empty)
profile store would leave every later resolve stuck on the stale cooldown."""
from hermes_cli.auth_codex import clear_codex_pool_quota_cooldowns
_write(profile_env["global"] / "auth.json", _make_auth_store(pool={
"openai-codex": [{"id": "glob", "auth_type": "oauth", "priority": 0,
"access_token": "global-codex-access-token", "refresh_token": "r",
"last_status": "exhausted", "last_error_reason": "rate_limit",
"last_error_reset_at": 4_102_444_800}],
}))
_write(profile_env["profile"] / "auth.json", _make_auth_store(pool={"openai-codex": []}))
assert clear_codex_pool_quota_cooldowns() == 1
root_rows = json.loads((profile_env["global"] / "auth.json").read_text())["credential_pool"]["openai-codex"]
assert root_rows[0].get("last_error_reset_at") is None
def test_codex_cooldown_clear_never_touches_root_when_profile_owns_rows(profile_env):
"""A profile with its own Codex rows is the owner: the root's cooldown state is not ours to
clear, even when none of the profile's rows are exhausted (0 cleared, root byte-identical)."""
from hermes_cli.auth_codex import clear_codex_pool_quota_cooldowns
root_file = profile_env["global"] / "auth.json"
_write(root_file, _make_auth_store(pool={
"openai-codex": [{"id": "glob", "auth_type": "oauth", "priority": 0,
"access_token": "global-codex-access-token", "refresh_token": "r",
"last_status": "exhausted", "last_error_reason": "rate_limit",
"last_error_reset_at": 4_102_444_800}],
}))
_write(profile_env["profile"] / "auth.json", _make_auth_store(pool={
"openai-codex": [{"id": "prof", "auth_type": "oauth", "priority": 0,
"access_token": "profile-codex-access-token", "refresh_token": "r"}],
}))
before = root_file.read_bytes()
assert clear_codex_pool_quota_cooldowns() == 0
assert root_file.read_bytes() == before
def test_root_write_through_is_visible_to_the_next_fallback_read(profile_env):
"""``_save_auth_store(target_path=root)`` must invalidate the mtime memo: a same-tick
read-after-write (coarse-mtime filesystems) would otherwise keep serving the stale root."""
import os
from hermes_cli.auth import _save_auth_store, read_credential_pool
root_file = profile_env["global"] / "auth.json"
_write(root_file, _make_auth_store(pool={"openai-codex": [{"id": "glob", "access_token": "old"}]}))
_write(profile_env["profile"] / "auth.json", _make_auth_store(pool={"openai-codex": []}))
assert read_credential_pool("openai-codex")[0]["access_token"] == "old" # primes the memo
stat = root_file.stat()
_save_auth_store(_make_auth_store(pool={"openai-codex": [{"id": "glob", "access_token": "new"}]}),
target_path=root_file)
os.utime(root_file, ns=(stat.st_atime_ns, stat.st_mtime_ns)) # simulate a same-tick write
assert read_credential_pool("openai-codex")[0]["access_token"] == "new"
# ---------------------------------------------------------------------------
# Classic mode — no fallback path should ever trigger
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# 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"]
def test_auth_lock_reentrancy_is_scoped_after_profile_context_switch(profile_env):
"""Changing profile context cannot inherit another store's lock depth."""
import hermes_cli.auth as auth
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
profile_b = profile_env["global"] / "profiles" / "reviewer"
profile_b.mkdir(parents=True)
profile_b_lock = profile_b / "auth.lock"
with auth._auth_store_lock():
holder_a = auth._auth_lock_holder_for(profile_env["profile"] / "auth.json")
assert getattr(holder_a, "depth", 0) == 1
token = set_hermes_home_override(profile_b)
try:
holder_b = auth._auth_lock_holder_for(profile_b / "auth.json")
assert holder_b is not holder_a
assert getattr(holder_b, "depth", 0) == 0
assert not profile_b_lock.exists()
with auth._auth_store_lock():
assert profile_b_lock.exists()
assert getattr(holder_b, "depth", 0) == 1
finally:
reset_hermes_home_override(token)
assert getattr(holder_a, "depth", 0) == 0
# ---------------------------------------------------------------------------
# write_credential_pool — stale-snapshot cooldown merge
# ---------------------------------------------------------------------------
@pytest.fixture()
def classic_env(tmp_path, monkeypatch):
"""Classic single-root layout (HERMES_HOME != ~/.hermes, no profiles)."""
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))
return hermes_home
def _pool_entry(**overrides) -> dict:
entry = {
"id": "cred-x",
"label": "key-x",
"auth_type": "api_key",
"priority": 0,
"source": "manual",
"access_token": "sk-x",
}
entry.update(overrides)
return entry
def test_write_pool_never_merges_cooldown_onto_reauthed_entry(classic_env):
"""A token change means re-auth: the old cooldown must never carry over.
A fresh login intentionally clears the entry's status; resurrecting the
stale cooldown onto the new credentials would bench a just-authorized key.
"""
from hermes_cli.auth import write_credential_pool
_write(classic_env / "auth.json", _make_auth_store(pool={
"openrouter": [_pool_entry(
access_token="sk-old",
last_status="exhausted",
last_status_at=time.time() - 60, # newer AND unexpired
last_error_code=429,
)],
}))
# Same entry id, freshly re-authed with a new token and cleared status.
write_credential_pool("openrouter", [_pool_entry(access_token="sk-new")])
data = json.loads((classic_env / "auth.json").read_text())
persisted = data["credential_pool"]["openrouter"][0]
assert persisted["access_token"] == "sk-new"
assert persisted.get("last_status") != "exhausted"
assert persisted.get("last_error_code") is None