mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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.
992 lines
38 KiB
Python
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"
|