Files
hermes-agent/tests/hermes_cli/test_auth_profile_fallback.py
teknium1 2632229bcf fix(auth): named profiles read the root auth.json again (revert #111724)
Reverts 93889b770d ("named profiles no longer inherit the root
profile's auth.json"). After the Desktop update every bot profile that had
relied on the root OpenAI Codex login failed with "No Codex credentials
stored. Run `hermes -p <bot> auth add openai-codex --type oauth`", and users
had to re-run the device-code flow once per bot (5-6 times in the field
report). Sharing one grant across profiles is the intended design: OAuth
refresh tokens are single-use, so ONE grant lives at the root, profiles
resolve it read-only, and a refresh under a profile writes the rotated chain
back to root (Codex / xAI write-through, borrowed-row pool bookkeeping,
forked-grant heal) — never a per-profile copy.

Restored: `_global_auth_file_path` / `_load_global_auth_store` fallback in
`_load_provider_state*` / `read_credential_pool` / `_provider_state_transaction`,
Codex + xAI root write-through, `credential_pool` borrowed-root persistence,
`heal_forked_single_use_oauth_grants`, `share_auth` on profile creation
(Desktop create dialog checkbox), and the docs. `profile_credential_audit.py`
(the `hermes update` "profiles without a provider" notice) is removed with it.

Kept from after #111724: `_save_codex_tokens(set_active=...)` for image gen,
the plugin-auth `status` dispatch and the external-login notice in
`hermes auth list`, and the registry-derived env-var hint in agent_init.
2026-09-21 09:55:40 -07:00

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