Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions tests/tools/test_xai_http.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""Tests for profile-scoped xAI HTTP credential resolution.

Regression for the #18594 follow-up: xAI credential probes (the gates behind
x_search, video_gen, and web-search) resolved the auth store from the *root*
``HERMES_HOME`` and missed a named profile's credential — gating those tools
out at boot whenever the gateway ran a named profile with ``HERMES_HOME``
pointed at the root (the Docker multi-profile layout, the multi-profile
dashboard, and lazy tool-gate re-checks).
"""

from __future__ import annotations

import json
from pathlib import Path

import pytest


def _write(path: Path, payload: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, indent=2))


def _xai_pool_store(access_token: str = "xai-oat-token") -> dict:
return {
"version": 1,
"credential_pool": {
"xai-oauth": [
{
"id": "x1",
"label": "xai",
"auth_type": "oauth",
"priority": 0,
"source": "manual",
"access_token": access_token,
}
]
},
}


def _xai_singleton_store(access_token: str = "xai-oat-token") -> dict:
return {
"version": 1,
"providers": {"xai-oauth": {"tokens": {"access_token": access_token}}},
}


@pytest.fixture()
def root_home_with_profile(tmp_path, monkeypatch):
"""Docker-like layout: ``HERMES_HOME`` at the ROOT, named profile active.

* ``Path.home()`` -> ``tmp_path``
* root -> ``tmp_path/.hermes`` (``HERMES_HOME`` points HERE)
* profile -> ``tmp_path/.hermes/profiles/coder`` (active)

Pointing ``HERMES_HOME`` at the root while a named profile is active is the
exact condition that triggered the bug.
"""
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.delenv("XAI_API_KEY", raising=False)
root = tmp_path / ".hermes"
profile = root / "profiles" / "coder"
profile.mkdir(parents=True)
(root / "active_profile").write_text("coder\n")
monkeypatch.setenv("HERMES_HOME", str(root))
return {"root": root, "profile": profile}


def test_has_xai_credentials_resolves_active_profile_pool(root_home_with_profile):
"""Pool-only credential in the active profile is found despite root HERMES_HOME."""
from tools.xai_http import has_xai_credentials

_write(root_home_with_profile["profile"] / "auth.json", _xai_pool_store())
_write(root_home_with_profile["root"] / "auth.json", {"version": 1, "credential_pool": {}})
assert has_xai_credentials() is True


def test_has_xai_credentials_resolves_active_profile_singleton(root_home_with_profile):
"""providers.xai-oauth.tokens singleton in the active profile is found too."""
from tools.xai_http import has_xai_credentials

_write(root_home_with_profile["profile"] / "auth.json", _xai_singleton_store())
_write(root_home_with_profile["root"] / "auth.json", {"version": 1, "providers": {}})
assert has_xai_credentials() is True


def test_has_xai_credentials_false_without_profile_credential(root_home_with_profile):
"""No xAI credential anywhere -> False (guards against over-broad scoping)."""
from tools.xai_http import has_xai_credentials

_write(root_home_with_profile["profile"] / "auth.json", {"version": 1, "credential_pool": {}})
_write(root_home_with_profile["root"] / "auth.json", {"version": 1, "credential_pool": {}})
assert has_xai_credentials() is False


def test_has_xai_credentials_classic_home_unchanged(tmp_path, monkeypatch):
"""Standard single-home layout (no named profile) is unaffected."""
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.delenv("XAI_API_KEY", raising=False)
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
from tools.xai_http import has_xai_credentials

_write(home / "auth.json", _xai_singleton_store())
assert has_xai_credentials() is True


def test_has_xai_credentials_xai_api_key_env(monkeypatch):
"""Explicit XAI_API_KEY short-circuits to True."""
monkeypatch.setenv("XAI_API_KEY", "sk-xai-explicit")
from tools.xai_http import has_xai_credentials

assert has_xai_credentials() is True


def test_resolve_xai_http_credentials_scopes_to_active_profile(root_home_with_profile, monkeypatch):
"""The public resolver applies the active-profile home scope before delegating."""
import tools.xai_http as xai_http
from hermes_constants import get_hermes_home

captured: dict = {}

def fake_inner(*, force_refresh: bool = False):
captured["home"] = str(get_hermes_home())
return {"provider": "xai-oauth", "api_key": "tok", "base_url": "https://api.x.ai/v1"}

monkeypatch.setattr(xai_http, "_resolve_xai_http_credentials", fake_inner)
xai_http.resolve_xai_http_credentials()
assert captured["home"] == str(root_home_with_profile["profile"])
91 changes: 84 additions & 7 deletions tools/xai_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,59 @@

import json
import os
from contextlib import contextmanager
from typing import Dict


@contextmanager
def _active_profile_home_scope():
"""Scope ``get_hermes_home()`` to the active profile when the process env
points at the *root* Hermes home.

