hermes-agent/tests/hermes_cli/test_auth_xai_oauth_provider.py
Teknium 39975613b1
test: prune wave 2 + speed fixes — 28,106 → 19,757 test functions, suite wall 315s → 294s
Second, deeper pass over tools/gateway/hermes_cli plus first pass over
the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker,
dashboard, conformance, monitoring, secret_sources, hermes_state,
providers). Same rubric as wave 1 (AGENTS.md test policy); security,
alternation/caching invariants, issue-number regressions, and E2E kept.

Real test-quality fixes found and rooted out along the way:
- tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls
  (DEFAULT_CONFIG smart-approval leaked in) — pinned approval
  mode=manual via autouse fixture: 17.4s → 0.4s.
- test_model_switch_custom_providers.py / test_user_providers_model_switch.py
  silently probed live provider catalogs (~2s/test) — stubbed
  cached_provider_model_ids/provider_model_ids/fetch_api_models.
- test_telegram_noise_filter.py: 15-platform copy-paste matrix over
  shared gateway.run logic → 3 representative platforms (55s → 3.9s).
- test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on
  MagicMock agents — interrupt.side_effect now clears _running_agents
  (22s → 1.0s).
- test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x
  (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps
  patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait
  5s → 0.5s.
- test_telegram_init_deadline.py: loop-block margin restored to 1.0s
  with rationale comment — the watchdog-dump assertion needs the loop
  blocked well past deadline+grace under parallel load (flaked once in
  the 40-worker verification run at a 0.2s margin).

Verification: full hermetic suite via scripts/run_tests.sh —
2,438 files, 21,718 tests passed, 0 failed, 293.9s wall.
Suite totals vs original baseline: 46,820 → 19,757 test functions
(−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
2026-07-29 13:39:40 -07:00

992 lines
38 KiB
Python

"""Tests for xAI Grok OAuth — tokens stored in Hermes auth store (~/.hermes/auth.json)."""
import base64
import json
import time
from pathlib import Path
import pytest
from hermes_cli.auth import (
AuthError,
DEFAULT_XAI_OAUTH_BASE_URL,
PROVIDER_REGISTRY,
XAI_OAUTH_CLIENT_ID,
XAI_OAUTH_SCOPE,
_read_xai_oauth_tokens,
_refresh_xai_oauth_tokens,
_save_xai_oauth_tokens,
_xai_access_token_is_expiring,
_xai_oauth_poll_device_token,
_xai_oauth_request_device_code,
_xai_validate_inference_base_url,
format_auth_error,
get_xai_oauth_auth_status,
refresh_xai_oauth_pure,
resolve_provider,
resolve_xai_oauth_runtime_credentials,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _setup_hermes_auth(
hermes_home: Path,
*,
access_token: str = "access",
refresh_token: str = "refresh",
discovery: dict | None = None,
auth_mode: str = "oauth_pkce",
):
"""Write xAI OAuth tokens into the Hermes auth store at the given root."""
hermes_home.mkdir(parents=True, exist_ok=True)
state = {
"tokens": {
"access_token": access_token,
"refresh_token": refresh_token,
"id_token": "",
"expires_in": 3600,
"token_type": "Bearer",
},
"last_refresh": "2026-05-14T00:00:00Z",
"auth_mode": auth_mode,
}
if discovery is not None:
state["discovery"] = discovery
auth_store = {
"version": 1,
"active_provider": "xai-oauth",
"providers": {"xai-oauth": state},
}
auth_file = hermes_home / "auth.json"
auth_file.write_text(json.dumps(auth_store, indent=2))
return auth_file
def _jwt_with_exp(exp_epoch: int) -> str:
"""Build a minimal JWT-shaped string with the given exp claim."""
payload = {"exp": exp_epoch}
encoded = (
base64.urlsafe_b64encode(json.dumps(payload).encode("utf-8"))
.rstrip(b"=")
.decode("utf-8")
)
return f"h.{encoded}.s"
class _StubHTTPResponse:
def __init__(self, status_code: int, payload):
self.status_code = status_code
self._payload = payload
self.text = json.dumps(payload) if isinstance(payload, (dict, list)) else str(payload)
def json(self):
if isinstance(self._payload, Exception):
raise self._payload
return self._payload
class _StubHTTPClient:
def __init__(self, response):
self._response = response
self.last_call = None
def __enter__(self):
return self
def __exit__(self, *args):
return False
def post(self, *args, **kwargs):
self.last_call = ("post", args, kwargs)
return self._response
def _patch_httpx_client(monkeypatch, response):
holder = {"client": None}
def _factory(*args, **kwargs):
client = _StubHTTPClient(response)
holder["client"] = client
return client
monkeypatch.setattr("hermes_cli.auth.httpx.Client", _factory)
return holder
# ---------------------------------------------------------------------------
# Constants and registry
# ---------------------------------------------------------------------------
def test_xai_oauth_provider_registered():
assert "xai-oauth" in PROVIDER_REGISTRY
pconfig = PROVIDER_REGISTRY["xai-oauth"]
assert pconfig.id == "xai-oauth"
assert pconfig.auth_type == "oauth_external"
assert pconfig.inference_base_url == DEFAULT_XAI_OAUTH_BASE_URL
def test_resolve_provider_normalizes_xai_oauth_aliases():
assert resolve_provider("xai-oauth") == "xai-oauth"
assert resolve_provider("grok-oauth") == "xai-oauth"
assert resolve_provider("x-ai-oauth") == "xai-oauth"
assert resolve_provider("xai-grok-oauth") == "xai-oauth"
# ---------------------------------------------------------------------------
# JWT expiry detection
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Device-code flow
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Token roundtrip + reads
# ---------------------------------------------------------------------------
def test_save_and_read_xai_oauth_tokens_roundtrip(tmp_path, monkeypatch):
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
(hermes_home / "auth.json").write_text(json.dumps({"version": 1, "providers": {}}))
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
_save_xai_oauth_tokens(
{
"access_token": "at-1",
"refresh_token": "rt-1",
"id_token": "",
"expires_in": 3600,
"token_type": "Bearer",
},
discovery={"token_endpoint": "https://auth.x.ai/oauth2/token"},
redirect_uri="http://127.0.0.1:56121/callback",
)
data = _read_xai_oauth_tokens()
assert data["tokens"]["access_token"] == "at-1"
assert data["tokens"]["refresh_token"] == "rt-1"
assert data["redirect_uri"] == "http://127.0.0.1:56121/callback"
assert data["discovery"]["token_endpoint"] == "https://auth.x.ai/oauth2/token"
def test_refresh_xai_oauth_tokens_preserves_active_provider(tmp_path, monkeypatch):
"""Token refresh must not flip active_provider away from the chat provider."""
hermes_home = tmp_path / "hermes"
near = _jwt_with_exp(int(time.time()) + 30)
_setup_hermes_auth(hermes_home, access_token=near, refresh_token="rt-old")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
auth_path = hermes_home / "auth.json"
raw = json.loads(auth_path.read_text())
raw["active_provider"] = "openrouter"
auth_path.write_text(json.dumps(raw))
new_access = _jwt_with_exp(int(time.time()) + 7200)
def _fake_pure(access_token, refresh_token, **kwargs):
return {
"access_token": new_access,
"refresh_token": "rt-new",
"id_token": "",
"expires_in": 3600,
"token_type": "Bearer",
"last_refresh": "2026-07-25T12:00:00Z",
}
monkeypatch.setattr("hermes_cli.auth.refresh_xai_oauth_pure", _fake_pure)
tokens = _read_xai_oauth_tokens()["tokens"]
_refresh_xai_oauth_tokens(
tokens,
token_endpoint="https://auth.x.ai/oauth2/token",
timeout_seconds=5.0,
)
after = json.loads(auth_path.read_text())
assert after["active_provider"] == "openrouter"
assert after["providers"]["xai-oauth"]["tokens"]["access_token"] == new_access
def test_read_xai_oauth_tokens_missing(tmp_path, monkeypatch):
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
(hermes_home / "auth.json").write_text(json.dumps({"version": 1, "providers": {}}))
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
with pytest.raises(AuthError) as exc:
_read_xai_oauth_tokens()
assert exc.value.code == "xai_auth_missing"
assert exc.value.relogin_required is True
# ---------------------------------------------------------------------------
# Runtime credential resolution
# ---------------------------------------------------------------------------
def test_resolve_xai_runtime_credentials_refreshes_expiring_token(tmp_path, monkeypatch):
hermes_home = tmp_path / "hermes"
expiring = _jwt_with_exp(int(time.time()) - 10)
_setup_hermes_auth(
hermes_home,
access_token=expiring,
refresh_token="rt-old",
discovery={"token_endpoint": "https://auth.x.ai/oauth2/token"},
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
new_access = _jwt_with_exp(int(time.time()) + 2 * 60 * 60)
called = {"count": 0}
def _fake_refresh(tokens, **kwargs):
called["count"] += 1
updated = dict(tokens)
updated["access_token"] = new_access
updated["refresh_token"] = "rt-new"
return updated
monkeypatch.setattr("hermes_cli.auth._refresh_xai_oauth_tokens", _fake_refresh)
creds = resolve_xai_oauth_runtime_credentials()
assert called["count"] == 1
assert creds["api_key"] == new_access
# ---------------------------------------------------------------------------
# Inference base-URL host guard (xai-oauth bearer leak protection)
#
# The xAI OAuth bearer is a high-value, long-lived SuperGrok credential.
# ``XAI_BASE_URL`` / ``HERMES_XAI_BASE_URL`` are a credential-leak vector
# unless the host is pinned to the xAI origin. These tests cover the
# accept/reject matrix for `_xai_validate_inference_base_url` and confirm
# the runtime resolver falls back to the default on rejection rather than
# leaking the bearer to an attacker-controlled endpoint.
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Quarantine: terminal refresh failure clears dead tokens (#28155 sibling)
# ---------------------------------------------------------------------------
_STALE_XAI_OAUTH_STATE = {
"tokens": {
"access_token": "dead-access-token",
"refresh_token": "dead-refresh-token",
"id_token": "",
"expires_in": 3600,
"token_type": "Bearer",
},
"discovery": {"token_endpoint": "https://auth.x.ai/oauth2/token"},
"redirect_uri": "http://127.0.0.1:51827/callback",
"last_refresh": "2000-01-01T00:00:00Z",
"auth_mode": "oauth_pkce",
}
def _seed_xai_oauth_state(
hermes_home: Path, state: dict, *, active_provider: str = "xai-oauth"
) -> None:
hermes_home.mkdir(parents=True, exist_ok=True)
auth_store = {
"version": 1,
"active_provider": active_provider,
"providers": {"xai-oauth": state},
}
(hermes_home / "auth.json").write_text(json.dumps(auth_store, indent=2))
def test_resolve_credentials_quarantines_dead_tokens_on_terminal_refresh_failure(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Terminal refresh failure (relogin_required=True, code=xai_refresh_failed)
must clear access_token/refresh_token from auth.json and write a
last_auth_error marker so subsequent calls fail fast without a network retry.
Mirrors the credential_pool.py quarantine for the singleton/direct resolve path.
"""
hermes_home = tmp_path / "hermes"
_seed_xai_oauth_state(hermes_home, dict(_STALE_XAI_OAUTH_STATE), active_provider="nous")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
def _terminal_refresh(tokens, **kwargs):
raise AuthError(
"xAI token refresh failed. Response: invalid_grant",
provider="xai-oauth",
code="xai_refresh_failed",
relogin_required=True,
)
monkeypatch.setattr("hermes_cli.auth._refresh_xai_oauth_tokens", _terminal_refresh)
with pytest.raises(AuthError) as exc_info:
resolve_xai_oauth_runtime_credentials(force_refresh=True)
assert exc_info.value.code == "xai_refresh_failed"
assert exc_info.value.relogin_required is True
raw = json.loads((hermes_home / "auth.json").read_text())
tokens = raw["providers"]["xai-oauth"]["tokens"]
# Dead OAuth fields must be cleared.
assert "access_token" not in tokens
assert "refresh_token" not in tokens
# Non-credential metadata must be preserved.
assert tokens.get("token_type") == "Bearer"
# Structured diagnostic blob must be written.
err = raw["providers"]["xai-oauth"].get("last_auth_error")
assert isinstance(err, dict)
assert err["provider"] == "xai-oauth"
assert err["code"] == "xai_refresh_failed"
assert err["reason"] == "runtime_refresh_failure"
assert err["relogin_required"] is True
assert "at" in err
# Active provider must be unchanged.
assert raw["active_provider"] == "nous"
# ---------------------------------------------------------------------------
# Auth status surface
# ---------------------------------------------------------------------------
def test_get_xai_oauth_auth_status_logged_out(tmp_path, monkeypatch):
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
(hermes_home / "auth.json").write_text(json.dumps({"version": 1, "providers": {}}))
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
status = get_xai_oauth_auth_status()
assert status["logged_in"] is False
assert "error" in status
# ---------------------------------------------------------------------------
# refresh_xai_oauth_pure error handling
# ---------------------------------------------------------------------------
def test_refresh_xai_oauth_pure_403_marked_tier_denied_not_relogin(monkeypatch):
"""403 from xAI's token endpoint is tier/entitlement, not stale tokens.
Regression test for #26847 — xAI's backend has been seen to 403
standard SuperGrok subscribers despite the in-app subscription
being active. Re-running ``hermes model`` won't help in that
case, so the AuthError must NOT set ``relogin_required=True``,
and must carry the dedicated ``xai_oauth_tier_denied`` code so
``format_auth_error`` doesn't append the misleading re-auth hint.
"""
response = _StubHTTPResponse(403, {"error": "permission_denied"})
_patch_httpx_client(monkeypatch, response)
with pytest.raises(AuthError) as exc:
refresh_xai_oauth_pure(
"at", "rt", token_endpoint="https://auth.x.ai/oauth2/token"
)
assert exc.value.code == "xai_oauth_tier_denied"
assert exc.value.relogin_required is False
message = str(exc.value).lower()
assert "403" in message
assert "xai_api_key" in message
assert "tier" in message
def test_format_auth_error_tier_denied_does_not_suggest_relogin():
"""``xai_oauth_tier_denied`` must not append the re-authenticate hint.
Regression for #26847: telling a tier-gated user to ``hermes model``
is actively wrong — re-logging in won't change xAI's allowlist
decision. The full message (with ``XAI_API_KEY`` fallback) is built
into the error itself.
"""
err = AuthError(
"xAI token refresh failed with HTTP 403. Response: forbidden. "
"This OAuth account is not authorized for xAI API access — "
"xAI may be restricting API/OAuth use to specific SuperGrok tiers. "
"Set ``XAI_API_KEY`` and switch to ``provider: xai``.",
provider="xai-oauth",
code="xai_oauth_tier_denied",
relogin_required=False,
)
rendered = format_auth_error(err)
assert "re-authenticate" not in rendered.lower()
assert "hermes model" not in rendered.lower()
assert "XAI_API_KEY" in rendered
def test_xai_oauth_discovery_raises_typed_error_on_malformed_json(monkeypatch):
"""Discovery is a cold-start, one-time fetch. If the response is HTTP
200 with a non-JSON body (corporate proxy / captive portal returning
HTML), surface a typed AuthError rather than letting the
``json.JSONDecodeError`` escape — so the message reads as an auth
problem instead of an internal parsing crash."""
from hermes_cli.auth import _xai_oauth_discovery
class _BadJSON:
status_code = 200
def json(self):
raise ValueError("Expecting value: line 1 column 1 (char 0)")
monkeypatch.setattr(
"hermes_cli.auth.httpx.get",
lambda *a, **kw: _BadJSON(),
)
with pytest.raises(AuthError) as exc:
_xai_oauth_discovery()
assert exc.value.code == "xai_discovery_invalid_json"
# ---------------------------------------------------------------------------
# OIDC discovery endpoint origin/scheme validation (MITM hardening)
# ---------------------------------------------------------------------------
def test_refresh_xai_oauth_pure_rejects_non_https_token_endpoint(monkeypatch):
"""A poisoned auth.json (from MITM during initial discovery, or an older
Hermes that didn't validate) must not be silently honored on the refresh
hot path. A non-HTTPS ``token_endpoint`` would leak the refresh_token in
cleartext on every refresh; refuse before the POST."""
# No HTTP stub installed — refresh must fail at validation, not at POST.
with pytest.raises(AuthError) as exc:
refresh_xai_oauth_pure(
"at", "rt", token_endpoint="http://auth.x.ai/oauth2/token"
)
assert exc.value.code == "xai_discovery_invalid"
def test_refresh_xai_oauth_pure_accepts_apex_and_subdomain_endpoints(monkeypatch):
"""The validator must accept BOTH the bare xAI apex (``x.ai``) and any
``*.x.ai`` subdomain (e.g. ``auth.x.ai`` today, future migrations to
``accounts.x.ai`` etc.). Without subdomain support we'd lock the
integration to whatever xAI happens to use today."""
new_access = _jwt_with_exp(int(time.time()) + 2 * 60 * 60)
response = _StubHTTPResponse(
200,
{"access_token": new_access, "expires_in": 3600, "token_type": "Bearer"},
)
_patch_httpx_client(monkeypatch, response)
# auth.x.ai (current production)
updated = refresh_xai_oauth_pure(
"at", "rt", token_endpoint="https://auth.x.ai/oauth2/token"
)
assert updated["access_token"] == new_access
# hypothetical migration to accounts.x.ai
_patch_httpx_client(monkeypatch, response)
updated2 = refresh_xai_oauth_pure(
"at", "rt", token_endpoint="https://accounts.x.ai/token"
)
assert updated2["access_token"] == new_access
def test_xai_oauth_discovery_validates_endpoints(monkeypatch):
"""The discovery response itself goes through endpoint validation, so a
one-time MITM during initial login cannot poison ``auth.json`` with an
attacker-controlled ``token_endpoint``. (The persistence is what makes
this attack worth defending against — one MITM = forever credential
leak.)"""
from hermes_cli.auth import _xai_oauth_discovery
class _StubGetResponse:
status_code = 200
def __init__(self, payload):
self._payload = payload
def json(self):
return self._payload
def _fake_get(url, headers=None, timeout=None):
return _StubGetResponse({
"authorization_endpoint": "https://auth.x.ai/oauth2/authorize",
"token_endpoint": "https://evil.example.com/token", # poisoned
})
monkeypatch.setattr("hermes_cli.auth.httpx.get", _fake_get)
with pytest.raises(AuthError) as exc:
_xai_oauth_discovery()
assert exc.value.code == "xai_discovery_invalid"
# ---------------------------------------------------------------------------
# Pool seeding from singleton
# ---------------------------------------------------------------------------
def test_credential_pool_seeds_xai_oauth_from_singleton(tmp_path, monkeypatch):
"""After `hermes model` -> xai-oauth, the singleton holds tokens. load_pool
must surface that as a pool entry so `hermes auth list` reflects truth and
refreshes route through the pool consistently with codex.
Device code is the only supported xAI OAuth flow, so the singleton is
always surfaced as ``device_code``."""
from agent.credential_pool import load_pool
hermes_home = tmp_path / "hermes"
fresh = _jwt_with_exp(int(time.time()) + 2 * 60 * 60)
_setup_hermes_auth(hermes_home, access_token=fresh, refresh_token="rt-1")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
pool = load_pool("xai-oauth")
assert pool.has_credentials()
entries = pool.entries()
assert len(entries) == 1
entry = entries[0]
assert entry.access_token == fresh
assert entry.refresh_token == "rt-1"
assert entry.source == "device_code"
assert entry.base_url == DEFAULT_XAI_OAUTH_BASE_URL
def test_credential_pool_device_code_seed_respects_suppression(tmp_path, monkeypatch):
from agent.credential_pool import load_pool
from hermes_cli.auth import suppress_credential_source
hermes_home = tmp_path / "hermes"
fresh = _jwt_with_exp(int(time.time()) + 2 * 60 * 60)
_setup_hermes_auth(
hermes_home,
access_token=fresh,
auth_mode="oauth_device_code",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
suppress_credential_source("xai-oauth", "device_code")
pool = load_pool("xai-oauth")
assert not pool.has_credentials()
def test_auth_remove_xai_oauth_clears_singleton_and_sticks(tmp_path, monkeypatch):
"""End-to-end regression: ``hermes auth remove xai-oauth 1`` for a
singleton-seeded entry must clear auth.json providers.xai-oauth AND
suppress further re-seeding — otherwise the next ``load_pool`` call
silently resurrects the entry from the still-present singleton, making
the user-facing removal a no-op (the entry reappears on the next
invocation with no warning).
The bug pre-fix: there was no RemovalStep registered for the
xai-oauth singleton source, so ``find_removal_step`` returned None
and ``auth_remove_command`` fell through to the "unregistered source —
nothing to clean up" branch. That branch is correct for ``manual``
entries (pool-only) but wrong for singleton-seeded ``device_code``
entries (auth.json singleton survives the in-memory removal)."""
from agent.credential_pool import load_pool
from hermes_cli.auth_commands import auth_remove_command
from types import SimpleNamespace
hermes_home = tmp_path / "hermes"
fresh = _jwt_with_exp(int(time.time()) + 2 * 60 * 60)
_setup_hermes_auth(hermes_home, access_token=fresh, refresh_token="rt-1")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
# Confirm pre-state: pool sees the seeded entry, auth.json has the singleton.
pool = load_pool("xai-oauth")
assert pool.has_credentials()
raw = json.loads((hermes_home / "auth.json").read_text())
assert "xai-oauth" in raw.get("providers", {})
# Act: the user runs `hermes auth remove xai-oauth 1`.
auth_remove_command(SimpleNamespace(provider="xai-oauth", target="1"))
# Post-state: auth.json singleton must be cleared so a re-seed has
# nothing to import.
raw_after = json.loads((hermes_home / "auth.json").read_text())
assert "xai-oauth" not in raw_after.get("providers", {}), (
"auth.json providers.xai-oauth must be cleared — otherwise the "
"next load_pool() reseeds the removed entry from the surviving "
"singleton, silently undoing the user's removal."
)
# And the next load must not reseed the entry from anywhere.
pool_after = load_pool("xai-oauth")
assert not pool_after.has_credentials(), (
"Removal must stick across load_pool() calls — without the "
"device_code RemovalStep, the seed function reads the singleton "
"and rebuilds the entry on every Hermes invocation."
)
def test_login_xai_oauth_relogin_clears_suppression_and_reseeds(tmp_path, monkeypatch):
"""remove -> ``hermes model`` re-login (``_login_xai_oauth``) must clear the
``device_code`` suppression marker so the singleton seed re-creates the
pool entry.
Pre-fix: ``auth_remove_command`` set ``["device_code"]`` suppression but
only ``auth_add_command`` cleared it — the ``hermes model`` re-login path did
not. So after remove -> re-login the seed kept skipping and ``hermes auth
list`` showed no xAI entry even though the agent still worked via the
singleton fallback. The fix calls ``unsuppress_credential_source`` on
explicit interactive login success.
"""
from types import SimpleNamespace
from agent.credential_pool import load_pool
from hermes_cli.auth import (
_login_xai_oauth,
is_source_suppressed,
suppress_credential_source,
)
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
(hermes_home / "auth.json").write_text(json.dumps({"version": 1, "providers": {}}))
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("HERMES_XAI_BASE_URL", raising=False)
monkeypatch.delenv("XAI_BASE_URL", raising=False)
# Post-remove state: singleton gone + device_code suppressed, so the
# seed is gated off and the pool is empty.
suppress_credential_source("xai-oauth", "device_code")
assert is_source_suppressed("xai-oauth", "device_code") is True
assert not load_pool("xai-oauth").has_credentials()
new_access = _jwt_with_exp(int(time.time()) + 2 * 60 * 60)
monkeypatch.setattr(
"hermes_cli.auth._xai_oauth_device_code_login",
lambda **kwargs: {
"tokens": {
"access_token": new_access,
"refresh_token": "rt-relogin",
"id_token": "",
"token_type": "Bearer",
},
"discovery": {"token_endpoint": "https://auth.x.ai/token"},
"redirect_uri": "",
"base_url": DEFAULT_XAI_OAUTH_BASE_URL,
"last_refresh": "2026-06-30T10:00:00Z",
},
)
# Don't mutate a real config file during the test.
monkeypatch.setattr(
"hermes_cli.auth._update_config_for_provider",
lambda *args, **kwargs: "config.toml",
)
_login_xai_oauth(
SimpleNamespace(no_browser=True, timeout=3),
None, # pconfig is `del`-eted inside the function
force_new_login=True,
)
# The explicit interactive login cleared the suppression marker...
assert is_source_suppressed("xai-oauth", "device_code") is False
# ...so the singleton seed re-creates the canonical pool entry.
pool = load_pool("xai-oauth")
assert pool.has_credentials()
entry = next(e for e in pool.entries() if e.source == "device_code")
assert entry.access_token == new_access
# ---------------------------------------------------------------------------
# Pool sync-back to singleton after refresh
# ---------------------------------------------------------------------------
def test_pool_sync_back_writes_to_singleton(tmp_path, monkeypatch):
"""When the pool refreshes a singleton-seeded xAI entry, the new tokens
must be written back to providers["xai-oauth"] so that
resolve_xai_oauth_runtime_credentials() (which reads the singleton)
doesn't keep using the consumed refresh token."""
from agent.credential_pool import load_pool
hermes_home = tmp_path / "hermes"
expired = _jwt_with_exp(int(time.time()) - 10)
_setup_hermes_auth(hermes_home, access_token=expired, refresh_token="rt-old")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
new_access = _jwt_with_exp(int(time.time()) + 2 * 60 * 60)
def _fake_refresh(access_token, refresh_token, **kwargs):
assert refresh_token == "rt-old"
return {
"access_token": new_access,
"refresh_token": "rt-new",
"id_token": "",
"expires_in": 3600,
"token_type": "Bearer",
"last_refresh": "2026-05-15T01:00:00Z",
}
monkeypatch.setattr("hermes_cli.auth.refresh_xai_oauth_pure", _fake_refresh)
pool = load_pool("xai-oauth")
selected = pool.select()
assert selected is not None
assert selected.access_token == new_access
assert selected.refresh_token == "rt-new"
# Singleton must reflect refreshed tokens — otherwise the next process
# to load credentials would re-seed the consumed refresh token.
auth_path = hermes_home / "auth.json"
raw = json.loads(auth_path.read_text())
state = raw["providers"]["xai-oauth"]
assert state["tokens"]["access_token"] == new_access
assert state["tokens"]["refresh_token"] == "rt-new"
assert state["last_refresh"] == "2026-05-15T01:00:00Z"
# ---------------------------------------------------------------------------
# Runtime provider routing
# ---------------------------------------------------------------------------
def test_runtime_provider_uses_pool_entry_for_xai_oauth(tmp_path, monkeypatch):
from hermes_cli.runtime_provider import resolve_runtime_provider
hermes_home = tmp_path / "hermes"
fresh = _jwt_with_exp(int(time.time()) + 2 * 60 * 60)
_setup_hermes_auth(hermes_home, access_token=fresh)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("HERMES_XAI_BASE_URL", raising=False)
monkeypatch.delenv("XAI_BASE_URL", raising=False)
runtime = resolve_runtime_provider(requested="xai-oauth")
assert runtime["provider"] == "xai-oauth"
assert runtime["api_mode"] == "codex_responses"
assert runtime["api_key"] == fresh
assert runtime["base_url"] == DEFAULT_XAI_OAUTH_BASE_URL
# ---------------------------------------------------------------------------
# Token-expiry behavior on the pool path
# ---------------------------------------------------------------------------
def test_pool_refresh_recovers_when_other_process_already_refreshed(tmp_path, monkeypatch):
"""Variant of the multi-process race where the other process refreshes
BETWEEN our proactive sync and the HTTP POST. Our refresh fails with a
consumed-token error; we must re-check auth.json, find the fresh pair
(written by the racing process), and adopt it instead of marking the
entry exhausted."""
from agent.credential_pool import load_pool
hermes_home = tmp_path / "hermes"
in_memory_at = _jwt_with_exp(int(time.time()) + 30)
_setup_hermes_auth(hermes_home, access_token=in_memory_at, refresh_token="rt-shared")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
pool = load_pool("xai-oauth")
other_process_at = _jwt_with_exp(int(time.time()) + 2 * 60 * 60)
def _fake_refresh(access_token, refresh_token, **kwargs):
# Simulate the racing process winning at the auth server right
# before our POST: by the time we reach this call, auth.json
# already holds the fresher pair, but we POSTed with rt-shared.
raw = json.loads((hermes_home / "auth.json").read_text())
raw["providers"]["xai-oauth"]["tokens"] = {
"access_token": other_process_at,
"refresh_token": "rt-rotated",
"id_token": "",
"expires_in": 3600,
"token_type": "Bearer",
}
(hermes_home / "auth.json").write_text(json.dumps(raw))
raise AuthError(
"refresh_token_reused",
provider="xai-oauth",
code="xai_refresh_failed",
relogin_required=True,
)
monkeypatch.setattr("hermes_cli.auth.refresh_xai_oauth_pure", _fake_refresh)
selected = pool.select()
# Even though refresh_xai_oauth_pure raised, the post-failure
# recovery path should adopt the fresher singleton tokens.
assert selected is not None
assert selected.access_token == other_process_at
assert selected.refresh_token == "rt-rotated"
def test_pool_manual_entry_does_not_sync_back_to_singleton(tmp_path, monkeypatch):
"""`hermes auth add xai-oauth` entries (source='manual:xai_pkce') are
independent credentials and must NOT write to the singleton. Sync-back
is restricted to entries seeded from the singleton. Otherwise adding a
second pool credential would silently overwrite the user's main login."""
from agent.credential_pool import load_pool, AUTH_TYPE_OAUTH, PooledCredential
import uuid
hermes_home = tmp_path / "hermes"
# Singleton has its own tokens (separate login).
singleton_at = _jwt_with_exp(int(time.time()) + 2 * 60 * 60)
_setup_hermes_auth(hermes_home, access_token=singleton_at, refresh_token="rt-singleton")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
manual_at_old = _jwt_with_exp(int(time.time()) + 30)
manual_at_new = _jwt_with_exp(int(time.time()) + 7200)
def _fake_refresh(access_token, refresh_token, **kwargs):
assert refresh_token == "rt-manual"
return {
"access_token": manual_at_new,
"refresh_token": "rt-manual-new",
"id_token": "",
"expires_in": 3600,
"token_type": "Bearer",
"last_refresh": "2026-05-15T04:00:00Z",
}
monkeypatch.setattr("hermes_cli.auth.refresh_xai_oauth_pure", _fake_refresh)
pool = load_pool("xai-oauth")
pool.add_entry(
PooledCredential(
provider="xai-oauth",
id=uuid.uuid4().hex[:6],
label="manual",
auth_type=AUTH_TYPE_OAUTH,
priority=0,
source="manual:xai_pkce",
access_token=manual_at_old,
refresh_token="rt-manual",
base_url=DEFAULT_XAI_OAUTH_BASE_URL,
)
)
# Refresh the manual entry — singleton must be left alone.
manual_entries = [e for e in pool.entries() if e.source == "manual:xai_pkce"]
assert len(manual_entries) == 1
pool._refresh_entry(manual_entries[0], force=True)
raw = json.loads((hermes_home / "auth.json").read_text())
tokens = raw["providers"]["xai-oauth"]["tokens"]
# Singleton must be untouched — manual refresh shouldn't leak across.
assert tokens["access_token"] == singleton_at
assert tokens["refresh_token"] == "rt-singleton"
# ---------------------------------------------------------------------------
# Auxiliary client routing
# ---------------------------------------------------------------------------
def test_auxiliary_client_routes_xai_oauth_through_responses_api(tmp_path, monkeypatch):
"""Without explicit xai-oauth handling in ``resolve_provider_client``, an
xai-oauth main provider falls through to the generic ``oauth_external``
arm and returns ``(None, None)`` — silently re-routing every auxiliary
task (compression, curator, web extract, session search, ...) to
whatever Step-2 fallback chain the user has configured (OpenRouter,
Nous, etc.). Users on xAI Grok OAuth would then see surprise charges
on those side providers for side tasks they thought were running on
their xAI subscription.
Pin the routing contract: ``resolve_provider_client("xai-oauth", model)``
must return a non-None client wrapping the xAI Responses API."""
from agent.auxiliary_client import (
CodexAuxiliaryClient,
resolve_provider_client,
)
hermes_home = tmp_path / "hermes"
fresh = _jwt_with_exp(int(time.time()) + 2 * 60 * 60)
_setup_hermes_auth(hermes_home, access_token=fresh)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("HERMES_XAI_BASE_URL", raising=False)
monkeypatch.delenv("XAI_BASE_URL", raising=False)
client, model = resolve_provider_client("xai-oauth", model="grok-4")
assert client is not None, (
"xai-oauth must route to a Responses-API client; falling through to "
"the generic oauth_external branch silently swaps providers for "
"every auxiliary task."
)
assert isinstance(client, CodexAuxiliaryClient)
assert model == "grok-4"
# The wrapper preserves base_url + api_key so async wrappers and cache
# eviction can introspect them. Pin both to the live xAI runtime.
assert str(client.base_url).rstrip("/") == DEFAULT_XAI_OAUTH_BASE_URL
assert client.api_key == fresh
def test_auxiliary_client_xai_oauth_requires_explicit_model(tmp_path, monkeypatch):
"""xAI's Responses API has no safe "cheap aux model" default —
pinning one would silently rot the same way Codex's did. Callers
must pass an explicit model (auxiliary.<task>.model in config.yaml)."""
from agent.auxiliary_client import resolve_provider_client
hermes_home = tmp_path / "hermes"
fresh = _jwt_with_exp(int(time.time()) + 2 * 60 * 60)
_setup_hermes_auth(hermes_home, access_token=fresh)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
client, model = resolve_provider_client("xai-oauth", model=None)
assert client is None
assert model is None
# ---------------------------------------------------------------------------
# active_provider preservation on pool sync-back
# ---------------------------------------------------------------------------
def test_pool_sync_back_preserves_active_provider(tmp_path, monkeypatch):
"""A token-rotation sync-back is a side effect of refresh, not the user
picking a provider. ``_save_provider_state`` flips ``active_provider``;
using it on the sync-back path means every xAI/Codex/Nous refresh in a
multi-provider setup silently overrides the user's chosen active
provider (visible to ``hermes auth status``, ``hermes setup``, and the
``hermes`` no-arg dispatcher). Pin the ``set_active=False`` contract so
no future refactor regresses to the legacy semantic."""
from agent.credential_pool import load_pool
hermes_home = tmp_path / "hermes"
near_expiry = _jwt_with_exp(int(time.time()) + 30)
_setup_hermes_auth(hermes_home, access_token=near_expiry, refresh_token="rt-xai")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
# Simulate a multi-provider user whose actual chosen provider is
# OpenRouter — xai-oauth tokens exist in the singleton but are NOT
# the active provider.
raw = json.loads((hermes_home / "auth.json").read_text())
raw["active_provider"] = "openrouter"
(hermes_home / "auth.json").write_text(json.dumps(raw))
new_access = _jwt_with_exp(int(time.time()) + 2 * 60 * 60)
def _fake_refresh(access_token, refresh_token, **kwargs):
return {
"access_token": new_access,
"refresh_token": "rt-rotated",
"id_token": "",
"expires_in": 3600,
"token_type": "Bearer",
"last_refresh": "2026-05-15T10:00:00Z",
}
monkeypatch.setattr("hermes_cli.auth.refresh_xai_oauth_pure", _fake_refresh)
pool = load_pool("xai-oauth")
selected = pool.select()
assert selected is not None
assert selected.access_token == new_access
# The refresh wrote new tokens back into the singleton — the user's
# prior ``active_provider`` choice (openrouter) MUST survive.
raw_after = json.loads((hermes_home / "auth.json").read_text())
assert raw_after["active_provider"] == "openrouter", (
"pool sync-back must not flip active_provider; otherwise xAI/Codex/"
"Nous token rotations silently take over multi-provider users' "
"auth.json `active_provider` flag."
)
# Tokens were actually written so the next process won't replay the
# consumed refresh_token (preserves the original sync-back fix).
state = raw_after["providers"]["xai-oauth"]["tokens"]
assert state["access_token"] == new_access
assert state["refresh_token"] == "rt-rotated"