xAI credential probes resolve the auth store from
``get_hermes_home() / "auth.json"``. When a named profile is active but
``HERMES_HOME`` resolves to the root — e.g. the multi-profile dashboard
process, or a lazy tool-gate re-check after the per-invocation profile env
mutation is no longer in effect — the named profile's xAI credential (under
``<root>/profiles/<name>/auth.json``) is missed and the xAI tools get gated
out. This recovers the active profile from the sticky ``active_profile``
file and scopes the home to it via the existing ``set_hermes_home_override``
ContextVar, so the auth layer's profile->global fallback resolves the right
store. No-op when already inside a profile, when no / ``default`` profile is
active, or on any error.
"""
token = None
reset = None
try:
from hermes_constants import (
get_default_hermes_root,
get_hermes_home,
reset_hermes_home_override,
set_hermes_home_override,
)

if get_hermes_home().resolve() == get_default_hermes_root().resolve():
from hermes_cli.profiles import get_active_profile, get_profile_dir

active = (get_active_profile() or "").strip()
if active and active != "default":
profile_dir = get_profile_dir(active)
if profile_dir.is_dir():
token = set_hermes_home_override(str(profile_dir))
reset = reset_hermes_home_override
except Exception:
token = None
reset = None
try:
yield
finally:
if token is not None and reset is not None:
try:
reset(token)
except Exception:
pass


def has_xai_credentials() -> bool:
"""Cheap probe — return True when xAI credentials are *likely* usable.

Expand All @@ -20,8 +70,11 @@ def has_xai_credentials() -> bool:
Resolution order, fast-to-slow:

1. ``XAI_API_KEY`` env var (cheapest; covers explicit-key users).
2. ``~/.hermes/auth.json`` has a non-empty ``providers.xai-oauth.tokens.access_token``
(single file read, no expiry check, no refresh).
2. The active profile's ``auth.json`` has a non-empty xAI access token —
either ``providers.xai-oauth.tokens.access_token`` or a
``credential_pool["xai-oauth"]`` entry (single file read, no expiry
check, no refresh). Profile scoping handled by
:func:`_active_profile_home_scope`.

Returns False on any exception so a corrupted auth store can't block
other availability scans. Truthful refresh + expiry handling happens
Expand All @@ -32,15 +85,28 @@ def has_xai_credentials() -> bool:
try:
from hermes_constants import get_hermes_home

auth_path = get_hermes_home() / "auth.json"
if not auth_path.exists():
return False
store = json.loads(auth_path.read_text())
with _active_profile_home_scope():
auth_path = get_hermes_home() / "auth.json"
if not auth_path.exists():
return False
store = json.loads(auth_path.read_text())
providers = store.get("providers") if isinstance(store, dict) else None
xai_state = providers.get("xai-oauth") if isinstance(providers, dict) else None
tokens = xai_state.get("tokens") if isinstance(xai_state, dict) else None
access_token = tokens.get("access_token") if isinstance(tokens, dict) else None
return bool(str(access_token or "").strip())
if str(access_token or "").strip():
return True
# Also honor a credential-pool entry (e.g. `hermes auth add`, source
# "manual") that has no providers.xai-oauth.tokens singleton.
pool = store.get("credential_pool") if isinstance(store, dict) else None
xai_pool = pool.get("xai-oauth") if isinstance(pool, dict) else None
if isinstance(xai_pool, list):
for entry in xai_pool:
if isinstance(entry, dict) and str(
entry.get("access_token") or entry.get("runtime_api_key") or ""
).strip():
return True
return False
except Exception:
return False

Expand Down Expand Up @@ -73,6 +139,17 @@ def hermes_xai_user_agent() -> str:


def resolve_xai_http_credentials(*, force_refresh: bool = False) -> Dict[str, str]:
"""Resolve xAI HTTP bearer credentials, scoped to the active Hermes profile.

Thin wrapper that applies :func:`_active_profile_home_scope` so a named
profile's credential is found even when ``HERMES_HOME`` resolves to the
root (multi-profile dashboard, lazy tool-gate re-checks, sudo re-entry).
"""
with _active_profile_home_scope():
return _resolve_xai_http_credentials(force_refresh=force_refresh)


def _resolve_xai_http_credentials(*, force_refresh: bool = False) -> Dict[str, str]:
"""Resolve bearer credentials for direct xAI HTTP endpoints.

Prefers Hermes-managed xAI OAuth credentials when available, then falls back
Expand Down
Loading