Hermes-agent

This commit is contained in:
Zakaria
2026-06-14 14:30:48 -04:00
commit dac4b88b94
5058 changed files with 1884848 additions and 0 deletions
View File
+56
View File
@@ -0,0 +1,56 @@
"""Fixtures shared across hermes_cli kanban tests."""
from __future__ import annotations
import pytest
@pytest.fixture
def all_assignees_spawnable(monkeypatch):
"""Pretend every assignee maps to a real Hermes profile.
Most dispatcher tests use synthetic assignees ("alice", "bob") that
don't correspond to actual profile directories on disk. Without this
patch, the dispatcher's profile-exists guard (PR #20105) routes
those tasks into ``skipped_nonspawnable`` instead of spawning, which
would break tests that assert spawn behavior.
"""
from hermes_cli import profiles
monkeypatch.setattr(profiles, "profile_exists", lambda name: True)
@pytest.fixture(autouse=True)
def _suppress_concurrent_hermes_gate(request, monkeypatch):
"""Default ``_detect_concurrent_hermes_instances`` to ``[]`` for every test.
The Windows update path now refuses to proceed when another
``hermes.exe`` is detected (issue #26670). On a developer's Windows
machine running the test suite via ``hermes`` itself, this would
flag the running agent as a concurrent instance and abort every
``cmd_update`` test. Tests that want to exercise the gate explicitly
re-patch ``_detect_concurrent_hermes_instances`` with their own
return value — autouse here gives a clean default without touching
the rest of the suite.
Tests that need to call the REAL function (e.g. unit tests for the
helper itself) opt out with ``@pytest.mark.real_concurrent_gate``.
"""
if request.node.get_closest_marker("real_concurrent_gate"):
return
try:
from hermes_cli import main as _cli_main
except Exception:
return
# raising=False: under pytest's per-test spawn isolation, a concurrent
# xdist worker importing a module that transitively touches hermes_cli.main
# can briefly expose a partially-initialized module object here — one where
# _detect_concurrent_hermes_instances isn't defined yet. A bare setattr
# would raise AttributeError and error the (unrelated) test. The attribute
# always exists once main.py finishes importing, so a no-op when it's
# transiently absent is the correct, race-free default.
monkeypatch.setattr(
_cli_main,
"_detect_concurrent_hermes_instances",
lambda *_a, **_k: [],
raising=False,
)
+184
View File
@@ -0,0 +1,184 @@
"""Stub auth provider + shared fixtures for dashboard-auth tests.
NOT a pytest conftest.py — this is an importable helper module. Phase 2
of the dashboard-OAuth plan; used by Phase 3's end-to-end gate tests.
Import via::
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
The stub bounces straight back to the callback with a fake code so tests
can complete the OAuth round trip in-process without external network.
Tokens are HMAC-signed JSON blobs (not real JWTs) — just enough structure
for ``verify_session`` to detect tampering and expiry.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import secrets
import time
from hermes_cli.dashboard_auth.base import (
DashboardAuthProvider,
InvalidCodeError,
LoginStart,
RefreshExpiredError,
Session,
)
_STUB_SECRET = b"stub-test-secret-not-for-prod"
# Length of HMAC-SHA256 digest. We append this many trailing bytes of
# signature after ``raw`` in ``_sign``; ``_unsign`` slices them back off
# rather than splitting on a separator. (A separator byte chosen
# arbitrarily, e.g. ``b"."``, fails ~12% of the time when the HMAC
# digest happens to contain that byte — ``bytes.rsplit`` then splits at
# the wrong index and HMAC verification spuriously rejects the token.)
_SIG_LEN = hashlib.sha256().digest_size
def _sign(payload: dict) -> str:
"""Produce a tamper-evident opaque token.
Not a real JWT — just a base64(JSON || HMAC-SHA256) blob with enough
structure to round-trip through verify_session. The signature is
appended as a fixed-length suffix (no separator) so binary HMAC bytes
can't be confused with a delimiter.
"""
raw = json.dumps(payload, separators=(",", ":")).encode()
sig = hmac.new(_STUB_SECRET, raw, hashlib.sha256).digest()
return base64.urlsafe_b64encode(raw + sig).decode()
def _unsign(token: str) -> dict | None:
"""Inverse of ``_sign``; returns None on any tamper/decode failure."""
try:
blob = base64.urlsafe_b64decode(token.encode())
if len(blob) <= _SIG_LEN:
return None
raw, sig = blob[:-_SIG_LEN], blob[-_SIG_LEN:]
expected = hmac.new(_STUB_SECRET, raw, hashlib.sha256).digest()
if not hmac.compare_digest(sig, expected):
return None
return json.loads(raw)
except Exception:
return None
class StubAuthProvider(DashboardAuthProvider):
"""Local fake IDP for E2E tests.
``start_login`` returns a redirect to
``{redirect_uri}?code=stub_code&state={s}`` so the test harness can
walk the full round trip in-process without talking to anything
external. ``access_token`` is an HMAC-signed JSON blob;
``verify_session`` decodes and checks ``exp``.
"""
name = "stub"
display_name = "Stub IdP (test only)"
def __init__(self, default_ttl: int = 3600):
self._default_ttl = default_ttl
# state → verifier mapping, cleared on complete_login
self._state_to_verifier: dict[str, str] = {}
def start_login(self, *, redirect_uri: str) -> LoginStart:
state = secrets.token_urlsafe(16)
verifier = secrets.token_urlsafe(32)
self._state_to_verifier[state] = verifier
return LoginStart(
redirect_url=f"{redirect_uri}?code=stub_code&state={state}",
cookie_payload={
"hermes_session_pkce": f"state={state};verifier={verifier}",
},
)
def complete_login(
self, *, code: str, state: str, code_verifier: str, redirect_uri: str,
) -> Session:
if code != "stub_code":
raise InvalidCodeError(
f"stub expects code='stub_code', got {code!r}"
)
expected_verifier = self._state_to_verifier.get(state)
if expected_verifier is None or expected_verifier != code_verifier:
raise InvalidCodeError("stub state/verifier mismatch")
del self._state_to_verifier[state]
now = int(time.time())
exp = now + self._default_ttl
return Session(
user_id="stub-user-1",
email="stub@example.test",
display_name="Stub User",
org_id="stub-org-1",
provider=self.name,
expires_at=exp,
access_token=_sign({
"sub": "stub-user-1",
"email": "stub@example.test",
"name": "Stub User",
"org_id": "stub-org-1",
"exp": exp,
}),
refresh_token=_sign({
"sub": "stub-user-1",
"kind": "refresh",
"exp": now + 30 * 86400,
}),
)
def verify_session(self, *, access_token: str):
payload = _unsign(access_token)
# ``<=`` so default_ttl=0 produces a born-expired token. This
# matches what Phase 6's silent-refresh tests need ("set a 0-TTL
# access token; the next request should refresh transparently").
if payload is None or payload.get("exp", 0) <= int(time.time()):
return None
return Session(
user_id=payload["sub"],
email=payload["email"],
display_name=payload["name"],
org_id=payload["org_id"],
provider=self.name,
expires_at=payload["exp"],
access_token=access_token,
refresh_token="", # not surfaced on verify
)
def refresh_session(self, *, refresh_token: str) -> Session:
payload = _unsign(refresh_token)
# ``<=`` for symmetry with verify_session — a 0-TTL token is
# treated as expired.
if payload is None or payload.get("exp", 0) <= int(time.time()):
raise RefreshExpiredError("stub refresh token expired/invalid")
now = int(time.time())
exp = now + self._default_ttl
return Session(
user_id=payload["sub"],
email="stub@example.test",
display_name="Stub User",
org_id="stub-org-1",
provider=self.name,
expires_at=exp,
access_token=_sign({
"sub": payload["sub"],
"email": "stub@example.test",
"name": "Stub User",
"org_id": "stub-org-1",
"exp": exp,
}),
refresh_token=_sign({
"sub": payload["sub"],
"kind": "refresh",
"exp": now + 30 * 86400,
}),
)
def revoke_session(self, *, refresh_token: str) -> None:
# Stub is in-memory; nothing to revoke server-side.
return None
+313
View File
@@ -0,0 +1,313 @@
import logging
import os
import subprocess
import sys
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from hermes_cli import active_sessions
def test_resolve_max_concurrent_sessions_values(caplog):
assert active_sessions.resolve_max_concurrent_sessions({}) is None
assert active_sessions.resolve_max_concurrent_sessions({"max_concurrent_sessions": None}) is None
assert active_sessions.resolve_max_concurrent_sessions({"max_concurrent_sessions": 0}) is None
assert active_sessions.resolve_max_concurrent_sessions({"max_concurrent_sessions": -1}) is None
assert active_sessions.resolve_max_concurrent_sessions({"max_concurrent_sessions": "3"}) == 3
assert (
active_sessions.resolve_max_concurrent_sessions(
{"gateway": {"max_concurrent_sessions": 4}}
)
== 4
)
assert (
active_sessions.resolve_max_concurrent_sessions(
{"max_concurrent_sessions": 2, "gateway": {"max_concurrent_sessions": 4}}
)
== 2
)
caplog.set_level(logging.WARNING)
assert active_sessions.resolve_max_concurrent_sessions({"max_concurrent_sessions": "many"}) is None
assert any(
"Ignoring invalid max_concurrent_sessions='many'" in record.message
for record in caplog.records
)
def test_active_session_lease_blocks_until_release(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
monkeypatch.setenv("HERMES_HOME", str(home))
cfg = {"max_concurrent_sessions": 1}
lease, message = active_sessions.try_acquire_active_session(
session_id="session-1",
surface="cli",
config=cfg,
)
assert message is None
assert lease is not None
blocked_lease, blocked_message = active_sessions.try_acquire_active_session(
session_id="session-2",
surface="tui",
config=cfg,
)
assert blocked_lease is None
assert blocked_message == (
"Hermes is at the active session limit (1/1). "
"Try again when another session finishes."
)
lease.release()
next_lease, next_message = active_sessions.try_acquire_active_session(
session_id="session-3",
surface="gateway:telegram",
config=cfg,
)
assert next_message is None
assert next_lease is not None
next_lease.release()
assert active_sessions.active_session_registry_snapshot() == []
def test_active_session_registry_prunes_dead_pids(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(
"gateway.status._pid_exists",
lambda pid: int(pid) != 99999999,
)
runtime = home / "runtime"
runtime.mkdir(parents=True)
active_sessions._write_entries(
runtime / "active_sessions.json",
[
{
"lease_id": "stale",
"session_id": "stale-session",
"surface": "cli",
"pid": 99999999,
"started_at": 1,
"updated_at": 1,
}
],
)
lease, message = active_sessions.try_acquire_active_session(
session_id="session-1",
surface="cli",
config={"max_concurrent_sessions": 1},
)
assert message is None
assert lease is not None
assert [entry["session_id"] for entry in active_sessions.active_session_registry_snapshot()] == [
"session-1"
]
lease.release()
def test_pid_alive_uses_safe_pid_exists_without_signalling(monkeypatch):
checked: list[int] = []
monkeypatch.setattr(
active_sessions.os,
"kill",
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("os.kill used")),
)
monkeypatch.setattr(
"gateway.status._pid_exists",
lambda pid: checked.append(int(pid)) or True,
)
assert active_sessions._pid_alive(12345) is True
assert checked == [12345]
def test_active_session_hard_exit_is_reclaimed(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
monkeypatch.setenv("HERMES_HOME", str(home))
repo_root = Path(__file__).resolve().parents[2]
env = os.environ.copy()
env["HERMES_HOME"] = str(home)
env["PYTHONPATH"] = str(repo_root)
child = subprocess.run(
[
sys.executable,
"-c",
(
"import os\n"
"from hermes_cli.active_sessions import try_acquire_active_session\n"
"lease, message = try_acquire_active_session("
"session_id='crash-session', surface='cli', "
"config={'max_concurrent_sessions': 1})\n"
"assert message is None, message\n"
"print(os.getpid(), flush=True)\n"
"os._exit(0)\n"
),
],
env=env,
text=True,
capture_output=True,
timeout=10,
check=True,
)
child_pid = int(child.stdout.strip())
lease, message = active_sessions.try_acquire_active_session(
session_id="next-session",
surface="cli",
config={"max_concurrent_sessions": 1},
)
assert child_pid > 0
assert message is None
assert lease is not None
assert [entry["session_id"] for entry in active_sessions.active_session_registry_snapshot()] == [
"next-session"
]
lease.release()
def test_concurrent_acquire_claims_only_one_last_slot(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
monkeypatch.setenv("HERMES_HOME", str(home))
cfg = {"max_concurrent_sessions": 1}
def _claim(index: int):
return active_sessions.try_acquire_active_session(
session_id=f"session-{index}",
surface="cli",
config=cfg,
)
with ThreadPoolExecutor(max_workers=8) as pool:
results = list(pool.map(_claim, range(8)))
leases = [lease for lease, message in results if lease is not None and message is None]
blocked = [message for lease, message in results if lease is None and message]
try:
assert len(leases) == 1
assert len(blocked) == 7
assert active_sessions.active_session_registry_snapshot()[0]["session_id"].startswith("session-")
finally:
for lease in leases:
lease.release()
def test_cross_process_acquire_claims_only_one_last_slot(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
monkeypatch.setenv("HERMES_HOME", str(home))
repo_root = Path(__file__).resolve().parents[2]
ready_dir = tmp_path / "ready"
ready_dir.mkdir()
go_file = tmp_path / "go"
env = os.environ.copy()
env["HERMES_HOME"] = str(home)
env["PYTHONPATH"] = str(repo_root)
script = (
"import os, time\n"
"from pathlib import Path\n"
"from hermes_cli.active_sessions import try_acquire_active_session\n"
"idx = os.environ['WORKER_INDEX']\n"
"ready_dir = Path(os.environ['READY_DIR'])\n"
"go_file = Path(os.environ['GO_FILE'])\n"
"(ready_dir / idx).write_text('ready', encoding='utf-8')\n"
"deadline = time.time() + 10\n"
"while not go_file.exists():\n"
" if time.time() > deadline:\n"
" raise RuntimeError('timed out waiting for go file')\n"
" time.sleep(0.01)\n"
"lease, message = try_acquire_active_session(\n"
" session_id=f'process-{idx}',\n"
" surface='cli',\n"
" config={'max_concurrent_sessions': 1},\n"
")\n"
"if lease is None:\n"
" print('BLOCK', flush=True)\n"
"else:\n"
" print('OK', flush=True)\n"
" time.sleep(2.0)\n"
" lease.release()\n"
)
workers: list[subprocess.Popen[str]] = []
try:
for index in range(6):
worker_env = env.copy()
worker_env["WORKER_INDEX"] = str(index)
worker_env["READY_DIR"] = str(ready_dir)
worker_env["GO_FILE"] = str(go_file)
workers.append(
subprocess.Popen(
[sys.executable, "-c", script],
env=worker_env,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
)
deadline = time.time() + 10
while len(list(ready_dir.iterdir())) < len(workers):
if time.time() > deadline:
raise AssertionError("workers did not become ready")
time.sleep(0.01)
go_file.write_text("go", encoding="utf-8")
outputs = []
for worker in workers:
stdout, stderr = worker.communicate(timeout=10)
assert worker.returncode == 0, stderr
outputs.append(stdout.strip())
finally:
for worker in workers:
if worker.poll() is None:
worker.kill()
worker.communicate()
assert outputs.count("OK") == 1
assert outputs.count("BLOCK") == len(workers) - 1
assert active_sessions.active_session_registry_snapshot() == []
def test_pid_start_time_mismatch_prunes_reused_pid(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr("gateway.status._pid_exists", lambda _pid: True)
monkeypatch.setattr(active_sessions, "_process_start_time", lambda _pid: 200.0)
runtime = home / "runtime"
runtime.mkdir(parents=True)
active_sessions._write_entries(
runtime / "active_sessions.json",
[
{
"lease_id": "stale-reused-pid",
"session_id": "stale-session",
"surface": "cli",
"pid": os.getpid(),
"process_start_time": 100.0,
"started_at": 1,
"updated_at": 1,
}
],
)
lease, message = active_sessions.try_acquire_active_session(
session_id="new-session",
surface="cli",
config={"max_concurrent_sessions": 1},
)
assert message is None
assert lease is not None
assert [entry["session_id"] for entry in active_sessions.active_session_registry_snapshot()] == [
"new-session"
]
lease.release()
@@ -0,0 +1,207 @@
"""Tests for Bug #12905 fix — stale OAuth token detection in hermes model flow.
Bug 3: `hermes model` with `provider=anthropic` skips OAuth re-authentication
when a stale ANTHROPIC_TOKEN exists in ~/.hermes/.env but no valid
Claude Code credentials are available. The fast-path silently proceeds to
model selection with a broken token instead of offering re-auth.
"""
from hermes_cli.config import save_env_value
class TestStaleOAuthTokenDetection:
"""Bug 3: stale OAuth token must trigger needs_auth=True in _model_flow_anthropic."""
def test_stale_oauth_token_triggers_reauth(self, tmp_path, monkeypatch, capsys):
"""
Scenario: ANTHROPIC_TOKEN is an expired OAuth token and there are no
valid Claude Code credentials anywhere. The flow MUST offer re-auth
instead of silently skipping to model selection.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
# Pre-load .env with an expired OAuth token (sk-ant- prefix = OAuth)
save_env_value("ANTHROPIC_TOKEN", "sk-ant-oat-ExpiredToken00000")
save_env_value("ANTHROPIC_API_KEY", "")
# No valid Claude Code credentials available (expired, no refresh token)
monkeypatch.setattr(
"agent.anthropic_adapter.read_claude_code_credentials",
lambda: {
"accessToken": "expired-cc-token",
"refreshToken": "", # No refresh — can't recover
"expiresAt": 0, # Already expired
"source": "claude_code_credentials_file",
},
)
monkeypatch.setattr(
"agent.anthropic_adapter.is_claude_code_token_valid",
lambda creds: False, # Explicitly expired
)
monkeypatch.setattr(
"agent.anthropic_adapter._is_oauth_token",
lambda key: key.startswith("sk-ant-"),
)
# _resolve_claude_code_token_from_credentials has no valid path
monkeypatch.setattr(
"agent.anthropic_adapter._resolve_claude_code_token_from_credentials",
lambda creds=None: None,
)
# Simulate user types "3" (Cancel) when prompted for re-auth
monkeypatch.setattr("builtins.input", lambda _: "3")
monkeypatch.setattr("hermes_cli.secret_prompt.masked_secret_prompt", lambda _: "")
from hermes_cli.main import _model_flow_anthropic
cfg = {}
_model_flow_anthropic(cfg)
output = capsys.readouterr().out
# Must show auth method choice since token is stale
assert "subscription" in output or "API key" in output, (
f"Expected auth method menu but got: {output!r}"
)
def test_valid_api_key_skips_stale_check(self, tmp_path, monkeypatch, capsys):
"""
A non-OAuth ANTHROPIC_API_KEY (regular pay-per-token key) must NOT be
flagged as stale even when cc_creds are invalid. Regular API keys don't
expire the same way OAuth tokens do.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
# Regular API key — NOT an OAuth token
save_env_value("ANTHROPIC_API_KEY", "sk-ant-api03-RegularPayPerTokenKey")
save_env_value("ANTHROPIC_TOKEN", "")
monkeypatch.setattr(
"agent.anthropic_adapter.read_claude_code_credentials",
lambda: None, # No CC creds
)
monkeypatch.setattr(
"agent.anthropic_adapter.is_claude_code_token_valid",
lambda creds: False,
)
monkeypatch.setattr(
"agent.anthropic_adapter._is_oauth_token",
lambda key: key.startswith("sk-ant-") and "oat" in key,
)
# Simulate user picks "1" (use existing)
monkeypatch.setattr("builtins.input", lambda _: "1")
from hermes_cli.main import _model_flow_anthropic
cfg = {}
_model_flow_anthropic(cfg)
output = capsys.readouterr().out
# Should show "Use existing credentials" menu, NOT auth method choice
assert "Use existing" in output or "credentials" in output.lower()
def test_valid_oauth_token_with_refresh_available_skips_reauth(self, tmp_path, monkeypatch, capsys):
"""
When ANTHROPIC_TOKEN is OAuth and valid cc_creds with refresh exist,
the flow should use existing credentials (no forced re-auth).
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
save_env_value("ANTHROPIC_TOKEN", "sk-ant-oat-GoodOAuthToken")
save_env_value("ANTHROPIC_API_KEY", "")
# Valid Claude Code credentials with refresh token
monkeypatch.setattr(
"agent.anthropic_adapter.read_claude_code_credentials",
lambda: {
"accessToken": "valid-cc-token",
"refreshToken": "valid-refresh",
"expiresAt": 9999999999999,
},
)
monkeypatch.setattr(
"agent.anthropic_adapter.is_claude_code_token_valid",
lambda creds: True,
)
monkeypatch.setattr(
"agent.anthropic_adapter._is_oauth_token",
lambda key: key.startswith("sk-ant-"),
)
monkeypatch.setattr(
"agent.anthropic_adapter._resolve_claude_code_token_from_credentials",
lambda creds=None: "valid-cc-token",
)
# Simulate user picks "1" (use existing)
monkeypatch.setattr("builtins.input", lambda _: "1")
from hermes_cli.main import _model_flow_anthropic
cfg = {}
_model_flow_anthropic(cfg)
output = capsys.readouterr().out
# Should show "Use existing" without forcing re-auth
assert "Use existing" in output or "credentials" in output.lower()
class TestStaleOAuthGuardLogic:
"""Unit-level test of the stale-OAuth detection guard logic."""
def test_stale_oauth_flag_logic_no_cc_creds(self):
"""
When existing_key is OAuth and cc_available is False,
existing_is_stale_oauth should be True → has_creds = False.
"""
existing_key = "sk-ant-oat-expiredtoken123"
_is_oauth_token = lambda k: k.startswith("sk-ant-")
cc_available = False
existing_is_stale_oauth = (
bool(existing_key) and
_is_oauth_token(existing_key) and
not cc_available
)
has_creds = (bool(existing_key) and not existing_is_stale_oauth) or cc_available
assert existing_is_stale_oauth is True
assert has_creds is False
def test_stale_oauth_flag_logic_with_valid_cc_creds(self):
"""
When existing_key is OAuth but cc_available is True (valid creds exist),
has_creds should be True — the cc_creds will be used instead.
"""
existing_key = "sk-ant-oat-sometoken"
_is_oauth_token = lambda k: k.startswith("sk-ant-")
cc_available = True
existing_is_stale_oauth = (
bool(existing_key) and
_is_oauth_token(existing_key) and
not cc_available
)
has_creds = (bool(existing_key) and not existing_is_stale_oauth) or cc_available
assert existing_is_stale_oauth is False
assert has_creds is True
def test_non_oauth_key_not_flagged_as_stale(self):
"""
Regular ANTHROPIC_API_KEY (non-OAuth) must not be flagged as stale
even when cc_available is False.
"""
existing_key = "sk-ant-api03-regular-key"
_is_oauth_token = lambda k: k.startswith("sk-ant-") and "oat" in k
cc_available = False
existing_is_stale_oauth = (
bool(existing_key) and
_is_oauth_token(existing_key) and
not cc_available
)
has_creds = (bool(existing_key) and not existing_is_stale_oauth) or cc_available
assert existing_is_stale_oauth is False
assert has_creds is True
@@ -0,0 +1,55 @@
"""Tests for Anthropic OAuth setup flow behavior."""
from hermes_cli.config import load_env, save_env_value
def test_run_anthropic_oauth_flow_prefers_claude_code_credentials(tmp_path, monkeypatch, capsys):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setattr(
"agent.anthropic_adapter.run_oauth_setup_token",
lambda: "sk-ant-oat01-from-claude-setup",
)
monkeypatch.setattr(
"agent.anthropic_adapter.read_claude_code_credentials",
lambda: {
"accessToken": "cc-access-token",
"refreshToken": "cc-refresh-token",
"expiresAt": 9999999999999,
},
)
monkeypatch.setattr(
"agent.anthropic_adapter.is_claude_code_token_valid",
lambda creds: True,
)
from hermes_cli.main import _run_anthropic_oauth_flow
save_env_value("ANTHROPIC_TOKEN", "stale-env-token")
assert _run_anthropic_oauth_flow(save_env_value) is True
env_vars = load_env()
assert env_vars["ANTHROPIC_TOKEN"] == ""
assert env_vars["ANTHROPIC_API_KEY"] == ""
output = capsys.readouterr().out
assert "Claude Code credentials linked" in output
def test_run_anthropic_oauth_flow_manual_token_still_persists(tmp_path, monkeypatch, capsys):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setattr("agent.anthropic_adapter.run_oauth_setup_token", lambda: None)
monkeypatch.setattr("agent.anthropic_adapter.read_claude_code_credentials", lambda: None)
monkeypatch.setattr("agent.anthropic_adapter.is_claude_code_token_valid", lambda creds: False)
monkeypatch.setattr("builtins.input", lambda _prompt="": "sk-ant-oat01-manual-token")
monkeypatch.setattr(
"hermes_cli.secret_prompt.masked_secret_prompt",
lambda _prompt="": "sk-ant-oat01-manual-token",
)
from hermes_cli.main import _run_anthropic_oauth_flow
assert _run_anthropic_oauth_flow(save_env_value) is True
env_vars = load_env()
assert env_vars["ANTHROPIC_TOKEN"] == "sk-ant-oat01-manual-token"
output = capsys.readouterr().out
assert "Setup-token saved" in output
@@ -0,0 +1,58 @@
"""Regression tests for the Anthropic model-picker dropping curated aliases.
Bug — newly-routed curated aliases vanished on a native Anthropic setup
``provider_model_ids("anthropic")`` returned the live ``/v1/models`` dump
verbatim whenever Anthropic credentials were configured. Anthropic's API
lags behind freshly-routed aliases (e.g. ``claude-fable-5``, which is
reachable on Anthropic before the models endpoint enumerates it), so the
curated entry disappeared from the picker. The picker now merges the
curated ``_PROVIDER_MODELS["anthropic"]`` list with the live catalog —
curated entries first, live-only models appended, deduped — mirroring the
OpenAI curated-merge philosophy.
"""
from unittest.mock import patch
from hermes_cli import models as M
def test_anthropic_curated_alias_survives_when_live_omits_it():
"""A curated alias missing from /v1/models still surfaces (first)."""
curated = M._PROVIDER_MODELS["anthropic"]
assert "claude-fable-5" in curated # sanity: the alias is curated
# Live catalog the API would actually return — no fable-5.
live = ["claude-opus-4-8", "claude-sonnet-4-6", "claude-haiku-4-5-20251001"]
with patch.object(M, "_fetch_anthropic_models", return_value=live):
result = M.provider_model_ids("anthropic")
assert "claude-fable-5" in result
# Curated order is preserved at the front.
assert result[:len(curated)] == list(curated)
def test_anthropic_merge_dedupes_overlap_and_appends_live_only():
"""Models in both lists appear once; live-only models are appended."""
live = [
"claude-opus-4-8", # overlaps curated
"claude-sonnet-4-6", # overlaps curated
"claude-future-9-99", # live-only, not curated
]
with patch.object(M, "_fetch_anthropic_models", return_value=live):
result = M.provider_model_ids("anthropic")
# No duplicates introduced by the merge.
assert result.count("claude-opus-4-8") == 1
# Live-only entry is preserved (discovery still works for unknown models).
assert "claude-future-9-99" in result
# Curated entries lead, live-only trails.
assert result.index("claude-fable-5") < result.index("claude-future-9-99")
def test_anthropic_falls_back_to_curated_when_live_unavailable():
"""No creds / live failure -> curated list verbatim (alias still present)."""
with patch.object(M, "_fetch_anthropic_models", return_value=None):
result = M.provider_model_ids("anthropic")
assert result == list(M._PROVIDER_MODELS["anthropic"])
assert "claude-fable-5" in result
@@ -0,0 +1,46 @@
"""Tests for Anthropic credential persistence helpers."""
from hermes_cli.config import load_env
def test_save_anthropic_oauth_token_uses_token_slot_and_clears_api_key(tmp_path, monkeypatch):
home = tmp_path / "hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
from hermes_cli.config import save_anthropic_oauth_token
save_anthropic_oauth_token("sk-ant-oat01-test-token")
env_vars = load_env()
assert env_vars["ANTHROPIC_TOKEN"] == "sk-ant-oat01-test-token"
assert env_vars["ANTHROPIC_API_KEY"] == ""
def test_use_anthropic_claude_code_credentials_clears_env_slots(tmp_path, monkeypatch):
home = tmp_path / "hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
from hermes_cli.config import save_anthropic_oauth_token, use_anthropic_claude_code_credentials
save_anthropic_oauth_token("sk-ant-oat01-token")
use_anthropic_claude_code_credentials()
env_vars = load_env()
assert env_vars["ANTHROPIC_TOKEN"] == ""
assert env_vars["ANTHROPIC_API_KEY"] == ""
def test_save_anthropic_api_key_uses_api_key_slot_and_clears_token(tmp_path, monkeypatch):
home = tmp_path / "hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
from hermes_cli.config import save_anthropic_api_key
save_anthropic_api_key("sk-ant-api03-key")
env_vars = load_env()
assert env_vars["ANTHROPIC_API_KEY"] == "sk-ant-api03-key"
assert env_vars["ANTHROPIC_TOKEN"] == ""
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,152 @@
"""Regression test for the `/model` picker confirmation display.
Bug (April 2026): after choosing a model from the interactive `/model` picker,
``HermesCLI._apply_model_switch_result()`` printed ``ModelInfo.context_window``
straight from models.dev, which always reports the vendor-wide value (e.g.
gpt-5.5 = 1,050,000 on ``openai``). That ignored provider-specific caps — in
particular, ChatGPT Codex OAuth enforces 272K on the same slug. The sibling
``_handle_model_switch()`` (typed ``/model <name>``) was already fixed to use
``resolve_display_context_length()``; the picker path was missed, causing
"sometimes 1M, sometimes 272K" for the same model across sibling UI paths.
Fix: both display paths now go through ``resolve_display_context_length()``.
"""
from __future__ import annotations
from unittest.mock import patch
from hermes_cli.model_switch import ModelSwitchResult
class _FakeModelInfo:
context_window = 1_050_000
max_output = 0
def has_cost_data(self):
return False
def format_capabilities(self):
return ""
class _StubCLI:
"""Minimum attrs ``_apply_model_switch_result`` reads on ``self``."""
agent = None
model = ""
provider = ""
requested_provider = ""
api_key = ""
_explicit_api_key = ""
base_url = ""
_explicit_base_url = ""
api_mode = ""
_pending_model_switch_note = ""
def _run_display(monkeypatch, result):
import cli as cli_mod
captured: list[str] = []
monkeypatch.setattr(cli_mod, "_cprint", lambda s, *a, **k: captured.append(str(s)))
# Avoid writing to ~/.hermes/config.yaml during the test.
monkeypatch.setattr(cli_mod, "save_config_value", lambda *a, **k: None)
cli_mod.HermesCLI._apply_model_switch_result(_StubCLI(), result, False)
return captured
def test_picker_path_uses_provider_aware_context_on_codex(monkeypatch):
"""``_apply_model_switch_result`` must prefer the provider-aware resolver
(272K on Codex) over the raw models.dev value (1.05M for gpt-5.5).
"""
result = ModelSwitchResult(
success=True,
new_model="gpt-5.5",
target_provider="openai-codex",
provider_changed=True,
api_key="",
base_url="https://chatgpt.com/backend-api/codex",
api_mode="codex_responses",
warning_message="",
provider_label="ChatGPT Codex",
resolved_via_alias=False,
capabilities=None,
model_info=_FakeModelInfo(), # models.dev says 1.05M
is_global=False,
)
with patch(
"agent.model_metadata.get_model_context_length",
return_value=272_000,
):
lines = _run_display(monkeypatch, result)
ctx_line = next((l for l in lines if "Context:" in l), "")
assert "272,000" in ctx_line, (
f"picker-path display must show Codex's 272K cap, got: {ctx_line!r}"
)
assert "1,050,000" not in ctx_line, (
f"picker-path display leaked models.dev's 1.05M for Codex: {ctx_line!r}"
)
def test_picker_path_shows_vendor_value_when_no_provider_cap(monkeypatch):
"""On providers with no enforced cap (e.g. OpenRouter), the picker path
should surface the real 1.05M context for gpt-5.5 — resolver and models.dev
agree here.
"""
result = ModelSwitchResult(
success=True,
new_model="openai/gpt-5.5",
target_provider="openrouter",
provider_changed=True,
api_key="",
base_url="https://openrouter.ai/api/v1",
api_mode="chat_completions",
warning_message="",
provider_label="OpenRouter",
resolved_via_alias=False,
capabilities=None,
model_info=_FakeModelInfo(),
is_global=False,
)
with patch(
"agent.model_metadata.get_model_context_length",
return_value=1_050_000,
):
lines = _run_display(monkeypatch, result)
ctx_line = next((l for l in lines if "Context:" in l), "")
assert "1,050,000" in ctx_line, (
f"OpenRouter gpt-5.5 should show 1.05M context, got: {ctx_line!r}"
)
def test_picker_path_falls_back_to_model_info_when_resolver_empty(monkeypatch):
"""If ``get_model_context_length`` returns nothing (rare — truly unknown
endpoint), the display still surfaces ``ModelInfo.context_window`` so the
user sees *something* rather than a silent blank.
"""
result = ModelSwitchResult(
success=True,
new_model="some-model",
target_provider="some-provider",
provider_changed=True,
api_key="",
base_url="",
api_mode="chat_completions",
warning_message="",
provider_label="Some Provider",
resolved_via_alias=False,
capabilities=None,
model_info=_FakeModelInfo(), # context_window = 1_050_000
is_global=False,
)
with patch(
"agent.model_metadata.get_model_context_length",
return_value=None,
):
lines = _run_display(monkeypatch, result)
ctx_line = next((l for l in lines if "Context:" in l), "")
assert "1,050,000" in ctx_line, (
f"resolver-empty path should fall back to ModelInfo, got: {ctx_line!r}"
)
@@ -0,0 +1,242 @@
"""Regression tests for _apply_profile_override HERMES_HOME guard (issue #22502).
When HERMES_HOME is set to the hermes root (e.g. systemd hardcodes
HERMES_HOME=/root/.hermes), _apply_profile_override must still read
active_profile and update HERMES_HOME to the profile directory.
When HERMES_HOME is already a profile directory (.../profiles/<name>),
_apply_profile_override must trust it and return without re-reading
active_profile (child-process inheritance contract).
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from types import SimpleNamespace
def _run_apply_profile_override(
tmp_path, monkeypatch, *, hermes_home: str | None, active_profile: str | None,
argv: list[str] | None = None,
):
"""Run _apply_profile_override in isolation.
Returns the value of os.environ["HERMES_HOME"] after the call,
or None if unset.
"""
hermes_root = tmp_path / ".hermes"
hermes_root.mkdir(parents=True, exist_ok=True)
if active_profile is not None:
(hermes_root / "active_profile").write_text(active_profile)
if active_profile and active_profile != "default":
(hermes_root / "profiles" / active_profile).mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
if hermes_home is not None:
monkeypatch.setenv("HERMES_HOME", hermes_home)
else:
monkeypatch.delenv("HERMES_HOME", raising=False)
monkeypatch.setattr(sys, "argv", argv or ["hermes", "gateway", "start"])
from hermes_cli.main import _apply_profile_override
_apply_profile_override()
return os.environ.get("HERMES_HOME")
class TestApplyProfileOverrideHermesHomeGuard:
"""Regression guard for issue #22502.
Verifies that HERMES_HOME pointing to the hermes root does NOT suppress
the active_profile check, while HERMES_HOME already pointing to a
profile directory IS trusted as-is.
"""
def test_hermes_home_at_root_with_active_profile_is_redirected(
self, tmp_path, monkeypatch
):
"""HERMES_HOME=/root/.hermes + active_profile=coder must redirect
HERMES_HOME to .../profiles/coder.
Bug scenario from #22502: systemd sets HERMES_HOME to the hermes root
and the user switches to a profile via `hermes profile use`.
Before the fix, the guard returned early and active_profile was ignored.
"""
hermes_root = tmp_path / ".hermes"
hermes_root.mkdir(parents=True, exist_ok=True)
result = _run_apply_profile_override(
tmp_path,
monkeypatch,
hermes_home=str(hermes_root),
active_profile="coder",
)
assert result is not None, "HERMES_HOME must be set after profile redirect"
assert "profiles" in result, (
f"Expected HERMES_HOME to point into profiles/ dir, got: {result!r}"
)
assert result.endswith("coder"), (
f"Expected HERMES_HOME to end with 'coder', got: {result!r}"
)
def test_hermes_home_already_profile_dir_is_trusted(self, tmp_path, monkeypatch):
"""HERMES_HOME=.../profiles/coder must not be overridden even when
active_profile says something different.
Preserves the child-process inheritance contract: a subprocess spawned
with HERMES_HOME already set to a specific profile must stay in that
profile.
"""
hermes_root = tmp_path / ".hermes"
profile_dir = hermes_root / "profiles" / "coder"
profile_dir.mkdir(parents=True, exist_ok=True)
(hermes_root / "active_profile").write_text("other")
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(profile_dir))
monkeypatch.setattr(sys, "argv", ["hermes", "gateway", "start"])
from hermes_cli.main import _apply_profile_override
_apply_profile_override()
assert os.environ.get("HERMES_HOME") == str(profile_dir), (
"HERMES_HOME must remain unchanged when already pointing to a profile dir"
)
def test_hermes_home_unset_reads_active_profile(self, tmp_path, monkeypatch):
"""Classic case: HERMES_HOME unset + active_profile=coder must set
HERMES_HOME to the profile directory (existing behaviour must not regress).
"""
result = _run_apply_profile_override(
tmp_path,
monkeypatch,
hermes_home=None,
active_profile="coder",
)
assert result is not None
assert "coder" in result
def test_sudo_explicit_profile_resolves_invoking_users_profile(self, tmp_path, monkeypatch):
"""sudo elias ... should resolve `-p elias` under SUDO_USER, not root."""
root_home = tmp_path / "root"
user_home = tmp_path / "home" / "hermes"
profile_dir = user_home / ".hermes" / "profiles" / "elias"
profile_dir.mkdir(parents=True, exist_ok=True)
(root_home / ".hermes").mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(Path, "home", lambda: root_home)
monkeypatch.setenv("SUDO_USER", "hermes")
monkeypatch.delenv("HERMES_HOME", raising=False)
monkeypatch.setattr(os, "geteuid", lambda: 0, raising=False)
monkeypatch.setattr(sys, "argv", ["hermes", "-p", "elias", "gateway", "install", "--system"])
import pwd
monkeypatch.setattr(pwd, "getpwnam", lambda name: SimpleNamespace(pw_dir=str(user_home)))
from hermes_cli.main import _apply_profile_override
_apply_profile_override()
assert os.environ.get("HERMES_HOME") == str(profile_dir)
assert sys.argv == ["hermes", "gateway", "install", "--system"]
def test_hermes_home_unset_default_profile_no_redirect(self, tmp_path, monkeypatch):
"""active_profile=default must not redirect HERMES_HOME."""
hermes_root = tmp_path / ".hermes"
hermes_root.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.delenv("HERMES_HOME", raising=False)
monkeypatch.setattr(sys, "argv", ["hermes", "gateway", "start"])
(hermes_root / "active_profile").write_text("default")
from hermes_cli.main import _apply_profile_override
_apply_profile_override()
assert os.environ.get("HERMES_HOME") is None
def test_subcommand_profile_flag_is_not_consumed(self, tmp_path, monkeypatch):
"""Command argv flags named --profile must stay with that command.
Docker Desktop's MCP Toolkit uses `docker mcp gateway run --profile ...`.
When that argv is passed through `hermes mcp add --args`, the early
profile pre-parser must not interpret the Docker profile as a Hermes
profile.
"""
hermes_root = tmp_path / ".hermes"
hermes_root.mkdir(parents=True, exist_ok=True)
argv = [
"hermes",
"mcp",
"add",
"docker-research",
"--command",
"docker",
"--args",
"mcp",
"gateway",
"run",
"--profile",
"research",
]
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.delenv("HERMES_HOME", raising=False)
monkeypatch.setattr(sys, "argv", list(argv))
from hermes_cli.main import _apply_profile_override
_apply_profile_override()
assert os.environ.get("HERMES_HOME") is None
assert sys.argv == argv
def test_profile_after_chat_subcommand_is_still_consumed(self, tmp_path, monkeypatch):
"""Profile flags historically work after normal Hermes subcommands."""
result = _run_apply_profile_override(
tmp_path,
monkeypatch,
hermes_home=None,
active_profile="coder",
argv=["hermes", "chat", "-p", "coder", "-q", "hello"],
)
assert result is not None
assert result.endswith("coder")
assert sys.argv == ["hermes", "chat", "-q", "hello"]
def test_top_level_profile_after_value_flag_is_consumed(self, tmp_path, monkeypatch):
"""Top-level --profile still works after other top-level value flags."""
result = _run_apply_profile_override(
tmp_path,
monkeypatch,
hermes_home=None,
active_profile="coder",
argv=["hermes", "-m", "gpt-5", "--profile", "coder", "chat"],
)
assert result is not None
assert result.endswith("coder")
assert sys.argv == ["hermes", "-m", "gpt-5", "chat"]
def test_top_level_profile_after_continue_flag_is_consumed(self, tmp_path, monkeypatch):
"""--continue has an optional value, so a following --profile is a flag."""
result = _run_apply_profile_override(
tmp_path,
monkeypatch,
hermes_home=None,
active_profile="coder",
argv=["hermes", "--continue", "--profile", "coder"],
)
assert result is not None
assert result.endswith("coder")
assert sys.argv == ["hermes", "--continue"]
+201
View File
@@ -0,0 +1,201 @@
"""Tests for Arcee AI provider support — standard direct API provider."""
import types
import pytest
from hermes_cli.auth import (
PROVIDER_REGISTRY,
resolve_provider,
get_api_key_provider_status,
resolve_api_key_provider_credentials,
)
_OTHER_PROVIDER_KEYS = (
"OPENAI_API_KEY", "ANTHROPIC_API_KEY", "DEEPSEEK_API_KEY",
"GOOGLE_API_KEY", "GEMINI_API_KEY", "DASHSCOPE_API_KEY",
"XAI_API_KEY", "KIMI_API_KEY", "KIMI_CN_API_KEY",
"MINIMAX_API_KEY", "MINIMAX_CN_API_KEY",
"KILOCODE_API_KEY", "HF_TOKEN", "GLM_API_KEY", "ZAI_API_KEY",
"XIAOMI_API_KEY", "TOKENHUB_API_KEY", "COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN",
)
# =============================================================================
# Provider Registry
# =============================================================================
class TestArceeProviderRegistry:
def test_registered(self):
assert "arcee" in PROVIDER_REGISTRY
def test_name(self):
assert PROVIDER_REGISTRY["arcee"].name == "Arcee AI"
def test_auth_type(self):
assert PROVIDER_REGISTRY["arcee"].auth_type == "api_key"
def test_inference_base_url(self):
assert PROVIDER_REGISTRY["arcee"].inference_base_url == "https://api.arcee.ai/api/v1"
def test_api_key_env_vars(self):
assert PROVIDER_REGISTRY["arcee"].api_key_env_vars == ("ARCEEAI_API_KEY",)
def test_base_url_env_var(self):
assert PROVIDER_REGISTRY["arcee"].base_url_env_var == "ARCEE_BASE_URL"
# =============================================================================
# Aliases
# =============================================================================
class TestArceeAliases:
@pytest.mark.parametrize("alias", ["arcee", "arcee-ai", "arceeai"])
def test_alias_resolves(self, alias, monkeypatch):
for key in _OTHER_PROVIDER_KEYS + ("OPENROUTER_API_KEY",):
monkeypatch.delenv(key, raising=False)
monkeypatch.setenv("ARCEEAI_API_KEY", "arc-test-12345")
assert resolve_provider(alias) == "arcee"
def test_normalize_provider_models_py(self):
from hermes_cli.models import normalize_provider
assert normalize_provider("arcee-ai") == "arcee"
assert normalize_provider("arceeai") == "arcee"
def test_normalize_provider_providers_py(self):
from hermes_cli.providers import normalize_provider
assert normalize_provider("arcee-ai") == "arcee"
assert normalize_provider("arceeai") == "arcee"
# =============================================================================
# Credentials
# =============================================================================
class TestArceeCredentials:
def test_status_configured(self, monkeypatch):
monkeypatch.setenv("ARCEEAI_API_KEY", "arc-test")
status = get_api_key_provider_status("arcee")
assert status["configured"]
def test_status_not_configured(self, monkeypatch):
monkeypatch.delenv("ARCEEAI_API_KEY", raising=False)
status = get_api_key_provider_status("arcee")
assert not status["configured"]
def test_openrouter_key_does_not_make_arcee_configured(self, monkeypatch):
"""OpenRouter users should NOT see arcee as configured."""
monkeypatch.delenv("ARCEEAI_API_KEY", raising=False)
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test")
status = get_api_key_provider_status("arcee")
assert not status["configured"]
def test_resolve_credentials(self, monkeypatch):
monkeypatch.setenv("ARCEEAI_API_KEY", "arc-direct-key")
monkeypatch.delenv("ARCEE_BASE_URL", raising=False)
creds = resolve_api_key_provider_credentials("arcee")
assert creds["api_key"] == "arc-direct-key"
assert creds["base_url"] == "https://api.arcee.ai/api/v1"
def test_custom_base_url_override(self, monkeypatch):
monkeypatch.setenv("ARCEEAI_API_KEY", "arc-x")
monkeypatch.setenv("ARCEE_BASE_URL", "https://custom.arcee.example/v1")
creds = resolve_api_key_provider_credentials("arcee")
assert creds["base_url"] == "https://custom.arcee.example/v1"
# =============================================================================
# Model catalog
# =============================================================================
class TestArceeModelCatalog:
def test_static_model_list(self):
"""Arcee has a static _PROVIDER_MODELS catalog entry. Specific model
names change with releases and don't belong in tests.
"""
from hermes_cli.models import _PROVIDER_MODELS
assert "arcee" in _PROVIDER_MODELS
assert len(_PROVIDER_MODELS["arcee"]) >= 1
def test_canonical_provider_entry(self):
from hermes_cli.models import CANONICAL_PROVIDERS
slugs = [p.slug for p in CANONICAL_PROVIDERS]
assert "arcee" in slugs
# =============================================================================
# Model normalization
# =============================================================================
class TestArceeNormalization:
def test_in_matching_prefix_strip_set(self):
from hermes_cli.model_normalize import _MATCHING_PREFIX_STRIP_PROVIDERS
assert "arcee" in _MATCHING_PREFIX_STRIP_PROVIDERS
def test_strips_prefix(self):
from hermes_cli.model_normalize import normalize_model_for_provider
assert normalize_model_for_provider("arcee/trinity-mini", "arcee") == "trinity-mini"
def test_bare_name_unchanged(self):
from hermes_cli.model_normalize import normalize_model_for_provider
assert normalize_model_for_provider("trinity-mini", "arcee") == "trinity-mini"
# =============================================================================
# URL mapping
# =============================================================================
class TestArceeURLMapping:
def test_url_to_provider(self):
from agent.model_metadata import _URL_TO_PROVIDER
assert _URL_TO_PROVIDER.get("api.arcee.ai") == "arcee"
def test_provider_prefixes(self):
from agent.model_metadata import _PROVIDER_PREFIXES
assert "arcee" in _PROVIDER_PREFIXES
assert "arcee-ai" in _PROVIDER_PREFIXES
assert "arceeai" in _PROVIDER_PREFIXES
def test_trajectory_compressor_detects_arcee(self):
import trajectory_compressor as tc
comp = tc.TrajectoryCompressor.__new__(tc.TrajectoryCompressor)
comp.config = types.SimpleNamespace(base_url="https://api.arcee.ai/api/v1")
assert comp._detect_provider() == "arcee"
# =============================================================================
# providers.py overlay + aliases
# =============================================================================
class TestArceeProvidersModule:
def test_overlay_exists(self):
from hermes_cli.providers import HERMES_OVERLAYS
assert "arcee" in HERMES_OVERLAYS
overlay = HERMES_OVERLAYS["arcee"]
assert overlay.transport == "openai_chat"
assert overlay.base_url_env_var == "ARCEE_BASE_URL"
assert not overlay.is_aggregator
def test_label(self):
from hermes_cli.models import _PROVIDER_LABELS
assert _PROVIDER_LABELS["arcee"] == "Arcee AI"
# =============================================================================
# Auxiliary client — main-model-first design
# =============================================================================
class TestArceeAuxiliary:
def test_main_model_first_design(self):
"""Arcee uses main-model-first — no entry in _API_KEY_PROVIDER_AUX_MODELS."""
from agent.auxiliary_client import _API_KEY_PROVIDER_AUX_MODELS
assert "arcee" not in _API_KEY_PROVIDER_AUX_MODELS
@@ -0,0 +1,184 @@
"""Tests for parent→subparser flag propagation.
When flags like --yolo, -w, -s exist on both the parent parser and the 'chat'
subparser, placing the flag BEFORE the subcommand (e.g. 'hermes --yolo chat')
must not silently drop the flag value.
Regression test for: argparse subparser default=False overwriting parent's
parsed True when the same argument is defined on both parsers.
Fix: chat subparser uses default=argparse.SUPPRESS for all duplicated flags,
so the subparser only sets the attribute when the user explicitly provides it.
"""
import argparse
import os
import sys
import pytest
def _build_parser():
"""Build the hermes argument parser from the real code.
We import the real main() and extract the parser it builds.
Since main() is a large function that does much more than parse args,
we replicate just the parser structure here to avoid side effects.
"""
parser = argparse.ArgumentParser(prog="hermes")
parser.add_argument("--resume", "-r", metavar="SESSION", default=None)
parser.add_argument(
"--continue", "-c", dest="continue_last", nargs="?",
const=True, default=None, metavar="SESSION_NAME",
)
parser.add_argument("--worktree", "-w", action="store_true", default=False)
parser.add_argument("--skills", "-s", action="append", default=None)
parser.add_argument("--yolo", action="store_true", default=False)
parser.add_argument("--pass-session-id", action="store_true", default=False)
subparsers = parser.add_subparsers(dest="command")
chat = subparsers.add_parser("chat")
# These MUST use argparse.SUPPRESS to avoid overwriting parent values
chat.add_argument("--yolo", action="store_true",
default=argparse.SUPPRESS)
chat.add_argument("--worktree", "-w", action="store_true",
default=argparse.SUPPRESS)
chat.add_argument("--skills", "-s", action="append",
default=argparse.SUPPRESS)
chat.add_argument("--pass-session-id", action="store_true",
default=argparse.SUPPRESS)
chat.add_argument("--resume", "-r", metavar="SESSION_ID",
default=argparse.SUPPRESS)
chat.add_argument(
"--continue", "-c", dest="continue_last", nargs="?",
const=True, default=argparse.SUPPRESS, metavar="SESSION_NAME",
)
return parser
class TestChatVerboseArg:
"""Verify chat --verbose preserves config fallback when absent."""
def test_chat_without_verbose_leaves_attribute_unset(self):
from hermes_cli._parser import build_top_level_parser
parser, _subparsers, _chat_parser = build_top_level_parser()
args = parser.parse_args(["chat"])
assert not hasattr(args, "verbose")
def test_chat_verbose_sets_attribute_true(self):
from hermes_cli._parser import build_top_level_parser
parser, _subparsers, _chat_parser = build_top_level_parser()
args = parser.parse_args(["chat", "--verbose"])
assert args.verbose is True
def test_cmd_chat_forwards_none_when_verbose_is_absent(self, monkeypatch):
import types
import sys
import hermes_cli.main as main_mod
from hermes_cli._parser import build_top_level_parser
parser, _subparsers, chat_parser = build_top_level_parser()
chat_parser.set_defaults(func=main_mod.cmd_chat)
args = parser.parse_args(["chat"])
captured = {}
fake_cli = types.ModuleType("cli")
def fake_main(**kwargs):
captured.update(kwargs)
setattr(fake_cli, "main", fake_main)
fake_banner = types.ModuleType("hermes_cli.banner")
setattr(fake_banner, "prefetch_update_check", lambda: None)
fake_skills_sync = types.ModuleType("tools.skills_sync")
setattr(fake_skills_sync, "sync_skills", lambda quiet=True: None)
monkeypatch.setitem(sys.modules, "cli", fake_cli)
monkeypatch.setitem(sys.modules, "hermes_cli.banner", fake_banner)
monkeypatch.setitem(sys.modules, "tools.skills_sync", fake_skills_sync)
monkeypatch.setattr(main_mod, "_has_any_provider_configured", lambda: True)
monkeypatch.setattr(main_mod, "_pin_kanban_board_env", lambda: None)
main_mod.cmd_chat(args)
assert captured["quiet"] is False
assert "verbose" not in captured
class TestYoloEnvVar:
"""Verify --yolo sets HERMES_YOLO_MODE regardless of flag position.
This tests the actual cmd_chat logic pattern (getattr → os.environ).
"""
@pytest.fixture(autouse=True)
def _clean_env(self):
os.environ.pop("HERMES_YOLO_MODE", None)
yield
os.environ.pop("HERMES_YOLO_MODE", None)
def _simulate_cmd_chat_yolo_check(self, args):
"""Replicate the exact check from cmd_chat in main.py."""
if getattr(args, "yolo", False):
os.environ["HERMES_YOLO_MODE"] = "1"
def test_yolo_before_chat_sets_env(self):
parser = _build_parser()
args = parser.parse_args(["--yolo", "chat"])
self._simulate_cmd_chat_yolo_check(args)
assert os.environ.get("HERMES_YOLO_MODE") == "1"
def test_yolo_after_chat_sets_env(self):
parser = _build_parser()
args = parser.parse_args(["chat", "--yolo"])
self._simulate_cmd_chat_yolo_check(args)
assert os.environ.get("HERMES_YOLO_MODE") == "1"
def test_no_yolo_no_env(self):
parser = _build_parser()
args = parser.parse_args(["chat"])
self._simulate_cmd_chat_yolo_check(args)
assert os.environ.get("HERMES_YOLO_MODE") is None
class TestAcceptHooksOnAgentSubparsers:
"""Verify --accept-hooks is accepted at every agent-subcommand
position (before the subcommand, between group/subcommand, and
after the leaf subcommand) for gateway/cron/mcp/acp. Regression
against prior behaviour where the flag only worked on the root
parser and `chat`, so `hermes gateway run --accept-hooks` failed
with `unrecognized arguments`."""
@pytest.mark.parametrize("argv", [
["--accept-hooks", "gateway", "run", "--help"],
["gateway", "--accept-hooks", "run", "--help"],
["gateway", "run", "--accept-hooks", "--help"],
["--accept-hooks", "cron", "tick", "--help"],
["cron", "--accept-hooks", "tick", "--help"],
["cron", "tick", "--accept-hooks", "--help"],
["cron", "run", "--accept-hooks", "dummy-id", "--help"],
["--accept-hooks", "mcp", "serve", "--help"],
["mcp", "--accept-hooks", "serve", "--help"],
["mcp", "serve", "--accept-hooks", "--help"],
["acp", "--accept-hooks", "--help"],
])
def test_accepted_at_every_position(self, argv):
"""Invoking `hermes <argv>` must exit 0 (help) rather than
failing with `unrecognized arguments`."""
import subprocess
result = subprocess.run(
[sys.executable, "-m", "hermes_cli.main", *argv],
capture_output=True,
text=True,
timeout=15,
)
assert result.returncode == 0, (
f"argv={argv!r} returned {result.returncode}\n"
f"stdout: {result.stdout[:300]}\n"
f"stderr: {result.stderr[:300]}"
)
assert "unrecognized arguments" not in result.stderr
@@ -0,0 +1,90 @@
"""Regression test: `@folder:` completion must only surface directories and
`@file:` must only surface regular files.
Reported during TUI v2 blitz testing: typing `@folder:` showed .dockerignore,
.env, .gitignore, etc. alongside the actual directories because the path-
completion branch yielded every entry regardless of the explicit prefix, and
auto-switched the completion kind based on `is_dir`. That defeated the user's
explicit choice and rendered the `@folder:` / `@file:` prefixes useless for
filtering.
"""
from __future__ import annotations
from pathlib import Path
from typing import Iterable
from hermes_cli.commands import SlashCommandCompleter
def _run(tmp_path: Path, word: str) -> list[tuple[str, str]]:
(tmp_path / "readme.md").write_text("x")
(tmp_path / ".env").write_text("x")
(tmp_path / "src").mkdir()
(tmp_path / "docs").mkdir()
completer = SlashCommandCompleter.__new__(SlashCommandCompleter)
completions: Iterable = completer._context_completions(word)
return [(c.text, c.display_meta) for c in completions if c.text.startswith(("@file:", "@folder:"))]
def test_at_folder_only_yields_directories(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
texts = [t for t, _ in _run(tmp_path, "@folder:")]
assert all(t.startswith("@folder:") for t in texts), texts
assert any(t == "@folder:src/" for t in texts)
assert any(t == "@folder:docs/" for t in texts)
assert not any(t == "@folder:readme.md" for t in texts)
assert not any(t == "@folder:.env" for t in texts)
def test_at_file_only_yields_files(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
texts = [t for t, _ in _run(tmp_path, "@file:")]
assert all(t.startswith("@file:") for t in texts), texts
assert any(t == "@file:readme.md" for t in texts)
assert any(t == "@file:.env" for t in texts)
assert not any(t == "@file:src/" for t in texts)
assert not any(t == "@file:docs/" for t in texts)
def test_at_folder_preserves_prefix_on_empty_match(tmp_path, monkeypatch):
"""User typed `@folder:` (no partial) — completion text must keep the
`@folder:` prefix even though the previous implementation auto-rewrote
it to `@file:` for non-dir entries.
"""
monkeypatch.chdir(tmp_path)
texts = [t for t, _ in _run(tmp_path, "@folder:")]
assert texts, "expected at least one directory completion"
for t in texts:
assert t.startswith("@folder:"), f"prefix leaked: {t}"
def test_at_folder_bare_without_colon_lists_directories(tmp_path, monkeypatch):
"""Typing `@folder` alone (no colon yet) should surface directories so
users don't need to first accept the static `@folder:` hint before
seeing what they're picking from.
"""
monkeypatch.chdir(tmp_path)
texts = [t for t, _ in _run(tmp_path, "@folder")]
assert any(t == "@folder:src/" for t in texts), texts
assert any(t == "@folder:docs/" for t in texts), texts
assert not any(t == "@folder:readme.md" for t in texts)
def test_at_file_bare_without_colon_lists_files(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
texts = [t for t, _ in _run(tmp_path, "@file")]
assert any(t == "@file:readme.md" for t in texts), texts
assert not any(t == "@file:src/" for t in texts)
+191
View File
@@ -0,0 +1,191 @@
"""Tests for utils.atomic_json_write — crash-safe JSON file writes."""
import json
import os
from pathlib import Path
from unittest.mock import patch
import pytest
from utils import atomic_json_write
class TestAtomicJsonWrite:
"""Core atomic write behavior."""
def test_writes_valid_json(self, tmp_path):
target = tmp_path / "data.json"
data = {"key": "value", "nested": {"a": 1}}
atomic_json_write(target, data)
result = json.loads(target.read_text(encoding="utf-8"))
assert result == data
def test_creates_parent_directories(self, tmp_path):
target = tmp_path / "deep" / "nested" / "dir" / "data.json"
atomic_json_write(target, {"ok": True})
assert target.exists()
assert json.loads(target.read_text())["ok"] is True
def test_overwrites_existing_file(self, tmp_path):
target = tmp_path / "data.json"
target.write_text('{"old": true}')
atomic_json_write(target, {"new": True})
result = json.loads(target.read_text())
assert result == {"new": True}
def test_preserves_original_on_serialization_error(self, tmp_path):
target = tmp_path / "data.json"
original = {"preserved": True}
target.write_text(json.dumps(original))
# Try to write non-serializable data — should fail
with pytest.raises(TypeError):
atomic_json_write(target, {"bad": object()})
# Original file should be untouched
result = json.loads(target.read_text())
assert result == original
def test_no_leftover_temp_files_on_success(self, tmp_path):
target = tmp_path / "data.json"
atomic_json_write(target, [1, 2, 3])
# No .tmp files should be left behind
tmp_files = [f for f in tmp_path.iterdir() if ".tmp" in f.name]
assert len(tmp_files) == 0
assert target.exists()
def test_no_leftover_temp_files_on_failure(self, tmp_path):
target = tmp_path / "data.json"
with pytest.raises(TypeError):
atomic_json_write(target, {"bad": object()})
# No temp files should be left behind
tmp_files = [f for f in tmp_path.iterdir() if ".tmp" in f.name]
assert len(tmp_files) == 0
def test_cleans_up_temp_file_on_baseexception(self, tmp_path):
class SimulatedAbort(BaseException):
pass
target = tmp_path / "data.json"
original = {"preserved": True}
target.write_text(json.dumps(original), encoding="utf-8")
with patch("utils.json.dump", side_effect=SimulatedAbort):
with pytest.raises(SimulatedAbort):
atomic_json_write(target, {"new": True})
tmp_files = [f for f in tmp_path.iterdir() if ".tmp" in f.name]
assert len(tmp_files) == 0
assert json.loads(target.read_text(encoding="utf-8")) == original
def test_accepts_string_path(self, tmp_path):
target = str(tmp_path / "string_path.json")
atomic_json_write(target, {"string": True})
result = json.loads(Path(target).read_text())
assert result == {"string": True}
def test_writes_list_data(self, tmp_path):
target = tmp_path / "list.json"
data = [1, "two", {"three": 3}]
atomic_json_write(target, data)
result = json.loads(target.read_text())
assert result == data
def test_empty_list(self, tmp_path):
target = tmp_path / "empty.json"
atomic_json_write(target, [])
result = json.loads(target.read_text())
assert result == []
def test_custom_indent(self, tmp_path):
target = tmp_path / "custom.json"
atomic_json_write(target, {"a": 1}, indent=4)
text = target.read_text()
assert ' "a"' in text # 4-space indent
def test_accepts_json_dump_default_hook(self, tmp_path):
class CustomValue:
def __str__(self):
return "custom-value"
target = tmp_path / "custom_default.json"
atomic_json_write(target, {"value": CustomValue()}, default=str)
result = json.loads(target.read_text(encoding="utf-8"))
assert result == {"value": "custom-value"}
def test_unicode_content(self, tmp_path):
target = tmp_path / "unicode.json"
data = {"emoji": "🎉", "japanese": "日本語"}
atomic_json_write(target, data)
result = json.loads(target.read_text(encoding="utf-8"))
assert result["emoji"] == "🎉"
assert result["japanese"] == "日本語"
def test_mode_does_not_crash_without_fchmod(self, tmp_path):
"""Regression: os.fchmod is Unix-only and absent on Windows. Passing a
mode must not raise AttributeError when fchmod is unavailable.
Simulates the Windows os module by removing fchmod from the namespace.
Previously this crashed in `hermes memory setup` while saving the
Hindsight config with mode=0o600 (GitHub: Windows setup traceback).
"""
import utils
target = tmp_path / "secret.json"
no_fchmod = {k: getattr(os, k) for k in dir(os) if k != "fchmod"}
fake_os = type("FakeOs", (), no_fchmod)
assert not hasattr(fake_os, "fchmod")
with patch.object(utils, "os", fake_os):
atomic_json_write(target, {"api_key": "secret"}, mode=0o600)
assert json.loads(target.read_text(encoding="utf-8")) == {"api_key": "secret"}
def test_mode_applied_when_supported(self, tmp_path):
import stat as stat_mod
target = tmp_path / "secret.json"
atomic_json_write(target, {"api_key": "secret"}, mode=0o600)
# os.chmod's effect is platform-dependent (Windows only honors the
# write bit), so only assert the durable mode on POSIX.
if hasattr(os, "fchmod"):
actual = stat_mod.S_IMODE(target.stat().st_mode)
assert actual == 0o600
def test_concurrent_writes_dont_corrupt(self, tmp_path):
"""Multiple rapid writes should each produce valid JSON."""
import threading
target = tmp_path / "concurrent.json"
errors = []
def writer(n):
try:
atomic_json_write(target, {"writer": n, "data": list(range(100))})
except Exception as e:
errors.append(e)
threads = [threading.Thread(target=writer, args=(i,)) for i in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
assert not errors
# File should contain valid JSON from one of the writers
result = json.loads(target.read_text())
assert "writer" in result
assert len(result["data"]) == 100
@@ -0,0 +1,43 @@
"""Tests for utils.atomic_yaml_write — crash-safe YAML file writes."""
from unittest.mock import patch
import pytest
import yaml
from utils import atomic_yaml_write
class TestAtomicYamlWrite:
def test_writes_valid_yaml(self, tmp_path):
target = tmp_path / "data.yaml"
data = {"key": "value", "nested": {"a": 1}}
atomic_yaml_write(target, data)
assert yaml.safe_load(target.read_text(encoding="utf-8")) == data
def test_cleans_up_temp_file_on_baseexception(self, tmp_path):
class SimulatedAbort(BaseException):
pass
target = tmp_path / "data.yaml"
original = {"preserved": True}
target.write_text(yaml.safe_dump(original), encoding="utf-8")
with patch("utils.yaml.dump", side_effect=SimulatedAbort):
with pytest.raises(SimulatedAbort):
atomic_yaml_write(target, {"new": True})
tmp_files = [f for f in tmp_path.iterdir() if ".tmp" in f.name]
assert len(tmp_files) == 0
assert yaml.safe_load(target.read_text(encoding="utf-8")) == original
def test_appends_extra_content(self, tmp_path):
target = tmp_path / "data.yaml"
atomic_yaml_write(target, {"key": "value"}, extra_content="\n# comment\n")
text = target.read_text(encoding="utf-8")
assert "key: value" in text
assert "# comment" in text
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,208 @@
"""Regression tests for Codex refresh_token self-heal (cross-store rotation).
Hermes keeps its OWN copy of the Codex OAuth token (per profile + top-level),
separate from the Codex CLI's ``~/.codex/auth.json``. OAuth refresh_tokens are
single-use, so when the Codex CLI (or another Hermes process) rotates the shared
token, the frozen copy's refresh_token goes stale and ``refresh_codex_oauth_pure``
fails with a relogin-required error. ``_refresh_codex_auth_tokens`` must then
recover by re-importing the canonical token from ``~/.codex/auth.json`` instead of
surfacing a hard 401 — but ONLY for relogin-required failures, never for transient
ones (e.g. 429 quota, where the stored token is still valid).
"""
import json
import pytest
import hermes_cli.auth as auth
from hermes_cli.auth import AuthError, _refresh_codex_auth_tokens, resolve_codex_runtime_credentials
STALE = {"access_token": "stale-access", "refresh_token": "stale-refresh"}
def test_self_heals_on_stale_refresh_token(monkeypatch):
"""invalid_grant (relogin-required) → reimport from ~/.codex and persist it."""
saved = {}
fresh = {
"access_token": "fresh-access",
"refresh_token": "fresh-refresh",
"last_refresh": "2026-06-12T00:00:00Z",
}
def _rejected(*_a, **_k):
raise AuthError(
"refresh token rejected",
provider="openai-codex",
code="invalid_grant",
relogin_required=True,
)
monkeypatch.setattr(auth, "refresh_codex_oauth_pure", _rejected)
monkeypatch.setattr(auth, "_import_codex_cli_tokens", lambda: dict(fresh))
monkeypatch.setattr(auth, "_save_codex_tokens", lambda t, *a, **k: saved.update(t))
out = _refresh_codex_auth_tokens(STALE, 20.0)
assert out["access_token"] == "fresh-access"
assert out["refresh_token"] == "fresh-refresh"
# the recovered token was persisted to the Hermes auth store
assert saved["access_token"] == "fresh-access"
def test_does_not_self_heal_on_rate_limit(monkeypatch):
"""429 quota keeps relogin_required=False — token still valid, must NOT reimport."""
import_calls = {"n": 0}
def _rate_limited(*_a, **_k):
raise AuthError(
"quota exhausted",
provider="openai-codex",
code="codex_rate_limited",
relogin_required=False,
)
def _import_spy():
import_calls["n"] += 1
return {"access_token": "should-not-be-used"}
monkeypatch.setattr(auth, "refresh_codex_oauth_pure", _rate_limited)
monkeypatch.setattr(auth, "_import_codex_cli_tokens", _import_spy)
monkeypatch.setattr(auth, "_save_codex_tokens", lambda *a, **k: None)
with pytest.raises(AuthError) as ei:
_refresh_codex_auth_tokens(STALE, 20.0)
assert ei.value.code == "codex_rate_limited"
assert import_calls["n"] == 0 # never touched ~/.codex on a transient failure
def test_reraises_when_codex_cli_token_absent(monkeypatch):
"""relogin-required but ~/.codex unavailable/expired → propagate original error."""
def _reused(*_a, **_k):
raise AuthError(
"refresh token reused",
provider="openai-codex",
code="refresh_token_reused",
relogin_required=True,
)
monkeypatch.setattr(auth, "refresh_codex_oauth_pure", _reused)
monkeypatch.setattr(auth, "_import_codex_cli_tokens", lambda: None)
monkeypatch.setattr(auth, "_save_codex_tokens", lambda *a, **k: None)
with pytest.raises(AuthError) as ei:
_refresh_codex_auth_tokens(STALE, 20.0)
assert ei.value.code == "refresh_token_reused"
def test_happy_path_unchanged(monkeypatch):
"""Normal refresh succeeds → rotated tokens persisted, ~/.codex never consulted."""
saved = {}
import_calls = {"n": 0}
def _import_spy():
import_calls["n"] += 1
return None
monkeypatch.setattr(
auth,
"refresh_codex_oauth_pure",
lambda *a, **k: {"access_token": "rotated", "refresh_token": "rotated-r"},
)
monkeypatch.setattr(auth, "_import_codex_cli_tokens", _import_spy)
monkeypatch.setattr(auth, "_save_codex_tokens", lambda t, *a, **k: saved.update(t))
out = _refresh_codex_auth_tokens({"access_token": "a", "refresh_token": "b"}, 20.0)
assert out["access_token"] == "rotated"
assert out["refresh_token"] == "rotated-r"
assert saved["access_token"] == "rotated"
assert import_calls["n"] == 0 # happy path must not consult ~/.codex
def test_reraises_when_imported_token_lacks_refresh_token(monkeypatch):
"""relogin-required, but ~/.codex returns an access_token with NO refresh_token →
re-raise rather than persist a half-token that would break the next refresh."""
saved = {}
def _rejected(*_a, **_k):
raise AuthError(
"refresh token rejected",
provider="openai-codex",
code="invalid_grant",
relogin_required=True,
)
monkeypatch.setattr(auth, "refresh_codex_oauth_pure", _rejected)
monkeypatch.setattr(auth, "_import_codex_cli_tokens", lambda: {"access_token": "fresh-only"})
monkeypatch.setattr(auth, "_save_codex_tokens", lambda t, *a, **k: saved.update(t))
with pytest.raises(AuthError) as ei:
_refresh_codex_auth_tokens(STALE, 20.0)
assert ei.value.code == "invalid_grant"
assert saved == {} # nothing was persisted
def test_self_heals_missing_singleton_access_token_from_codex_cli(tmp_path, monkeypatch):
"""Exact cron failure path: Hermes auth has refresh_token but missing access_token."""
hermes_home = tmp_path / "hermes"
codex_home = tmp_path / "codex"
hermes_home.mkdir()
codex_home.mkdir()
(hermes_home / "auth.json").write_text(json.dumps({
"version": 1,
"providers": {
"openai-codex": {
"tokens": {"refresh_token": "stale-refresh"},
"last_refresh": "2026-06-01T00:00:00Z",
"auth_mode": "chatgpt",
},
},
}))
(codex_home / "auth.json").write_text(json.dumps({
"tokens": {
"access_token": "fresh-access",
"refresh_token": "fresh-refresh",
},
}))
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("CODEX_HOME", str(codex_home))
resolved = resolve_codex_runtime_credentials()
assert resolved["api_key"] == "fresh-access"
assert resolved["source"] == "hermes-auth-store"
stored = json.loads((hermes_home / "auth.json").read_text())
tokens = stored["providers"]["openai-codex"]["tokens"]
assert tokens["access_token"] == "fresh-access"
assert tokens["refresh_token"] == "fresh-refresh"
def test_missing_singleton_access_token_reraises_when_codex_cli_half_token(tmp_path, monkeypatch):
"""Missing access_token must not be masked by a malformed Codex CLI import."""
hermes_home = tmp_path / "hermes"
codex_home = tmp_path / "codex"
hermes_home.mkdir()
codex_home.mkdir()
(hermes_home / "auth.json").write_text(json.dumps({
"version": 1,
"providers": {
"openai-codex": {
"tokens": {"refresh_token": "stale-refresh"},
"auth_mode": "chatgpt",
},
},
}))
(codex_home / "auth.json").write_text(json.dumps({
"tokens": {"access_token": "fresh-only"},
}))
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("CODEX_HOME", str(codex_home))
with pytest.raises(AuthError) as ei:
resolve_codex_runtime_credentials()
assert ei.value.code == "codex_auth_missing_access_token"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,148 @@
"""Unit tests for _print_loopback_ssh_hint() in hermes_cli/auth.py.
The helper exists to warn users that loopback OAuth flows (xAI Grok OAuth,
Spotify) don't work over SSH unless they set up an `ssh -L` port forward
between their laptop's browser and the remote host's loopback listener.
"""
from __future__ import annotations
import io
import contextlib
import socket
from hermes_cli import auth as auth_mod
def _cap(fn):
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
fn()
return buf.getvalue()
def test_loopback_ssh_hint_silent_when_not_remote(monkeypatch):
monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: False)
out = _cap(lambda: auth_mod._print_loopback_ssh_hint(
"http://127.0.0.1:56121/callback", docs_url=auth_mod.XAI_OAUTH_DOCS_URL
))
assert out == ""
def test_loopback_ssh_hint_prints_tunnel_command_on_ssh(monkeypatch):
monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True)
out = _cap(lambda: auth_mod._print_loopback_ssh_hint(
"http://127.0.0.1:56121/callback", docs_url=auth_mod.XAI_OAUTH_DOCS_URL
))
# Must include the exact ssh -L command with the port from the redirect URI
assert "ssh -N -L 56121:127.0.0.1:56121" in out
# Must include the provider-specific docs URL
assert auth_mod.XAI_OAUTH_DOCS_URL in out
# Must always include the cross-provider SSH guide
assert auth_mod.OAUTH_OVER_SSH_DOCS_URL in out
def test_loopback_ssh_hint_uses_actual_bound_port(monkeypatch):
"""When the preferred port is busy, _xai_start_callback_server falls back to
an OS-assigned port. The hint must echo whichever port actually got bound,
not the hardcoded constant."""
monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True)
out = _cap(lambda: auth_mod._print_loopback_ssh_hint(
"http://127.0.0.1:51234/callback", docs_url=auth_mod.XAI_OAUTH_DOCS_URL
))
assert "ssh -N -L 51234:127.0.0.1:51234" in out
assert "56121" not in out
def test_loopback_ssh_hint_silent_for_non_loopback_uri(monkeypatch):
"""Defense in depth: if a future caller passes a non-loopback redirect URI
by mistake, we don't tell the user to forward an external port."""
monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True)
out = _cap(lambda: auth_mod._print_loopback_ssh_hint(
"https://example.com/callback", docs_url=auth_mod.XAI_OAUTH_DOCS_URL
))
assert out == ""
def test_loopback_ssh_hint_silent_for_malformed_uri(monkeypatch):
monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True)
out = _cap(lambda: auth_mod._print_loopback_ssh_hint(
"not-a-uri", docs_url=auth_mod.XAI_OAUTH_DOCS_URL
))
assert out == ""
def test_loopback_ssh_hint_works_without_provider_docs_url(monkeypatch):
monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True)
out = _cap(lambda: auth_mod._print_loopback_ssh_hint(
"http://127.0.0.1:43827/spotify/callback"
))
assert "ssh -N -L 43827:127.0.0.1:43827" in out
# Generic SSH guide is always present even without a provider-specific URL
assert auth_mod.OAUTH_OVER_SSH_DOCS_URL in out
# Should not falsely show "Provider docs:" when no docs_url was passed
assert "Provider docs:" not in out
def test_loopback_ssh_hint_accepts_localhost_hostname(monkeypatch):
"""The constant is 127.0.0.1, but parsing tolerates `localhost` too in case
a future caller normalizes the URI differently."""
monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True)
out = _cap(lambda: auth_mod._print_loopback_ssh_hint(
"http://localhost:56121/callback"
))
assert "ssh -N -L 56121:127.0.0.1:56121" in out
def test_loopback_ssh_hint_includes_user_at_host(monkeypatch):
"""The SSH command should include a detected user@host so the user can
copy-paste it without manually substituting placeholders."""
monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True)
monkeypatch.setattr(auth_mod, "_ssh_user_at_host", lambda: "alice@myserver.lan")
out = _cap(lambda: auth_mod._print_loopback_ssh_hint(
"http://127.0.0.1:56121/callback"
))
assert "ssh -N -L 56121:127.0.0.1:56121 alice@myserver.lan" in out
def test_loopback_ssh_hint_has_visual_header(monkeypatch):
"""The hint should print a divider and header so it stands out in noisy output."""
monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True)
out = _cap(lambda: auth_mod._print_loopback_ssh_hint(
"http://127.0.0.1:56121/callback"
))
assert "Remote session detected" in out
assert "---" in out # divider is present
class TestSshUserAtHost:
def test_resolves_user_and_hostname(self, monkeypatch):
monkeypatch.setenv("USER", "alice")
monkeypatch.delenv("LOGNAME", raising=False)
monkeypatch.setattr(socket, "gethostname", lambda: "myserver")
assert auth_mod._ssh_user_at_host() == "alice@myserver"
def test_falls_back_to_logname(self, monkeypatch):
monkeypatch.delenv("USER", raising=False)
monkeypatch.setenv("LOGNAME", "bob")
monkeypatch.setattr(socket, "gethostname", lambda: "host1")
assert auth_mod._ssh_user_at_host() == "bob@host1"
def test_placeholder_when_no_env_vars(self, monkeypatch):
monkeypatch.delenv("USER", raising=False)
monkeypatch.delenv("LOGNAME", raising=False)
monkeypatch.setattr(socket, "gethostname", lambda: "host1")
assert auth_mod._ssh_user_at_host() == "<user>@host1"
def test_placeholder_when_socket_raises(self, monkeypatch):
monkeypatch.setenv("USER", "charlie")
def _raise():
raise OSError("no network")
monkeypatch.setattr(socket, "gethostname", _raise)
assert auth_mod._ssh_user_at_host() == "charlie@<this-host>"
def test_placeholder_when_empty_hostname(self, monkeypatch):
monkeypatch.setenv("USER", "dave")
monkeypatch.setattr(socket, "gethostname", lambda: "")
assert auth_mod._ssh_user_at_host() == "dave@<this-host>"
+684
View File
@@ -0,0 +1,684 @@
"""Tests for the OAuth manual-paste fallback for browser-only remotes.
Regression coverage for [#26923](https://github.com/NousResearch/hermes-agent/issues/26923):
GCP Cloud Shell, GitHub Codespaces, AWS EC2 Instance Connect and
other browser-only remote consoles can't reach the
``http://127.0.0.1:56121/callback`` loopback listener bound on the
remote VM. The previous SSH-tunnel hint was useless without a real
SSH client, leaving the user with no path forward. This test file
locks in four things:
* ``_is_remote_session`` recognises the cloud-shell / Codespaces
envvars (so the existing hint at least fires).
* ``_parse_pasted_callback`` accepts every form a user might paste
(full URL, ``?code=...&state=...`` fragment, bare ``code=...``,
bare opaque value) and returns the same shape the loopback HTTP
handler does.
* ``_prompt_manual_callback_paste`` reads stdin and produces that
same shape.
* ``_xai_oauth_loopback_login(manual_paste=True)`` skips the HTTP
server entirely, validates ``state``, and goes straight to the
token exchange — proving the paste path actually wires up.
"""
from __future__ import annotations
import builtins
import io
import contextlib
import pytest
from hermes_cli import auth as auth_mod
# ---------------------------------------------------------------------------
# _is_remote_session — broadened detection (#26923)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"envvar",
[
"SSH_CLIENT",
"SSH_TTY",
"CLOUD_SHELL",
"CODESPACES",
"CODESPACE_NAME",
"GITPOD_WORKSPACE_ID",
"REPL_ID",
"STACKBLITZ",
],
)
def test_is_remote_session_detects_known_remote_envvar(monkeypatch, envvar):
"""Each documented remote-console env var must trip the check.
The SSH ones preserve historical behaviour; the cloud-shell ones
are what closes #26923. Without these, the SSH hint never fires
and the user has no signal that ``--manual-paste`` exists.
"""
for name in (
"SSH_CLIENT",
"SSH_TTY",
"CLOUD_SHELL",
"CODESPACES",
"CODESPACE_NAME",
"GITPOD_WORKSPACE_ID",
"REPL_ID",
"STACKBLITZ",
):
monkeypatch.delenv(name, raising=False)
monkeypatch.setenv(envvar, "1")
assert auth_mod._is_remote_session() is True
def test_is_remote_session_false_when_no_remote_envvars(monkeypatch):
for name in (
"SSH_CLIENT",
"SSH_TTY",
"CLOUD_SHELL",
"CODESPACES",
"CODESPACE_NAME",
"GITPOD_WORKSPACE_ID",
"REPL_ID",
"STACKBLITZ",
):
monkeypatch.delenv(name, raising=False)
assert auth_mod._is_remote_session() is False
# ---------------------------------------------------------------------------
# _parse_pasted_callback — accept every plausible paste form
# ---------------------------------------------------------------------------
def test_parse_full_callback_url():
out = auth_mod._parse_pasted_callback(
"http://127.0.0.1:56121/callback?code=abc123&state=deadbeef"
)
assert out == {
"code": "abc123",
"state": "deadbeef",
"error": None,
"error_description": None,
}
def test_parse_callback_url_https_and_extra_params():
out = auth_mod._parse_pasted_callback(
"https://127.0.0.1:56121/callback?code=abc&state=xyz&scope=openid"
)
assert out["code"] == "abc"
assert out["state"] == "xyz"
def test_parse_bare_query_string_with_leading_question_mark():
out = auth_mod._parse_pasted_callback("?code=p1&state=s1")
assert out["code"] == "p1"
assert out["state"] == "s1"
def test_parse_bare_query_fragment_no_question_mark():
out = auth_mod._parse_pasted_callback("code=p2&state=s2")
assert out["code"] == "p2"
assert out["state"] == "s2"
def test_parse_bare_opaque_code_value():
"""Some users only copy the ``code`` value itself."""
out = auth_mod._parse_pasted_callback("ABCDEF-the-code-value")
assert out["code"] == "ABCDEF-the-code-value"
assert out["state"] is None
def test_parse_callback_with_error_field():
out = auth_mod._parse_pasted_callback(
"http://127.0.0.1:56121/callback?error=access_denied"
"&error_description=user+rejected"
)
assert out["code"] is None
assert out["error"] == "access_denied"
assert out["error_description"] == "user rejected"
def test_parse_empty_input_returns_all_none():
out = auth_mod._parse_pasted_callback("")
assert out == {
"code": None,
"state": None,
"error": None,
"error_description": None,
}
def test_parse_whitespace_only_returns_all_none():
out = auth_mod._parse_pasted_callback(" \n\t ")
assert out["code"] is None
def test_parse_malformed_url_does_not_crash():
out = auth_mod._parse_pasted_callback("http://[not a url")
# Malformed URLs return all-None rather than raising — the caller
# (state check) will reject the empty payload with a clear error.
assert out["code"] is None
# ---------------------------------------------------------------------------
# _prompt_manual_callback_paste — stdin handling
# ---------------------------------------------------------------------------
def test_prompt_reads_stdin_and_parses(monkeypatch):
monkeypatch.setattr(
builtins, "input",
lambda *_a, **_k: "http://127.0.0.1:56121/callback?code=abc&state=xyz",
)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
out = auth_mod._prompt_manual_callback_paste(
"http://127.0.0.1:56121/callback"
)
rendered = buf.getvalue()
assert "Manual callback paste" in rendered
assert "127.0.0.1:56121" in rendered
assert out["code"] == "abc"
assert out["state"] == "xyz"
def test_prompt_eof_returns_all_none(monkeypatch):
def _raise_eof(*_a, **_k):
raise EOFError()
monkeypatch.setattr(builtins, "input", _raise_eof)
with contextlib.redirect_stdout(io.StringIO()):
out = auth_mod._prompt_manual_callback_paste(
"http://127.0.0.1:56121/callback"
)
assert out["code"] is None
def test_prompt_keyboard_interrupt_returns_all_none(monkeypatch):
def _raise_kbi(*_a, **_k):
raise KeyboardInterrupt()
monkeypatch.setattr(builtins, "input", _raise_kbi)
with contextlib.redirect_stdout(io.StringIO()):
out = auth_mod._prompt_manual_callback_paste(
"http://127.0.0.1:56121/callback"
)
assert out["code"] is None
# ---------------------------------------------------------------------------
# _xai_oauth_loopback_login(manual_paste=True) — full integration
# ---------------------------------------------------------------------------
class _StubTokenResponse:
status_code = 200
def __init__(self, payload):
self._payload = payload
self.text = ""
def json(self):
return self._payload
def test_xai_loopback_login_manual_paste_skips_http_server(monkeypatch):
"""``manual_paste=True`` must NOT bind a loopback HTTP server.
Direct end-to-end regression for #26923: the whole point is that
the listener is unreachable on browser-only remotes, so the paste
path must avoid it entirely. We assert this by replacing
``_xai_start_callback_server`` with a function that fails if
invoked, then driving the full happy path with a stubbed prompt
+ stubbed token endpoint.
"""
monkeypatch.setattr(
auth_mod, "_xai_oauth_discovery",
lambda *_a, **_k: {
"authorization_endpoint": "https://auth.x.ai/oauth2/authorize",
"token_endpoint": "https://auth.x.ai/oauth2/token",
},
)
def _server_must_not_be_called(*_a, **_k):
raise AssertionError(
"manual_paste=True must skip the loopback HTTP server "
"(regression for #26923)"
)
monkeypatch.setattr(
auth_mod, "_xai_start_callback_server", _server_must_not_be_called
)
captured_state: dict = {}
def _fake_prompt(_redirect_uri):
# Hermes generates state internally; we won't know it ahead of
# time, so capture the state Hermes baked into the authorize
# URL via a sneak peek on ``_xai_oauth_build_authorize_url``.
return {
"code": "fake-auth-code",
"state": captured_state["value"],
"error": None,
"error_description": None,
}
monkeypatch.setattr(
auth_mod, "_prompt_manual_callback_paste", _fake_prompt
)
original_build = auth_mod._xai_oauth_build_authorize_url
def _capture_state(**kwargs):
captured_state["value"] = kwargs["state"]
return original_build(**kwargs)
monkeypatch.setattr(
auth_mod, "_xai_oauth_build_authorize_url", _capture_state
)
def _fake_token_post(*_a, **_k):
return _StubTokenResponse(
{
"access_token": "at",
"refresh_token": "rt",
"id_token": "",
"expires_in": 3600,
"token_type": "Bearer",
}
)
monkeypatch.setattr(auth_mod.httpx, "post", _fake_token_post)
with contextlib.redirect_stdout(io.StringIO()):
creds = auth_mod._xai_oauth_loopback_login(manual_paste=True)
assert creds["tokens"]["access_token"] == "at"
assert creds["tokens"]["refresh_token"] == "rt"
assert "127.0.0.1:56121" in creds["redirect_uri"]
def test_xai_loopback_login_manual_paste_state_mismatch_raises(monkeypatch):
"""A pasted callback with the wrong state must still be rejected.
The HTTP-server path uses the same state check; manual-paste
must not be a CSRF bypass.
"""
monkeypatch.setattr(
auth_mod, "_xai_oauth_discovery",
lambda *_a, **_k: {
"authorization_endpoint": "https://auth.x.ai/oauth2/authorize",
"token_endpoint": "https://auth.x.ai/oauth2/token",
},
)
monkeypatch.setattr(
auth_mod, "_prompt_manual_callback_paste",
lambda _ru: {
"code": "fake",
"state": "WRONG-STATE",
"error": None,
"error_description": None,
},
)
with contextlib.redirect_stdout(io.StringIO()):
with pytest.raises(auth_mod.AuthError) as exc:
auth_mod._xai_oauth_loopback_login(manual_paste=True)
assert exc.value.code == "xai_state_mismatch"
def test_xai_loopback_login_manual_paste_bare_code_succeeds(monkeypatch):
"""Bare-code paste (state=None) must complete login under manual_paste.
xAI's consent page renders the authorization code in-page rather than
redirecting through 127.0.0.1, so on remote/headless setups the only
value the user can obtain is the opaque code with no ``state=``
parameter. ``_parse_pasted_callback`` correctly returns
``state=None`` for that input. The login flow must accept this case
(PKCE still protects the exchange); historically it raised
``xai_state_mismatch``. Regression for the bare-code branch of #26923.
"""
monkeypatch.setattr(
auth_mod, "_xai_oauth_discovery",
lambda *_a, **_k: {
"authorization_endpoint": "https://auth.x.ai/oauth2/authorize",
"token_endpoint": "https://auth.x.ai/oauth2/token",
},
)
monkeypatch.setattr(
auth_mod, "_prompt_manual_callback_paste",
lambda _ru: {
"code": "bare-opaque-code",
"state": None,
"error": None,
"error_description": None,
},
)
def _fake_token_post(*_a, **_k):
return _StubTokenResponse(
{
"access_token": "at",
"refresh_token": "rt",
"id_token": "",
"expires_in": 3600,
"token_type": "Bearer",
}
)
monkeypatch.setattr(auth_mod.httpx, "post", _fake_token_post)
with contextlib.redirect_stdout(io.StringIO()):
creds = auth_mod._xai_oauth_loopback_login(manual_paste=True)
assert creds["tokens"]["access_token"] == "at"
assert creds["tokens"]["refresh_token"] == "rt"
def test_xai_loopback_login_loopback_path_rejects_missing_state(monkeypatch):
"""Loopback (manual_paste=False) must NOT accept ``state=None``.
The bare-code relaxation only applies to the manual-paste path,
where the user demonstrably has no way to supply ``state``. The
HTTP-server path always sees ``state`` populated from the real
callback query string, so missing state there means something is
wrong (a malformed callback, an attacker-supplied request) and
must still raise ``xai_state_mismatch``.
"""
monkeypatch.setattr(
auth_mod, "_xai_oauth_discovery",
lambda *_a, **_k: {
"authorization_endpoint": "https://auth.x.ai/oauth2/authorize",
"token_endpoint": "https://auth.x.ai/oauth2/token",
},
)
class _StubServer:
def shutdown(self):
return None
def server_close(self):
return None
monkeypatch.setattr(
auth_mod, "_xai_start_callback_server",
lambda *_a, **_k: (
_StubServer(),
None,
{"code": "fake", "state": None, "error": None,
"error_description": None},
"http://127.0.0.1:56121/callback",
),
)
monkeypatch.setattr(
auth_mod, "_xai_wait_for_callback",
lambda *_a, **_k: {
"code": "fake",
"state": None,
"error": None,
"error_description": None,
},
)
monkeypatch.setattr(auth_mod, "_xai_validate_loopback_redirect_uri", lambda _u: None)
monkeypatch.setattr(auth_mod, "_print_loopback_ssh_hint", lambda *_a, **_k: None)
with contextlib.redirect_stdout(io.StringIO()):
with pytest.raises(auth_mod.AuthError) as exc:
auth_mod._xai_oauth_loopback_login(manual_paste=False, open_browser=False)
assert exc.value.code == "xai_state_mismatch"
def test_xai_loopback_login_manual_paste_missing_code_raises(monkeypatch):
"""Empty paste must surface as ``xai_code_missing``, not crash."""
monkeypatch.setattr(
auth_mod, "_xai_oauth_discovery",
lambda *_a, **_k: {
"authorization_endpoint": "https://auth.x.ai/oauth2/authorize",
"token_endpoint": "https://auth.x.ai/oauth2/token",
},
)
captured: dict = {"state": None}
original_build = auth_mod._xai_oauth_build_authorize_url
def _capture(**kw):
captured["state"] = kw["state"]
return original_build(**kw)
monkeypatch.setattr(auth_mod, "_xai_oauth_build_authorize_url", _capture)
monkeypatch.setattr(
auth_mod, "_prompt_manual_callback_paste",
lambda _ru: {
"code": None,
"state": captured["state"],
"error": None,
"error_description": None,
},
)
with contextlib.redirect_stdout(io.StringIO()):
with pytest.raises(auth_mod.AuthError) as exc:
auth_mod._xai_oauth_loopback_login(manual_paste=True)
assert exc.value.code == "xai_code_missing"
def test_xai_loopback_login_timeout_falls_back_to_manual_paste(monkeypatch):
"""Loopback timeout should accept a bare Grok Build code paste."""
monkeypatch.setattr(
auth_mod, "_xai_oauth_discovery",
lambda *_a, **_k: {
"authorization_endpoint": "https://auth.x.ai/oauth2/authorize",
"token_endpoint": "https://auth.x.ai/oauth2/token",
},
)
class _StubServer:
def shutdown(self):
return None
def server_close(self):
return None
class _StubThread:
def join(self, timeout=None):
return None
monkeypatch.setattr(
auth_mod,
"_xai_start_callback_server",
lambda: (
_StubServer(),
_StubThread(),
{
"code": None,
"state": None,
"error": None,
"error_description": None,
},
"http://127.0.0.1:56121/callback",
),
)
captured: dict = {"state": None, "prompt_calls": 0}
original_build = auth_mod._xai_oauth_build_authorize_url
def _capture(**kwargs):
captured["state"] = kwargs["state"]
return original_build(**kwargs)
monkeypatch.setattr(auth_mod, "_xai_oauth_build_authorize_url", _capture)
def _raise_timeout(*_a, **_k):
raise auth_mod.AuthError(
"xAI authorization timed out waiting for the local callback.",
provider="xai-oauth",
code="xai_callback_timeout",
)
monkeypatch.setattr(auth_mod, "_xai_wait_for_callback", _raise_timeout)
def _fake_prompt(_redirect_uri):
captured["prompt_calls"] += 1
return {
"code": "manual-auth-code",
"state": None,
"error": None,
"error_description": None,
}
monkeypatch.setattr(auth_mod, "_prompt_manual_callback_paste", _fake_prompt)
monkeypatch.setattr(
auth_mod.sys, "stdin", type("StubStdin", (), {"isatty": lambda self: True})()
)
monkeypatch.setattr(
auth_mod.httpx,
"post",
lambda *_a, **_k: _StubTokenResponse(
{
"access_token": "at-timeout",
"refresh_token": "rt-timeout",
"id_token": "",
"expires_in": 3600,
"token_type": "Bearer",
}
),
)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
creds = auth_mod._xai_oauth_loopback_login(manual_paste=False)
rendered = buf.getvalue()
assert "xAI loopback callback timed out." in rendered
assert "--manual-paste" in rendered
assert captured["prompt_calls"] == 1
assert creds["tokens"]["access_token"] == "at-timeout"
assert creds["tokens"]["refresh_token"] == "rt-timeout"
def test_xai_wait_for_callback_accepts_ready_stdin_code(monkeypatch):
"""Users can paste the Grok Build code while Hermes is still waiting."""
class _StubServer:
shutdown_called = False
close_called = False
def shutdown(self):
self.shutdown_called = True
def server_close(self):
self.close_called = True
class _StubThread:
joined = False
def join(self, timeout=None):
self.joined = True
server = _StubServer()
thread = _StubThread()
monkeypatch.setattr(
auth_mod,
"_read_ready_stdin_line",
lambda: "ready-grok-build-code\n",
)
out = auth_mod._xai_wait_for_callback(
server,
thread,
{"code": None, "error": None},
timeout_seconds=5,
manual_paste_redirect_uri="http://127.0.0.1:56121/callback",
)
assert out["code"] == "ready-grok-build-code"
assert out["state"] is None
assert out["_manual_paste"] is True
assert server.shutdown_called is True
assert server.close_called is True
assert thread.joined is True
def test_xai_loopback_login_timeout_noninteractive_reraises(monkeypatch):
"""Non-interactive stdin must keep the original timeout error."""
monkeypatch.setattr(
auth_mod, "_xai_oauth_discovery",
lambda *_a, **_k: {
"authorization_endpoint": "https://auth.x.ai/oauth2/authorize",
"token_endpoint": "https://auth.x.ai/oauth2/token",
},
)
class _StubServer:
def shutdown(self):
return None
def server_close(self):
return None
class _StubThread:
def join(self, timeout=None):
return None
monkeypatch.setattr(
auth_mod,
"_xai_start_callback_server",
lambda: (
_StubServer(),
_StubThread(),
{
"code": None,
"state": None,
"error": None,
"error_description": None,
},
"http://127.0.0.1:56121/callback",
),
)
monkeypatch.setattr(
auth_mod,
"_xai_wait_for_callback",
lambda *_a, **_k: (_ for _ in ()).throw(
auth_mod.AuthError(
"xAI authorization timed out waiting for the local callback.",
provider="xai-oauth",
code="xai_callback_timeout",
)
),
)
monkeypatch.setattr(
auth_mod.sys, "stdin", type("StubStdin", (), {"isatty": lambda self: False})()
)
monkeypatch.setattr(
auth_mod,
"_prompt_manual_callback_paste",
lambda *_a, **_k: pytest.fail("manual-paste fallback should not run"),
)
with contextlib.redirect_stdout(io.StringIO()):
with pytest.raises(auth_mod.AuthError) as exc:
auth_mod._xai_oauth_loopback_login(manual_paste=False)
assert exc.value.code == "xai_callback_timeout"
# ---------------------------------------------------------------------------
# _print_loopback_ssh_hint — now also mentions --manual-paste
# ---------------------------------------------------------------------------
def test_ssh_hint_mentions_manual_paste_for_non_ssh_remotes(monkeypatch):
"""Users on Cloud Shell / Codespaces have no real SSH client; the
hint must point them at the new ``--manual-paste`` flag instead
of leaving them stuck on the ``ssh -L`` recipe."""
monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
auth_mod._print_loopback_ssh_hint(
"http://127.0.0.1:56121/callback",
docs_url=auth_mod.XAI_OAUTH_DOCS_URL,
)
rendered = buf.getvalue()
assert "--manual-paste" in rendered
assert "Cloud Shell" in rendered or "Codespaces" in rendered
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,452 @@
"""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
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_profile_with_zero_entries_falls_back_to_global(profile_env):
"""Empty profile pool inherits the global-root entries for that provider."""
from hermes_cli.auth import read_credential_pool
_write(profile_env["global"] / "auth.json", _make_auth_store(pool={
"openrouter": [{
"id": "glob-1",
"label": "global-key",
"auth_type": "api_key",
"priority": 0,
"source": "manual",
"access_token": "sk-or-global",
}],
}))
# Profile auth.json: exists but has no openrouter entries.
_write(profile_env["profile"] / "auth.json", _make_auth_store(pool={}))
entries = read_credential_pool("openrouter")
assert len(entries) == 1
assert entries[0]["id"] == "glob-1"
assert entries[0]["access_token"] == "sk-or-global"
def test_profile_with_entries_fully_shadows_global(profile_env):
"""Once the profile has any entries for a provider, global is ignored."""
from hermes_cli.auth import read_credential_pool
_write(profile_env["global"] / "auth.json", _make_auth_store(pool={
"openrouter": [{
"id": "glob-1",
"label": "global-key",
"auth_type": "api_key",
"priority": 0,
"source": "manual",
"access_token": "sk-or-global",
}],
}))
_write(profile_env["profile"] / "auth.json", _make_auth_store(pool={
"openrouter": [{
"id": "prof-1",
"label": "profile-key",
"auth_type": "api_key",
"priority": 0,
"source": "manual",
"access_token": "sk-or-profile",
}],
}))
entries = read_credential_pool("openrouter")
assert len(entries) == 1
assert entries[0]["id"] == "prof-1"
assert entries[0]["access_token"] == "sk-or-profile"
def test_per_provider_shadowing_is_independent(profile_env):
"""Profile can override one provider while inheriting another from global."""
from hermes_cli.auth import read_credential_pool
_write(profile_env["global"] / "auth.json", _make_auth_store(pool={
"openrouter": [{
"id": "glob-or",
"label": "global-or",
"auth_type": "api_key",
"priority": 0,
"source": "manual",
"access_token": "sk-or-global",
}],
"anthropic": [{
"id": "glob-ant",
"label": "global-ant",
"auth_type": "api_key",
"priority": 0,
"source": "manual",
"access_token": "sk-ant-global",
}],
}))
_write(profile_env["profile"] / "auth.json", _make_auth_store(pool={
# Profile has openrouter only — anthropic should still fall back.
"openrouter": [{
"id": "prof-or",
"label": "profile-or",
"auth_type": "api_key",
"priority": 0,
"source": "manual",
"access_token": "sk-or-profile",
}],
}))
or_entries = read_credential_pool("openrouter")
ant_entries = read_credential_pool("anthropic")
assert [e["id"] for e in or_entries] == ["prof-or"]
assert [e["id"] for e in ant_entries] == ["glob-ant"]
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)
# ---------------------------------------------------------------------------
def test_whole_pool_merges_global_providers_when_missing_locally(profile_env):
from hermes_cli.auth import read_credential_pool
_write(profile_env["global"] / "auth.json", _make_auth_store(pool={
"openrouter": [{
"id": "glob-or",
"label": "global-or",
"auth_type": "api_key",
"priority": 0,
"source": "manual",
"access_token": "sk-or-global",
}],
"anthropic": [{
"id": "glob-ant",
"label": "global-ant",
"auth_type": "api_key",
"priority": 0,
"source": "manual",
"access_token": "sk-ant-global",
}],
}))
_write(profile_env["profile"] / "auth.json", _make_auth_store(pool={
"openrouter": [{
"id": "prof-or",
"label": "profile-or",
"auth_type": "api_key",
"priority": 0,
"source": "manual",
"access_token": "sk-or-profile",
}],
}))
pool = read_credential_pool(None)
# Profile wins for openrouter, global fills in anthropic.
assert [e["id"] for e in pool["openrouter"]] == ["prof-or"]
assert [e["id"] for e in pool["anthropic"]] == ["glob-ant"]
# ---------------------------------------------------------------------------
# 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_profile_wins_when_present(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"},
}))
_write(profile_env["profile"] / "auth.json", _make_auth_store(providers={
"nous": {"access_token": "nous-profile"},
}))
state = get_provider_auth_state("nous")
assert state is not None
assert state["access_token"] == "nous-profile"
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_load_provider_state_falls_back_to_global(profile_env):
"""When the loaded profile store has no provider entry, fall back to global."""
from hermes_cli.auth import _load_auth_store, _load_provider_state
_write(profile_env["global"] / "auth.json", _make_auth_store(providers={
"nous": {"access_token": "global-nous-token", "refresh_token": "rt"},
}))
_write(profile_env["profile"] / "auth.json", _make_auth_store(providers={}))
auth_store = _load_auth_store()
state = _load_provider_state(auth_store, "nous")
assert state is not None
assert state["access_token"] == "global-nous-token"
def test_load_provider_state_profile_wins_over_global(profile_env):
from hermes_cli.auth import _load_auth_store, _load_provider_state
_write(profile_env["global"] / "auth.json", _make_auth_store(providers={
"nous": {"access_token": "global-token"},
}))
_write(profile_env["profile"] / "auth.json", _make_auth_store(providers={
"nous": {"access_token": "profile-token"},
}))
auth_store = _load_auth_store()
state = _load_provider_state(auth_store, "nous")
assert state is not None
assert state["access_token"] == "profile-token"
def test_load_provider_state_returns_none_when_neither_has_it(profile_env):
from hermes_cli.auth import _load_auth_store, _load_provider_state
_write(profile_env["global"] / "auth.json", _make_auth_store(providers={}))
_write(profile_env["profile"] / "auth.json", _make_auth_store(providers={}))
auth_store = _load_auth_store()
assert _load_provider_state(auth_store, "nous") is None
def test_load_provider_state_classic_mode_no_fallback(tmp_path, monkeypatch):
"""In classic mode there is no global to fall back to; behavior is unchanged."""
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))
_write(hermes_home / "auth.json", _make_auth_store(providers={
"nous": {"access_token": "classic-token"},
}))
from hermes_cli.auth import _load_auth_store, _load_provider_state
auth_store = _load_auth_store()
state = _load_provider_state(auth_store, "nous")
assert state is not None
assert state["access_token"] == "classic-token"
# Absent providers still return None.
assert _load_provider_state(auth_store, "anthropic") is None
def test_load_provider_state_malformed_global_does_not_break_profile(profile_env):
"""A corrupt global auth.json must not break profile reads."""
(profile_env["global"] / "auth.json").write_text("{not valid json")
_write(profile_env["profile"] / "auth.json", _make_auth_store(providers={
"nous": {"access_token": "profile-token"},
}))
from hermes_cli.auth import _load_auth_store, _load_provider_state
auth_store = _load_auth_store()
state = _load_provider_state(auth_store, "nous")
assert state is not None
assert state["access_token"] == "profile-token"
# ---------------------------------------------------------------------------
# Classic mode — no fallback path should ever trigger
# ---------------------------------------------------------------------------
def test_classic_mode_does_not_double_read_same_file(tmp_path, monkeypatch):
"""In classic mode (HERMES_HOME == global root), no fallback path runs.
This guards against the merge accidentally duplicating entries when the
profile and global resolve to the same directory.
"""
# Put Path.home() under a subdir so the seat belt in _auth_file_path()
# sees tmp_path/home/.hermes as the "real home" — which is NOT equal
# to the HERMES_HOME we set (tmp_path/classic), so the guard passes.
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))
_write(hermes_home / "auth.json", _make_auth_store(pool={
"openrouter": [{
"id": "only",
"label": "classic",
"auth_type": "api_key",
"priority": 0,
"source": "manual",
"access_token": "sk-classic",
}],
}))
from hermes_cli.auth import read_credential_pool, _global_auth_file_path
# Classic mode: HERMES_HOME is set to a custom path that is NOT under
# ~/.hermes/profiles/ — get_default_hermes_root() returns HERMES_HOME
# itself, so the profile root and global root are the same directory,
# and the helper correctly returns None (no fallback).
assert _global_auth_file_path() is None
# And the read should return exactly one entry (not two).
entries = read_credential_pool("openrouter")
assert len(entries) == 1
assert entries[0]["id"] == "only"
# ---------------------------------------------------------------------------
# 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"]
@@ -0,0 +1,84 @@
"""Tests for is_provider_explicitly_configured()."""
import json
import pytest
def _write_config(tmp_path, config: dict) -> None:
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
import yaml
(hermes_home / "config.yaml").write_text(yaml.dump(config))
def _write_auth_store(tmp_path, payload: dict) -> None:
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
(hermes_home / "auth.json").write_text(json.dumps(payload, indent=2))
@pytest.fixture(autouse=True)
def _clean_anthropic_env(monkeypatch):
"""Strip Anthropic env vars so CI secrets don't leak into tests."""
for key in ("ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"):
monkeypatch.delenv(key, raising=False)
def test_returns_false_when_no_config(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
(tmp_path / "hermes").mkdir(parents=True, exist_ok=True)
from hermes_cli.auth import is_provider_explicitly_configured
assert is_provider_explicitly_configured("anthropic") is False
def test_returns_true_when_active_provider_matches(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_write_auth_store(tmp_path, {
"version": 1,
"providers": {},
"active_provider": "anthropic",
})
from hermes_cli.auth import is_provider_explicitly_configured
assert is_provider_explicitly_configured("anthropic") is True
def test_returns_true_when_config_provider_matches(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_write_config(tmp_path, {"model": {"provider": "anthropic", "default": "claude-sonnet-4-6"}})
from hermes_cli.auth import is_provider_explicitly_configured
assert is_provider_explicitly_configured("anthropic") is True
def test_returns_false_when_config_provider_is_different(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_write_config(tmp_path, {"model": {"provider": "kimi-coding", "default": "kimi-k2"}})
_write_auth_store(tmp_path, {
"version": 1,
"providers": {},
"active_provider": None,
})
from hermes_cli.auth import is_provider_explicitly_configured
assert is_provider_explicitly_configured("anthropic") is False
def test_returns_true_when_anthropic_env_var_set(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-realkey")
(tmp_path / "hermes").mkdir(parents=True, exist_ok=True)
from hermes_cli.auth import is_provider_explicitly_configured
assert is_provider_explicitly_configured("anthropic") is True
def test_claude_code_oauth_token_does_not_count_as_explicit(tmp_path, monkeypatch):
"""CLAUDE_CODE_OAUTH_TOKEN is set by Claude Code, not the user — must not gate."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "sk-ant-oat01-auto-token")
(tmp_path / "hermes").mkdir(parents=True, exist_ok=True)
from hermes_cli.auth import is_provider_explicitly_configured
assert is_provider_explicitly_configured("anthropic") is False
+474
View File
@@ -0,0 +1,474 @@
"""Tests for Qwen OAuth provider authentication (hermes_cli/auth.py).
Covers: _qwen_cli_auth_path, _read_qwen_cli_tokens, _save_qwen_cli_tokens,
_qwen_access_token_is_expiring, _refresh_qwen_cli_tokens,
resolve_qwen_runtime_credentials, get_qwen_auth_status.
"""
import json
import stat
import time
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from hermes_cli.auth import (
AuthError,
DEFAULT_QWEN_BASE_URL,
QWEN_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
_qwen_cli_auth_path,
_read_qwen_cli_tokens,
_save_qwen_cli_tokens,
_qwen_access_token_is_expiring,
_refresh_qwen_cli_tokens,
resolve_qwen_runtime_credentials,
get_qwen_auth_status,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_qwen_tokens(
access_token="test-access-token",
refresh_token="test-refresh-token",
expiry_date=None,
**extra,
):
"""Create a minimal Qwen CLI OAuth credential dict."""
if expiry_date is None:
# 1 hour from now in milliseconds
expiry_date = int((time.time() + 3600) * 1000)
data = {
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": "Bearer",
"expiry_date": expiry_date,
"resource_url": "portal.qwen.ai",
}
data.update(extra)
return data
def _write_qwen_creds(tmp_path, tokens=None):
"""Write tokens to the Qwen CLI credentials file and return the path."""
qwen_dir = tmp_path / ".qwen"
qwen_dir.mkdir(parents=True, exist_ok=True)
creds_path = qwen_dir / "oauth_creds.json"
if tokens is None:
tokens = _make_qwen_tokens()
creds_path.write_text(json.dumps(tokens), encoding="utf-8")
return creds_path
@pytest.fixture()
def qwen_env(tmp_path, monkeypatch):
"""Redirect _qwen_cli_auth_path to tmp_path/.qwen/oauth_creds.json."""
creds_path = tmp_path / ".qwen" / "oauth_creds.json"
monkeypatch.setattr(
"hermes_cli.auth._qwen_cli_auth_path", lambda: creds_path
)
return tmp_path
# ---------------------------------------------------------------------------
# _qwen_cli_auth_path
# ---------------------------------------------------------------------------
def test_qwen_cli_auth_path_returns_expected_location():
path = _qwen_cli_auth_path()
assert path == Path.home() / ".qwen" / "oauth_creds.json"
# ---------------------------------------------------------------------------
# _read_qwen_cli_tokens
# ---------------------------------------------------------------------------
def test_read_qwen_cli_tokens_success(qwen_env):
tokens = _make_qwen_tokens(access_token="my-access")
_write_qwen_creds(qwen_env, tokens)
result = _read_qwen_cli_tokens()
assert result["access_token"] == "my-access"
assert result["refresh_token"] == "test-refresh-token"
def test_read_qwen_cli_tokens_missing_file(qwen_env):
with pytest.raises(AuthError) as exc:
_read_qwen_cli_tokens()
assert exc.value.code == "qwen_auth_missing"
def test_read_qwen_cli_tokens_invalid_json(qwen_env):
creds_path = qwen_env / ".qwen" / "oauth_creds.json"
creds_path.parent.mkdir(parents=True, exist_ok=True)
creds_path.write_text("not json{{{", encoding="utf-8")
with pytest.raises(AuthError) as exc:
_read_qwen_cli_tokens()
assert exc.value.code == "qwen_auth_read_failed"
def test_read_qwen_cli_tokens_non_dict(qwen_env):
creds_path = qwen_env / ".qwen" / "oauth_creds.json"
creds_path.parent.mkdir(parents=True, exist_ok=True)
creds_path.write_text(json.dumps(["a", "b"]), encoding="utf-8")
with pytest.raises(AuthError) as exc:
_read_qwen_cli_tokens()
assert exc.value.code == "qwen_auth_invalid"
# ---------------------------------------------------------------------------
# _save_qwen_cli_tokens
# ---------------------------------------------------------------------------
def test_save_qwen_cli_tokens_roundtrip(qwen_env):
tokens = _make_qwen_tokens(access_token="saved-token")
saved_path = _save_qwen_cli_tokens(tokens)
assert saved_path.exists()
loaded = json.loads(saved_path.read_text(encoding="utf-8"))
assert loaded["access_token"] == "saved-token"
def test_save_qwen_cli_tokens_creates_parent(qwen_env):
tokens = _make_qwen_tokens()
saved_path = _save_qwen_cli_tokens(tokens)
assert saved_path.parent.exists()
def test_save_qwen_cli_tokens_permissions(qwen_env):
tokens = _make_qwen_tokens()
saved_path = _save_qwen_cli_tokens(tokens)
mode = saved_path.stat().st_mode
assert mode & stat.S_IRUSR # owner read
assert mode & stat.S_IWUSR # owner write
assert not (mode & stat.S_IRGRP) # no group read
assert not (mode & stat.S_IROTH) # no other read
# ---------------------------------------------------------------------------
# _qwen_access_token_is_expiring
# ---------------------------------------------------------------------------
def test_expiring_token_not_expired():
# 1 hour from now in milliseconds
future_ms = int((time.time() + 3600) * 1000)
assert not _qwen_access_token_is_expiring(future_ms)
def test_expiring_token_already_expired():
# 1 hour ago in milliseconds
past_ms = int((time.time() - 3600) * 1000)
assert _qwen_access_token_is_expiring(past_ms)
def test_expiring_token_within_skew():
# Just inside the default skew window
near_ms = int((time.time() + QWEN_ACCESS_TOKEN_REFRESH_SKEW_SECONDS - 5) * 1000)
assert _qwen_access_token_is_expiring(near_ms)
def test_expiring_token_none_returns_true():
assert _qwen_access_token_is_expiring(None)
def test_expiring_token_non_numeric_returns_true():
assert _qwen_access_token_is_expiring("not-a-number")
# ---------------------------------------------------------------------------
# _refresh_qwen_cli_tokens
# ---------------------------------------------------------------------------
def test_refresh_qwen_cli_tokens_success(qwen_env):
tokens = _make_qwen_tokens(refresh_token="old-refresh")
resp = MagicMock()
resp.status_code = 200
resp.json.return_value = {
"access_token": "new-access",
"refresh_token": "new-refresh",
"expires_in": 7200,
}
with patch("hermes_cli.auth.httpx") as mock_httpx:
mock_httpx.post.return_value = resp
result = _refresh_qwen_cli_tokens(tokens)
assert result["access_token"] == "new-access"
assert result["refresh_token"] == "new-refresh"
assert "expiry_date" in result
def test_refresh_qwen_cli_tokens_preserves_old_refresh_if_not_in_response(qwen_env):
tokens = _make_qwen_tokens(refresh_token="keep-me")
resp = MagicMock()
resp.status_code = 200
resp.json.return_value = {
"access_token": "new-access",
# No refresh_token in response — should keep old one
"expires_in": 3600,
}
with patch("hermes_cli.auth.httpx") as mock_httpx:
mock_httpx.post.return_value = resp
result = _refresh_qwen_cli_tokens(tokens)
assert result["refresh_token"] == "keep-me"
def test_refresh_qwen_cli_tokens_missing_refresh_token():
tokens = {"access_token": "at", "refresh_token": ""}
with pytest.raises(AuthError) as exc:
_refresh_qwen_cli_tokens(tokens)
assert exc.value.code == "qwen_refresh_token_missing"
def test_refresh_qwen_cli_tokens_http_error(qwen_env):
tokens = _make_qwen_tokens()
resp = MagicMock()
resp.status_code = 401
resp.text = "unauthorized"
with patch("hermes_cli.auth.httpx") as mock_httpx:
mock_httpx.post.return_value = resp
with pytest.raises(AuthError) as exc:
_refresh_qwen_cli_tokens(tokens)
assert exc.value.code == "qwen_refresh_failed"
def test_refresh_qwen_cli_tokens_network_error(qwen_env):
tokens = _make_qwen_tokens()
with patch("hermes_cli.auth.httpx") as mock_httpx:
mock_httpx.post.side_effect = ConnectionError("timeout")
with pytest.raises(AuthError) as exc:
_refresh_qwen_cli_tokens(tokens)
assert exc.value.code == "qwen_refresh_failed"
def test_refresh_qwen_cli_tokens_invalid_json_response(qwen_env):
tokens = _make_qwen_tokens()
resp = MagicMock()
resp.status_code = 200
resp.json.side_effect = ValueError("bad json")
with patch("hermes_cli.auth.httpx") as mock_httpx:
mock_httpx.post.return_value = resp
with pytest.raises(AuthError) as exc:
_refresh_qwen_cli_tokens(tokens)
assert exc.value.code == "qwen_refresh_invalid_json"
def test_refresh_qwen_cli_tokens_missing_access_token_in_response(qwen_env):
tokens = _make_qwen_tokens()
resp = MagicMock()
resp.status_code = 200
resp.json.return_value = {"something": "but no access_token"}
with patch("hermes_cli.auth.httpx") as mock_httpx:
mock_httpx.post.return_value = resp
with pytest.raises(AuthError) as exc:
_refresh_qwen_cli_tokens(tokens)
assert exc.value.code == "qwen_refresh_invalid_response"
def test_refresh_qwen_cli_tokens_default_expires_in(qwen_env):
"""When expires_in is missing, default to 6 hours."""
tokens = _make_qwen_tokens()
resp = MagicMock()
resp.status_code = 200
resp.json.return_value = {"access_token": "new"}
with patch("hermes_cli.auth.httpx") as mock_httpx:
mock_httpx.post.return_value = resp
result = _refresh_qwen_cli_tokens(tokens)
# Verify expiry_date is roughly now + 6h (within 60s tolerance)
expected_ms = int(time.time() * 1000) + 6 * 60 * 60 * 1000
assert abs(result["expiry_date"] - expected_ms) < 60_000
def test_refresh_qwen_cli_tokens_saves_to_disk(qwen_env):
tokens = _make_qwen_tokens()
resp = MagicMock()
resp.status_code = 200
resp.json.return_value = {
"access_token": "disk-check",
"expires_in": 3600,
}
with patch("hermes_cli.auth.httpx") as mock_httpx:
mock_httpx.post.return_value = resp
_refresh_qwen_cli_tokens(tokens)
# Verify it was persisted
creds_path = qwen_env / ".qwen" / "oauth_creds.json"
assert creds_path.exists()
saved = json.loads(creds_path.read_text(encoding="utf-8"))
assert saved["access_token"] == "disk-check"
# ---------------------------------------------------------------------------
# resolve_qwen_runtime_credentials
# ---------------------------------------------------------------------------
def test_resolve_qwen_runtime_credentials_fresh_token(qwen_env):
tokens = _make_qwen_tokens(access_token="fresh-at")
_write_qwen_creds(qwen_env, tokens)
creds = resolve_qwen_runtime_credentials(refresh_if_expiring=False)
assert creds["provider"] == "qwen-oauth"
assert creds["api_key"] == "fresh-at"
assert creds["base_url"] == DEFAULT_QWEN_BASE_URL
assert creds["source"] == "qwen-cli"
def test_resolve_qwen_runtime_credentials_triggers_refresh(qwen_env):
# Write an expired token
expired_ms = int((time.time() - 3600) * 1000)
tokens = _make_qwen_tokens(access_token="old", expiry_date=expired_ms)
_write_qwen_creds(qwen_env, tokens)
refreshed = _make_qwen_tokens(access_token="refreshed-at")
with patch(
"hermes_cli.auth._refresh_qwen_cli_tokens", return_value=refreshed
) as mock_refresh:
creds = resolve_qwen_runtime_credentials()
mock_refresh.assert_called_once()
assert creds["api_key"] == "refreshed-at"
def test_resolve_qwen_runtime_credentials_force_refresh(qwen_env):
tokens = _make_qwen_tokens(access_token="old-at")
_write_qwen_creds(qwen_env, tokens)
refreshed = _make_qwen_tokens(access_token="force-refreshed")
with patch(
"hermes_cli.auth._refresh_qwen_cli_tokens", return_value=refreshed
) as mock_refresh:
creds = resolve_qwen_runtime_credentials(force_refresh=True)
mock_refresh.assert_called_once()
assert creds["api_key"] == "force-refreshed"
def test_resolve_qwen_runtime_credentials_missing_access_token(qwen_env):
tokens = _make_qwen_tokens(access_token="")
_write_qwen_creds(qwen_env, tokens)
with pytest.raises(AuthError) as exc:
resolve_qwen_runtime_credentials(refresh_if_expiring=False)
assert exc.value.code == "qwen_access_token_missing"
def test_resolve_qwen_runtime_credentials_base_url_env_override(qwen_env, monkeypatch):
tokens = _make_qwen_tokens(access_token="at")
_write_qwen_creds(qwen_env, tokens)
monkeypatch.setenv("HERMES_QWEN_BASE_URL", "https://custom.qwen.ai/v1")
creds = resolve_qwen_runtime_credentials(refresh_if_expiring=False)
assert creds["base_url"] == "https://custom.qwen.ai/v1"
# ---------------------------------------------------------------------------
# get_qwen_auth_status
# ---------------------------------------------------------------------------
def test_get_qwen_auth_status_logged_in(qwen_env):
tokens = _make_qwen_tokens(access_token="status-at")
_write_qwen_creds(qwen_env, tokens)
status = get_qwen_auth_status()
assert status["logged_in"] is True
assert status["api_key"] == "status-at"
def test_get_qwen_auth_status_refreshes_expired_token(qwen_env):
expired_ms = int((time.time() - 3600) * 1000)
tokens = _make_qwen_tokens(access_token="old-at", expiry_date=expired_ms)
_write_qwen_creds(qwen_env, tokens)
refreshed = _make_qwen_tokens(access_token="refreshed-at")
with patch(
"hermes_cli.auth._refresh_qwen_cli_tokens", return_value=refreshed
) as mock_refresh:
status = get_qwen_auth_status()
mock_refresh.assert_called_once()
assert status["logged_in"] is True
assert status["api_key"] == "refreshed-at"
def test_get_qwen_auth_status_expired_unrefreshable_token_is_not_logged_in(qwen_env):
expired_ms = int((time.time() - 3600) * 1000)
tokens = _make_qwen_tokens(access_token="dead-at", expiry_date=expired_ms)
_write_qwen_creds(qwen_env, tokens)
with patch(
"hermes_cli.auth._refresh_qwen_cli_tokens",
side_effect=AuthError(
"Qwen refresh rejected. Re-run 'qwen auth qwen-oauth'.",
provider="qwen-oauth",
code="qwen_refresh_failed",
),
) as mock_refresh:
status = get_qwen_auth_status()
mock_refresh.assert_called_once()
assert status["logged_in"] is False
assert "qwen auth qwen-oauth" in status["error"]
def test_get_qwen_auth_status_not_logged_in(qwen_env):
# No credentials file
status = get_qwen_auth_status()
assert status["logged_in"] is False
assert "error" in status
def test_model_flow_qwen_oauth_stale_token_shows_reauth_guidance(qwen_env, monkeypatch, capsys):
from hermes_cli.main import _model_flow_qwen_oauth
expired_ms = int((time.time() - 3600) * 1000)
tokens = _make_qwen_tokens(access_token="dead-at", expiry_date=expired_ms)
_write_qwen_creds(qwen_env, tokens)
monkeypatch.setattr(
"hermes_cli.auth._refresh_qwen_cli_tokens",
lambda *args, **kwargs: (_ for _ in ()).throw(
AuthError(
"Qwen refresh rejected. Re-run 'qwen auth qwen-oauth'.",
provider="qwen-oauth",
code="qwen_refresh_failed",
)
),
)
prompt_called = {"value": False}
update_called = {"value": False}
monkeypatch.setattr(
"hermes_cli.auth._prompt_model_selection",
lambda *args, **kwargs: prompt_called.__setitem__("value", True),
)
monkeypatch.setattr(
"hermes_cli.auth._update_config_for_provider",
lambda *args, **kwargs: update_called.__setitem__("value", True),
)
_model_flow_qwen_oauth({}, current_model="qwen3-coder-plus")
out = capsys.readouterr().out
assert "Run: qwen auth qwen-oauth" in out
assert "Qwen refresh rejected" in out
assert prompt_called["value"] is False
assert update_called["value"] is False
+115
View File
@@ -0,0 +1,115 @@
"""Tests for hermes_cli.auth._default_verify platform-aware fallback.
On macOS with Homebrew Python, the system OpenSSL cannot locate the
system trust store, so we explicitly load certifi's bundle. On other
platforms we defer to httpx's own default (which itself uses certifi).
Most tests use monkeypatching no real SSL handshakes. A handful use
an openssl-generated self-signed cert via the `real_bundle_file`
fixture because `ssl.create_default_context(cafile=...)` parses the
bundle and refuses stubs.
"""
import os
import shutil
import ssl
import subprocess
import sys
from pathlib import Path
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from hermes_cli.auth import _default_verify, _resolve_verify
@pytest.fixture
def real_bundle_file(tmp_path: Path) -> str:
"""Return a path to a real openssl-generated self-signed cert.
Skips the test when the `openssl` binary isn't on PATH, so CI images
without it degrade gracefully instead of erroring out.
"""
if shutil.which("openssl") is None:
pytest.skip("openssl binary not available")
cert = tmp_path / "ca.pem"
key = tmp_path / "key.pem"
result = subprocess.run(
[
"openssl", "req", "-x509", "-newkey", "rsa:2048",
"-keyout", str(key), "-out", str(cert),
"-sha256", "-days", "1", "-nodes",
"-subj", "/CN=test",
],
capture_output=True,
timeout=10,
)
if result.returncode != 0:
pytest.skip(f"openssl failed: {result.stderr.decode('utf-8', 'ignore')[:200]}")
return str(cert)
class TestDefaultVerify:
def test_returns_ssl_context_on_darwin(self, monkeypatch):
monkeypatch.setattr(sys, "platform", "darwin")
result = _default_verify()
assert isinstance(result, ssl.SSLContext)
def test_returns_true_on_linux(self, monkeypatch):
monkeypatch.setattr(sys, "platform", "linux")
assert _default_verify() is True
def test_returns_true_on_windows(self, monkeypatch):
monkeypatch.setattr(sys, "platform", "win32")
assert _default_verify() is True
def test_darwin_falls_back_to_true_when_certifi_missing(self, monkeypatch):
monkeypatch.setattr(sys, "platform", "darwin")
real_import = __import__
def fake_import(name, *args, **kwargs):
if name == "certifi":
raise ImportError("simulated missing certifi")
return real_import(name, *args, **kwargs)
monkeypatch.setattr("builtins.__import__", fake_import)
assert _default_verify() is True
class TestResolveVerifyIntegration:
"""_resolve_verify should defer to _default_verify in the no-CA path."""
def test_no_ca_uses_default_verify_on_darwin(self, monkeypatch):
monkeypatch.setattr(sys, "platform", "darwin")
for var in ("HERMES_CA_BUNDLE", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE"):
monkeypatch.delenv(var, raising=False)
result = _resolve_verify()
assert isinstance(result, ssl.SSLContext)
def test_no_ca_uses_default_verify_on_linux(self, monkeypatch):
monkeypatch.setattr(sys, "platform", "linux")
for var in ("HERMES_CA_BUNDLE", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE"):
monkeypatch.delenv(var, raising=False)
assert _resolve_verify() is True
def test_requests_ca_bundle_respected(self, monkeypatch, real_bundle_file):
for var in ("HERMES_CA_BUNDLE", "SSL_CERT_FILE"):
monkeypatch.delenv(var, raising=False)
monkeypatch.setenv("REQUESTS_CA_BUNDLE", real_bundle_file)
result = _resolve_verify()
assert isinstance(result, ssl.SSLContext)
def test_missing_ca_path_falls_back_to_default_verify(self, monkeypatch, tmp_path):
monkeypatch.setattr(sys, "platform", "linux")
monkeypatch.setenv("HERMES_CA_BUNDLE", str(tmp_path / "missing.pem"))
for var in ("SSL_CERT_FILE", "REQUESTS_CA_BUNDLE"):
monkeypatch.delenv(var, raising=False)
assert _resolve_verify() is True
def test_insecure_wins_over_everything(self, monkeypatch, tmp_path):
bundle = tmp_path / "ca.pem"
bundle.write_text("stub")
monkeypatch.setenv("HERMES_CA_BUNDLE", str(bundle))
assert _resolve_verify(insecure=True) is False
@@ -0,0 +1,202 @@
"""Regression tests for TOCTOU-safe credential file writers in ``hermes_cli.auth``.
Background
==========
The three writers below used to create a temp file via ``Path.write_text`` /
``Path.open('w')`` and only ``chmod``'d it to ``0o600`` afterward. Between
create and chmod the file existed at the process umask (typically ``0o644``),
briefly exposing OAuth tokens to other local users on multi-user hosts. The
fix switches them to ``os.open(O_EXCL, mode=0o600)`` + ``os.fdopen`` +
``fsync`` so the file is atomic at ``0o600`` on creation. Mirrors the fixes
shipped for ``agent/google_oauth.py`` (#19673) and ``tools/mcp_oauth.py``
(#21148).
These tests stay green only while the token file and its parent directory
end up at ``0o600`` / ``0o700`` after every write. POSIX-only the mode-bit
enforcement does not exist on Windows.
"""
from __future__ import annotations
import json
import os
import stat
import sys
from unittest.mock import patch
import pytest
pytestmark = pytest.mark.skipif(
sys.platform.startswith("win"),
reason="POSIX mode bits not enforced on Windows",
)
# ---------------------------------------------------------------------------
# _save_auth_store (~/.hermes/auth.json — every native OAuth provider)
# ---------------------------------------------------------------------------
def test_save_auth_store_writes_0o600_with_0o700_parent(tmp_path, monkeypatch):
"""``_save_auth_store`` must land ``auth.json`` at 0o600 and parent at 0o700."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
old_umask = os.umask(0o022) # make the race observable if it regresses
try:
from hermes_cli import auth as auth_mod
auth_store = {
"version": auth_mod.AUTH_STORE_VERSION,
"providers": {"openai-codex": {"tokens": {"access_token": "secret-x"}}},
"active_provider": "openai-codex",
}
auth_path = auth_mod._save_auth_store(auth_store)
finally:
os.umask(old_umask)
mode = stat.S_IMODE(auth_path.stat().st_mode)
parent_mode = stat.S_IMODE(auth_path.parent.stat().st_mode)
assert mode == 0o600, (
f"auth.json mode 0o{mode:o} != 0o600 — TOCTOU race regressed"
)
assert parent_mode == 0o700, (
f"auth.json parent dir mode 0o{parent_mode:o} != 0o700 — siblings can traverse"
)
# Content survived the rewrite
data = json.loads(auth_path.read_text())
assert data["providers"]["openai-codex"]["tokens"]["access_token"] == "secret-x"
# ---------------------------------------------------------------------------
# _save_qwen_cli_tokens (Qwen CLI OAuth tokens)
# ---------------------------------------------------------------------------
def test_save_qwen_cli_tokens_writes_0o600_with_0o700_parent(tmp_path, monkeypatch):
"""``_save_qwen_cli_tokens`` must land the token file at 0o600 and parent at 0o700."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
# The Qwen CLI auth path lives under $HOME/.qwen by default — isolate it.
monkeypatch.setenv("HOME", str(tmp_path))
old_umask = os.umask(0o022)
try:
from hermes_cli import auth as auth_mod
tokens = {
"access_token": "qwen-secret",
"refresh_token": "qwen-refresh",
"token_type": "Bearer",
"expiry_date": 123,
}
auth_path = auth_mod._save_qwen_cli_tokens(tokens)
finally:
os.umask(old_umask)
mode = stat.S_IMODE(auth_path.stat().st_mode)
parent_mode = stat.S_IMODE(auth_path.parent.stat().st_mode)
assert mode == 0o600, (
f"Qwen token file mode 0o{mode:o} != 0o600 — TOCTOU race regressed"
)
assert parent_mode == 0o700, (
f"Qwen token parent dir mode 0o{parent_mode:o} != 0o700"
)
data = json.loads(auth_path.read_text())
assert data["access_token"] == "qwen-secret"
# ---------------------------------------------------------------------------
# Nous shared-credential store write (inside _write_shared_nous_state)
# ---------------------------------------------------------------------------
def test_shared_nous_store_writes_0o600_with_0o700_parent(tmp_path, monkeypatch):
"""The Nous shared-credential store must land at 0o600 / parent 0o700."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
# _nous_shared_store_path() refuses to touch the real shared store during
# pytest runs; redirect it into tmp_path explicitly. Use a distinct
# subdirectory name (``shared_override``) so the guard's "real user
# home" reference — which currently tracks HERMES_HOME via
# get_default_hermes_root() — can't collide with our override and
# falsely claim we're writing to the real user's shared store.
monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared_override"))
old_umask = os.umask(0o022)
try:
from hermes_cli import auth as auth_mod
state = {
"access_token": "nous-access-xxx",
"refresh_token": "nous-refresh-xxx",
"token_type": "Bearer",
"scope": "openid profile",
"client_id": "test-client",
"obtained_at": "2026-01-01T00:00:00Z",
"expires_at": "2026-01-01T01:00:00Z",
}
auth_mod._write_shared_nous_state(state)
path = auth_mod._nous_shared_store_path()
finally:
os.umask(old_umask)
assert path.exists(), "shared Nous store was not written"
mode = stat.S_IMODE(path.stat().st_mode)
parent_mode = stat.S_IMODE(path.parent.stat().st_mode)
assert mode == 0o600, (
f"Nous shared store mode 0o{mode:o} != 0o600 — TOCTOU race regressed"
)
assert parent_mode == 0o700, (
f"Nous shared store parent dir mode 0o{parent_mode:o} != 0o700"
)
data = json.loads(path.read_text())
assert data["refresh_token"] == "nous-refresh-xxx"
# ---------------------------------------------------------------------------
# Atomicity: verify ``os.open`` is called with an explicit 0o600 mode.
# ---------------------------------------------------------------------------
def test_save_auth_store_uses_os_open_with_0o600_mode(tmp_path, monkeypatch):
"""Regression: the writer must call ``os.open`` with an explicit restricted
mode so the file is created at 0o600 atomically closing the TOCTOU
window the previous ``Path.open('w')`` left open (fd inherited process
umask and was briefly 0o644 before post-write chmod)."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
observed_opens: list[tuple[str, int, int]] = []
real_os_open = os.open
def spying_os_open(path, flags, mode=0o777, *args, **kwargs):
observed_opens.append((str(path), flags, mode))
return real_os_open(path, flags, mode, *args, **kwargs)
with patch.object(os, "open", spying_os_open):
from hermes_cli import auth as auth_mod
auth_mod._save_auth_store(
{"version": auth_mod.AUTH_STORE_VERSION, "providers": {}}
)
auth_tmp_opens = [
(p, fl, m) for (p, fl, m) in observed_opens if "auth.json.tmp" in p
]
assert auth_tmp_opens, (
f"os.open was never called for the auth.json temp file; "
f"observed={observed_opens!r}"
)
for path, flags, mode in auth_tmp_opens:
assert flags & os.O_CREAT, f"auth.json temp open missing O_CREAT: path={path}"
assert flags & os.O_EXCL, (
f"auth.json temp open missing O_EXCL — TOCTOU-safe pattern regressed: "
f"path={path}, flags={flags}"
)
# Must be exactly S_IRUSR | S_IWUSR (0o600) — no group/other bits.
expected = stat.S_IRUSR | stat.S_IWUSR
assert mode == expected, (
f"auth.json temp open mode 0o{mode:o} != 0o{expected:o}"
f"umask would apply and potentially expose tokens"
)
@@ -0,0 +1,13 @@
"""Tests for placeholder API key detection in hermes_cli.auth."""
from hermes_cli.auth import has_usable_secret
def test_has_usable_secret_rejects_documented_placeholder_key() -> None:
"""Network-exposed API server key must reject static documentation placeholders."""
assert not has_usable_secret("your_api_key_here", min_length=8)
def test_has_usable_secret_accepts_generated_key() -> None:
"""Random-looking keys should still be accepted."""
assert has_usable_secret("b4d59f7fe8b857d0b367ef0f5710b6a4", min_length=8)
File diff suppressed because it is too large Load Diff
+301
View File
@@ -0,0 +1,301 @@
"""Tests for the auxiliary-model configuration UI in ``hermes model``.
Covers the helper functions:
- ``_save_aux_choice`` writes to config.yaml without touching main model config
- ``_reset_aux_to_auto`` clears routing fields but preserves timeouts
- ``_format_aux_current`` renders current task config for the menu
- ``_AUX_TASKS`` stays in sync with ``DEFAULT_CONFIG["auxiliary"]``
These are pure-function tests the interactive menu loops are not covered
here (they're stdin-driven curses prompts).
"""
from __future__ import annotations
import pytest
from hermes_cli.config import DEFAULT_CONFIG, load_config
from hermes_cli.main import (
_AUX_TASKS,
_format_aux_current,
_reset_aux_to_auto,
_save_aux_choice,
)
# ── Default config ──────────────────────────────────────────────────────────
def test_title_generation_present_in_default_config():
"""`title_generation` task must be defined in DEFAULT_CONFIG.
Regression for an existing gap: title_generator.py calls
``call_llm(task="title_generation", ...)`` but the task was missing
from DEFAULT_CONFIG["auxiliary"], so the config-backed timeout/provider
overrides never worked for that task.
"""
assert "title_generation" in DEFAULT_CONFIG["auxiliary"]
tg = DEFAULT_CONFIG["auxiliary"]["title_generation"]
assert tg["provider"] == "auto"
assert tg["model"] == ""
assert tg["timeout"] > 0
assert tg["extra_body"] == {}
def test_session_search_no_longer_appears_in_auxiliary_model_config():
"""session_search is a direct DB-backed tool, not an auxiliary LLM task."""
assert "session_search" not in DEFAULT_CONFIG["auxiliary"]
assert "session_search" not in {key for key, _name, _desc in _AUX_TASKS}
def test_aux_tasks_keys_all_exist_in_default_config():
"""Every task the menu offers must be defined in DEFAULT_CONFIG."""
aux_keys = {k for k, _name, _desc in _AUX_TASKS}
default_keys = set(DEFAULT_CONFIG["auxiliary"].keys())
missing = aux_keys - default_keys
assert not missing, (
f"_AUX_TASKS references tasks not in DEFAULT_CONFIG.auxiliary: {missing}"
)
# ── _format_aux_current ─────────────────────────────────────────────────────
@pytest.mark.parametrize(
"task_cfg,expected",
[
({}, "auto"),
({"provider": "", "model": ""}, "auto"),
({"provider": "auto", "model": ""}, "auto"),
({"provider": "auto", "model": "gpt-4o"}, "auto · gpt-4o"),
({"provider": "openrouter", "model": ""}, "openrouter"),
(
{"provider": "openrouter", "model": "google/gemini-2.5-flash"},
"openrouter · google/gemini-2.5-flash",
),
({"provider": "nous", "model": "gemini-3-flash"}, "nous · gemini-3-flash"),
(
{"provider": "custom", "base_url": "http://localhost:11434/v1", "model": ""},
"custom (localhost:11434/v1)",
),
(
{
"provider": "custom",
"base_url": "http://localhost:11434/v1/",
"model": "qwen2.5:32b",
},
"custom (localhost:11434/v1) · qwen2.5:32b",
),
],
)
def test_format_aux_current(task_cfg, expected):
assert _format_aux_current(task_cfg) == expected
def test_format_aux_current_handles_non_dict():
assert _format_aux_current(None) == "auto"
assert _format_aux_current("string") == "auto"
# ── _save_aux_choice ────────────────────────────────────────────────────────
def test_save_aux_choice_persists_to_config_yaml(tmp_path, monkeypatch):
"""Saving a task writes provider/model/base_url/api_key to auxiliary.<task>."""
from pathlib import Path
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
(tmp_path / ".hermes").mkdir(exist_ok=True)
_save_aux_choice(
"vision", provider="openrouter", model="google/gemini-2.5-flash",
)
cfg = load_config()
v = cfg["auxiliary"]["vision"]
assert v["provider"] == "openrouter"
assert v["model"] == "google/gemini-2.5-flash"
assert v["base_url"] == ""
assert v["api_key"] == ""
def test_save_aux_choice_preserves_timeout(tmp_path, monkeypatch):
"""Saving must NOT clobber user-tuned timeout values."""
from pathlib import Path
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
(tmp_path / ".hermes").mkdir(exist_ok=True)
# Default vision timeout is 120
cfg_before = load_config()
default_timeout = cfg_before["auxiliary"]["vision"]["timeout"]
assert default_timeout == 120
_save_aux_choice("vision", provider="nous", model="gemini-3-flash")
cfg_after = load_config()
assert cfg_after["auxiliary"]["vision"]["timeout"] == default_timeout
# download_timeout also preserved for vision
assert cfg_after["auxiliary"]["vision"].get("download_timeout") == 30
def test_save_aux_choice_does_not_touch_main_model(tmp_path, monkeypatch):
"""Aux config must never mutate model.default / model.provider / model.base_url."""
from pathlib import Path
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
(tmp_path / ".hermes").mkdir(exist_ok=True)
# Simulate a configured main model
from hermes_cli.config import save_config
cfg = load_config()
cfg["model"] = {
"default": "claude-sonnet-4.6",
"provider": "anthropic",
"base_url": "",
}
save_config(cfg)
_save_aux_choice(
"compression", provider="custom",
base_url="http://localhost:11434/v1", model="qwen2.5:32b",
)
cfg = load_config()
# Main model untouched
assert cfg["model"]["default"] == "claude-sonnet-4.6"
assert cfg["model"]["provider"] == "anthropic"
# Aux saved correctly
c = cfg["auxiliary"]["compression"]
assert c["provider"] == "custom"
assert c["model"] == "qwen2.5:32b"
assert c["base_url"] == "http://localhost:11434/v1"
def test_save_aux_choice_creates_missing_task_entry(tmp_path, monkeypatch):
"""Saving a task that was wiped from config.yaml should recreate it."""
from pathlib import Path
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
(tmp_path / ".hermes").mkdir(exist_ok=True)
# Remove vision from config entirely
from hermes_cli.config import save_config
cfg = load_config()
cfg.setdefault("auxiliary", {}).pop("vision", None)
save_config(cfg)
_save_aux_choice("vision", provider="nous", model="gemini-3-flash")
cfg = load_config()
assert cfg["auxiliary"]["vision"]["provider"] == "nous"
assert cfg["auxiliary"]["vision"]["model"] == "gemini-3-flash"
# ── _reset_aux_to_auto ──────────────────────────────────────────────────────
def test_reset_aux_to_auto_clears_routing_preserves_timeouts(tmp_path, monkeypatch):
from pathlib import Path
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
(tmp_path / ".hermes").mkdir(exist_ok=True)
# Configure two tasks non-auto, and bump a timeout
_save_aux_choice("vision", provider="openrouter", model="gpt-4o")
_save_aux_choice("compression", provider="nous", model="gemini-3-flash")
from hermes_cli.config import save_config
cfg = load_config()
cfg["auxiliary"]["vision"]["timeout"] = 300 # user-tuned
save_config(cfg)
n = _reset_aux_to_auto()
assert n == 2 # both changed
cfg = load_config()
for task in ("vision", "compression"):
v = cfg["auxiliary"][task]
assert v["provider"] == "auto"
assert v["model"] == ""
assert v["base_url"] == ""
assert v["api_key"] == ""
# User-tuned timeout survives reset
assert cfg["auxiliary"]["vision"]["timeout"] == 300
# Default compression timeout preserved
assert cfg["auxiliary"]["compression"]["timeout"] == 120
def test_reset_aux_to_auto_idempotent(tmp_path, monkeypatch):
"""Second reset on already-auto config returns 0 without errors."""
from pathlib import Path
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
(tmp_path / ".hermes").mkdir(exist_ok=True)
assert _reset_aux_to_auto() == 0
_save_aux_choice("vision", provider="nous", model="gemini-3-flash")
assert _reset_aux_to_auto() == 1
assert _reset_aux_to_auto() == 0
# ── Menu dispatch ───────────────────────────────────────────────────────────
def test_select_provider_and_model_dispatches_to_aux_menu(tmp_path, monkeypatch):
"""Picking 'Configure auxiliary models...' in the provider list calls _aux_config_menu."""
from pathlib import Path
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
(tmp_path / ".hermes").mkdir(exist_ok=True)
from hermes_cli import main as main_mod
called = {"aux": 0, "flow": 0}
def fake_prompt(choices, *, default=0):
# Find the aux-config entry by its label text and return its index
for i, label in enumerate(choices):
if "Configure auxiliary models" in label:
return i
raise AssertionError("aux entry not in provider list")
monkeypatch.setattr(main_mod, "_prompt_provider_choice", fake_prompt)
monkeypatch.setattr(main_mod, "_aux_config_menu", lambda: called.__setitem__("aux", called["aux"] + 1))
# Guard against any main flow accidentally running
monkeypatch.setattr(main_mod, "_model_flow_openrouter",
lambda *a, **kw: called.__setitem__("flow", called["flow"] + 1))
main_mod.select_provider_and_model()
assert called["aux"] == 1, "aux menu not invoked"
assert called["flow"] == 0, "main provider flow should not run"
def test_leave_unchanged_replaces_cancel_label(tmp_path, monkeypatch):
"""The bottom cancel entry now reads 'Leave unchanged' (UX polish)."""
from pathlib import Path
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
(tmp_path / ".hermes").mkdir(exist_ok=True)
from hermes_cli import main as main_mod
captured: list[list[str]] = []
def fake_prompt(choices, *, default=0):
captured.append(list(choices))
# Pick 'Leave unchanged' (last item) to exit cleanly
for i, label in enumerate(choices):
if label == "Leave unchanged":
return i
raise AssertionError("Leave unchanged not in provider list")
monkeypatch.setattr(main_mod, "_prompt_provider_choice", fake_prompt)
main_mod.select_provider_and_model()
assert captured, "provider menu never rendered"
labels = captured[0]
assert "Leave unchanged" in labels
assert "Cancel" not in labels, "Cancel label should be replaced"
assert any("Configure auxiliary models" in label for label in labels)
+237
View File
@@ -0,0 +1,237 @@
"""Tests for hermes_cli.azure_detect — transport & model auto-detection."""
from __future__ import annotations
import json
from unittest.mock import MagicMock, patch
import pytest
from hermes_cli import azure_detect
# ----------------------------------------------------------------------
# Helpers
# ----------------------------------------------------------------------
class _FakeHTTPResponse:
"""Minimal stand-in for urllib.request.urlopen's context manager."""
def __init__(self, status: int, body: bytes):
self.status = status
self._body = body
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def read(self) -> bytes:
return self._body
def _openai_models_body(*ids: str) -> bytes:
return json.dumps({
"object": "list",
"data": [{"id": i, "object": "model"} for i in ids],
}).encode()
def _anthropic_error_body(msg: str = "model not found") -> bytes:
return json.dumps({
"type": "error",
"error": {"type": "invalid_request_error", "message": msg},
}).encode()
# ----------------------------------------------------------------------
# _looks_like_anthropic_path
# ----------------------------------------------------------------------
@pytest.mark.parametrize("url, expected", [
("https://foo.services.ai.azure.com/anthropic", True),
("https://foo.services.ai.azure.com/anthropic/", True),
("https://foo.services.ai.azure.com/anthropic/v1", True),
("https://foo.openai.azure.com/openai/v1", False),
("https://foo.openai.azure.com/", False),
("https://openrouter.ai/api/v1", False),
])
def test_looks_like_anthropic_path(url, expected):
assert azure_detect._looks_like_anthropic_path(url) is expected
# ----------------------------------------------------------------------
# _extract_model_ids
# ----------------------------------------------------------------------
def test_extract_model_ids_openai_shape():
body = {
"object": "list",
"data": [
{"id": "gpt-4.1-mini", "object": "model"},
{"id": "claude-sonnet-4-6", "object": "model"},
],
}
assert azure_detect._extract_model_ids(body) == ["gpt-4.1-mini", "claude-sonnet-4-6"]
def test_extract_model_ids_bad_shape_returns_empty():
assert azure_detect._extract_model_ids({}) == []
assert azure_detect._extract_model_ids({"data": "not-a-list"}) == []
assert azure_detect._extract_model_ids({"data": [{"no-id": True}]}) == []
# ----------------------------------------------------------------------
# detect() integration
# ----------------------------------------------------------------------
def test_detect_anthropic_path_wins_without_http():
"""URL path sniff short-circuits — no HTTP call happens."""
with patch.object(azure_detect, "_http_get_json") as fake_get, \
patch.object(azure_detect, "_probe_anthropic_messages") as fake_probe:
result = azure_detect.detect(
"https://foo.services.ai.azure.com/anthropic", "key-abc",
)
assert result.api_mode == "anthropic_messages"
assert result.is_anthropic is True
assert "path" in result.reason.lower()
fake_get.assert_not_called()
fake_probe.assert_not_called()
def test_detect_openai_models_probe_success():
"""/models probe returning a model list → chat_completions."""
def _fake_get(url, api_key, timeout=6.0, **kwargs):
assert "key-abc" == api_key
return 200, json.loads(_openai_models_body("gpt-5.4", "claude-opus-4-6"))
with patch.object(azure_detect, "_http_get_json", side_effect=_fake_get):
result = azure_detect.detect(
"https://my.openai.azure.com/openai/v1", "key-abc",
)
assert result.api_mode == "chat_completions"
assert result.models_probe_ok is True
assert result.models == ["gpt-5.4", "claude-opus-4-6"]
assert "/models" in result.reason
def test_detect_openai_models_probe_empty_list_still_counts():
"""Endpoint returned OpenAI shape but no models → still chat_completions."""
def _fake_get(url, api_key, timeout=6.0, **kwargs):
return 200, {"object": "list", "data": []}
with patch.object(azure_detect, "_http_get_json", side_effect=_fake_get):
result = azure_detect.detect(
"https://my.openai.azure.com/openai/v1", "key-abc",
)
assert result.api_mode == "chat_completions"
assert result.models == []
assert result.models_probe_ok is True
def test_detect_falls_back_to_anthropic_probe():
"""/models fails but Anthropic Messages probe succeeds."""
def _fake_get(url, api_key, timeout=6.0, **kwargs):
return 401, None # /models forbidden
with patch.object(azure_detect, "_http_get_json", side_effect=_fake_get), \
patch.object(azure_detect, "_probe_anthropic_messages", return_value=True):
result = azure_detect.detect(
"https://my.services.ai.azure.com/v1", "key-abc",
)
assert result.api_mode == "anthropic_messages"
assert result.is_anthropic is True
def test_detect_all_probes_fail_returns_none():
"""Every probe fails → api_mode is None and caller falls back to manual."""
with patch.object(azure_detect, "_http_get_json", return_value=(500, None)), \
patch.object(azure_detect, "_probe_anthropic_messages", return_value=False):
result = azure_detect.detect(
"https://some-private.example.com/", "key-abc",
)
assert result.api_mode is None
assert result.models == []
assert "manual" in result.reason.lower()
# ----------------------------------------------------------------------
# _probe_openai_models URL list (Azure vs v1 api-version)
# ----------------------------------------------------------------------
def test_probe_openai_models_tries_multiple_api_versions():
"""First call (no api-version) fails, api-version fallback succeeds."""
calls = []
def _fake_get(url, api_key, timeout=6.0, **kwargs):
calls.append(url)
if "api-version" not in url:
return 404, None
return 200, json.loads(_openai_models_body("gpt-4.1"))
with patch.object(azure_detect, "_http_get_json", side_effect=_fake_get):
ok, models = azure_detect._probe_openai_models(
"https://my.openai.azure.com/openai/v1", "k",
)
assert ok is True
assert models == ["gpt-4.1"]
# Should have tried without api-version first, then with at least one
assert any("api-version" not in u for u in calls)
assert any("api-version" in u for u in calls)
# ----------------------------------------------------------------------
# _http_get_json error handling
# ----------------------------------------------------------------------
def test_http_get_json_on_urlerror_returns_zero_none():
"""Network failure returns (0, None), never raises."""
import urllib.error
with patch("hermes_cli.azure_detect.urllib_request.urlopen",
side_effect=urllib.error.URLError("dns fail")):
status, body = azure_detect._http_get_json("https://bad.example/", "k")
assert status == 0
assert body is None
def test_http_get_json_on_http_error_returns_code_none():
"""HTTP 4xx/5xx returns (code, None)."""
import urllib.error
err = urllib.error.HTTPError("https://x/", 403, "Forbidden", {}, None)
with patch("hermes_cli.azure_detect.urllib_request.urlopen", side_effect=err):
status, body = azure_detect._http_get_json("https://x/", "k")
assert status == 403
assert body is None
# ----------------------------------------------------------------------
# lookup_context_length
# ----------------------------------------------------------------------
def test_lookup_context_length_returns_known():
"""When model_metadata returns a non-fallback value, we pass it through."""
fake = MagicMock(return_value=400000)
with patch("agent.model_metadata.get_model_context_length", fake), \
patch("agent.model_metadata.DEFAULT_FALLBACK_CONTEXT", 128000):
n = azure_detect.lookup_context_length(
"gpt-5.4", "https://x.openai.azure.com/openai/v1", "k",
)
assert n == 400000
def test_lookup_context_length_returns_none_on_fallback():
"""When resolver falls through to DEFAULT_FALLBACK_CONTEXT, we return None."""
with patch("agent.model_metadata.get_model_context_length", return_value=128000), \
patch("agent.model_metadata.DEFAULT_FALLBACK_CONTEXT", 128000):
n = azure_detect.lookup_context_length(
"totally-unknown-model", "https://x.openai.azure.com/openai/v1", "k",
)
assert n is None
def test_lookup_context_length_swallows_exceptions():
"""Resolver raising must not crash the wizard."""
with patch("agent.model_metadata.get_model_context_length",
side_effect=RuntimeError("boom")):
assert azure_detect.lookup_context_length("m", "https://x/", "k") is None
@@ -0,0 +1,403 @@
"""Tests for Azure Foundry Entra ID runtime resolution.
Covers the contract introduced in PR for Microsoft Entra ID auth on
``azure-foundry``:
* ``_resolve_azure_foundry_runtime`` returns a callable ``api_key`` for
``model.auth_mode = entra_id`` (OpenAI-style only).
* Anthropic-style endpoints with ``auth_mode = entra_id`` return the same
callable runtime credential as OpenAI-style endpoints.
* The legacy ``api_key`` path is unchanged when ``auth_mode`` is absent
or set to ``api_key``.
* Explicit ``--api-key`` overrides at runtime still work in entra mode
(escape hatch for one-off testing).
* ``model.entra.scope`` propagates to the token-provider config; Azure
identity selection stays in standard AZURE_* env vars.
* ``_get_azure_foundry_auth_status`` is structural never mints a
token (verified by checking the credential cache untouched).
* ``has_usable_secret`` for ``AZURE_FOUNDRY_API_KEY`` is irrelevant
when ``auth_mode == entra_id``.
"""
from __future__ import annotations
import sys
from types import SimpleNamespace
from typing import cast
import pytest
@pytest.fixture(autouse=True)
def _reset_credential_cache():
from agent.azure_identity_adapter import reset_credential_cache
reset_credential_cache()
yield
reset_credential_cache()
@pytest.fixture
def fake_azure_identity(monkeypatch):
"""Identical fake to test_azure_identity_adapter — keeps Azure SDK
out of these tests so they run in CI without the package installed."""
from agent import azure_identity_adapter as _adapter
last = {"scope": None, "kwargs": None, "credential_count": 0}
def _provider(scope):
return lambda: f"jwt-for-{scope}"
fake_module = SimpleNamespace(
DefaultAzureCredential=lambda **kw: SimpleNamespace(
kwargs=kw,
get_token=lambda scope: SimpleNamespace(token="fake", expires_on=9999999999),
),
get_bearer_token_provider=lambda credential, scope: (
last.__setitem__("scope", scope),
last.__setitem__("kwargs", credential.kwargs),
last.__setitem__("credential_count", cast(int, last["credential_count"]) + 1),
_provider(scope),
)[-1],
)
monkeypatch.setattr(_adapter, "_require_azure_identity", lambda: fake_module)
monkeypatch.setitem(sys.modules, "azure.identity", fake_module)
return last
# ---------------------------------------------------------------------------
# _resolve_azure_foundry_runtime: entra_id branch
# ---------------------------------------------------------------------------
class TestResolveAzureFoundryRuntimeEntra:
def test_returns_callable_api_key_for_entra(self, fake_azure_identity):
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
runtime = _resolve_azure_foundry_runtime(
requested_provider="azure-foundry",
model_cfg={
"provider": "azure-foundry",
"base_url": "https://my-resource.openai.azure.com/openai/v1",
"api_mode": "chat_completions",
"auth_mode": "entra_id",
"default": "gpt-4o", # stays on chat_completions (no codex auto-upgrade)
},
)
assert runtime["provider"] == "azure-foundry"
assert runtime["auth_mode"] == "entra_id"
assert runtime["api_mode"] == "chat_completions"
assert callable(runtime["api_key"])
assert runtime["source"] == "entra_id"
def test_entra_inherits_codex_responses_for_gpt5_family(self, fake_azure_identity):
"""GPT-5.x / o-series / codex models on Azure are Responses-API-only.
The runtime auto-upgrades api_mode regardless of auth mode this is
the same behaviour as the static-key path (see
``hermes_cli/models.py::azure_foundry_model_api_mode``)."""
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
runtime = _resolve_azure_foundry_runtime(
requested_provider="azure-foundry",
model_cfg={
"provider": "azure-foundry",
"base_url": "https://my-resource.openai.azure.com/openai/v1",
"api_mode": "chat_completions",
"auth_mode": "entra_id",
"default": "gpt-5.4",
},
)
# GPT-5.x is upgraded to codex_responses — Entra path inherits.
assert runtime["api_mode"] == "codex_responses"
assert callable(runtime["api_key"])
assert runtime["auth_mode"] == "entra_id"
def test_entra_propagates_scope_only(self, fake_azure_identity):
"""``model.entra.scope`` is the only Hermes-managed Azure SDK
setting. Identity selection (client ID, tenant, authority,
service principal secret, federated token file) flows through
standard ``AZURE_*`` env vars read by azure-identity directly.
Legacy ``model.entra.client_id`` / ``tenant_id`` / ``authority``
keys in config.yaml are silently ignored."""
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
_resolve_azure_foundry_runtime(
requested_provider="azure-foundry",
model_cfg={
"provider": "azure-foundry",
"base_url": "https://my-resource.services.ai.azure.com/v1",
"api_mode": "chat_completions",
"auth_mode": "entra_id",
"entra": {
"scope": "https://custom.example/.default",
"client_id": "client-uuid",
# Legacy keys must not crash — they are accepted in
# from_dict but never propagated to the SDK.
"tenant_id": "legacy-tenant",
"authority": "https://login.microsoftonline.us",
},
},
)
assert fake_azure_identity["scope"] == "https://custom.example/.default"
kw = fake_azure_identity["kwargs"]
assert "managed_identity_client_id" not in kw
assert "workload_identity_client_id" not in kw
assert "interactive_browser_tenant_id" not in kw
assert "authority" not in kw
def test_entra_default_scope_when_unset(self, fake_azure_identity):
"""When ``model.entra.scope`` is not set, the runtime resolves
Microsoft's documented inference scope —
``https://ai.azure.com/.default`` regardless of whether the
endpoint is ``*.openai.azure.com`` or ``*.services.ai.azure.com``.
Both shapes use the SAME scope per Microsoft's docs; the
``cognitiveservices.azure.com`` scope is the control-plane
audience and is rejected for inference by newer resources."""
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
from agent.azure_identity_adapter import SCOPE_AI_AZURE_DEFAULT
_resolve_azure_foundry_runtime(
requested_provider="azure-foundry",
model_cfg={
"provider": "azure-foundry",
"base_url": "https://r.openai.azure.com/openai/v1",
"api_mode": "chat_completions",
"auth_mode": "entra_id",
},
)
assert fake_azure_identity["scope"] == SCOPE_AI_AZURE_DEFAULT
def test_entra_scope_override_wins(self, fake_azure_identity):
"""Users on sovereign clouds / unusual tenants can set
``model.entra.scope`` to override the default."""
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
_resolve_azure_foundry_runtime(
requested_provider="azure-foundry",
model_cfg={
"provider": "azure-foundry",
"base_url": "https://r.openai.azure.com/openai/v1",
"api_mode": "chat_completions",
"auth_mode": "entra_id",
"entra": {
"scope": "https://cognitiveservices.azure.com/.default",
},
},
)
assert (
fake_azure_identity["scope"]
== "https://cognitiveservices.azure.com/.default"
)
def test_entra_with_anthropic_messages_is_supported(self, fake_azure_identity):
"""Entra ID now works for both OpenAI-style and Anthropic-style
Azure Foundry endpoints. The runtime returns a callable
``api_key``; downstream
:func:`agent.anthropic_adapter.build_anthropic_client` detects
the callable and installs an httpx event hook that mints a
fresh bearer JWT per request (the Anthropic SDK does not
accept callable auth_token natively)."""
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
runtime = _resolve_azure_foundry_runtime(
requested_provider="azure-foundry",
model_cfg={
"provider": "azure-foundry",
"base_url": "https://r.services.ai.azure.com/anthropic",
"api_mode": "anthropic_messages",
"auth_mode": "entra_id",
"default": "claude-sonnet-4-5",
},
)
assert runtime["provider"] == "azure-foundry"
assert runtime["auth_mode"] == "entra_id"
assert runtime["api_mode"] == "anthropic_messages"
# Callable api_key — the anthropic_adapter detects this and
# plumbs through an httpx event hook.
assert callable(runtime["api_key"])
assert not isinstance(runtime["api_key"], str)
def test_entra_with_explicit_api_key_uses_string_escape_hatch(self, fake_azure_identity):
"""Passing --api-key on the CLI overrides the entra path so a
user can debug a single request with a static key without
editing config.yaml."""
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
runtime = _resolve_azure_foundry_runtime(
requested_provider="azure-foundry",
model_cfg={
"provider": "azure-foundry",
"base_url": "https://r.openai.azure.com/openai/v1",
"api_mode": "chat_completions",
"auth_mode": "entra_id",
},
explicit_api_key="explicit-string-key",
)
assert runtime["api_key"] == "explicit-string-key"
assert runtime["auth_mode"] == "api_key"
assert runtime["source"] == "explicit"
def test_entra_runtime_dict_keeps_only_scope_override(self, fake_azure_identity):
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
runtime = _resolve_azure_foundry_runtime(
requested_provider="azure-foundry",
model_cfg={
"provider": "azure-foundry",
"base_url": "https://r.openai.azure.com/openai/v1",
"api_mode": "chat_completions",
"auth_mode": "entra_id",
"entra": {
"scope": "https://custom.example/.default",
"client_id": "legacy-client",
},
},
)
assert runtime["entra"] == {"scope": "https://custom.example/.default"}
# ---------------------------------------------------------------------------
# _resolve_azure_foundry_runtime: legacy api_key branch (regression)
# ---------------------------------------------------------------------------
class TestResolveAzureFoundryRuntimeApiKey:
def test_default_auth_mode_uses_static_key(self, monkeypatch):
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "sk-azure-static-key")
runtime = _resolve_azure_foundry_runtime(
requested_provider="azure-foundry",
model_cfg={
"provider": "azure-foundry",
"base_url": "https://r.openai.azure.com/openai/v1",
"api_mode": "chat_completions",
},
)
assert runtime["api_key"] == "sk-azure-static-key"
assert runtime["auth_mode"] == "api_key"
assert "entra" not in runtime # only present in entra mode
def test_explicit_auth_mode_api_key(self, monkeypatch):
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "sk-static")
runtime = _resolve_azure_foundry_runtime(
requested_provider="azure-foundry",
model_cfg={
"provider": "azure-foundry",
"base_url": "https://r.openai.azure.com/openai/v1",
"api_mode": "chat_completions",
"auth_mode": "api_key",
},
)
assert runtime["api_key"] == "sk-static"
assert runtime["auth_mode"] == "api_key"
def test_anthropic_messages_strips_v1_suffix(self, monkeypatch):
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "k")
runtime = _resolve_azure_foundry_runtime(
requested_provider="azure-foundry",
model_cfg={
"provider": "azure-foundry",
"base_url": "https://r.services.ai.azure.com/anthropic/v1",
"api_mode": "anthropic_messages",
},
)
assert runtime["base_url"] == "https://r.services.ai.azure.com/anthropic"
def test_missing_api_key_raises_with_entra_hint(self, monkeypatch):
from hermes_cli.auth import AuthError
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
monkeypatch.delenv("AZURE_FOUNDRY_API_KEY", raising=False)
with pytest.raises(AuthError) as exc_info:
_resolve_azure_foundry_runtime(
requested_provider="azure-foundry",
model_cfg={
"provider": "azure-foundry",
"base_url": "https://r.openai.azure.com/openai/v1",
"api_mode": "chat_completions",
},
)
msg = str(exc_info.value)
assert "AZURE_FOUNDRY_API_KEY" in msg
# Surface the Entra alternative so users discover the keyless path.
assert "entra_id" in msg
# ---------------------------------------------------------------------------
# _get_azure_foundry_auth_status (auth.py) — never mints a token
# ---------------------------------------------------------------------------
class TestAzureFoundryAuthStatus:
def test_entra_status_does_not_mint_token(self, monkeypatch, tmp_path):
"""Structural check — must return logged_in=True based on
importable + config, never call get_bearer_token_provider."""
from hermes_cli import auth as _auth
# Force load_config to return our entra config.
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {
"model": {
"provider": "azure-foundry",
"auth_mode": "entra_id",
"base_url": "https://r.openai.azure.com/openai/v1",
},
},
)
# Patch has_azure_identity_installed to True; do NOT patch the
# token provider — if the code path tried to mint, the SDK
# missing would raise.
monkeypatch.setattr(
"agent.azure_identity_adapter.has_azure_identity_installed",
lambda: True,
)
info = _auth._get_azure_foundry_auth_status()
assert info["logged_in"] is True
assert info["auth_mode"] == "entra_id"
assert info["azure_identity_installed"] is True
assert info["scope"].endswith("/.default")
def test_entra_status_reports_missing_package(self, monkeypatch):
from hermes_cli import auth as _auth
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {
"model": {
"provider": "azure-foundry",
"auth_mode": "entra_id",
"base_url": "https://r.openai.azure.com/openai/v1",
},
},
)
monkeypatch.setattr(
"agent.azure_identity_adapter.has_azure_identity_installed",
lambda: False,
)
info = _auth._get_azure_foundry_auth_status()
assert info["logged_in"] is False
assert info["azure_identity_installed"] is False
assert "azure-identity" in info["hint"]
def test_api_key_status_uses_env_var(self, monkeypatch):
from hermes_cli import auth as _auth
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {
"model": {
"provider": "azure-foundry",
"auth_mode": "api_key",
"base_url": "https://r.openai.azure.com/openai/v1",
},
},
)
monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "sk-real-key-xxx")
info = _auth._get_azure_foundry_auth_status()
assert info["auth_mode"] == "api_key"
assert info["logged_in"] is True
def test_api_key_status_false_when_missing(self, monkeypatch):
from hermes_cli import auth as _auth
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {
"model": {
"provider": "azure-foundry",
"auth_mode": "api_key",
},
},
)
monkeypatch.delenv("AZURE_FOUNDRY_API_KEY", raising=False)
info = _auth._get_azure_foundry_auth_status()
assert info["logged_in"] is False
File diff suppressed because it is too large Load Diff
+202
View File
@@ -0,0 +1,202 @@
"""Tests for banner toolset name normalization and skin color usage."""
from unittest.mock import patch
from rich.console import Console
import hermes_cli.banner as banner
import model_tools
import tools.mcp_tool
def test_display_toolset_name_strips_legacy_suffix():
assert banner._display_toolset_name("homeassistant_tools") == "homeassistant"
assert banner._display_toolset_name("honcho_tools") == "honcho"
assert banner._display_toolset_name("web_tools") == "web"
def test_display_toolset_name_preserves_clean_names():
assert banner._display_toolset_name("browser") == "browser"
assert banner._display_toolset_name("file") == "file"
assert banner._display_toolset_name("terminal") == "terminal"
def test_display_toolset_name_handles_empty():
assert banner._display_toolset_name("") == "unknown"
assert banner._display_toolset_name(None) == "unknown"
def test_build_welcome_banner_uses_normalized_toolset_names():
"""Unavailable toolsets should not have '_tools' appended in banner output."""
with (
patch.object(
model_tools,
"check_tool_availability",
return_value=(
["web"],
[
{"name": "homeassistant", "tools": ["ha_call_service"]},
{"name": "honcho", "tools": ["honcho_conclude"]},
],
),
),
patch.object(banner, "get_available_skills", return_value={}),
patch.object(banner, "get_update_result", return_value=None),
patch.object(tools.mcp_tool, "get_mcp_status", return_value=[]),
):
console = Console(
record=True, force_terminal=False, color_system=None, width=160
)
banner.build_welcome_banner(
console=console,
model="anthropic/test-model",
cwd="/tmp/project",
tools=[
{"function": {"name": "web_search"}},
{"function": {"name": "read_file"}},
],
get_toolset_for_tool=lambda name: {
"web_search": "web_tools",
"read_file": "file",
}.get(name),
)
output = console.export_text()
assert "homeassistant:" in output
assert "honcho:" in output
assert "web:" in output
assert "homeassistant_tools:" not in output
assert "honcho_tools:" not in output
assert "web_tools:" not in output
def test_build_welcome_banner_title_is_hyperlinked_to_release():
"""Panel title (version label) is wrapped in an OSC-8 hyperlink to the GitHub release."""
import io
from unittest.mock import patch as _patch
import hermes_cli.banner as _banner
import model_tools as _mt
import tools.mcp_tool as _mcp
_banner._latest_release_cache = None
tag_url = ("v2026.4.23", "https://github.com/NousResearch/hermes-agent/releases/tag/v2026.4.23")
buf = io.StringIO()
with (
_patch.object(_mt, "check_tool_availability", return_value=(["web"], [])),
_patch.object(_banner, "get_available_skills", return_value={}),
_patch.object(_banner, "get_update_result", return_value=None),
_patch.object(_mcp, "get_mcp_status", return_value=[]),
_patch.object(_banner, "get_latest_release_tag", return_value=tag_url),
):
console = Console(file=buf, force_terminal=True, color_system="truecolor", width=160)
_banner.build_welcome_banner(
console=console, model="x", cwd="/tmp",
session_id="abc123",
tools=[{"function": {"name": "read_file"}}],
get_toolset_for_tool=lambda n: "file",
)
raw = buf.getvalue()
# The existing version label must still be present in the title
assert "Hermes Agent v" in raw, "Version label missing from title"
# OSC-8 hyperlink escape sequence present with the release URL
assert "\x1b]8;" in raw, "OSC-8 hyperlink not emitted"
assert "releases/tag/v2026.4.23" in raw, "Release URL missing from banner output"
def test_build_welcome_banner_title_falls_back_when_no_tag():
"""Without a resolvable tag, the panel title renders as plain text (no hyperlink escape)."""
import io
from unittest.mock import patch as _patch
import hermes_cli.banner as _banner
import model_tools as _mt
import tools.mcp_tool as _mcp
_banner._latest_release_cache = None
buf = io.StringIO()
with (
_patch.object(_mt, "check_tool_availability", return_value=(["web"], [])),
_patch.object(_banner, "get_available_skills", return_value={}),
_patch.object(_banner, "get_update_result", return_value=None),
_patch.object(_mcp, "get_mcp_status", return_value=[]),
_patch.object(_banner, "get_latest_release_tag", return_value=None),
):
console = Console(file=buf, force_terminal=True, color_system="truecolor", width=160)
_banner.build_welcome_banner(
console=console, model="x", cwd="/tmp",
session_id="abc123",
tools=[{"function": {"name": "read_file"}}],
get_toolset_for_tool=lambda n: "file",
)
raw = buf.getvalue()
assert "Hermes Agent v" in raw, "Version label missing from title"
assert "\x1b]8;" not in raw, "OSC-8 hyperlink should not be emitted without a tag"
def test_build_welcome_banner_disabled_mcp_shows_disabled_not_failed():
"""A disabled MCP server renders '— disabled' (dim), not '— failed' (red)."""
with (
patch.object(model_tools, "check_tool_availability", return_value=(["web"], [])),
patch.object(banner, "get_available_skills", return_value={}),
patch.object(banner, "get_update_result", return_value=None),
patch.object(
tools.mcp_tool,
"get_mcp_status",
return_value=[
{"name": "linear", "transport": "http", "tools": 0,
"connected": False, "disabled": True},
{"name": "broken", "transport": "stdio", "tools": 0,
"connected": False, "disabled": False},
],
),
):
console = Console(record=True, force_terminal=False, color_system=None, width=160)
banner.build_welcome_banner(
console=console, model="anthropic/test-model", cwd="/tmp/project",
tools=[{"function": {"name": "read_file"}}],
get_toolset_for_tool=lambda n: "file",
)
output = console.export_text()
# Disabled server is labeled "disabled", not "failed"
assert "linear" in output
assert "disabled" in output
# A genuinely unreachable server still reads "failed"
assert "broken" in output
assert "failed" in output
def test_build_welcome_banner_configured_mcp_is_not_failed():
"""A configured MCP server with no connection attempt yet is not a failure."""
with (
patch.object(model_tools, "check_tool_availability", return_value=(["web"], [])),
patch.object(banner, "get_available_skills", return_value={}),
patch.object(banner, "get_update_result", return_value=None),
patch.object(
tools.mcp_tool,
"get_mcp_status",
return_value=[
{
"name": "docker-profile",
"transport": "stdio",
"tools": 0,
"connected": False,
"disabled": False,
"status": "configured",
},
],
),
):
console = Console(record=True, force_terminal=False, color_system=None, width=160)
banner.build_welcome_banner(
console=console, model="anthropic/test-model", cwd="/tmp/project",
tools=[{"function": {"name": "read_file"}}],
get_toolset_for_tool=lambda n: "file",
)
output = console.export_text()
assert "docker-profile" in output
assert "configured" in output
assert "failed" not in output
+116
View File
@@ -0,0 +1,116 @@
from unittest.mock import MagicMock, patch
def test_format_banner_version_label_without_git_state():
from hermes_cli import banner
with patch.object(banner, "get_git_banner_state", return_value=None):
value = banner.format_banner_version_label()
assert value == f"Hermes Agent v{banner.VERSION} ({banner.RELEASE_DATE})"
def test_format_banner_version_label_on_upstream_main():
from hermes_cli import banner
with patch.object(
banner,
"get_git_banner_state",
return_value={"upstream": "b2f477a3", "local": "b2f477a3", "ahead": 0},
):
value = banner.format_banner_version_label()
assert value.endswith("· upstream b2f477a3")
assert "local" not in value
def test_format_banner_version_label_with_carried_commits():
from hermes_cli import banner
with patch.object(
banner,
"get_git_banner_state",
return_value={"upstream": "b2f477a3", "local": "af8aad31", "ahead": 3},
):
value = banner.format_banner_version_label()
assert "upstream b2f477a3" in value
assert "local af8aad31" in value
assert "+3 carried commits" in value
def test_get_git_banner_state_reads_origin_and_head(tmp_path):
from hermes_cli import banner
repo_dir = tmp_path / "repo"
(repo_dir / ".git").mkdir(parents=True)
results = {
("git", "rev-parse", "--short=8", "origin/main"): MagicMock(returncode=0, stdout="b2f477a3\n"),
("git", "rev-parse", "--short=8", "HEAD"): MagicMock(returncode=0, stdout="af8aad31\n"),
("git", "rev-list", "--count", "origin/main..HEAD"): MagicMock(returncode=0, stdout="3\n"),
}
def fake_run(cmd, **kwargs):
key = tuple(cmd)
if key not in results:
raise AssertionError(f"unexpected command: {cmd}")
return results[key]
with patch("hermes_cli.banner.subprocess.run", side_effect=fake_run):
state = banner.get_git_banner_state(repo_dir)
assert state == {"upstream": "b2f477a3", "local": "af8aad31", "ahead": 3}
def test_get_git_banner_state_falls_back_to_build_sha_when_no_repo():
"""Docker image case: no .git checkout — baked build SHA fills the gap.
``_resolve_repo_dir`` returns None when neither the running code's
parent nor ``$HERMES_HOME/hermes-agent/`` is a git repo (the canonical
case inside the published container, where .git is dockerignored).
The banner should still report the build SHA so support bug reports
can identify the running commit.
"""
from hermes_cli import banner
with patch.object(banner, "_resolve_repo_dir", return_value=None), \
patch("hermes_cli.build_info.get_build_sha", return_value="abcdef12"):
state = banner.get_git_banner_state()
assert state == {"upstream": "abcdef12", "local": "abcdef12", "ahead": 0}
def test_get_git_banner_state_returns_none_when_no_repo_and_no_build_sha():
"""Pip-installed wheel with neither git checkout nor baked SHA → None.
Banner correctly omits the upstream/local suffix in this case.
"""
from hermes_cli import banner
with patch.object(banner, "_resolve_repo_dir", return_value=None), \
patch("hermes_cli.build_info.get_build_sha", return_value=None):
state = banner.get_git_banner_state()
assert state is None
def test_get_git_banner_state_falls_back_when_live_git_returns_nothing(tmp_path):
"""Shallow clone without origin/main → still surface build SHA if baked.
Some install paths (e.g. ``git clone --depth 1`` without a remote) have
a ``.git`` directory but ``git rev-parse origin/main`` fails. When that
happens AND a baked SHA exists, return the baked one instead of None.
"""
from hermes_cli import banner
repo_dir = tmp_path / "repo"
(repo_dir / ".git").mkdir(parents=True)
# All git invocations fail (returncode=1, empty stdout).
failed = MagicMock(returncode=1, stdout="")
with patch("hermes_cli.banner.subprocess.run", return_value=failed), \
patch("hermes_cli.build_info.get_build_sha", return_value="cafef00d"):
state = banner.get_git_banner_state(repo_dir)
assert state == {"upstream": "cafef00d", "local": "cafef00d", "ahead": 0}
@@ -0,0 +1,35 @@
from unittest.mock import patch
def testcheck_via_pypi_detects_update():
"""check_via_pypi returns 1 when PyPI has newer version."""
from hermes_cli.banner import check_via_pypi
with patch("hermes_cli.banner.VERSION", "0.12.0"):
with patch("hermes_cli.banner._fetch_pypi_latest", return_value="0.13.0"):
result = check_via_pypi()
assert result == 1
def testcheck_via_pypi_up_to_date():
"""check_via_pypi returns 0 when versions match."""
from hermes_cli.banner import check_via_pypi
with patch("hermes_cli.banner.VERSION", "0.13.0"):
with patch("hermes_cli.banner._fetch_pypi_latest", return_value="0.13.0"):
result = check_via_pypi()
assert result == 0
def testcheck_via_pypi_network_failure():
"""check_via_pypi returns None on network error."""
from hermes_cli.banner import check_via_pypi
with patch("hermes_cli.banner._fetch_pypi_latest", return_value=None):
result = check_via_pypi()
assert result is None
def test_version_tuple_comparison():
"""Version comparison works with multi-segment versions."""
from hermes_cli.banner import _version_tuple
assert _version_tuple("0.13.0") > _version_tuple("0.12.0")
assert _version_tuple("0.13.0") == _version_tuple("0.13.0")
assert _version_tuple("1.0.0") > _version_tuple("0.99.99")
+67
View File
@@ -0,0 +1,67 @@
"""Tests for banner get_available_skills() — disabled and platform filtering."""
from unittest.mock import patch
_MOCK_SKILLS = [
{"name": "skill-a", "description": "A skill", "category": "tools"},
{"name": "skill-b", "description": "B skill", "category": "tools"},
{"name": "skill-c", "description": "C skill", "category": "creative"},
]
def test_get_available_skills_delegates_to_find_all_skills():
"""get_available_skills should call _find_all_skills (which handles filtering)."""
with patch("tools.skills_tool._find_all_skills", return_value=list(_MOCK_SKILLS)):
from hermes_cli.banner import get_available_skills
result = get_available_skills()
assert "tools" in result
assert "creative" in result
assert sorted(result["tools"]) == ["skill-a", "skill-b"]
assert result["creative"] == ["skill-c"]
def test_get_available_skills_excludes_disabled():
"""Disabled skills should not appear in the banner count."""
# _find_all_skills already filters disabled skills, so if we give it
# a filtered list, get_available_skills should reflect that.
filtered = [s for s in _MOCK_SKILLS if s["name"] != "skill-b"]
with patch("tools.skills_tool._find_all_skills", return_value=filtered):
from hermes_cli.banner import get_available_skills
result = get_available_skills()
all_names = [n for names in result.values() for n in names]
assert "skill-b" not in all_names
assert "skill-a" in all_names
assert len(all_names) == 2
def test_get_available_skills_empty_when_no_skills():
"""No skills installed returns empty dict."""
with patch("tools.skills_tool._find_all_skills", return_value=[]):
from hermes_cli.banner import get_available_skills
result = get_available_skills()
assert result == {}
def test_get_available_skills_handles_import_failure():
"""If _find_all_skills import fails, return empty dict gracefully."""
with patch("tools.skills_tool._find_all_skills", side_effect=ImportError("boom")):
from hermes_cli.banner import get_available_skills
result = get_available_skills()
assert result == {}
def test_get_available_skills_null_category_becomes_general():
"""Skills with None category should be grouped under 'general'."""
skills = [{"name": "orphan-skill", "description": "No cat", "category": None}]
with patch("tools.skills_tool._find_all_skills", return_value=skills):
from hermes_cli.banner import get_available_skills
result = get_available_skills()
assert "general" in result
assert result["general"] == ["orphan-skill"]
@@ -0,0 +1,361 @@
"""Tests for AWS Bedrock integration in the model picker and provider catalog.
Covers the three paths changed by fix/bedrock-provider-model-ids-live-discovery:
1. provider_model_ids("bedrock") uses live discover_bedrock_models() instead
of the static _PROVIDER_MODELS table, with curated fallback.
2. list_authenticated_providers() Section 2 (HERMES_OVERLAYS) bedrock
appears when AWS credentials are present; model list comes from live
discovery keyed by the resolved region, NOT the static us.* table.
3. Region resolution resolve_bedrock_region() reads from botocore profile
when no AWS_REGION / AWS_DEFAULT_REGION env vars are set, so EU/AP users
in eu-central-1 get eu.* profile IDs, not us.* ones.
All Bedrock API calls are mocked no real AWS credentials needed.
"""
from contextlib import contextmanager
from types import ModuleType
from unittest.mock import MagicMock, patch
# ---------------------------------------------------------------------------
# Shared helpers / fixtures
# ---------------------------------------------------------------------------
@contextmanager
def _mock_botocore_session(*, return_value=None):
"""Patch botocore.session even when botocore is not installed."""
botocore_mod = ModuleType("botocore")
session_mod = ModuleType("botocore.session")
session_mod.get_session = MagicMock(return_value=return_value)
botocore_mod.session = session_mod
with patch.dict("sys.modules", {"botocore": botocore_mod, "botocore.session": session_mod}):
yield session_mod.get_session
_EU_MODELS = [
{"id": "eu.anthropic.claude-sonnet-4-6-20250514-v1:0", "name": "Claude Sonnet 4.6 (EU)", "provider": "inference-profile"},
{"id": "eu.anthropic.claude-haiku-4-5-20251015-v1:0", "name": "Claude Haiku 4.5 (EU)", "provider": "inference-profile"},
{"id": "eu.amazon.nova-pro-v1:0", "name": "Nova Pro (EU)", "provider": "inference-profile"},
]
_US_MODELS = [
{"id": "us.anthropic.claude-sonnet-4-6-20250514-v1:0", "name": "Claude Sonnet 4.6 (US)", "provider": "inference-profile"},
{"id": "us.amazon.nova-pro-v1:0", "name": "Nova Pro (US)", "provider": "inference-profile"},
]
def _mock_discover(region: str):
"""Return EU models for eu-* regions, US models otherwise."""
return _EU_MODELS if region.startswith("eu-") else _US_MODELS
# ---------------------------------------------------------------------------
# 1. provider_model_ids("bedrock")
# ---------------------------------------------------------------------------
class TestProviderModelIdsBedrock:
"""provider_model_ids("bedrock") should use live Bedrock discovery."""
def test_returns_live_discovered_model_ids(self, monkeypatch):
"""Live discovery result is returned as a flat list of model ID strings."""
from hermes_cli.models import provider_model_ids
monkeypatch.setenv("AWS_REGION", "eu-central-1")
with patch("agent.bedrock_adapter.discover_bedrock_models", side_effect=_mock_discover), \
patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
result = provider_model_ids("bedrock")
assert "eu.anthropic.claude-sonnet-4-6-20250514-v1:0" in result
assert "eu.anthropic.claude-haiku-4-5-20251015-v1:0" in result
assert len(result) == len(_EU_MODELS)
def test_region_determines_model_ids(self, monkeypatch):
"""Different regions produce different model ID prefixes (eu.* vs us.*)."""
from hermes_cli.models import provider_model_ids
with patch("agent.bedrock_adapter.discover_bedrock_models", side_effect=_mock_discover):
with patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
eu_result = provider_model_ids("bedrock")
with patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="us-east-1"):
us_result = provider_model_ids("bedrock")
assert all(m.startswith("eu.") for m in eu_result)
assert all(m.startswith("us.") for m in us_result)
assert eu_result != us_result
def test_falls_back_to_static_list_when_discovery_empty(self, monkeypatch):
"""When discover_bedrock_models() returns [], fall back to curated static list."""
from hermes_cli.models import provider_model_ids
with patch("agent.bedrock_adapter.discover_bedrock_models", return_value=[]), \
patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
result = provider_model_ids("bedrock")
# Should fall back to static table (may be empty or populated depending on
# the current static list, but must not crash and must be a list).
assert isinstance(result, list)
def test_falls_back_to_static_list_on_exception(self, monkeypatch):
"""When discover_bedrock_models() raises, fall back gracefully."""
from hermes_cli.models import provider_model_ids
with patch("agent.bedrock_adapter.discover_bedrock_models",
side_effect=Exception("boto3 not installed")), \
patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
result = provider_model_ids("bedrock")
assert isinstance(result, list) # no crash
def test_accepts_bedrock_aliases(self, monkeypatch):
"""Provider aliases (aws, aws-bedrock, amazon) should also trigger live discovery."""
from hermes_cli.models import provider_model_ids
_expected_ids = [m["id"] for m in _US_MODELS]
with patch("agent.bedrock_adapter.discover_bedrock_models", side_effect=_mock_discover), \
patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="us-east-1"):
for alias in ("aws", "aws-bedrock", "amazon-bedrock"):
result = provider_model_ids(alias)
assert result == _expected_ids, \
f"alias {alias!r} should return live-discovered US model IDs, got {result!r}"
# ---------------------------------------------------------------------------
# 2. list_authenticated_providers() — bedrock via HERMES_OVERLAYS (Section 2)
# ---------------------------------------------------------------------------
class TestListAuthenticatedProvidersBedrock:
"""Bedrock should appear in the /model picker when AWS creds are present."""
def test_bedrock_appears_with_aws_profile(self, monkeypatch):
"""Bedrock shows up when AWS_PROFILE is set."""
from hermes_cli.model_switch import list_authenticated_providers
monkeypatch.setenv("AWS_PROFILE", "my-sso-profile")
monkeypatch.setenv("AWS_REGION", "eu-central-1")
with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \
patch("agent.bedrock_adapter.discover_bedrock_models", side_effect=_mock_discover), \
patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
providers = list_authenticated_providers(current_provider="bedrock")
bedrock = next((p for p in providers if p["slug"] == "bedrock"), None)
assert bedrock is not None, "bedrock should appear when AWS credentials are present"
def test_bedrock_uses_live_discovery_not_static_list(self, monkeypatch):
"""Model IDs come from discover_bedrock_models(), not the static _PROVIDER_MODELS table."""
from hermes_cli.model_switch import list_authenticated_providers
monkeypatch.setenv("AWS_PROFILE", "my-sso-profile")
with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \
patch("agent.bedrock_adapter.discover_bedrock_models", side_effect=_mock_discover), \
patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
providers = list_authenticated_providers(current_provider="bedrock")
bedrock = next((p for p in providers if p["slug"] == "bedrock"), None)
assert bedrock is not None
# All returned model IDs should have eu.* prefix — live discovery result
for model_id in bedrock["models"]:
assert model_id.startswith("eu."), \
f"Expected eu.* model ID from live discovery, got {model_id!r}"
def test_bedrock_total_models_matches_discovery(self, monkeypatch):
"""total_models reflects the actual discovered count."""
from hermes_cli.model_switch import list_authenticated_providers
monkeypatch.setenv("AWS_PROFILE", "my-sso-profile")
with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \
patch("agent.bedrock_adapter.discover_bedrock_models", return_value=_EU_MODELS), \
patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
providers = list_authenticated_providers(current_provider="openai")
bedrock = next((p for p in providers if p["slug"] == "bedrock"), None)
assert bedrock is not None
assert bedrock["total_models"] == len(_EU_MODELS)
def test_bedrock_is_current_when_selected(self, monkeypatch):
"""is_current=True when current_provider matches bedrock."""
from hermes_cli.model_switch import list_authenticated_providers
monkeypatch.setenv("AWS_PROFILE", "my-sso-profile")
with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \
patch("agent.bedrock_adapter.discover_bedrock_models", return_value=_EU_MODELS), \
patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
providers = list_authenticated_providers(current_provider="bedrock")
bedrock = next((p for p in providers if p["slug"] == "bedrock"), None)
assert bedrock is not None
assert bedrock["is_current"] is True
def test_bedrock_not_shown_without_credentials(self, monkeypatch):
"""Bedrock must not appear when no AWS credentials are present."""
from hermes_cli.model_switch import list_authenticated_providers
monkeypatch.delenv("AWS_PROFILE", raising=False)
monkeypatch.delenv("AWS_ACCESS_KEY_ID", raising=False)
monkeypatch.delenv("AWS_SECRET_ACCESS_KEY", raising=False)
monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False)
monkeypatch.delenv("AWS_WEB_IDENTITY_TOKEN_FILE", raising=False)
monkeypatch.delenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", raising=False)
with patch("agent.bedrock_adapter.has_aws_credentials", return_value=False):
providers = list_authenticated_providers(current_provider="openai")
bedrock = next((p for p in providers if p["slug"] == "bedrock"), None)
assert bedrock is None, "bedrock should NOT appear when AWS credentials are absent"
def test_non_bedrock_picker_does_not_probe_full_aws_chain(self, monkeypatch):
"""Non-Bedrock provider discovery must not touch boto3's full credential chain."""
from hermes_cli.model_switch import list_authenticated_providers
monkeypatch.delenv("AWS_PROFILE", raising=False)
monkeypatch.delenv("AWS_ACCESS_KEY_ID", raising=False)
monkeypatch.delenv("AWS_SECRET_ACCESS_KEY", raising=False)
monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False)
monkeypatch.delenv("AWS_WEB_IDENTITY_TOKEN_FILE", raising=False)
monkeypatch.delenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", raising=False)
monkeypatch.delenv("AWS_CONTAINER_CREDENTIALS_FULL_URI", raising=False)
calls = {"has_aws_credentials": 0}
def _has_aws_credentials():
calls["has_aws_credentials"] += 1
return False
with patch("agent.bedrock_adapter.has_aws_credentials", side_effect=_has_aws_credentials):
providers = list_authenticated_providers(current_provider="openrouter", max_models=0)
assert calls["has_aws_credentials"] == 0
assert all(p["slug"] != "bedrock" for p in providers)
def test_bedrock_falls_back_to_curated_when_discovery_fails(self, monkeypatch):
"""When discover_bedrock_models() raises, fall back to curated list without crashing."""
from hermes_cli.model_switch import list_authenticated_providers
monkeypatch.setenv("AWS_PROFILE", "my-sso-profile")
with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \
patch("agent.bedrock_adapter.discover_bedrock_models",
side_effect=Exception("API call failed")), \
patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
providers = list_authenticated_providers(current_provider="bedrock")
# Should not raise — bedrock entry may or may not appear depending on
# whether the curated fallback has entries, but the call must succeed.
assert isinstance(providers, list)
def test_bedrock_no_duplicate_entries(self, monkeypatch):
"""Bedrock must appear at most once — not in both Section 1 and Section 2."""
from hermes_cli.model_switch import list_authenticated_providers
monkeypatch.setenv("AWS_PROFILE", "my-sso-profile")
with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \
patch("agent.bedrock_adapter.discover_bedrock_models", return_value=_EU_MODELS), \
patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
providers = list_authenticated_providers(current_provider="bedrock")
bedrock_entries = [p for p in providers if p["slug"] == "bedrock"]
assert len(bedrock_entries) <= 1, \
f"bedrock should appear at most once, got {len(bedrock_entries)} entries"
# ---------------------------------------------------------------------------
# 3. Region routing: EU/AP users see regional model IDs
# ---------------------------------------------------------------------------
class TestBedrockRegionRouting:
"""End-to-end: region from botocore profile is used for discovery, so EU/AP
users get eu.*/ap.* model IDs rather than the hardcoded us-east-1 list."""
def test_eu_region_from_botocore_profile_yields_eu_models(self):
"""When botocore resolves eu-central-1, picker shows eu.* model IDs."""
from hermes_cli.model_switch import list_authenticated_providers
mock_session = MagicMock()
mock_session.get_config_variable.return_value = "eu-central-1"
with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \
patch("agent.bedrock_adapter.discover_bedrock_models", side_effect=_mock_discover), \
_mock_botocore_session(return_value=mock_session):
providers = list_authenticated_providers(current_provider="bedrock")
bedrock = next((p for p in providers if p["slug"] == "bedrock"), None)
assert bedrock is not None
for model_id in bedrock["models"]:
assert model_id.startswith("eu."), \
f"Expected eu.* model ID from eu-central-1 profile, got {model_id!r}"
def test_us_region_from_env_var_yields_us_models(self, monkeypatch):
"""Explicit AWS_REGION=us-east-1 returns us.* model IDs."""
from hermes_cli.model_switch import list_authenticated_providers
monkeypatch.setenv("AWS_REGION", "us-east-1")
with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \
patch("agent.bedrock_adapter.discover_bedrock_models", side_effect=_mock_discover):
providers = list_authenticated_providers(current_provider="bedrock")
bedrock = next((p for p in providers if p["slug"] == "bedrock"), None)
assert bedrock is not None
for model_id in bedrock["models"]:
assert model_id.startswith("us."), \
f"Expected us.* model ID from us-east-1, got {model_id!r}"
def test_env_var_takes_priority_over_botocore_profile(self, monkeypatch):
"""AWS_REGION env var wins over botocore profile region."""
from agent.bedrock_adapter import resolve_bedrock_region
monkeypatch.setenv("AWS_REGION", "us-west-2")
mock_session = MagicMock()
mock_session.get_config_variable.return_value = "eu-central-1"
with _mock_botocore_session(return_value=mock_session):
region = resolve_bedrock_region()
assert region == "us-west-2", "env var should override botocore profile"
# ---------------------------------------------------------------------------
# 4. providers.py overlay registration
# ---------------------------------------------------------------------------
class TestBedrockOverlayRegistration:
"""bedrock entry in HERMES_OVERLAYS is correctly configured."""
def test_bedrock_overlay_exists(self):
from hermes_cli.providers import HERMES_OVERLAYS
assert "bedrock" in HERMES_OVERLAYS
def test_bedrock_overlay_transport(self):
from hermes_cli.providers import HERMES_OVERLAYS
assert HERMES_OVERLAYS["bedrock"].transport == "bedrock_converse"
def test_bedrock_overlay_auth_type(self):
from hermes_cli.providers import HERMES_OVERLAYS
assert HERMES_OVERLAYS["bedrock"].auth_type == "aws_sdk"
def test_bedrock_label(self):
from hermes_cli.providers import get_label
label = get_label("bedrock")
assert label # non-empty
assert "bedrock" in label.lower() or "aws" in label.lower()
def test_bedrock_aliases_resolve(self):
from hermes_cli.providers import normalize_provider
for alias in ("aws", "aws-bedrock", "amazon-bedrock", "amazon"):
assert normalize_provider(alias) == "bedrock", \
f"alias {alias!r} should normalize to 'bedrock'"
+78
View File
@@ -0,0 +1,78 @@
"""Tests for hermes_cli.build_info — baked-in build SHA resolution.
The build SHA is written by the Dockerfile's ``HERMES_GIT_SHA`` build-arg
into ``<project_root>/.hermes_build_sha``. These tests cover the read-side
helper: missing file, malformed file, truncation, and error tolerance.
"""
from pathlib import Path
from unittest.mock import patch
def test_get_build_sha_returns_none_when_file_absent(tmp_path):
"""Source installs: no file present → None, callers fall back to git."""
from hermes_cli import build_info
missing = tmp_path / ".hermes_build_sha" # never created
with patch.object(build_info, "_BUILD_SHA_FILE", missing):
assert build_info.get_build_sha() is None
def test_get_build_sha_reads_baked_file(tmp_path):
"""Docker image case: file exists with full 40-char SHA → truncated to 8."""
from hermes_cli import build_info
sha_file = tmp_path / ".hermes_build_sha"
sha_file.write_text("abcdef1234567890abcdef1234567890abcdef12\n")
with patch.object(build_info, "_BUILD_SHA_FILE", sha_file):
assert build_info.get_build_sha() == "abcdef12"
def test_get_build_sha_respects_short_argument(tmp_path):
"""``short=N`` truncates to N chars; ``short<=0`` returns full SHA."""
from hermes_cli import build_info
sha_file = tmp_path / ".hermes_build_sha"
full_sha = "abcdef1234567890abcdef1234567890abcdef12"
sha_file.write_text(full_sha + "\n")
with patch.object(build_info, "_BUILD_SHA_FILE", sha_file):
assert build_info.get_build_sha(short=12) == "abcdef123456"
assert build_info.get_build_sha(short=0) == full_sha
assert build_info.get_build_sha(short=-1) == full_sha
def test_get_build_sha_strips_whitespace(tmp_path):
"""The Dockerfile uses ``printf '%s\\n'`` — strip the trailing newline."""
from hermes_cli import build_info
sha_file = tmp_path / ".hermes_build_sha"
sha_file.write_text(" abcdef1234567890\n\n")
with patch.object(build_info, "_BUILD_SHA_FILE", sha_file):
assert build_info.get_build_sha() == "abcdef12"
def test_get_build_sha_returns_none_for_empty_file(tmp_path):
"""A whitespace-only file is treated as absent."""
from hermes_cli import build_info
sha_file = tmp_path / ".hermes_build_sha"
sha_file.write_text(" \n\n")
with patch.object(build_info, "_BUILD_SHA_FILE", sha_file):
assert build_info.get_build_sha() is None
def test_get_build_sha_swallows_read_errors(tmp_path):
"""Any IO exception from the read returns None — never raises."""
from hermes_cli import build_info
sha_file = tmp_path / ".hermes_build_sha"
sha_file.write_text("abcdef1234567890\n")
with patch.object(build_info, "_BUILD_SHA_FILE", sha_file), \
patch.object(Path, "read_text", side_effect=OSError("boom")):
assert build_info.get_build_sha() is None
+92
View File
@@ -0,0 +1,92 @@
"""Tests for hermes_cli/bundles.py — the `hermes bundles` CLI subcommand."""
import argparse
import pytest
from hermes_cli.bundles import (
bundles_command,
register_cli,
)
@pytest.fixture
def bundles_env(tmp_path, monkeypatch):
bundles_dir = tmp_path / "skill-bundles"
monkeypatch.setenv("HERMES_BUNDLES_DIR", str(bundles_dir))
# Reset module-level cache between tests.
import agent.skill_bundles as mod
mod._bundles_cache = {}
mod._bundles_cache_mtime = None
return bundles_dir
def _parse(argv):
parser = argparse.ArgumentParser()
register_cli(parser)
return parser.parse_args(argv)
class TestBundlesCli:
def test_create_and_list(self, bundles_env, capsys):
args = _parse(["create", "my-bundle", "--skill", "a", "--skill", "b", "-d", "desc"])
bundles_command(args)
out = capsys.readouterr().out
assert "Created bundle" in out
# File should exist
assert (bundles_env / "my-bundle.yaml").exists()
args = _parse(["list"])
bundles_command(args)
out = capsys.readouterr().out
assert "my-bundle" in out
def test_show(self, bundles_env, capsys):
bundles_command(_parse(["create", "x", "--skill", "s1", "--skill", "s2"]))
capsys.readouterr() # clear
bundles_command(_parse(["show", "x"]))
out = capsys.readouterr().out
assert "/x" in out
assert "s1" in out
assert "s2" in out
def test_delete(self, bundles_env, capsys):
bundles_command(_parse(["create", "doomed", "--skill", "s1"]))
capsys.readouterr()
bundles_command(_parse(["delete", "doomed"]))
out = capsys.readouterr().out
assert "Deleted bundle" in out
assert not (bundles_env / "doomed.yaml").exists()
def test_create_refuses_overwrite(self, bundles_env, capsys):
bundles_command(_parse(["create", "dup", "--skill", "s1"]))
capsys.readouterr()
with pytest.raises(SystemExit) as ei:
bundles_command(_parse(["create", "dup", "--skill", "s2"]))
assert ei.value.code == 1
out = capsys.readouterr().out
assert "already exists" in out.lower() or "--force" in out.lower()
def test_create_force_overwrites(self, bundles_env, capsys):
bundles_command(_parse(["create", "dup", "--skill", "s1"]))
capsys.readouterr()
bundles_command(_parse(["create", "dup", "--skill", "s2", "--force"]))
out = capsys.readouterr().out
assert "Created bundle" in out
def test_create_requires_skills(self, bundles_env, capsys, monkeypatch):
# Simulate user pressing Ctrl-D immediately at the interactive prompt.
monkeypatch.setattr("builtins.input", lambda *_a, **_kw: (_ for _ in ()).throw(EOFError()))
with pytest.raises(SystemExit):
bundles_command(_parse(["create", "empty"]))
def test_show_missing(self, bundles_env, capsys):
with pytest.raises(SystemExit) as ei:
bundles_command(_parse(["show", "ghost"]))
assert ei.value.code == 1
def test_reload(self, bundles_env, capsys):
# Reload on an empty dir reports no changes.
bundles_command(_parse(["reload"]))
out = capsys.readouterr().out
assert "No changes" in out or "0" in out
+101
View File
@@ -0,0 +1,101 @@
import sys
def test_top_level_skills_flag_defaults_to_chat(monkeypatch):
import hermes_cli.main as main_mod
captured = {}
def fake_cmd_chat(args):
captured["skills"] = args.skills
captured["command"] = args.command
monkeypatch.setattr(main_mod, "cmd_chat", fake_cmd_chat)
monkeypatch.setattr(
sys,
"argv",
["hermes", "-s", "hermes-agent-dev,github-auth"],
)
main_mod.main()
assert captured == {
"skills": ["hermes-agent-dev,github-auth"],
"command": None,
}
def test_chat_subcommand_accepts_skills_flag(monkeypatch):
import hermes_cli.main as main_mod
captured = {}
def fake_cmd_chat(args):
captured["skills"] = args.skills
captured["query"] = args.query
monkeypatch.setattr(main_mod, "cmd_chat", fake_cmd_chat)
monkeypatch.setattr(
sys,
"argv",
["hermes", "chat", "-s", "github-auth", "-q", "hello"],
)
main_mod.main()
assert captured == {
"skills": ["github-auth"],
"query": "hello",
}
def test_chat_subcommand_accepts_image_flag(monkeypatch):
import hermes_cli.main as main_mod
captured = {}
def fake_cmd_chat(args):
captured["query"] = args.query
captured["image"] = args.image
monkeypatch.setattr(main_mod, "cmd_chat", fake_cmd_chat)
monkeypatch.setattr(
sys,
"argv",
["hermes", "chat", "-q", "hello", "--image", "~/storage/shared/Pictures/cat.png"],
)
main_mod.main()
assert captured == {
"query": "hello",
"image": "~/storage/shared/Pictures/cat.png",
}
def test_continue_worktree_and_skills_flags_work_together(monkeypatch):
import hermes_cli.main as main_mod
captured = {}
def fake_cmd_chat(args):
captured["continue_last"] = args.continue_last
captured["worktree"] = args.worktree
captured["skills"] = args.skills
captured["command"] = args.command
monkeypatch.setattr(main_mod, "cmd_chat", fake_cmd_chat)
monkeypatch.setattr(
sys,
"argv",
["hermes", "-c", "-w", "-s", "hermes-agent-dev"],
)
main_mod.main()
assert captured == {
"continue_last": True,
"worktree": True,
"skills": ["hermes-agent-dev"],
"command": "chat",
}
+799
View File
@@ -0,0 +1,799 @@
"""Tests for hermes claw commands."""
from argparse import Namespace
import subprocess
from types import ModuleType
from unittest.mock import MagicMock, patch
import pytest
from hermes_cli import claw as claw_mod
# ---------------------------------------------------------------------------
# _find_migration_script
# ---------------------------------------------------------------------------
class TestFindMigrationScript:
"""Test script discovery in known locations."""
def test_finds_project_root_script(self, tmp_path):
script = tmp_path / "openclaw_to_hermes.py"
script.write_text("# placeholder")
with patch.object(claw_mod, "_OPENCLAW_SCRIPT", script):
assert claw_mod._find_migration_script() == script
def test_finds_installed_script(self, tmp_path):
installed = tmp_path / "installed.py"
installed.write_text("# placeholder")
with (
patch.object(claw_mod, "_OPENCLAW_SCRIPT", tmp_path / "nonexistent.py"),
patch.object(claw_mod, "_OPENCLAW_SCRIPT_INSTALLED", installed),
):
assert claw_mod._find_migration_script() == installed
def test_returns_none_when_missing(self, tmp_path):
with (
patch.object(claw_mod, "_OPENCLAW_SCRIPT", tmp_path / "a.py"),
patch.object(claw_mod, "_OPENCLAW_SCRIPT_INSTALLED", tmp_path / "b.py"),
):
assert claw_mod._find_migration_script() is None
# ---------------------------------------------------------------------------
# _find_openclaw_dirs
# ---------------------------------------------------------------------------
class TestFindOpenclawDirs:
"""Test discovery of OpenClaw directories."""
def test_finds_openclaw_dir(self, tmp_path):
openclaw = tmp_path / ".openclaw"
openclaw.mkdir()
with patch("pathlib.Path.home", return_value=tmp_path):
found = claw_mod._find_openclaw_dirs()
assert openclaw in found
def test_finds_legacy_dirs(self, tmp_path):
clawdbot = tmp_path / ".clawdbot"
clawdbot.mkdir()
moltbot = tmp_path / ".moltbot"
moltbot.mkdir()
with patch("pathlib.Path.home", return_value=tmp_path):
found = claw_mod._find_openclaw_dirs()
assert len(found) == 2
assert clawdbot in found
assert moltbot in found
def test_returns_empty_when_none_exist(self, tmp_path):
with patch("pathlib.Path.home", return_value=tmp_path):
found = claw_mod._find_openclaw_dirs()
assert found == []
# ---------------------------------------------------------------------------
# _scan_workspace_state
# ---------------------------------------------------------------------------
class TestScanWorkspaceState:
"""Test scanning for workspace state files."""
def test_finds_root_state_files(self, tmp_path):
(tmp_path / "todo.json").write_text("{}")
(tmp_path / "sessions").mkdir()
findings = claw_mod._scan_workspace_state(tmp_path)
descs = [desc for _, desc in findings]
assert any("todo.json" in d for d in descs)
assert any("sessions" in d for d in descs)
def test_finds_workspace_state_files(self, tmp_path):
ws = tmp_path / "workspace"
ws.mkdir()
(ws / "todo.json").write_text("{}")
(ws / "sessions").mkdir()
findings = claw_mod._scan_workspace_state(tmp_path)
descs = [desc for _, desc in findings]
assert any("workspace/todo.json" in d for d in descs)
assert any("workspace/sessions" in d for d in descs)
def test_ignores_hidden_dirs(self, tmp_path):
scan_dir = tmp_path / "scan_target"
scan_dir.mkdir()
hidden = scan_dir / ".git"
hidden.mkdir()
(hidden / "todo.json").write_text("{}")
findings = claw_mod._scan_workspace_state(scan_dir)
assert len(findings) == 0
def test_empty_dir_returns_empty(self, tmp_path):
scan_dir = tmp_path / "scan_target"
scan_dir.mkdir()
findings = claw_mod._scan_workspace_state(scan_dir)
assert findings == []
# ---------------------------------------------------------------------------
# _archive_directory
# ---------------------------------------------------------------------------
class TestArchiveDirectory:
"""Test directory archival (rename)."""
def test_renames_to_pre_migration(self, tmp_path):
source = tmp_path / ".openclaw"
source.mkdir()
(source / "test.txt").write_text("data")
archive_path = claw_mod._archive_directory(source)
assert archive_path == tmp_path / ".openclaw.pre-migration"
assert archive_path.is_dir()
assert not source.exists()
assert (archive_path / "test.txt").read_text() == "data"
def test_adds_timestamp_when_archive_exists(self, tmp_path):
source = tmp_path / ".openclaw"
source.mkdir()
# Pre-existing archive
(tmp_path / ".openclaw.pre-migration").mkdir()
archive_path = claw_mod._archive_directory(source)
assert ".pre-migration-" in archive_path.name
assert archive_path.is_dir()
assert not source.exists()
def test_dry_run_does_not_rename(self, tmp_path):
source = tmp_path / ".openclaw"
source.mkdir()
archive_path = claw_mod._archive_directory(source, dry_run=True)
assert archive_path == tmp_path / ".openclaw.pre-migration"
assert source.is_dir() # Still exists
# ---------------------------------------------------------------------------
# claw_command routing
# ---------------------------------------------------------------------------
class TestClawCommand:
"""Test the claw_command router."""
def test_routes_to_migrate(self):
args = Namespace(claw_action="migrate", source=None, dry_run=True,
preset="full", overwrite=False, migrate_secrets=False,
workspace_target=None, skill_conflict="skip", yes=False)
with patch.object(claw_mod, "_cmd_migrate") as mock:
claw_mod.claw_command(args)
mock.assert_called_once_with(args)
def test_routes_to_cleanup(self):
args = Namespace(claw_action="cleanup", source=None, dry_run=False, yes=False)
with patch.object(claw_mod, "_cmd_cleanup") as mock:
claw_mod.claw_command(args)
mock.assert_called_once_with(args)
def test_routes_clean_alias(self):
args = Namespace(claw_action="clean", source=None, dry_run=False, yes=False)
with patch.object(claw_mod, "_cmd_cleanup") as mock:
claw_mod.claw_command(args)
mock.assert_called_once_with(args)
def test_shows_help_for_no_action(self, capsys):
args = Namespace(claw_action=None)
claw_mod.claw_command(args)
captured = capsys.readouterr()
assert "migrate" in captured.out
assert "cleanup" in captured.out
# ---------------------------------------------------------------------------
# _cmd_migrate
# ---------------------------------------------------------------------------
class TestCmdMigrate:
"""Test the migrate command handler."""
@pytest.fixture(autouse=True)
def _mock_openclaw_running(self):
with patch.object(claw_mod, "_detect_openclaw_processes", return_value=[]):
yield
def test_error_when_source_missing(self, tmp_path, capsys):
args = Namespace(
source=str(tmp_path / "nonexistent"),
dry_run=True, preset="full", overwrite=False,
migrate_secrets=False, workspace_target=None,
skill_conflict="skip", yes=False,
)
claw_mod._cmd_migrate(args)
captured = capsys.readouterr()
assert "not found" in captured.out
def test_error_when_script_missing(self, tmp_path, capsys):
openclaw_dir = tmp_path / ".openclaw"
openclaw_dir.mkdir()
args = Namespace(
source=str(openclaw_dir),
dry_run=True, preset="full", overwrite=False,
migrate_secrets=False, workspace_target=None,
skill_conflict="skip", yes=False,
)
with (
patch.object(claw_mod, "_OPENCLAW_SCRIPT", tmp_path / "a.py"),
patch.object(claw_mod, "_OPENCLAW_SCRIPT_INSTALLED", tmp_path / "b.py"),
):
claw_mod._cmd_migrate(args)
captured = capsys.readouterr()
assert "Migration script not found" in captured.out
def test_dry_run_succeeds(self, tmp_path, capsys):
openclaw_dir = tmp_path / ".openclaw"
openclaw_dir.mkdir()
script = tmp_path / "script.py"
script.write_text("# placeholder")
# Build a fake migration module
fake_mod = ModuleType("openclaw_to_hermes")
fake_mod.resolve_selected_options = MagicMock(return_value={"soul", "memory"})
fake_migrator = MagicMock()
fake_migrator.migrate.return_value = {
"summary": {"migrated": 0, "skipped": 5, "conflict": 0, "error": 0},
"items": [
{"kind": "soul", "status": "skipped", "reason": "Not found"},
],
"preset": "full",
}
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
args = Namespace(
source=str(openclaw_dir),
dry_run=True, preset="full", overwrite=False,
migrate_secrets=False, workspace_target=None,
skill_conflict="skip", yes=False,
)
with (
patch.object(claw_mod, "_find_migration_script", return_value=script),
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
patch.object(claw_mod, "get_config_path", return_value=tmp_path / "config.yaml"),
patch.object(claw_mod, "save_config"),
patch.object(claw_mod, "load_config", return_value={}),
):
claw_mod._cmd_migrate(args)
captured = capsys.readouterr()
assert "Dry Run Results" in captured.out
assert "5 skipped" in captured.out
def test_execute_with_confirmation(self, tmp_path, capsys):
openclaw_dir = tmp_path / ".openclaw"
openclaw_dir.mkdir()
config_path = tmp_path / "config.yaml"
config_path.write_text("agent:\n max_turns: 90\n")
fake_mod = ModuleType("openclaw_to_hermes")
fake_mod.resolve_selected_options = MagicMock(return_value={"soul"})
fake_migrator = MagicMock()
fake_migrator.migrate.return_value = {
"summary": {"migrated": 2, "skipped": 1, "conflict": 0, "error": 0},
"items": [
{"kind": "soul", "status": "migrated", "destination": str(tmp_path / "SOUL.md")},
{"kind": "memory", "status": "migrated", "destination": str(tmp_path / "memories/MEMORY.md")},
],
}
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
args = Namespace(
source=str(openclaw_dir),
dry_run=False, preset="user-data", overwrite=False,
migrate_secrets=False, workspace_target=None,
skill_conflict="skip", yes=False,
)
mock_stdin = MagicMock()
mock_stdin.isatty.return_value = True
with (
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
patch.object(claw_mod, "get_config_path", return_value=config_path),
patch.object(claw_mod, "prompt_yes_no", return_value=True),
patch("sys.stdin", mock_stdin),
):
claw_mod._cmd_migrate(args)
captured = capsys.readouterr()
assert "Migration Results" in captured.out
assert "Migration complete!" in captured.out
def test_dry_run_does_not_touch_source(self, tmp_path, capsys):
"""Dry run should not modify the source directory."""
openclaw_dir = tmp_path / ".openclaw"
openclaw_dir.mkdir()
fake_mod = ModuleType("openclaw_to_hermes")
fake_mod.resolve_selected_options = MagicMock(return_value=set())
fake_migrator = MagicMock()
fake_migrator.migrate.return_value = {
"summary": {"migrated": 2, "skipped": 0, "conflict": 0, "error": 0},
"items": [],
"preset": "full",
}
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
args = Namespace(
source=str(openclaw_dir),
dry_run=True, preset="full", overwrite=False,
migrate_secrets=False, workspace_target=None,
skill_conflict="skip", yes=False,
)
with (
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
patch.object(claw_mod, "get_config_path", return_value=tmp_path / "config.yaml"),
patch.object(claw_mod, "save_config"),
patch.object(claw_mod, "load_config", return_value={}),
):
claw_mod._cmd_migrate(args)
assert openclaw_dir.is_dir() # Source untouched
def test_execute_cancelled_by_user(self, tmp_path, capsys):
openclaw_dir = tmp_path / ".openclaw"
openclaw_dir.mkdir()
config_path = tmp_path / "config.yaml"
config_path.write_text("")
# Preview must succeed before the confirmation prompt is shown
fake_mod = ModuleType("openclaw_to_hermes")
fake_mod.resolve_selected_options = MagicMock(return_value=set())
fake_migrator = MagicMock()
fake_migrator.migrate.return_value = {
"summary": {"migrated": 1, "skipped": 0, "conflict": 0, "error": 0},
"items": [{"kind": "soul", "status": "migrated", "source": "s", "destination": "d", "reason": ""}],
}
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
args = Namespace(
source=str(openclaw_dir),
dry_run=False, preset="full", overwrite=False,
migrate_secrets=False, workspace_target=None,
skill_conflict="skip", yes=False,
)
mock_stdin = MagicMock()
mock_stdin.isatty.return_value = True
with (
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
patch.object(claw_mod, "get_config_path", return_value=config_path),
patch.object(claw_mod, "prompt_yes_no", return_value=False),
patch("sys.stdin", mock_stdin),
):
claw_mod._cmd_migrate(args)
captured = capsys.readouterr()
assert "Migration cancelled" in captured.out
def test_execute_with_yes_skips_confirmation(self, tmp_path, capsys):
openclaw_dir = tmp_path / ".openclaw"
openclaw_dir.mkdir()
config_path = tmp_path / "config.yaml"
config_path.write_text("")
fake_mod = ModuleType("openclaw_to_hermes")
fake_mod.resolve_selected_options = MagicMock(return_value=set())
fake_migrator = MagicMock()
fake_migrator.migrate.return_value = {
"summary": {"migrated": 0, "skipped": 0, "conflict": 0, "error": 0},
"items": [],
}
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
args = Namespace(
source=str(openclaw_dir),
dry_run=False, preset="full", overwrite=False,
migrate_secrets=False, workspace_target=None,
skill_conflict="skip", yes=True,
)
with (
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
patch.object(claw_mod, "get_config_path", return_value=config_path),
patch.object(claw_mod, "prompt_yes_no") as mock_prompt,
):
claw_mod._cmd_migrate(args)
mock_prompt.assert_not_called()
def test_handles_migration_error(self, tmp_path, capsys):
openclaw_dir = tmp_path / ".openclaw"
openclaw_dir.mkdir()
config_path = tmp_path / "config.yaml"
config_path.write_text("")
args = Namespace(
source=str(openclaw_dir),
dry_run=True, preset="full", overwrite=False,
migrate_secrets=False, workspace_target=None,
skill_conflict="skip", yes=False,
)
with (
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
patch.object(claw_mod, "_load_migration_module", side_effect=RuntimeError("boom")),
patch.object(claw_mod, "get_config_path", return_value=config_path),
patch.object(claw_mod, "save_config"),
patch.object(claw_mod, "load_config", return_value={}),
):
claw_mod._cmd_migrate(args)
captured = capsys.readouterr()
assert "Could not load migration script" in captured.out
def test_full_preset_does_not_enable_secrets_silently(self, tmp_path, capsys):
"""The 'full' preset must NOT auto-enable migrate_secrets.
Users have to opt in to secret import explicitly via --migrate-secrets,
even under the 'full' preset. This mirrors OpenClaw's migrate-hermes
posture (two-phase import) and prevents a 'full' run from silently
copying API keys.
"""
openclaw_dir = tmp_path / ".openclaw"
openclaw_dir.mkdir()
fake_mod = ModuleType("openclaw_to_hermes")
fake_mod.resolve_selected_options = MagicMock(return_value=set())
fake_migrator = MagicMock()
fake_migrator.migrate.return_value = {
"summary": {"migrated": 0, "skipped": 0, "conflict": 0, "error": 0},
"items": [],
}
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
args = Namespace(
source=str(openclaw_dir),
dry_run=True, preset="full", overwrite=False,
migrate_secrets=False, # Not explicitly set by user
workspace_target=None,
skill_conflict="skip", yes=False,
no_backup=False,
)
with (
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
patch.object(claw_mod, "get_config_path", return_value=tmp_path / "config.yaml"),
patch.object(claw_mod, "save_config"),
patch.object(claw_mod, "load_config", return_value={}),
):
claw_mod._cmd_migrate(args)
# Migrator should have been called with migrate_secrets=False — the
# 'full' preset on its own no longer opts the user into secret import.
call_kwargs = fake_mod.Migrator.call_args[1]
assert call_kwargs["migrate_secrets"] is False
def test_full_preset_with_explicit_migrate_secrets_passes_through(self, tmp_path, capsys):
"""Explicit --migrate-secrets still works under --preset full."""
openclaw_dir = tmp_path / ".openclaw"
openclaw_dir.mkdir()
fake_mod = ModuleType("openclaw_to_hermes")
fake_mod.resolve_selected_options = MagicMock(return_value=set())
fake_migrator = MagicMock()
fake_migrator.migrate.return_value = {
"summary": {"migrated": 0, "skipped": 0, "conflict": 0, "error": 0},
"items": [],
}
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
args = Namespace(
source=str(openclaw_dir),
dry_run=True, preset="full", overwrite=False,
migrate_secrets=True, # Explicitly requested
workspace_target=None,
skill_conflict="skip", yes=False,
no_backup=False,
)
with (
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
patch.object(claw_mod, "get_config_path", return_value=tmp_path / "config.yaml"),
patch.object(claw_mod, "save_config"),
patch.object(claw_mod, "load_config", return_value={}),
):
claw_mod._cmd_migrate(args)
call_kwargs = fake_mod.Migrator.call_args[1]
assert call_kwargs["migrate_secrets"] is True
# ---------------------------------------------------------------------------
# _cmd_cleanup
# ---------------------------------------------------------------------------
class TestCmdCleanup:
"""Test the cleanup command handler."""
@pytest.fixture(autouse=True)
def _mock_openclaw_running(self):
with patch.object(claw_mod, "_detect_openclaw_processes", return_value=[]):
yield
def test_no_dirs_found(self, tmp_path, capsys):
args = Namespace(source=None, dry_run=False, yes=False)
with patch.object(claw_mod, "_find_openclaw_dirs", return_value=[]):
claw_mod._cmd_cleanup(args)
captured = capsys.readouterr()
assert "No OpenClaw directories found" in captured.out
def test_dry_run_lists_dirs(self, tmp_path, capsys):
openclaw = tmp_path / ".openclaw"
openclaw.mkdir()
ws = openclaw / "workspace"
ws.mkdir()
(ws / "todo.json").write_text("{}")
args = Namespace(source=None, dry_run=True, yes=False)
with patch.object(claw_mod, "_find_openclaw_dirs", return_value=[openclaw]):
claw_mod._cmd_cleanup(args)
captured = capsys.readouterr()
assert "Would archive" in captured.out
assert openclaw.is_dir() # Not actually archived
def test_archives_with_yes(self, tmp_path, capsys):
openclaw = tmp_path / ".openclaw"
openclaw.mkdir()
(openclaw / "workspace").mkdir()
(openclaw / "workspace" / "todo.json").write_text("{}")
args = Namespace(source=None, dry_run=False, yes=True)
with patch.object(claw_mod, "_find_openclaw_dirs", return_value=[openclaw]):
claw_mod._cmd_cleanup(args)
captured = capsys.readouterr()
assert "Archived" in captured.out
assert "Cleaned up 1" in captured.out
assert not openclaw.exists()
assert (tmp_path / ".openclaw.pre-migration").is_dir()
def test_skips_when_user_declines(self, tmp_path, capsys):
openclaw = tmp_path / ".openclaw"
openclaw.mkdir()
mock_stdin = MagicMock()
mock_stdin.isatty.return_value = True
args = Namespace(source=None, dry_run=False, yes=False)
with (
patch.object(claw_mod, "_find_openclaw_dirs", return_value=[openclaw]),
patch.object(claw_mod, "prompt_yes_no", return_value=False),
patch("sys.stdin", mock_stdin),
):
claw_mod._cmd_cleanup(args)
captured = capsys.readouterr()
assert "Skipped" in captured.out
assert openclaw.is_dir()
def test_explicit_source(self, tmp_path, capsys):
custom_dir = tmp_path / "my-openclaw"
custom_dir.mkdir()
(custom_dir / "todo.json").write_text("{}")
args = Namespace(source=str(custom_dir), dry_run=False, yes=True)
claw_mod._cmd_cleanup(args)
captured = capsys.readouterr()
assert "Archived" in captured.out
assert not custom_dir.exists()
def test_shows_workspace_details(self, tmp_path, capsys):
openclaw = tmp_path / ".openclaw"
openclaw.mkdir()
ws = openclaw / "workspace"
ws.mkdir()
(ws / "todo.json").write_text("{}")
(ws / "SOUL.md").write_text("# Soul")
args = Namespace(source=None, dry_run=True, yes=False)
with patch.object(claw_mod, "_find_openclaw_dirs", return_value=[openclaw]):
claw_mod._cmd_cleanup(args)
captured = capsys.readouterr()
assert "workspace/" in captured.out
assert "todo.json" in captured.out
def test_handles_multiple_dirs(self, tmp_path, capsys):
openclaw = tmp_path / ".openclaw"
openclaw.mkdir()
clawdbot = tmp_path / ".clawdbot"
clawdbot.mkdir()
args = Namespace(source=None, dry_run=False, yes=True)
with patch.object(claw_mod, "_find_openclaw_dirs", return_value=[openclaw, clawdbot]):
claw_mod._cmd_cleanup(args)
captured = capsys.readouterr()
assert "Cleaned up 2" in captured.out
assert not openclaw.exists()
assert not clawdbot.exists()
# ---------------------------------------------------------------------------
# _print_migration_report
# ---------------------------------------------------------------------------
class TestPrintMigrationReport:
"""Test the report formatting function."""
def test_dry_run_report(self, capsys):
report = {
"summary": {"migrated": 2, "skipped": 1, "conflict": 1, "error": 0},
"items": [
{"kind": "soul", "status": "migrated", "destination": "/home/user/.hermes/SOUL.md"},
{"kind": "memory", "status": "migrated", "destination": "/home/user/.hermes/memories/MEMORY.md"},
{"kind": "skills", "status": "conflict", "reason": "already exists"},
{"kind": "tts-assets", "status": "skipped", "reason": "not found"},
],
"preset": "full",
}
claw_mod._print_migration_report(report, dry_run=True)
captured = capsys.readouterr()
assert "Dry Run Results" in captured.out
assert "Would migrate" in captured.out
assert "2 would migrate" in captured.out
assert "--dry-run" in captured.out
def test_execute_report(self, capsys):
report = {
"summary": {"migrated": 3, "skipped": 0, "conflict": 0, "error": 0},
"items": [
{"kind": "soul", "status": "migrated", "destination": "/home/user/.hermes/SOUL.md"},
],
"output_dir": "/home/user/.hermes/migration/openclaw/20250312T120000",
}
claw_mod._print_migration_report(report, dry_run=False)
captured = capsys.readouterr()
assert "Migration Results" in captured.out
assert "Migrated" in captured.out
assert "Full report saved to" in captured.out
def test_empty_report(self, capsys):
report = {
"summary": {"migrated": 0, "skipped": 0, "conflict": 0, "error": 0},
"items": [],
}
claw_mod._print_migration_report(report, dry_run=False)
captured = capsys.readouterr()
assert "Nothing to migrate" in captured.out
class TestDetectOpenclawProcesses:
def test_returns_match_when_pgrep_finds_openclaw(self):
with patch.object(claw_mod, "sys") as mock_sys:
mock_sys.platform = "linux"
with patch.object(claw_mod, "subprocess") as mock_subprocess:
# systemd check misses, pgrep finds openclaw
mock_subprocess.run.side_effect = [
MagicMock(returncode=1, stdout=""), # systemctl
MagicMock(returncode=0, stdout="1234\n"), # pgrep
]
mock_subprocess.TimeoutExpired = subprocess.TimeoutExpired
result = claw_mod._detect_openclaw_processes()
assert len(result) == 1
assert "1234" in result[0]
def test_returns_empty_when_pgrep_finds_nothing(self):
with patch.object(claw_mod, "sys") as mock_sys:
mock_sys.platform = "darwin"
with patch.object(claw_mod, "subprocess") as mock_subprocess:
mock_subprocess.run.side_effect = [
MagicMock(returncode=1, stdout=""), # systemctl (not found)
MagicMock(returncode=1, stdout=""), # pgrep
]
mock_subprocess.TimeoutExpired = subprocess.TimeoutExpired
result = claw_mod._detect_openclaw_processes()
assert result == []
def test_detects_systemd_service(self):
with patch.object(claw_mod, "sys") as mock_sys:
mock_sys.platform = "linux"
with patch.object(claw_mod, "subprocess") as mock_subprocess:
mock_subprocess.run.side_effect = [
MagicMock(returncode=0, stdout="active\n"), # systemctl
MagicMock(returncode=1, stdout=""), # pgrep
]
mock_subprocess.TimeoutExpired = subprocess.TimeoutExpired
result = claw_mod._detect_openclaw_processes()
assert len(result) == 1
assert "systemd" in result[0]
def test_returns_match_on_windows_when_openclaw_exe_running(self):
with patch.object(claw_mod, "sys") as mock_sys:
mock_sys.platform = "win32"
with patch.object(claw_mod, "subprocess") as mock_subprocess:
mock_subprocess.run.side_effect = [
MagicMock(returncode=0, stdout="openclaw.exe 1234 Console 1 45,056 K\n"),
]
result = claw_mod._detect_openclaw_processes()
assert len(result) >= 1
assert any("openclaw.exe" in r for r in result)
def test_returns_match_on_windows_when_node_exe_has_openclaw_in_cmdline(self):
with patch.object(claw_mod, "sys") as mock_sys:
mock_sys.platform = "win32"
with patch.object(claw_mod, "subprocess") as mock_subprocess:
mock_subprocess.run.side_effect = [
MagicMock(returncode=0, stdout=""), # tasklist openclaw.exe
MagicMock(returncode=0, stdout=""), # tasklist clawd.exe
MagicMock(returncode=0, stdout="1234\n"), # PowerShell
]
result = claw_mod._detect_openclaw_processes()
assert len(result) >= 1
assert any("node.exe" in r for r in result)
def test_returns_empty_on_windows_when_nothing_found(self):
with patch.object(claw_mod, "sys") as mock_sys:
mock_sys.platform = "win32"
with patch.object(claw_mod, "subprocess") as mock_subprocess:
mock_subprocess.run.side_effect = [
MagicMock(returncode=0, stdout=""),
MagicMock(returncode=0, stdout=""),
MagicMock(returncode=0, stdout=""),
]
result = claw_mod._detect_openclaw_processes()
assert result == []
class TestWarnIfOpenclawRunning:
def test_noop_when_not_running(self, capsys):
with patch.object(claw_mod, "_detect_openclaw_processes", return_value=[]):
claw_mod._warn_if_openclaw_running(auto_yes=False)
captured = capsys.readouterr()
assert captured.out == ""
def test_warns_and_exits_when_running_and_user_declines(self, capsys):
with patch.object(claw_mod, "_detect_openclaw_processes", return_value=["openclaw process(es) (PIDs: 1234)"]):
with patch.object(claw_mod, "prompt_yes_no", return_value=False):
with patch.object(claw_mod.sys.stdin, "isatty", return_value=True):
with pytest.raises(SystemExit) as exc_info:
claw_mod._warn_if_openclaw_running(auto_yes=False)
assert exc_info.value.code == 0
captured = capsys.readouterr()
assert "OpenClaw appears to be running" in captured.out
def test_warns_and_continues_when_running_and_user_accepts(self, capsys):
with patch.object(claw_mod, "_detect_openclaw_processes", return_value=["openclaw process(es) (PIDs: 1234)"]):
with patch.object(claw_mod, "prompt_yes_no", return_value=True):
with patch.object(claw_mod.sys.stdin, "isatty", return_value=True):
claw_mod._warn_if_openclaw_running(auto_yes=False)
captured = capsys.readouterr()
assert "OpenClaw appears to be running" in captured.out
def test_warns_and_continues_in_auto_yes_mode(self, capsys):
with patch.object(claw_mod, "_detect_openclaw_processes", return_value=["openclaw process(es) (PIDs: 1234)"]):
claw_mod._warn_if_openclaw_running(auto_yes=True)
captured = capsys.readouterr()
assert "OpenClaw appears to be running" in captured.out
def test_warns_and_continues_in_non_interactive_session(self, capsys):
with patch.object(claw_mod, "_detect_openclaw_processes", return_value=["openclaw process(es) (PIDs: 1234)"]):
with patch.object(claw_mod.sys.stdin, "isatty", return_value=False):
claw_mod._warn_if_openclaw_running(auto_yes=False)
captured = capsys.readouterr()
assert "OpenClaw appears to be running" in captured.out
assert "Non-interactive session" in captured.out
@@ -0,0 +1,74 @@
"""Tests for _clear_stale_openai_base_url() cleanup after provider switch (#5161)."""
from __future__ import annotations
from hermes_cli.config import load_config, save_config, save_env_value, get_env_value
def _write_provider(provider: str, model: str = "test-model"):
"""Helper: write a provider + model to config.yaml."""
cfg = load_config()
model_cfg = cfg.get("model", {})
if not isinstance(model_cfg, dict):
model_cfg = {}
model_cfg["provider"] = provider
model_cfg["default"] = model
cfg["model"] = model_cfg
save_config(cfg)
class TestClearStaleOpenaiBaseUrl:
"""_clear_stale_openai_base_url() removes OPENAI_BASE_URL when provider is not custom."""
def test_clears_when_provider_is_named(self, monkeypatch):
"""OPENAI_BASE_URL is cleared when config provider is a named provider."""
from hermes_cli.main import _clear_stale_openai_base_url
_write_provider("openrouter")
save_env_value("OPENAI_BASE_URL", "http://localhost:11434/v1")
_clear_stale_openai_base_url()
result = get_env_value("OPENAI_BASE_URL")
assert not result, f"Expected OPENAI_BASE_URL to be cleared, got: {result!r}"
def test_preserves_when_provider_is_custom(self, monkeypatch):
"""OPENAI_BASE_URL is NOT cleared when config provider is 'custom'."""
from hermes_cli.main import _clear_stale_openai_base_url
_write_provider("custom")
save_env_value("OPENAI_BASE_URL", "http://localhost:11434/v1")
_clear_stale_openai_base_url()
result = get_env_value("OPENAI_BASE_URL")
assert result == "http://localhost:11434/v1", \
f"Expected OPENAI_BASE_URL to be preserved, got: {result!r}"
def test_noop_when_no_openai_base_url(self, monkeypatch):
"""No error when OPENAI_BASE_URL is not set."""
from hermes_cli.main import _clear_stale_openai_base_url
_write_provider("openrouter")
# Ensure it's not set
save_env_value("OPENAI_BASE_URL", "")
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
# Should not raise
_clear_stale_openai_base_url()
def test_noop_when_provider_empty(self, monkeypatch):
"""No cleanup when provider is not set in config."""
from hermes_cli.main import _clear_stale_openai_base_url
cfg = load_config()
cfg.pop("model", None)
save_config(cfg)
save_env_value("OPENAI_BASE_URL", "http://localhost:11434/v1")
_clear_stale_openai_base_url()
result = get_env_value("OPENAI_BASE_URL")
assert result == "http://localhost:11434/v1", \
"Should not clear when provider is not configured"
@@ -0,0 +1,41 @@
from cli import HermesCLI
from hermes_cli.active_sessions import (
active_session_registry_snapshot,
try_acquire_active_session,
)
def test_cli_claim_active_session_respects_global_limit(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
cfg = {"max_concurrent_sessions": 1}
held, message = try_acquire_active_session(
session_id="held-session",
surface="tui",
config=cfg,
)
assert message is None
assert held is not None
cli = object.__new__(HermesCLI)
cli.session_id = "new-cli-session"
cli.config = cfg
cli._active_session_lease = None
printed: list[str] = []
cli._console_print = lambda text: printed.append(text)
try:
assert cli._claim_active_session("cli") is False
assert printed == [
"[bold red]Hermes is at the active session limit (1/1). "
"Try again when another session finishes.[/]"
]
held.release()
assert cli._claim_active_session("cli") is True
assert [entry["session_id"] for entry in active_session_registry_snapshot()] == [
"new-cli-session"
]
finally:
held.release()
cli._release_active_session()
+20
View File
@@ -0,0 +1,20 @@
from hermes_cli import cli_output
def test_password_prompt_uses_masked_secret_prompt(monkeypatch):
seen = {}
def fake_masked_secret_prompt(display):
seen["display"] = display
return " secret "
monkeypatch.setattr(cli_output, "masked_secret_prompt", fake_masked_secret_prompt)
assert cli_output.prompt("API key", default="old", password=True) == "secret"
assert "API key [old]" in seen["display"]
def test_empty_password_prompt_returns_default(monkeypatch):
monkeypatch.setattr(cli_output, "masked_secret_prompt", lambda _display: "")
assert cli_output.prompt("API key", default="old", password=True) == "old"
+827
View File
@@ -0,0 +1,827 @@
"""Tests for cmd_update — branch fallback when remote branch doesn't exist."""
import subprocess
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from hermes_cli.main import cmd_update, PROJECT_ROOT
def _make_run_side_effect(branch="main", verify_ok=True, commit_count="0"):
"""Build a side_effect function for subprocess.run that simulates git commands."""
def side_effect(cmd, **kwargs):
joined = " ".join(str(c) for c in cmd)
# git rev-parse --abbrev-ref HEAD (get current branch)
if "rev-parse" in joined and "--abbrev-ref" in joined:
return subprocess.CompletedProcess(cmd, 0, stdout=f"{branch}\n", stderr="")
# git rev-parse --verify origin/{branch} (check remote branch exists)
if "rev-parse" in joined and "--verify" in joined:
rc = 0 if verify_ok else 128
return subprocess.CompletedProcess(cmd, rc, stdout="", stderr="")
# git rev-list HEAD..origin/{branch} --count
if "rev-list" in joined:
return subprocess.CompletedProcess(cmd, 0, stdout=f"{commit_count}\n", stderr="")
# Fallback: return a successful CompletedProcess with empty stdout
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
return side_effect
@pytest.fixture
def mock_args():
return SimpleNamespace()
# ---------------------------------------------------------------------------
# Managed-uv compatibility for tests that patch shutil.which
# ---------------------------------------------------------------------------
# The production code now uses ``ensure_uv()`` / ``update_managed_uv()``
# instead of ``shutil.which("uv")``. Many tests in this file patch
# ``shutil.which`` to control whether uv is "available" — these autouse
# fixtures make the managed_uv functions delegate to the patched
# ``shutil.which`` so the existing test setup keeps working without
# per-test changes.
@pytest.fixture(autouse=True)
def _patch_managed_uv(request):
"""Make managed_uv helpers follow shutil.which mocking in tests."""
import shutil
# resolve_uv delegates to shutil.which("uv") so that test patches
# on shutil.which flow through naturally.
def _fake_resolve_uv():
return shutil.which("uv")
def _fake_ensure_uv():
return shutil.which("uv")
def _fake_update_managed_uv():
return None # never actually self-update in tests
with patch("hermes_cli.managed_uv.resolve_uv", side_effect=_fake_resolve_uv), \
patch("hermes_cli.managed_uv.ensure_uv", side_effect=_fake_ensure_uv), \
patch("hermes_cli.managed_uv.update_managed_uv", side_effect=_fake_update_managed_uv):
yield
class TestCmdUpdatePip:
"""Regression tests for pip-install update flows."""
@patch("shutil.which", return_value="/usr/bin/uv")
@patch("subprocess.run")
def test_update_pip_exports_virtualenv_from_sys_prefix(
self, mock_run, _mock_which, mock_args, monkeypatch
):
from hermes_cli import main as hm
mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="")
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
monkeypatch.setattr(hm.sys, "prefix", "/tmp/hermes-launcher-venv")
monkeypatch.setattr(hm.sys, "base_prefix", "/usr")
hm._cmd_update_pip(mock_args)
assert mock_run.call_count == 1
assert mock_run.call_args.args[0] == ["/usr/bin/uv", "pip", "install", "--upgrade", "hermes-agent"]
assert mock_run.call_args.kwargs["env"]["VIRTUAL_ENV"] == "/tmp/hermes-launcher-venv"
@patch("shutil.which", return_value="/usr/bin/uv")
@patch("subprocess.run")
def test_update_pip_does_not_export_virtualenv_for_system_python(
self, mock_run, _mock_which, mock_args, monkeypatch
):
from hermes_cli import main as hm
mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="")
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
monkeypatch.setattr(hm.sys, "prefix", "/usr")
monkeypatch.setattr(hm.sys, "base_prefix", "/usr")
hm._cmd_update_pip(mock_args)
assert mock_run.call_count == 1
assert "env" not in mock_run.call_args.kwargs
class TestCmdUpdateBranchFallback:
"""cmd_update falls back to main when current branch has no remote counterpart."""
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_update_falls_back_to_main_when_branch_not_on_remote(
self, mock_run, _mock_which, mock_args, capsys
):
mock_run.side_effect = _make_run_side_effect(
branch="fix/stoicneko", verify_ok=False, commit_count="3"
)
cmd_update(mock_args)
commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list]
# rev-list should use origin/main, not origin/fix/stoicneko
rev_list_cmds = [c for c in commands if "rev-list" in c]
assert len(rev_list_cmds) == 1
assert "origin/main" in rev_list_cmds[0]
assert "origin/fix/stoicneko" not in rev_list_cmds[0]
# pull should use main, not fix/stoicneko
pull_cmds = [c for c in commands if "pull" in c]
assert len(pull_cmds) == 1
assert "main" in pull_cmds[0]
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_update_uses_current_branch_when_on_remote(
self, mock_run, _mock_which, mock_args, capsys
):
mock_run.side_effect = _make_run_side_effect(
branch="main", verify_ok=True, commit_count="2"
)
cmd_update(mock_args)
commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list]
rev_list_cmds = [c for c in commands if "rev-list" in c]
assert len(rev_list_cmds) == 1
assert "origin/main" in rev_list_cmds[0]
pull_cmds = [c for c in commands if "pull" in c]
assert len(pull_cmds) == 1
assert "main" in pull_cmds[0]
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_update_already_up_to_date(
self, mock_run, _mock_which, mock_args, capsys
):
mock_run.side_effect = _make_run_side_effect(
branch="main", verify_ok=True, commit_count="0"
)
cmd_update(mock_args)
captured = capsys.readouterr()
assert "Already up to date!" in captured.out
# Should NOT have called pull
commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list]
pull_cmds = [c for c in commands if "pull" in c]
assert len(pull_cmds) == 0
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_update_on_fork_checks_upstream_when_origin_up_to_date(
self, mock_run, _mock_which, mock_args, capsys
):
"""Regression for issue #26172: forks whose local HEAD already matches
origin/main must still consult upstream/main before printing
"Already up to date!" otherwise a fork that's caught up to its own
origin but behind NousResearch/hermes-agent silently misses updates.
"""
from hermes_cli import main as hm
mock_run.side_effect = _make_run_side_effect(
branch="main", verify_ok=True, commit_count="0"
)
with patch.object(
hm,
"_get_origin_url",
return_value="https://github.com/example/hermes-agent.git",
), patch.object(hm, "_sync_with_upstream_if_needed") as sync_mock:
cmd_update(mock_args)
sync_mock.assert_called_once_with(["git"], PROJECT_ROOT)
captured = capsys.readouterr()
assert "Already up to date!" in captured.out
@patch("shutil.which")
@patch("subprocess.run")
def test_update_refreshes_repo_and_tui_node_dependencies(
self, mock_run, mock_which, mock_args
):
from hermes_cli import main as hm
mock_which.side_effect = {"uv": "/usr/bin/uv", "npm": "/usr/bin/npm"}.get
mock_run.side_effect = _make_run_side_effect(
branch="main", verify_ok=True, commit_count="1"
)
# The web UI build runs through _run_with_idle_timeout now (issue
# #33788) so it no longer appears in subprocess.run's call list.
# Mock it so the test doesn't actually shell out to ``tsc``.
import subprocess as _subprocess
build_ok = _subprocess.CompletedProcess([], 0, stdout="", stderr="")
with patch.object(hm, "_is_termux_env", return_value=False), \
patch.object(hm, "_run_with_idle_timeout", return_value=build_ok) as mock_idle:
cmd_update(mock_args)
npm_calls = [
(call.args[0], call.kwargs.get("cwd"))
for call in mock_run.call_args_list
if call.args and call.args[0][0] == "/usr/bin/npm"
]
# cmd_update runs npm commands in these locations:
# 1. repo root — root-only install (--workspaces=false)
# 2. repo root — workspace install (--workspace ui-tui --workspace web)
# 3. web/ — npm ci --silent (if lockfile not at root)
# via _build_web_ui (subprocess.run)
# 4. web/ — npm run build (_run_with_idle_timeout)
#
# With a single workspace lockfile at the repo root, the root
# install covers all workspaces. The web/ ci call runs from the
# workspace root too (parent of web_dir) when the root lockfile
# exists.
#
# The root install omits `--silent` and runs without
# `capture_output` so optional postinstall scripts (e.g.
# `@askjo/camofox-browser`'s browser-binary fetch) print progress —
# otherwise long downloads look like a hang (#18840).
root_flags = [
"/usr/bin/npm",
"ci",
"--no-fund",
"--no-audit",
"--progress=false",
"--workspaces=false",
]
ws_flags = [
"/usr/bin/npm",
"ci",
"--no-fund",
"--no-audit",
"--progress=false",
"--workspace",
"ui-tui",
"--workspace",
"web",
]
assert npm_calls[:2] == [
(root_flags, PROJECT_ROOT),
(ws_flags, PROJECT_ROOT),
]
if len(npm_calls) > 2:
# The web/ install runs from the workspace root when the root
# lockfile exists (npm workspaces hoist node_modules upward).
assert npm_calls[2:] == [
(["/usr/bin/npm", "ci", "--workspace", "web", "--silent"], PROJECT_ROOT),
]
# The web UI build itself went through the streaming helper.
mock_idle.assert_called_once()
idle_args, idle_kwargs = mock_idle.call_args
assert idle_args[0] == ["/usr/bin/npm", "run", "build"]
assert idle_kwargs["cwd"] == PROJECT_ROOT / "web"
# Regression for #18840: root npm installs must stream output
# (capture_output=False) so postinstall progress is visible
# to the user. The _build_web_ui install uses --silent and
# capture_output=True, so exclude it.
root_install_calls = [
call
for call in mock_run.call_args_list
if call.args
and call.args[0][0] == "/usr/bin/npm"
and call.args[0][1] == "ci"
and call.kwargs.get("cwd") == PROJECT_ROOT
and "--silent" not in call.args[0]
]
assert len(root_install_calls) == 2 # root-only + workspace install
for call in root_install_calls:
assert call.kwargs.get("capture_output") is False, (
"repo-root npm install must stream output "
"(no capture_output) so postinstall progress is visible"
)
def test_update_non_interactive_runs_safe_config_migrations(self, mock_args, capsys):
"""Dashboard/web updates apply non-interactive migrations before restart."""
with patch("shutil.which", return_value=None), patch(
"subprocess.run"
) as mock_run, patch("builtins.input") as mock_input, patch(
"hermes_cli.config.get_missing_env_vars", return_value=["MISSING_KEY"]
), patch(
"hermes_cli.config.get_missing_config_fields",
return_value=[{"key": "new.option", "default": True}],
), patch("hermes_cli.config.check_config_version", return_value=(1, 2)), patch(
"hermes_cli.config.migrate_config",
return_value={"env_added": [], "config_added": ["new.option"]},
), patch("hermes_cli.main.sys") as mock_sys:
mock_sys.stdin.isatty.return_value = False
mock_sys.stdout.isatty.return_value = False
mock_run.side_effect = _make_run_side_effect(
branch="main", verify_ok=True, commit_count="1"
)
cmd_update(mock_args)
mock_input.assert_not_called()
from hermes_cli.config import migrate_config
migrate_config.assert_called_once_with(interactive=False, quiet=False)
captured = capsys.readouterr()
assert "applying safe config migrations" in captured.out
assert "API keys require manual entry" in captured.out
class TestCmdUpdateMigrationPrompt:
"""The config-migration prompt names what changed and skips the prompt
entirely when only the config format version moved.
Regression guard for the contentless-prompt report (ScottFive / Tt2021):
previously the prompt printed only counts ("1 new config option") and
asked "configure them now?" even for pure version bumps, where saying
yes looked like a no-op.
"""
def test_version_bump_only_applies_silently_without_prompt(
self, mock_args, capsys
):
"""Only the version moved → apply non-interactively, never prompt."""
with patch("shutil.which", return_value=None), patch(
"subprocess.run"
) as mock_run, patch("builtins.input") as mock_input, patch(
"hermes_cli.config.get_missing_env_vars", return_value=[]
), patch(
"hermes_cli.config.get_missing_config_fields", return_value=[]
), patch(
"hermes_cli.config.check_config_version", return_value=(5, 24)
), patch(
"hermes_cli.config.migrate_config",
return_value={"env_added": [], "config_added": [], "warnings": []},
) as mock_migrate:
mock_run.side_effect = _make_run_side_effect(
branch="main", verify_ok=True, commit_count="1"
)
cmd_update(mock_args)
mock_input.assert_not_called()
mock_migrate.assert_called_once_with(interactive=False, quiet=True)
out = capsys.readouterr().out
assert "Updating config format (v5 → v24)" in out
assert "no new settings to configure" in out
# The misleading question must NOT appear for a pure version bump.
assert "configure them now" not in out.lower()
def test_new_options_are_listed_by_name_before_prompt(
self, mock_args, capsys
):
"""New env/config keys are printed by name so the user can decide."""
env_items = [
{"name": "FOO_API_KEY", "description": "Foo service API key"},
]
cfg_items = [
{"key": "display.new_widget", "description": "New config option: display.new_widget"},
]
with patch("shutil.which", return_value=None), patch(
"subprocess.run"
) as mock_run, patch("builtins.input", return_value="n"), patch(
"hermes_cli.config.get_missing_env_vars", return_value=env_items
), patch(
"hermes_cli.config.get_missing_config_fields", return_value=cfg_items
), patch(
"hermes_cli.config.check_config_version", return_value=(1, 24)
), patch(
"hermes_cli.config.migrate_config",
return_value={"env_added": [], "config_added": [], "warnings": []},
), patch("hermes_cli.main.sys") as mock_sys:
mock_sys.stdin.isatty.return_value = True
mock_sys.stdout.isatty.return_value = True
mock_run.side_effect = _make_run_side_effect(
branch="main", verify_ok=True, commit_count="1"
)
cmd_update(mock_args)
out = capsys.readouterr().out
# Names, not just counts.
assert "FOO_API_KEY" in out
assert "Foo service API key" in out
assert "display.new_widget" in out
class TestCmdUpdateProfileSkillSync:
"""cmd_update syncs bundled skills to all profiles, including the active one.
Regression guard for #16176: previously the active profile was excluded
from the seed_profile_skills loop, leaving it on stale skill content.
"""
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_active_profile_included_in_skill_sync(
self, mock_run, _mock_which, mock_args, capsys
):
from pathlib import Path
mock_run.side_effect = _make_run_side_effect(
branch="main", verify_ok=True, commit_count="1"
)
default_p = SimpleNamespace(name="default", path=Path("/fake/.hermes"))
active_p = SimpleNamespace(name="bit", path=Path("/fake/.hermes/profiles/bit"))
other_p = SimpleNamespace(name="work", path=Path("/fake/.hermes/profiles/work"))
all_profiles = [default_p, active_p, other_p]
synced_paths = []
def fake_seed(path, quiet=False):
synced_paths.append(path)
return {"copied": [], "updated": [], "user_modified": []}
empty_sync = {"copied": [], "updated": [], "user_modified": [], "cleaned": []}
with (
patch("hermes_cli.profiles.list_profiles", return_value=all_profiles),
patch("hermes_cli.profiles.seed_profile_skills", side_effect=fake_seed),
patch("tools.skills_sync.sync_skills", return_value=empty_sync),
):
cmd_update(mock_args)
assert active_p.path in synced_paths, (
f"Active profile 'bit' must be included in skill sync; got: {synced_paths}"
)
assert set(synced_paths) == {p.path for p in all_profiles}, (
f"All profiles must be synced; got: {synced_paths}"
)
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_single_profile_default_is_synced(
self, mock_run, _mock_which, mock_args, capsys
):
from pathlib import Path
mock_run.side_effect = _make_run_side_effect(
branch="main", verify_ok=True, commit_count="1"
)
default_p = SimpleNamespace(name="default", path=Path("/fake/.hermes"))
synced_paths = []
def fake_seed(path, quiet=False):
synced_paths.append(path)
return {"copied": [], "updated": [], "user_modified": []}
empty_sync = {"copied": [], "updated": [], "user_modified": [], "cleaned": []}
with (
patch("hermes_cli.profiles.list_profiles", return_value=[default_p]),
patch("hermes_cli.profiles.seed_profile_skills", side_effect=fake_seed),
patch("tools.skills_sync.sync_skills", return_value=empty_sync),
):
cmd_update(mock_args)
assert default_p.path in synced_paths
class TestCmdUpdateBranchFlag:
"""``hermes update --branch <name>`` targets the requested branch.
The CLI default stays 'main'; --branch lets callers pick a different
target without monkey-patching the implementation.
"""
def _branch_side_effect(self, current_branch, target_branch, *, checkout_fails=False, track_fails=False, commit_count="0"):
"""Mock side-effect that knows about checkout/track behavior.
- ``current_branch`` what ``git rev-parse --abbrev-ref HEAD`` returns
- ``target_branch`` passed via --branch; what we expect the code to switch to
- ``checkout_fails`` if True, ``git checkout <target>`` returns non-zero
(simulates branch absent locally; code should retry with -B)
- ``track_fails`` if True, ``git checkout -B <target> origin/<target>`` ALSO fails
(simulates branch absent on origin too)
- ``commit_count`` rev-list count returned (0 = up-to-date, >0 = behind)
"""
def side_effect(cmd, **kwargs):
joined = " ".join(str(c) for c in cmd)
if "rev-parse" in joined and "--abbrev-ref" in joined:
return subprocess.CompletedProcess(cmd, 0, stdout=f"{current_branch}\n", stderr="")
if "checkout" in joined and "-B" in joined:
rc = 128 if track_fails else 0
err = f"fatal: '{target_branch}' did not match any file(s) known to git\n" if track_fails else ""
return subprocess.CompletedProcess(cmd, rc, stdout="", stderr=err)
if "checkout" in joined and "-B" not in joined and "rev-parse" not in joined:
rc = 128 if checkout_fails else 0
err = f"error: pathspec '{target_branch}' did not match\n" if checkout_fails else ""
return subprocess.CompletedProcess(cmd, rc, stdout="", stderr=err)
if "rev-list" in joined:
return subprocess.CompletedProcess(cmd, 0, stdout=f"{commit_count}\n", stderr="")
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
return side_effect
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_branch_flag_pulls_against_named_branch(self, mock_run, _mock_which, capsys):
"""--branch bb/gui makes rev-list and pull target origin/bb/gui."""
mock_run.side_effect = self._branch_side_effect(
current_branch="bb/gui", target_branch="bb/gui", commit_count="3"
)
args = SimpleNamespace(branch="bb/gui")
cmd_update(args)
commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list]
# rev-list must compare against origin/bb/gui, not origin/main
rev_list_cmds = [c for c in commands if "rev-list" in c]
assert any("origin/bb/gui" in c for c in rev_list_cmds), rev_list_cmds
assert not any("origin/main" in c for c in rev_list_cmds), rev_list_cmds
# pull must target bb/gui
pull_cmds = [c for c in commands if "pull" in c and "ff-only" in c]
assert any("bb/gui" in c and "main" not in c.split() for c in pull_cmds), pull_cmds
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_branch_flag_defaults_to_main_when_none(self, mock_run, _mock_which, capsys):
"""No --branch (or --branch=None) preserves the historical 'main' default."""
mock_run.side_effect = self._branch_side_effect(
current_branch="main", target_branch="main", commit_count="0"
)
args = SimpleNamespace(branch=None)
cmd_update(args)
commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list]
rev_list_cmds = [c for c in commands if "rev-list" in c]
assert all("origin/main" in c for c in rev_list_cmds), rev_list_cmds
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_branch_flag_switches_from_different_branch(self, mock_run, _mock_which, capsys):
"""When HEAD is on main and --branch=bb/gui, switch to bb/gui first."""
mock_run.side_effect = self._branch_side_effect(
current_branch="main", target_branch="bb/gui", commit_count="2"
)
args = SimpleNamespace(branch="bb/gui")
cmd_update(args)
commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list]
# First checkout call should switch us to bb/gui (not -B; happy-path branch exists locally)
checkout_cmds = [c for c in commands if "checkout" in c and "rev-parse" not in c]
assert len(checkout_cmds) >= 1
assert "bb/gui" in checkout_cmds[0]
out = capsys.readouterr().out
assert "switching to bb/gui" in out
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_branch_flag_tracks_remote_when_branch_absent_locally(self, mock_run, _mock_which, capsys):
"""If local lacks the branch but origin has it, fall back to ``checkout -B``."""
mock_run.side_effect = self._branch_side_effect(
current_branch="main",
target_branch="bb/gui",
checkout_fails=True, # plain checkout fails
track_fails=False, # -B from origin/bb/gui succeeds
commit_count="2",
)
args = SimpleNamespace(branch="bb/gui")
cmd_update(args)
commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list]
# Should have BOTH a failed `checkout bb/gui` AND a successful `checkout -B bb/gui origin/bb/gui`
track_cmds = [c for c in commands if "checkout" in c and "-B" in c]
assert len(track_cmds) == 1
assert "bb/gui" in track_cmds[0]
assert "origin/bb/gui" in track_cmds[0]
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_branch_flag_fails_when_branch_missing_everywhere(self, mock_run, _mock_which, capsys):
"""If branch doesn't exist locally OR on origin, exit non-zero with clear error."""
mock_run.side_effect = self._branch_side_effect(
current_branch="main",
target_branch="nonexistent",
checkout_fails=True,
track_fails=True,
commit_count="0",
)
args = SimpleNamespace(branch="nonexistent")
with pytest.raises(SystemExit) as exc_info:
cmd_update(args)
assert exc_info.value.code == 1
out = capsys.readouterr().out
assert "does not exist locally or on origin" in out
assert "nonexistent" in out
class TestCmdUpdateCheckBranchFlag:
"""``hermes update --check --branch <name>`` honors the branch override.
The check path used to call ``git rev-list HEAD..origin/<branch> --count``
with ``check=True``. When the branch didn't exist on origin, the fetch
silently succeeded (no refspec) but rev-list exited 128 and a raw
``CalledProcessError`` propagated to the user. These tests pin the
friendlier behavior: detect-the-missing-ref before rev-list, exit 1
with a clear message.
"""
def _check_side_effect(
self,
target_branch: str,
*,
verify_ok: bool = True,
commit_count: str = "0",
upstream_fetch_ok: bool = True,
):
"""Mock side-effect for the _cmd_update_check git pipeline.
- ``target_branch`` what we expect compare ref to point at
- ``verify_ok`` if False, ``git rev-parse --verify --quiet
origin/<branch>`` fails (branch missing
on origin)
- ``commit_count`` rev-list count (0 = up-to-date)
- ``upstream_fetch_ok`` if False, ``git fetch upstream`` fails
(forces fallback to origin on branch==main)
"""
def side_effect(cmd, **kwargs):
joined = " ".join(str(c) for c in cmd)
if "fetch" in joined and "upstream" in joined:
rc = 0 if upstream_fetch_ok else 128
err = "" if upstream_fetch_ok else "fatal: 'upstream' does not appear to be a git repository\n"
return subprocess.CompletedProcess(cmd, rc, stdout="", stderr=err)
if "fetch" in joined and "origin" in joined:
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
if "rev-parse" in joined and "--verify" in joined:
rc = 0 if verify_ok else 1
return subprocess.CompletedProcess(cmd, rc, stdout="", stderr="")
if "rev-list" in joined:
return subprocess.CompletedProcess(cmd, 0, stdout=f"{commit_count}\n", stderr="")
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
return side_effect
@patch("hermes_cli.config.detect_install_method", return_value="git")
@patch("subprocess.run")
def test_check_branch_compares_against_named_origin_branch(
self, mock_run, _mock_method, capsys
):
"""--check --branch bb/gui compares against origin/bb/gui, never origin/main."""
mock_run.side_effect = self._check_side_effect(
target_branch="bb/gui", verify_ok=True, commit_count="2"
)
args = SimpleNamespace(check=True, branch="bb/gui")
cmd_update(args)
commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list]
# Non-main branch skips upstream probe entirely.
assert not any("fetch" in c and "upstream" in c for c in commands), commands
# Verify and rev-list both target origin/bb/gui.
verify_cmds = [c for c in commands if "rev-parse" in c and "--verify" in c]
assert any("origin/bb/gui" in c for c in verify_cmds), verify_cmds
rev_list_cmds = [c for c in commands if "rev-list" in c]
assert any("origin/bb/gui" in c for c in rev_list_cmds), rev_list_cmds
assert not any("origin/main" in c for c in rev_list_cmds), rev_list_cmds
@patch("hermes_cli.config.detect_install_method", return_value="git")
@patch("subprocess.run")
def test_check_branch_missing_on_origin_exits_cleanly(
self, mock_run, _mock_method, capsys
):
"""If origin/<branch> doesn't exist, surface a friendly error and exit 1.
Pre-fix this case raised CalledProcessError from rev-list's check=True
and dumped a Python traceback to stdout.
"""
mock_run.side_effect = self._check_side_effect(
target_branch="ghost", verify_ok=False
)
args = SimpleNamespace(check=True, branch="ghost")
with pytest.raises(SystemExit) as exc_info:
cmd_update(args)
assert exc_info.value.code == 1
out = capsys.readouterr().out
# No raw Python traceback.
assert "Traceback" not in out
assert "CalledProcessError" not in out
# Friendly message naming the branch.
assert "ghost" in out
assert "not found" in out
# rev-list must never have been called once verify failed.
commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list]
assert not any("rev-list" in c for c in commands), commands
@patch("hermes_cli.config.detect_install_method", return_value="git")
@patch("subprocess.run")
def test_check_default_main_still_prefers_upstream(
self, mock_run, _mock_method, capsys
):
"""No --branch (or --branch=None) preserves the upstream-then-origin probe."""
mock_run.side_effect = self._check_side_effect(
target_branch="main", verify_ok=True, commit_count="0"
)
args = SimpleNamespace(check=True, branch=None)
cmd_update(args)
commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list]
# Should have tried upstream first.
assert any("fetch" in c and "upstream" in c for c in commands), commands
# Compare ref is upstream/main (upstream fetch succeeded).
rev_list_cmds = [c for c in commands if "rev-list" in c]
assert any("upstream/main" in c for c in rev_list_cmds), rev_list_cmds
@patch("hermes_cli.config.detect_install_method", return_value="pip")
@patch("hermes_cli.banner.check_via_pypi", return_value=0)
@patch("subprocess.run")
def test_check_branch_warns_on_pypi_install(
self, mock_run, _mock_pypi, _mock_method, capsys
):
"""PyPI install + --branch=<non-main> surfaces a warning instead of silent drop."""
args = SimpleNamespace(check=True, branch="bb/gui")
cmd_update(args)
out = capsys.readouterr().out
assert "--branch is ignored for PyPI installs" in out
assert "bb/gui" in out
class TestCmdUpdateZipBranchRefusal:
"""``hermes update --branch=<non-main>`` must refuse on the ZIP fallback path.
The ZIP fallback hard-codes a GitHub archive URL for main.zip; honoring
--branch arbitrarily would require remote-branch existence checks the
fallback can't easily do. Refusing is the right move — silently lying
about which branch got installed is the bug --branch was meant to prevent.
"""
def test_zip_fallback_refuses_non_main_branch(self, capsys):
from hermes_cli.main import _update_via_zip
args = SimpleNamespace(branch="bb/gui")
with pytest.raises(SystemExit) as exc_info:
_update_via_zip(args)
assert exc_info.value.code == 1
out = capsys.readouterr().out
assert "bb/gui" in out
assert "not supported" in out
# No actual download attempted.
assert "Downloading latest version" not in out
def test_is_termux_env_true_for_termux_prefix():
from hermes_cli import main as hm
assert hm._is_termux_env({"PREFIX": "/data/data/com.termux/files/usr"}) is True
def test_is_termux_env_false_for_non_termux_prefix():
from hermes_cli import main as hm
assert hm._is_termux_env({"PREFIX": "/usr/local"}) is False
def test_load_installable_optional_extras_supports_termux_group(tmp_path, monkeypatch):
from hermes_cli import main as hm
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text(
"""
[project]
name = "x"
version = "0.0.0"
[project.optional-dependencies]
all = ["x[mcp]"]
termux-all = ["x[termux]", "x[mcp]"]
mcp = ["mcp>=1"]
termux = ["rich>=14"]
""".strip()
)
monkeypatch.setattr(hm, "PROJECT_ROOT", tmp_path)
assert hm._load_installable_optional_extras(group="all") == ["mcp"]
assert hm._load_installable_optional_extras(group="termux-all") == ["termux", "mcp"]
+185
View File
@@ -0,0 +1,185 @@
"""Tests for ``hermes update`` / ``--check`` inside the Docker container.
Background: ``.dockerignore`` excludes ``.git``, so the existing git-pull
update path can never succeed inside the published image. Before this
fix, ``hermes update`` would fall through to ``"✗ Not a git repository.
Please reinstall: curl ... install.sh"`` — that script installs a *new*
host-side Hermes, not an update to the running container, so the message
was actively misleading.
These tests pin the new behaviour: when ``detect_install_method`` reports
``"docker"`` (stamped by ``docker/stage2-hook.sh``), both the apply path
(``cmd_update``) and the check path (``_cmd_update_check``) print the
``docker pull`` guidance from ``format_docker_update_message`` and exit
with status 1, without running ``git fetch`` / ``subprocess.run``.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from hermes_cli.main import _cmd_update_check, cmd_update
# ---------- cmd_update (apply path) ----------
@patch("hermes_cli.config.is_managed", return_value=False)
@patch("hermes_cli.config.detect_install_method", return_value="docker")
@patch("subprocess.run")
def test_cmd_update_in_docker_prints_guidance_and_exits(
mock_run, _mock_method, _mock_managed, capsys
):
"""``hermes update`` inside Docker → friendly message + exit 1, no git calls."""
with pytest.raises(SystemExit) as excinfo:
cmd_update(SimpleNamespace(check=False))
assert excinfo.value.code == 1
out = capsys.readouterr().out
# Spot-check the key guidance — exhaustive wording is locked in by the
# config-module test below to keep these CLI tests resilient to copy edits.
assert "doesn't apply inside the Docker container" in out
assert "docker pull nousresearch/hermes-agent:latest" in out
# No git invocations — the early-return must beat every git command.
git_calls = [c for c in mock_run.call_args_list if c.args and c.args[0] and "git" in str(c.args[0][0])]
assert git_calls == [], f"expected no git calls, got: {git_calls}"
@patch("hermes_cli.config.is_managed", return_value=False)
@patch("hermes_cli.config.detect_install_method", return_value="docker")
@patch("subprocess.run")
def test_cmd_update_check_in_docker_prints_guidance_and_exits(
mock_run, _mock_method, _mock_managed, capsys
):
"""``hermes update --check`` inside Docker → same message + exit 1, no fetch."""
with pytest.raises(SystemExit) as excinfo:
cmd_update(SimpleNamespace(check=True, branch=None))
assert excinfo.value.code == 1
out = capsys.readouterr().out
assert "doesn't apply inside the Docker container" in out
assert "docker pull nousresearch/hermes-agent:latest" in out
git_calls = [c for c in mock_run.call_args_list if c.args and c.args[0] and "git" in str(c.args[0][0])]
assert git_calls == [], f"expected no git calls, got: {git_calls}"
@patch("hermes_cli.config.is_managed", return_value=False)
@patch("hermes_cli.config.detect_install_method", return_value="docker")
@patch("subprocess.run")
def test_cmd_update_in_docker_ignores_yes_and_force(
mock_run, _mock_method, _mock_managed, capsys
):
"""``--yes`` / ``--force`` don't bypass the Docker bail-out.
The point of the bail-out is "git pull will never work here", so even
a user trying to barge through with ``--yes --force`` should see the
docker-pull guidance.
"""
with pytest.raises(SystemExit):
cmd_update(SimpleNamespace(check=False, yes=True, force=True))
assert "docker pull" in capsys.readouterr().out
git_calls = [c for c in mock_run.call_args_list if c.args and c.args[0] and "git" in str(c.args[0][0])]
assert git_calls == []
# ---------- _cmd_update_check (check path, direct entry) ----------
@patch("hermes_cli.config.detect_install_method", return_value="docker")
@patch("subprocess.run")
def test_cmd_update_check_direct_in_docker(mock_run, _mock_method, capsys):
"""Calling ``_cmd_update_check`` directly (no apply path) also bails."""
with pytest.raises(SystemExit) as excinfo:
_cmd_update_check()
assert excinfo.value.code == 1
assert "docker pull" in capsys.readouterr().out
git_calls = [c for c in mock_run.call_args_list if c.args and c.args[0] and "git" in str(c.args[0][0])]
assert git_calls == []
# ---------- Non-Docker installs unaffected ----------
@patch("hermes_cli.config.is_managed", return_value=False)
@patch("hermes_cli.config.detect_install_method", return_value="git")
@patch(
"subprocess.run",
return_value=SimpleNamespace(returncode=0, stdout="0\n", stderr=""),
)
def test_cmd_update_on_git_install_does_not_print_docker_message(
_mock_run, _mock_method, _mock_managed, capsys
):
"""Source/git installs MUST NOT hit the Docker branch.
Regression guard: an over-eager detection refactor could accidentally
route git users through the docker-pull message. We swallow
SystemExit / unrelated errors from the rest of the update flow
those don't matter for this assertion; what matters is that the
docker text is absent.
``subprocess.run`` is mocked because the git path will otherwise shell
out to ``git fetch upstream`` / ``git fetch origin`` on CI runners
with no ``upstream`` remote configured this can hang past a timeout
depending on git's network behaviour. The stub
returns a successful CompletedProcess-shaped object with ``"0\\n"``
stdout, which both keeps the flow shell-free AND parses cleanly as
the "0 commits behind" rev-list output the check path later parses
via ``int(rev_result.stdout.strip())``.
"""
try:
cmd_update(SimpleNamespace(check=True, branch=None))
except (SystemExit, Exception):
# Update flow may exit for unrelated reasons in a stubbed env —
# that's fine; we only care about the banner not appearing.
pass
assert "doesn't apply inside the Docker container" not in capsys.readouterr().out
@patch("hermes_cli.config.detect_install_method", return_value="pip")
@patch("hermes_cli.banner.check_via_pypi", return_value=0)
def test_cmd_update_check_on_pip_install_still_uses_pypi(
_mock_pypi, _mock_method, capsys
):
"""PyPI installs route to PyPI check, not the Docker bail-out."""
_cmd_update_check()
out = capsys.readouterr().out
assert "Already up to date" in out
assert "doesn't apply inside the Docker container" not in out
# ---------- format_docker_update_message — content lock ----------
def test_format_docker_update_message_contents():
"""Lock in the high-value content of the Docker update message.
These are the bits a user actually needs to act on; if any of them
disappear in a copy edit, the message has lost its value. Specific
wording around them is free to evolve (we don't assert full text).
"""
from hermes_cli.config import format_docker_update_message
msg = format_docker_update_message()
# Primary command — the entire reason this message exists.
assert "docker pull nousresearch/hermes-agent:latest" in msg
# The four key concepts the message must cover:
assert "restart" in msg.lower(), "must explain that a restart is required"
assert "--version" in msg, "must show how to verify the new version"
assert ":latest" in msg, "must mention tag pinning caveat"
assert "HERMES_HOME" in msg or "/opt/data" in msg, (
"must address config persistence across upgrades"
)
# Acknowledges that forks exist (build-your-own-image escape hatch).
assert "fork" in msg.lower() or "Dockerfile" in msg
@@ -0,0 +1,112 @@
"""Tests for _coalesce_session_name_args — multi-word session name merging."""
from hermes_cli.main import _coalesce_session_name_args
class TestCoalesceSessionNameArgs:
"""Ensure unquoted multi-word session names are merged into one token."""
# ── -c / --continue ──────────────────────────────────────────────────
def test_continue_multiword_unquoted(self):
"""hermes -c Pokemon Agent Dev → -c 'Pokemon Agent Dev'"""
assert _coalesce_session_name_args(
["-c", "Pokemon", "Agent", "Dev"]
) == ["-c", "Pokemon Agent Dev"]
def test_continue_long_form_multiword(self):
"""hermes --continue Pokemon Agent Dev"""
assert _coalesce_session_name_args(
["--continue", "Pokemon", "Agent", "Dev"]
) == ["--continue", "Pokemon Agent Dev"]
def test_continue_single_word(self):
"""hermes -c MyProject (no merging needed)"""
assert _coalesce_session_name_args(["-c", "MyProject"]) == [
"-c",
"MyProject",
]
def test_continue_already_quoted(self):
"""hermes -c 'Pokemon Agent Dev' (shell already merged)"""
assert _coalesce_session_name_args(
["-c", "Pokemon Agent Dev"]
) == ["-c", "Pokemon Agent Dev"]
def test_continue_bare_flag(self):
"""hermes -c (no name — means 'continue latest')"""
assert _coalesce_session_name_args(["-c"]) == ["-c"]
def test_continue_followed_by_flag(self):
"""hermes -c -w (no name consumed, -w stays separate)"""
assert _coalesce_session_name_args(["-c", "-w"]) == ["-c", "-w"]
def test_continue_multiword_then_flag(self):
"""hermes -c my project -w"""
assert _coalesce_session_name_args(
["-c", "my", "project", "-w"]
) == ["-c", "my project", "-w"]
def test_continue_multiword_then_subcommand(self):
"""hermes -c my project chat -q hello"""
assert _coalesce_session_name_args(
["-c", "my", "project", "chat", "-q", "hello"]
) == ["-c", "my project", "chat", "-q", "hello"]
# ── -r / --resume ────────────────────────────────────────────────────
def test_resume_multiword(self):
"""hermes -r My Session Name"""
assert _coalesce_session_name_args(
["-r", "My", "Session", "Name"]
) == ["-r", "My Session Name"]
def test_resume_long_form_multiword(self):
"""hermes --resume My Session Name"""
assert _coalesce_session_name_args(
["--resume", "My", "Session", "Name"]
) == ["--resume", "My Session Name"]
def test_resume_multiword_then_flag(self):
"""hermes -r My Session -w"""
assert _coalesce_session_name_args(
["-r", "My", "Session", "-w"]
) == ["-r", "My Session", "-w"]
# ── combined flags ───────────────────────────────────────────────────
def test_worktree_and_continue_multiword(self):
"""hermes -w -c Pokemon Agent Dev (the original failing case)"""
assert _coalesce_session_name_args(
["-w", "-c", "Pokemon", "Agent", "Dev"]
) == ["-w", "-c", "Pokemon Agent Dev"]
def test_continue_multiword_and_worktree(self):
"""hermes -c Pokemon Agent Dev -w (order reversed)"""
assert _coalesce_session_name_args(
["-c", "Pokemon", "Agent", "Dev", "-w"]
) == ["-c", "Pokemon Agent Dev", "-w"]
# ── passthrough (no session flags) ───────────────────────────────────
def test_no_session_flags_passthrough(self):
"""hermes -w chat -q hello (nothing to merge)"""
result = _coalesce_session_name_args(["-w", "chat", "-q", "hello"])
assert result == ["-w", "chat", "-q", "hello"]
def test_empty_argv(self):
assert _coalesce_session_name_args([]) == []
# ── subcommand boundary ──────────────────────────────────────────────
def test_stops_at_sessions_subcommand(self):
"""hermes -c my project sessions list → stops before 'sessions'"""
assert _coalesce_session_name_args(
["-c", "my", "project", "sessions", "list"]
) == ["-c", "my project", "sessions", "list"]
def test_stops_at_setup_subcommand(self):
"""hermes -c my setup → 'setup' is a subcommand, not part of name"""
assert _coalesce_session_name_args(
["-c", "my", "setup"]
) == ["-c", "my", "setup"]
@@ -0,0 +1,192 @@
"""Regression tests for the /model picker's credential-discovery paths.
Covers:
- Normal path (tokens already in Hermes auth store)
- Claude Code fallback (tokens only in ~/.claude/.credentials.json)
- Negative case (no credentials anywhere)
Note: auto-import from ~/.codex/auth.json was removed in #12360 — Hermes
now owns its own openai-codex auth state, and users explicitly adopt
existing Codex CLI tokens via `hermes auth openai-codex`. The old
"Codex CLI shared file" discovery tests were removed with that change.
"""
import base64
import json
import time
from pathlib import Path
import pytest
def _make_fake_jwt(expiry_offset: int = 3600) -> str:
"""Build a fake JWT with a future expiry."""
header = base64.urlsafe_b64encode(b'{"alg":"RS256"}').rstrip(b"=").decode()
exp = int(time.time()) + expiry_offset
payload_bytes = json.dumps({"exp": exp, "sub": "test"}).encode()
payload = base64.urlsafe_b64encode(payload_bytes).rstrip(b"=").decode()
return f"{header}.{payload}.fakesig"
@pytest.fixture()
def hermes_auth_only_env(tmp_path, monkeypatch):
"""Tokens already in Hermes auth store (no Codex CLI needed)."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
# Point CODEX_HOME to nonexistent dir to prove it's not needed
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "no_codex"))
(hermes_home / "auth.json").write_text(json.dumps({
"version": 2,
"providers": {
"openai-codex": {
"tokens": {
"access_token": _make_fake_jwt(),
"refresh_token": "fake-refresh",
},
"last_refresh": "2026-04-12T00:00:00Z",
}
},
}))
for var in [
"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
"NOUS_API_KEY", "DEEPSEEK_API_KEY",
]:
monkeypatch.delenv(var, raising=False)
return hermes_home
def test_normal_path_still_works(hermes_auth_only_env):
"""openai-codex appears when tokens are already in Hermes auth store."""
from hermes_cli.model_switch import list_authenticated_providers
providers = list_authenticated_providers(
current_provider="openai-codex",
max_models=10,
)
slugs = [p["slug"] for p in providers]
assert "openai-codex" in slugs
def test_codex_picker_uses_live_codex_catalog(hermes_auth_only_env, tmp_path, monkeypatch):
"""The gateway /model picker should surface Codex CLI-only listed models."""
from hermes_cli.model_switch import list_authenticated_providers
codex_home = tmp_path / "codex-home"
codex_home.mkdir()
(codex_home / "models_cache.json").write_text(json.dumps({
"models": [
{"slug": "gpt-5.5", "priority": 0, "supported_in_api": True},
{"slug": "gpt-5.3-codex-spark", "priority": 7, "supported_in_api": False},
]
}))
monkeypatch.setenv("CODEX_HOME", str(codex_home))
# Force the cache fallback path — without this the test issues a real
# 10s HTTP probe to chatgpt.com/backend-api/codex/models which is both
# slow and non-deterministic in CI/sandboxed environments.
monkeypatch.setattr(
"hermes_cli.codex_models._fetch_models_from_api",
lambda access_token: [],
)
providers = list_authenticated_providers(
current_provider="openai-codex",
max_models=10,
)
codex = next(p for p in providers if p["slug"] == "openai-codex")
assert "gpt-5.3-codex-spark" in codex["models"]
assert codex["total_models"] == len(codex["models"])
@pytest.fixture()
def claude_code_only_env(tmp_path, monkeypatch):
"""Set up an environment where Anthropic credentials only exist in
~/.claude/.credentials.json (Claude Code) not in env vars or Hermes
auth store."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
# No Codex CLI
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "no_codex"))
(hermes_home / "auth.json").write_text(
json.dumps({"version": 2, "providers": {}})
)
# Claude Code credentials in the correct format
claude_dir = tmp_path / ".claude"
claude_dir.mkdir()
(claude_dir / ".credentials.json").write_text(json.dumps({
"claudeAiOauth": {
"accessToken": _make_fake_jwt(),
"refreshToken": "fake-refresh",
"expiresAt": int(time.time() * 1000) + 3_600_000,
}
}))
# Patch Path.home() so the adapter finds the file
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path))
for var in [
"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
"ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN",
"NOUS_API_KEY", "DEEPSEEK_API_KEY",
]:
monkeypatch.delenv(var, raising=False)
return hermes_home
def test_claude_code_file_detected_by_model_picker(claude_code_only_env):
"""anthropic should appear when credentials only exist in ~/.claude/.credentials.json."""
from hermes_cli.model_switch import list_authenticated_providers
providers = list_authenticated_providers(
current_provider="anthropic",
max_models=10,
)
slugs = [p["slug"] for p in providers]
assert "anthropic" in slugs, (
f"anthropic not found in /model picker providers: {slugs}"
)
anthropic = next(p for p in providers if p["slug"] == "anthropic")
assert anthropic["is_current"] is True
assert anthropic["total_models"] > 0
def test_no_codex_when_no_credentials(tmp_path, monkeypatch):
"""openai-codex should NOT appear when no credentials exist anywhere."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "no_codex"))
(hermes_home / "auth.json").write_text(
json.dumps({"version": 2, "providers": {}})
)
for var in [
"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
"NOUS_API_KEY", "DEEPSEEK_API_KEY", "COPILOT_GITHUB_TOKEN",
"GH_TOKEN", "GEMINI_API_KEY",
]:
monkeypatch.delenv(var, raising=False)
from hermes_cli.model_switch import list_authenticated_providers
providers = list_authenticated_providers(
current_provider="openrouter",
max_models=10,
)
slugs = [p["slug"] for p in providers]
assert "openai-codex" not in slugs, (
"openai-codex should not appear without any credentials"
)
+394
View File
@@ -0,0 +1,394 @@
import json
from unittest.mock import patch
from hermes_cli.codex_models import DEFAULT_CODEX_MODELS, get_codex_model_ids
def test_get_codex_model_ids_prioritizes_default_and_cache(tmp_path, monkeypatch):
codex_home = tmp_path / "codex-home"
codex_home.mkdir(parents=True, exist_ok=True)
(codex_home / "config.toml").write_text('model = "gpt-5.2-codex"\n')
(codex_home / "models_cache.json").write_text(
json.dumps(
{
"models": [
{"slug": "gpt-5.3-codex", "priority": 20, "supported_in_api": True},
{"slug": "gpt-5.3-codex-spark", "priority": 6, "supported_in_api": False},
{"slug": "gpt-5.1-codex", "priority": 5, "supported_in_api": True},
{"slug": "gpt-5.4", "priority": 1, "supported_in_api": True},
{"slug": "gpt-5-hidden-codex", "priority": 2, "visibility": "hidden"},
]
}
)
)
monkeypatch.setenv("CODEX_HOME", str(codex_home))
models = get_codex_model_ids()
assert models[0] == "gpt-5.2-codex"
assert "gpt-5.1-codex" in models
assert "gpt-5.3-codex" in models
# Codex CLI marks Spark unsupported in the public API, but the Codex
# backend still accepts it via the OAuth-backed CLI/Hermes route.
assert "gpt-5.3-codex-spark" in models
# Non-codex-suffixed models are included when the cache says they're available
assert "gpt-5.4" in models
assert "gpt-5.4-mini" in models
assert "gpt-5-hidden-codex" not in models
def test_setup_wizard_codex_import_resolves():
"""Regression test for #712: setup.py must import the correct function name."""
# This mirrors the exact import used in hermes_cli/setup.py line 873.
# A prior bug had 'get_codex_models' (wrong) instead of 'get_codex_model_ids'.
from hermes_cli.codex_models import get_codex_model_ids as setup_import
assert callable(setup_import)
def test_get_codex_model_ids_falls_back_to_curated_defaults(tmp_path, monkeypatch):
codex_home = tmp_path / "codex-home"
codex_home.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("CODEX_HOME", str(codex_home))
models = get_codex_model_ids()
assert models[: len(DEFAULT_CODEX_MODELS)] == DEFAULT_CODEX_MODELS
assert "gpt-5.4" in models
assert "gpt-5.3-codex-spark" in models
def test_get_codex_model_ids_adds_forward_compat_models_from_templates(monkeypatch):
monkeypatch.setattr(
"hermes_cli.codex_models._fetch_models_from_api",
lambda access_token: ["gpt-5.3-codex"],
)
models = get_codex_model_ids(access_token="codex-access-token")
# When live discovery only returns gpt-5.3-codex, forward-compat synthesis
# should surface gpt-5.5, gpt-5.4, gpt-5.4-mini, and gpt-5.3-codex-spark
# (each is templated off gpt-5.3-codex).
assert models == [
"gpt-5.3-codex",
"gpt-5.5",
"gpt-5.4-mini",
"gpt-5.4",
"gpt-5.3-codex-spark",
]
def test_fetch_from_api_keeps_supported_in_api_false_models(monkeypatch):
"""Regression: gpt-5.3-codex-spark is returned by the live Codex backend
with ``supported_in_api: false`` because it isn't in the public OpenAI
API. The Codex CLI / OAuth route still serves it for ChatGPT Pro
accounts, so we must not drop it on that flag. visibility=hidden is
the separate signal that *should* still filter entries out.
"""
import sys
from hermes_cli import codex_models
class _FakeResp:
status_code = 200
def json(self):
return {
"models": [
{"slug": "gpt-5.5", "priority": 0, "supported_in_api": True},
{"slug": "gpt-5.3-codex-spark", "priority": 7, "supported_in_api": False},
{"slug": "gpt-5-internal", "priority": 99, "visibility": "hidden"},
]
}
class _FakeHttpx:
@staticmethod
def get(url, headers=None, timeout=None):
return _FakeResp()
monkeypatch.setitem(sys.modules, "httpx", _FakeHttpx)
models = codex_models._fetch_models_from_api(access_token="tok")
assert "gpt-5.5" in models
assert "gpt-5.3-codex-spark" in models
assert "gpt-5-internal" not in models
def test_model_command_uses_runtime_access_token_for_codex_list(monkeypatch):
from hermes_cli.main import _model_flow_openai_codex
captured = {}
choices = iter(["1"])
monkeypatch.setattr("builtins.input", lambda prompt="": next(choices))
monkeypatch.setattr(
"hermes_cli.auth.get_codex_auth_status",
lambda: {"logged_in": True},
)
monkeypatch.setattr(
"hermes_cli.auth.resolve_codex_runtime_credentials",
lambda *args, **kwargs: {"api_key": "codex-access-token"},
)
def _fake_get_codex_model_ids(access_token=None):
captured["access_token"] = access_token
return ["gpt-5.2-codex", "gpt-5.2"]
def _fake_prompt_model_selection(model_ids, current_model="", **_kwargs):
captured["model_ids"] = list(model_ids)
captured["current_model"] = current_model
return None
monkeypatch.setattr(
"hermes_cli.codex_models.get_codex_model_ids",
_fake_get_codex_model_ids,
)
monkeypatch.setattr(
"hermes_cli.auth._prompt_model_selection",
_fake_prompt_model_selection,
)
_model_flow_openai_codex({}, current_model="openai/gpt-5.4")
assert captured["access_token"] == "codex-access-token"
assert captured["model_ids"] == ["gpt-5.2-codex", "gpt-5.2"]
assert captured["current_model"] == "openai/gpt-5.4"
def test_model_command_prompts_to_reuse_or_reauthenticate_codex_session(monkeypatch, capsys):
from hermes_cli.main import _model_flow_openai_codex
captured = {"login_calls": 0}
choices = iter(["2"])
monkeypatch.setattr("builtins.input", lambda prompt="": next(choices))
monkeypatch.setattr(
"hermes_cli.auth.get_codex_auth_status",
lambda: {"logged_in": True, "source": "hermes-auth-store"},
)
monkeypatch.setattr(
"hermes_cli.auth.resolve_codex_runtime_credentials",
lambda *args, **kwargs: {"api_key": "fresh-codex-token"},
)
def _fake_login(*args, force_new_login=False, **kwargs):
captured["login_calls"] += 1
captured["force_new_login"] = force_new_login
monkeypatch.setattr("hermes_cli.auth._login_openai_codex", _fake_login)
monkeypatch.setattr(
"hermes_cli.codex_models.get_codex_model_ids",
lambda access_token=None: ["gpt-5.4", "gpt-5.3-codex"],
)
monkeypatch.setattr(
"hermes_cli.auth._prompt_model_selection",
lambda model_ids, current_model="", **_kwargs: None,
)
_model_flow_openai_codex({}, current_model="gpt-5.4")
out = capsys.readouterr().out
assert "Use existing credentials" in out
assert "Reauthenticate (new OAuth login)" in out
assert captured["login_calls"] == 1
assert captured["force_new_login"] is True
def test_model_command_uses_existing_codex_session_without_relogin(monkeypatch):
from hermes_cli.main import _model_flow_openai_codex
choices = iter(["1"])
captured = {}
monkeypatch.setattr("builtins.input", lambda prompt="": next(choices))
monkeypatch.setattr(
"hermes_cli.auth.get_codex_auth_status",
lambda: {"logged_in": True, "source": "hermes-auth-store"},
)
monkeypatch.setattr(
"hermes_cli.auth.resolve_codex_runtime_credentials",
lambda *args, **kwargs: {"api_key": "existing-codex-token"},
)
def _fake_get_codex_model_ids(access_token=None):
captured["access_token"] = access_token
return ["gpt-5.4"]
monkeypatch.setattr(
"hermes_cli.codex_models.get_codex_model_ids",
_fake_get_codex_model_ids,
)
monkeypatch.setattr(
"hermes_cli.auth._prompt_model_selection",
lambda model_ids, current_model="", **_kwargs: None,
)
monkeypatch.setattr(
"hermes_cli.auth._login_openai_codex",
lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("should not reauthenticate")),
)
_model_flow_openai_codex({}, current_model="gpt-5.4")
assert captured["access_token"] == "existing-codex-token"
# ── Tests for _normalize_model_for_provider ──────────────────────────
def _make_cli(model="anthropic/claude-opus-4.6", **kwargs):
"""Create a HermesCLI with minimal mocking."""
import cli as _cli_mod
from cli import HermesCLI
_clean_config = {
"model": {
"default": "anthropic/claude-opus-4.6",
"base_url": "https://openrouter.ai/api/v1",
"provider": "auto",
},
"display": {"compact": False, "tool_progress": "all", "resume_display": "full"},
"agent": {},
"terminal": {"env_type": "local"},
}
clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}
with (
patch("cli.get_tool_definitions", return_value=[]),
patch.dict("os.environ", clean_env, clear=False),
patch.dict(_cli_mod.__dict__, {"CLI_CONFIG": _clean_config}),
):
cli = HermesCLI(model=model, **kwargs)
return cli
class TestNormalizeModelForProvider:
"""_normalize_model_for_provider() trusts user-selected models.
Only two things happen:
1. Provider prefixes are stripped (API needs bare slugs)
2. The *untouched default* model is swapped for a Codex model
Everything else passes through the API is the judge.
"""
def test_non_codex_provider_is_noop(self):
cli = _make_cli(model="gpt-5.4")
changed = cli._normalize_model_for_provider("openrouter")
assert changed is False
assert cli.model == "gpt-5.4"
def test_native_provider_prefix_is_stripped_before_agent_startup(self):
cli = _make_cli(model="zai/glm-5.1")
changed = cli._normalize_model_for_provider("zai")
assert changed is True
assert cli.model == "glm-5.1"
def test_bare_codex_model_passes_through(self):
cli = _make_cli(model="gpt-5.3-codex")
changed = cli._normalize_model_for_provider("openai-codex")
assert changed is False
assert cli.model == "gpt-5.3-codex"
def test_bare_non_codex_model_passes_through(self):
"""gpt-5.4 (no 'codex' suffix) passes through — user chose it."""
cli = _make_cli(model="gpt-5.4")
changed = cli._normalize_model_for_provider("openai-codex")
assert changed is False
assert cli.model == "gpt-5.4"
def test_any_bare_model_trusted(self):
"""Even a non-OpenAI bare model passes through — user explicitly set it."""
cli = _make_cli(model="claude-opus-4-6")
changed = cli._normalize_model_for_provider("openai-codex")
# User explicitly chose this model — we trust them, API will error if wrong
assert changed is False
assert cli.model == "claude-opus-4-6"
def test_provider_prefix_stripped(self):
"""openai/gpt-5.4 → gpt-5.4 (strip prefix, keep model)."""
cli = _make_cli(model="openai/gpt-5.4")
changed = cli._normalize_model_for_provider("openai-codex")
assert changed is True
assert cli.model == "gpt-5.4"
def test_any_provider_prefix_stripped(self):
"""anthropic/claude-opus-4.6 → claude-opus-4.6 (strip prefix only).
User explicitly chose this let the API decide if it works."""
cli = _make_cli(model="anthropic/claude-opus-4.6")
changed = cli._normalize_model_for_provider("openai-codex")
assert changed is True
assert cli.model == "claude-opus-4.6"
def test_opencode_go_prefix_stripped(self):
cli = _make_cli(model="opencode-go/kimi-k2.5")
cli.api_mode = "chat_completions"
changed = cli._normalize_model_for_provider("opencode-go")
assert changed is True
assert cli.model == "kimi-k2.5"
assert cli.api_mode == "chat_completions"
def test_opencode_zen_claude_sets_messages_mode(self):
cli = _make_cli(model="opencode-zen/claude-sonnet-4-6")
cli.api_mode = "chat_completions"
changed = cli._normalize_model_for_provider("opencode-zen")
assert changed is True
assert cli.model == "claude-sonnet-4-6"
assert cli.api_mode == "anthropic_messages"
def test_default_model_replaced(self):
"""No model configured (empty default) gets swapped for codex."""
import cli as _cli_mod
_clean_config = {
"model": {
"default": "",
"base_url": "",
"provider": "auto",
},
"display": {"compact": False, "tool_progress": "all", "resume_display": "full"},
"agent": {},
"terminal": {"env_type": "local"},
}
# Don't pass model= so _model_is_default is True
with (
patch("cli.get_tool_definitions", return_value=[]),
patch.dict("os.environ", {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}, clear=False),
patch.dict(_cli_mod.__dict__, {"CLI_CONFIG": _clean_config}),
):
from cli import HermesCLI
cli = HermesCLI()
assert cli._model_is_default is True
with patch(
"hermes_cli.codex_models.get_codex_model_ids",
return_value=["gpt-5.3-codex", "gpt-5.4"],
):
changed = cli._normalize_model_for_provider("openai-codex")
assert changed is True
# Uses first from available list
assert cli.model == "gpt-5.3-codex"
def test_default_fallback_when_api_fails(self):
"""No model configured falls back to gpt-5.3-codex when API unreachable."""
import cli as _cli_mod
_clean_config = {
"model": {
"default": "",
"base_url": "",
"provider": "auto",
},
"display": {"compact": False, "tool_progress": "all", "resume_display": "full"},
"agent": {},
"terminal": {"env_type": "local"},
}
with (
patch("cli.get_tool_definitions", return_value=[]),
patch.dict("os.environ", {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}, clear=False),
patch.dict(_cli_mod.__dict__, {"CLI_CONFIG": _clean_config}),
):
from cli import HermesCLI
cli = HermesCLI()
with patch(
"hermes_cli.codex_models.get_codex_model_ids",
side_effect=Exception("offline"),
):
changed = cli._normalize_model_for_provider("openai-codex")
assert changed is True
assert cli.model == "gpt-5.3-codex"
@@ -0,0 +1,863 @@
"""Tests for the codex MCP plugin migration helper."""
from __future__ import annotations
import pytest
from hermes_cli.codex_runtime_plugin_migration import (
MIGRATION_MARKER,
MIGRATION_END_MARKER,
_build_hermes_tools_mcp_entry,
_format_toml_value,
_looks_like_test_tempdir,
_strip_existing_managed_block,
_strip_unmanaged_plugin_tables,
_translate_one_server,
migrate,
render_codex_toml_section,
)
# ---- per-server translation ----
class TestTranslateOneServer:
def test_stdio_basic(self):
cfg, skipped = _translate_one_server("filesystem", {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
"env": {"FOO": "bar"},
})
assert cfg == {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
"env": {"FOO": "bar"},
}
assert skipped == []
def test_stdio_with_cwd(self):
cfg, _ = _translate_one_server("custom", {
"command": "/usr/bin/myserver",
"cwd": "/var/lib/mcp",
})
assert cfg["cwd"] == "/var/lib/mcp"
def test_http_basic(self):
cfg, skipped = _translate_one_server("api", {
"url": "https://x.example/mcp",
"headers": {"Authorization": "Bearer abc"},
})
assert cfg == {
"url": "https://x.example/mcp",
"http_headers": {"Authorization": "Bearer abc"},
}
assert skipped == []
def test_sse_falls_under_streamable_http_with_warning(self):
cfg, skipped = _translate_one_server("sse_server", {
"url": "http://localhost:8000/sse",
"transport": "sse",
})
assert cfg["url"] == "http://localhost:8000/sse"
assert any("sse" in s.lower() for s in skipped)
def test_timeouts_translate(self):
cfg, _ = _translate_one_server("x", {
"command": "y",
"timeout": 180,
"connect_timeout": 30,
})
assert cfg["tool_timeout_sec"] == 180.0
assert cfg["startup_timeout_sec"] == 30.0
def test_non_numeric_timeout_skipped(self):
cfg, skipped = _translate_one_server("x", {
"command": "y",
"timeout": "not-a-number",
})
assert "tool_timeout_sec" not in cfg
assert any("timeout" in s and "numeric" in s for s in skipped)
def test_disabled_server_emits_enabled_false(self):
cfg, _ = _translate_one_server("x", {
"command": "y",
"enabled": False,
})
assert cfg["enabled"] is False
def test_enabled_true_omitted(self):
cfg, _ = _translate_one_server("x", {"command": "y", "enabled": True})
assert "enabled" not in cfg # codex defaults to true
def test_command_and_url_prefers_stdio_warns(self):
cfg, skipped = _translate_one_server("x", {
"command": "y", "url": "http://z",
})
assert "command" in cfg
assert "url" not in cfg
assert any("url" in s for s in skipped)
def test_no_transport_returns_none(self):
cfg, skipped = _translate_one_server("broken", {"description": "x"})
assert cfg is None
assert "no command or url" in skipped[0]
def test_sampling_dropped_with_warning(self):
cfg, skipped = _translate_one_server("x", {
"command": "y",
"sampling": {"enabled": True, "model": "gemini-3-flash"},
})
assert "sampling" not in cfg
assert any("sampling" in s for s in skipped)
def test_unknown_keys_warned(self):
cfg, skipped = _translate_one_server("x", {
"command": "y",
"totally_made_up_key": "value",
})
assert "totally_made_up_key" not in cfg
assert any("totally_made_up_key" in s for s in skipped)
def test_non_dict_input(self):
cfg, skipped = _translate_one_server("x", "notadict") # type: ignore[arg-type]
assert cfg is None
# ---- TOML rendering ----
class TestTomlValueFormatter:
def test_string_quoted(self):
assert _format_toml_value("hello") == '"hello"'
def test_string_with_quotes_escaped(self):
assert _format_toml_value('a"b') == '"a\\"b"'
def test_bool(self):
assert _format_toml_value(True) == "true"
assert _format_toml_value(False) == "false"
def test_int(self):
assert _format_toml_value(42) == "42"
def test_float(self):
assert _format_toml_value(180.0) == "180.0"
def test_list_of_strings(self):
assert _format_toml_value(["a", "b"]) == '["a", "b"]'
def test_inline_table(self):
out = _format_toml_value({"FOO": "bar"})
assert out == '{ FOO = "bar" }'
def test_empty_inline_table(self):
assert _format_toml_value({}) == "{}"
def test_string_with_newline_escaped(self):
"""TOML basic strings don't allow literal newlines — a path or
env var containing a newline must use \\n. Otherwise codex would
refuse to load the config."""
out = _format_toml_value("line one\nline two")
assert "\n" not in out # no raw newline in output
assert "\\n" in out
def test_string_with_tab_escaped(self):
out = _format_toml_value("col1\tcol2")
assert "\t" not in out
assert "\\t" in out
def test_string_with_other_controls_escaped(self):
for raw, expected in [
("\r", "\\r"),
("\f", "\\f"),
("\b", "\\b"),
]:
out = _format_toml_value(f"x{raw}y")
assert raw not in out, f"{raw!r} should be escaped"
assert expected in out, f"{expected!r} should be in output"
def test_windows_path_escaped_correctly(self):
out = _format_toml_value(r"C:\Users\Alice\.codex")
# Each backslash should be doubled
assert out == r'"C:\\Users\\Alice\\.codex"'
def test_atomic_write_no_temp_leak_on_success(self, tmp_path):
"""The atomic-write path uses tempfile.mkstemp + rename. On
success the temp file should not be left behind."""
migrate({"mcp_servers": {"x": {"command": "y"}}},
codex_home=tmp_path,
discover_plugins=False,
expose_hermes_tools=False,
default_permission_profile=None)
# config.toml should exist
assert (tmp_path / "config.toml").exists()
# And no .config.toml.* temp files left behind
leftover = [p.name for p in tmp_path.iterdir()
if p.name.startswith(".config.toml.")]
assert leftover == [], f"temp file leaked after migration: {leftover}"
def test_atomic_write_cleanup_on_rename_failure(self, tmp_path, monkeypatch):
"""If rename fails partway through (out of disk, permissions,
crash), the temp file must be cleaned up. Otherwise repeated
failed migrations would pile up .config.toml.* files."""
from pathlib import Path as _Path
original_replace = _Path.replace
def failing_replace(self, target):
raise OSError("simulated disk full")
monkeypatch.setattr(_Path, "replace", failing_replace)
report = migrate(
{"mcp_servers": {"x": {"command": "y"}}},
codex_home=tmp_path,
discover_plugins=False,
expose_hermes_tools=False,
default_permission_profile=None,
)
# Error surfaced
assert any("simulated disk full" in e for e in report.errors)
# And no leaked temp file
leftover = [p.name for p in tmp_path.iterdir()
if p.name.startswith(".config.toml.")]
assert leftover == [], f"temp files leaked: {leftover}"
def test_unsupported_type_raises(self):
with pytest.raises(ValueError):
_format_toml_value(object())
class TestRenderToml:
def test_starts_with_marker(self):
out = render_codex_toml_section({})
assert out.startswith(MIGRATION_MARKER)
def test_empty_servers_emits_placeholder(self):
out = render_codex_toml_section({})
assert "no MCP servers" in out
def test_servers_sorted_alphabetically(self):
out = render_codex_toml_section({
"zoo": {"command": "z"},
"alpha": {"command": "a"},
"middle": {"command": "m"},
})
# Find the section header positions and confirm order
a_pos = out.find("[mcp_servers.alpha]")
m_pos = out.find("[mcp_servers.middle]")
z_pos = out.find("[mcp_servers.zoo]")
assert 0 < a_pos < m_pos < z_pos
def test_server_with_args_and_env(self):
out = render_codex_toml_section({
"fs": {
"command": "npx",
"args": ["-y", "filesystem"],
"env": {"PATH": "/usr/bin"},
}
})
assert "[mcp_servers.fs]" in out
assert 'command = "npx"' in out
assert 'args = ["-y", "filesystem"]' in out
# Env emitted as inline table
assert 'env = { PATH = "/usr/bin" }' in out
# ---- existing-block stripping ----
class TestStripExistingManagedBlock:
def test_no_managed_block_unchanged(self):
text = "[other]\nfoo = 1\n"
assert _strip_existing_managed_block(text) == text
def test_strips_managed_block_alone(self):
text = (
f"{MIGRATION_MARKER}\n"
"\n"
"[mcp_servers.fs]\n"
'command = "npx"\n'
)
assert _strip_existing_managed_block(text).strip() == ""
def test_preserves_user_content_above_managed_block(self):
text = (
"[model]\n"
'name = "gpt-5.5"\n'
"\n"
f"{MIGRATION_MARKER}\n"
"[mcp_servers.fs]\n"
'command = "x"\n'
)
out = _strip_existing_managed_block(text)
assert "[model]" in out
assert 'name = "gpt-5.5"' in out
assert "mcp_servers.fs" not in out
def test_preserves_unrelated_section_after_managed_block(self):
text = (
f"{MIGRATION_MARKER}\n"
"[mcp_servers.fs]\n"
'command = "x"\n'
"\n"
"[providers]\n"
'foo = "bar"\n'
)
out = _strip_existing_managed_block(text)
assert "mcp_servers.fs" not in out
assert "[providers]" in out
assert 'foo = "bar"' in out
# ---- end-to-end migrate(, expose_hermes_tools=False) ----
class TestMigrate:
def test_no_servers_no_plugins_no_perms_writes_placeholder(self, tmp_path):
report = migrate({}, codex_home=tmp_path,
discover_plugins=False,
default_permission_profile=None, expose_hermes_tools=False)
assert report.written
text = (tmp_path / "config.toml").read_text()
assert MIGRATION_MARKER in text
assert "no MCP servers" in text or "no MCP servers, plugins, or permissions" in text
def test_no_servers_still_writes_permissions_default(self, tmp_path):
"""Even with zero MCP servers, enabling the runtime should write the
default permissions profile so users don't get prompted on every
write attempt. This is the fix for quirk #2."""
report = migrate({}, codex_home=tmp_path, discover_plugins=False, expose_hermes_tools=False)
assert report.written
text = (tmp_path / "config.toml").read_text()
# Codex's schema: top-level `default_permissions` keying a built-in
# profile name (prefixed with ":"). NOT a [permissions] section
# (which is for *user-defined* profiles with structured fields).
assert 'default_permissions = ":workspace"' in text
assert report.wrote_permissions_default == ":workspace"
def test_explicit_none_permissions_skips_block(self, tmp_path):
report = migrate({"mcp_servers": {"x": {"command": "y"}}},
codex_home=tmp_path,
discover_plugins=False,
default_permission_profile=None, expose_hermes_tools=False)
text = (tmp_path / "config.toml").read_text()
assert "default_permissions" not in text
assert "[permissions]" not in text
assert report.wrote_permissions_default is None
def test_plugin_discovery_writes_plugin_blocks(self, tmp_path, monkeypatch):
"""Discovered curated plugins land as [plugins."<name>@<marketplace>"]
blocks. This is what OpenClaw calls 'migrate native codex plugins.'"""
from hermes_cli import codex_runtime_plugin_migration as crpm
def fake_query(codex_home=None, timeout=8.0):
return [
{"name": "google-calendar", "marketplace": "openai-curated",
"enabled": True},
{"name": "github", "marketplace": "openai-curated",
"enabled": True},
], None
monkeypatch.setattr(crpm, "_query_codex_plugins", fake_query)
report = migrate({}, codex_home=tmp_path, discover_plugins=True)
text = (tmp_path / "config.toml").read_text()
assert '[plugins."github@openai-curated"]' in text
assert '[plugins."google-calendar@openai-curated"]' in text
assert "enabled = true" in text
assert "google-calendar@openai-curated" in report.migrated_plugins
assert "github@openai-curated" in report.migrated_plugins
def test_plugin_discovery_skips_unavailable_plugins(self):
"""Plugins where codex reports availability != AVAILABLE should
be skipped they're broken/uninstallable on codex's side, so
migrating them would write config that fails at activation
time. Cf. openclaw#80815."""
from hermes_cli.codex_runtime_plugin_migration import _query_codex_plugins
from unittest.mock import patch
# Fake a plugin/list response where one plugin is unavailable
fake_response = {
"marketplaces": [{
"name": "openai-curated",
"plugins": [
{"name": "good-plugin", "installed": True,
"enabled": True, "availability": "AVAILABLE"},
{"name": "broken-plugin", "installed": True,
"enabled": True, "availability": "UNAVAILABLE"},
{"name": "auth-pending", "installed": True,
"enabled": True, "availability": "REQUIRES_AUTH"},
# Plugin without availability field — pass through
# (older codex versions or marketplaces that don't
# set it should still work).
{"name": "legacy-plugin", "installed": True,
"enabled": True},
]
}]
}
class FakeClient:
def __init__(self, **kw): pass
def initialize(self, **kw): pass
def request(self, method, params, timeout=None):
return fake_response
def close(self): pass
def __enter__(self): return self
def __exit__(self, *a): pass
with patch("agent.transports.codex_app_server.CodexAppServerClient",
FakeClient):
plugins, err = _query_codex_plugins()
assert err is None
names = [p["name"] for p in plugins]
assert "good-plugin" in names
assert "legacy-plugin" in names # no field → don't skip
assert "broken-plugin" not in names
assert "auth-pending" not in names
def test_plugin_discovery_failure_non_fatal(self, tmp_path, monkeypatch):
"""If codex isn't installed or RPC fails, MCP migration still
completes. The error surfaces in the report but doesn't abort."""
from hermes_cli import codex_runtime_plugin_migration as crpm
def fake_query_fails(codex_home=None, timeout=8.0):
return [], "codex CLI not available"
monkeypatch.setattr(crpm, "_query_codex_plugins", fake_query_fails)
report = migrate({"mcp_servers": {"x": {"command": "y"}}},
codex_home=tmp_path, discover_plugins=True, expose_hermes_tools=False)
assert report.written
assert report.migrated == ["x"]
assert report.plugin_query_error == "codex CLI not available"
assert report.migrated_plugins == []
def test_discover_plugins_false_skips_query(self, tmp_path, monkeypatch):
"""Tests and restricted environments can opt out of the subprocess
spawn entirely."""
from hermes_cli import codex_runtime_plugin_migration as crpm
called = {"yes": False}
def boom(*a, **kw):
called["yes"] = True
return [], None
monkeypatch.setattr(crpm, "_query_codex_plugins", boom)
migrate({"mcp_servers": {"x": {"command": "y"}}},
codex_home=tmp_path, discover_plugins=False, expose_hermes_tools=False)
assert called["yes"] is False
def test_dry_run_skips_plugin_query(self, tmp_path, monkeypatch):
"""Dry run should never spawn codex. Even with discover_plugins=True
the query is skipped because dry_run takes precedence."""
from hermes_cli import codex_runtime_plugin_migration as crpm
called = {"yes": False}
def boom(*a, **kw):
called["yes"] = True
return [], None
monkeypatch.setattr(crpm, "_query_codex_plugins", boom)
migrate({"mcp_servers": {"x": {"command": "y"}}},
codex_home=tmp_path, dry_run=True, discover_plugins=True, expose_hermes_tools=False)
assert called["yes"] is False
def test_re_run_replaces_plugin_block(self, tmp_path, monkeypatch):
"""Plugin blocks are managed and re-runs should replace them
cleanly same idempotency contract as MCP servers."""
from hermes_cli import codex_runtime_plugin_migration as crpm
# First run: only github
monkeypatch.setattr(crpm, "_query_codex_plugins",
lambda codex_home=None, timeout=8.0: (
[{"name": "github", "marketplace": "openai-curated", "enabled": True}],
None,
))
migrate({}, codex_home=tmp_path, discover_plugins=True,
default_permission_profile=None, expose_hermes_tools=False)
first = (tmp_path / "config.toml").read_text()
assert "github@openai-curated" in first
# Second run: only canva (github went away)
monkeypatch.setattr(crpm, "_query_codex_plugins",
lambda codex_home=None, timeout=8.0: (
[{"name": "canva", "marketplace": "openai-curated", "enabled": True}],
None,
))
migrate({}, codex_home=tmp_path, discover_plugins=True,
default_permission_profile=None, expose_hermes_tools=False)
second = (tmp_path / "config.toml").read_text()
assert "github@openai-curated" not in second
assert "canva@openai-curated" in second
def test_expose_hermes_tools_writes_callback_mcp_entry(self, tmp_path):
"""When expose_hermes_tools=True (production default), an
[mcp_servers.hermes-tools] entry is written so codex calls back
into Hermes for browser/web/delegate_task/vision/memory tools.
This is the fix for 'all other tools that codex doesn't provide
should be useable by hermes' — quirk #7."""
report = migrate({}, codex_home=tmp_path,
discover_plugins=False,
default_permission_profile=None,
expose_hermes_tools=True)
text = (tmp_path / "config.toml").read_text()
assert "[mcp_servers.hermes-tools]" in text
assert "hermes_tools_mcp_server" in text
# Must include startup + tool timeouts so codex doesn't give up
assert "startup_timeout_sec" in text
assert "tool_timeout_sec" in text
# And the entry is reported
assert "hermes-tools" in report.migrated
def test_expose_hermes_tools_disabled_skips_entry(self, tmp_path):
"""expose_hermes_tools=False suppresses the callback registration."""
migrate({}, codex_home=tmp_path,
discover_plugins=False,
default_permission_profile=None,
expose_hermes_tools=False)
text = (tmp_path / "config.toml").read_text()
assert "[mcp_servers.hermes-tools]" not in text
assert "hermes_tools_mcp_server" not in text
def test_dry_run_doesnt_write(self, tmp_path):
report = migrate({"mcp_servers": {"x": {"command": "y"}}},
codex_home=tmp_path, dry_run=True, expose_hermes_tools=False)
assert report.dry_run is True
assert not (tmp_path / "config.toml").exists()
assert "x" in report.migrated
def test_full_migration_round_trip(self, tmp_path):
hermes_cfg = {
"mcp_servers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem"],
},
"github": {
"url": "https://api.github.com/mcp",
"headers": {"Authorization": "Bearer x"},
},
}
}
report = migrate(hermes_cfg, codex_home=tmp_path, expose_hermes_tools=False)
assert report.written
text = (tmp_path / "config.toml").read_text()
assert "[mcp_servers.filesystem]" in text
assert "[mcp_servers.github]" in text
assert 'command = "npx"' in text
assert 'url = "https://api.github.com/mcp"' in text
def test_idempotent_re_run_replaces_managed_block(self, tmp_path):
# First migration
migrate({"mcp_servers": {"a": {"command": "x"}}}, codex_home=tmp_path, expose_hermes_tools=False)
first_text = (tmp_path / "config.toml").read_text()
assert "[mcp_servers.a]" in first_text
# Second migration with different servers
migrate({"mcp_servers": {"b": {"command": "y"}}}, codex_home=tmp_path, expose_hermes_tools=False)
second_text = (tmp_path / "config.toml").read_text()
assert "[mcp_servers.a]" not in second_text
assert "[mcp_servers.b]" in second_text
def test_preserves_user_codex_config_above_marker(self, tmp_path):
target = tmp_path / "config.toml"
target.write_text(
"[model]\n"
'profile = "default"\n'
"\n"
"[providers.openai]\n"
'api_key = "sk-test"\n'
)
migrate({"mcp_servers": {"a": {"command": "x"}}}, codex_home=tmp_path, expose_hermes_tools=False)
new_text = target.read_text()
# User's codex config preserved
assert "[model]" in new_text
assert 'profile = "default"' in new_text
assert "[providers.openai]" in new_text
# And new MCP block inserted without breaking user tables
assert "[mcp_servers.a]" in new_text
assert MIGRATION_MARKER in new_text
def test_managed_root_keys_stay_top_level_when_config_ends_in_table(self, tmp_path):
"""TOML has no explicit 'leave current table' syntax. If Hermes appends
root keys like default_permissions after a user table such as [features],
Codex parses them as features.default_permissions and rejects the config.
The managed block must therefore be inserted before the first table."""
import tomllib
target = tmp_path / "config.toml"
target.write_text(
'model = "gpt-5.5"\n'
"\n"
"[features]\n"
"terminal_resize_reflow = true\n"
)
migrate({}, codex_home=tmp_path, discover_plugins=False, expose_hermes_tools=False)
new_text = target.read_text()
parsed = tomllib.loads(new_text)
assert parsed["default_permissions"] == ":workspace"
assert "default_permissions" not in parsed["features"]
assert new_text.index(MIGRATION_MARKER) < new_text.index("[features]")
def test_preserves_user_mcp_server_outside_managed_block(self, tmp_path):
"""Quirk #6: when a user adds their own MCP server entry directly
to ~/.codex/config.toml outside Hermes' managed block, re-running
migration must preserve it. Tested both above and below the
managed block."""
target = tmp_path / "config.toml"
target.write_text(
"[mcp_servers.user-above]\n"
'command = "/usr/bin/above-server"\n'
'args = ["--above"]\n'
)
# First migrate — adds managed block below user content
migrate({"mcp_servers": {"hermes-mcp": {"command": "npx"}}},
codex_home=tmp_path, discover_plugins=False,
expose_hermes_tools=False)
text = target.read_text()
assert "user-above" in text, "user MCP server above managed block got nuked"
assert 'command = "/usr/bin/above-server"' in text
# Append another user entry below the managed block
target.write_text(
text + "\n[mcp_servers.user-below]\ncommand = \"below-server\"\n"
)
# Re-migrate — both should survive
migrate({"mcp_servers": {"hermes-mcp": {"command": "npx"}}},
codex_home=tmp_path, discover_plugins=False,
expose_hermes_tools=False)
final = target.read_text()
assert "user-above" in final
assert "user-below" in final
# And our managed block is still there with the new content
assert "[mcp_servers.hermes-mcp]" in final
def test_skipped_keys_reported(self, tmp_path):
report = migrate({
"mcp_servers": {
"x": {
"command": "y",
"sampling": {"enabled": True}, # codex has no equivalent
}
}
}, codex_home=tmp_path, expose_hermes_tools=False)
assert "x" in report.skipped_keys_per_server
assert any("sampling" in s for s in report.skipped_keys_per_server["x"])
def test_invalid_mcp_servers_value(self, tmp_path):
report = migrate({"mcp_servers": "notadict"}, codex_home=tmp_path, expose_hermes_tools=False)
assert any("not a dict" in e for e in report.errors)
def test_server_without_transport_skipped_with_error(self, tmp_path):
report = migrate({
"mcp_servers": {"broken": {"description": "no command/url"}}
}, codex_home=tmp_path, expose_hermes_tools=False)
assert "broken" not in report.migrated
assert any("broken" in e for e in report.errors)
def test_summary_reports_migration_count(self, tmp_path):
report = migrate({
"mcp_servers": {"a": {"command": "x"}, "b": {"command": "y"}}
}, codex_home=tmp_path, expose_hermes_tools=False)
summary = report.summary()
assert "Migrated 2 MCP server(s)" in summary
assert "- a" in summary
assert "- b" in summary
# ---- Bug B: duplicate [plugins.X] tables ----
class TestStripUnmanagedPluginTables:
"""Regression tests for issue #26250 Bug B.
When codex itself writes ``[plugins."<name>@<marketplace>"]`` tables
(via the user running ``codex plugins enable`` directly), re-running
``hermes codex-runtime migrate`` would re-emit them inside the managed
block and the resulting duplicate-table-header would crash codex.
"""
def test_strips_plugin_tables_outside_managed_block(self):
text = (
'model = "gpt-5.5"\n'
"\n"
"[mcp_servers.user-thing]\n"
'command = "x"\n'
"\n"
'[plugins."tasks@openai-curated"]\n'
"enabled = true\n"
"\n"
'[plugins."web-search@openai-curated"]\n'
"enabled = true\n"
"\n"
"[features]\n"
"terminal_resize_reflow = true\n"
)
stripped = _strip_unmanaged_plugin_tables(text)
assert "[plugins." not in stripped
# Non-plugin content preserved
assert "[mcp_servers.user-thing]" in stripped
assert "[features]" in stripped
assert "terminal_resize_reflow = true" in stripped
def test_preserves_content_when_no_plugin_tables(self):
text = (
'model = "gpt-5.5"\n'
"\n"
"[mcp_servers.x]\n"
'command = "y"\n'
)
assert _strip_unmanaged_plugin_tables(text) == text
def test_multi_line_array_in_plugin_table_does_not_leak(self):
"""A multi-line TOML array inside a [plugins.X] table whose
continuation lines start with ``[`` (e.g. nested arrays) must NOT
prematurely exit the strip region otherwise array fragments
leak into top-level output and produce invalid TOML on the next
codex startup. Regression guard for #26260 review.
"""
text = (
'[plugins."tasks@openai-curated"]\n'
"allowed = [\n"
' "a",\n'
' ["nested"],\n'
"]\n"
"[features]\n"
"x = 1\n"
)
stripped = _strip_unmanaged_plugin_tables(text)
# Everything inside the plugin table — including the multi-line
# array's continuation lines starting with `[` — should be gone.
assert '["nested"]' not in stripped
assert "allowed" not in stripped
# Sibling user table survives intact.
assert "[features]" in stripped
assert "x = 1" in stripped
# Result is still valid TOML.
import tomllib
tomllib.loads(stripped)
def test_migrate_dedups_codex_owned_plugin_tables(self, tmp_path, monkeypatch):
"""End-to-end: codex's pre-existing [plugins.X] tables get replaced by
the managed block's re-emission rather than duplicated."""
target = tmp_path / "config.toml"
target.write_text(
"[mcp_servers.user-server]\n"
'command = "x"\n'
"\n"
'[plugins."tasks@openai-curated"]\n'
"enabled = true\n"
)
# Simulate codex's plugin/list reporting the same plugin tasks@openai-curated.
def fake_query(codex_home=None, timeout=8.0):
return (
[{"name": "tasks", "marketplace": "openai-curated", "enabled": True}],
None,
)
monkeypatch.setattr(
"hermes_cli.codex_runtime_plugin_migration._query_codex_plugins",
fake_query,
)
migrate({}, codex_home=tmp_path, discover_plugins=True, expose_hermes_tools=False)
new_text = target.read_text()
# Only ONE [plugins."tasks@openai-curated"] header should remain — inside
# the managed block — not the original outside-the-block copy.
assert new_text.count('[plugins."tasks@openai-curated"]') == 1
# And the surviving one is inside our managed section.
managed_start = new_text.index(MIGRATION_MARKER)
managed_end = new_text.index(MIGRATION_END_MARKER)
plugin_idx = new_text.index('[plugins."tasks@openai-curated"]')
assert managed_start < plugin_idx < managed_end
# File parses cleanly as TOML (the original duplicate-key error is gone).
import tomllib
tomllib.loads(new_text)
def test_migrate_preserves_plugin_tables_when_plugin_list_fails(self, tmp_path, monkeypatch):
"""If plugin/list RPC fails, we can't re-emit plugins authoritatively,
so we must NOT strip the user's existing [plugins.X] tables — that
would silently lose them."""
target = tmp_path / "config.toml"
target.write_text(
'[plugins."tasks@openai-curated"]\n'
"enabled = true\n"
)
def fake_query(codex_home=None, timeout=8.0):
return ([], "plugin/list query failed: codex not installed")
monkeypatch.setattr(
"hermes_cli.codex_runtime_plugin_migration._query_codex_plugins",
fake_query,
)
migrate({}, codex_home=tmp_path, discover_plugins=True, expose_hermes_tools=False)
new_text = target.read_text()
# User's plugin table preserved verbatim — we can't re-emit it.
assert '[plugins."tasks@openai-curated"]' in new_text
# ---- Bug C: HERMES_HOME tempdir leak into ~/.codex/config.toml ----
class TestHermesHomeLeakGuard:
"""Regression tests for issue #26250 Bug C.
Previously ``_build_hermes_tools_mcp_entry()`` read ``HERMES_HOME``
directly from ``os.environ``, so a pytest ``monkeypatch.setenv`` would
leak a transient tempdir path into the user's real ``~/.codex/config.toml``
once codex spawned the hermes-tools MCP subprocess.
"""
def test_tempdir_detector_recognizes_pytest_paths(self):
assert _looks_like_test_tempdir(
"/private/var/folders/abc/pytest-of-kshitij/pytest-137/popen-gw2/test_X/hermes_test"
)
assert _looks_like_test_tempdir(
"/tmp/pytest-of-user/pytest-12/test_X/hermes"
)
assert _looks_like_test_tempdir(
"/private/var/folders/zz/T/pytest-of-bob/pytest-1"
)
def test_tempdir_detector_accepts_real_hermes_home(self):
assert not _looks_like_test_tempdir("/Users/alice/.hermes")
assert not _looks_like_test_tempdir("/home/bob/.hermes")
assert not _looks_like_test_tempdir("/opt/hermes")
assert not _looks_like_test_tempdir("")
def test_pytest_tempdir_not_burned_into_mcp_env(self, monkeypatch):
"""The headline regression: even when HERMES_HOME points at a pytest
tempdir, _build_hermes_tools_mcp_entry() must NOT propagate it."""
monkeypatch.setenv(
"HERMES_HOME",
"/private/var/folders/xx/pytest-of-user/pytest-99/test_x/hermes_test",
)
entry = _build_hermes_tools_mcp_entry()
env = entry.get("env", {})
assert "HERMES_HOME" not in env, (
f"pytest-tempdir HERMES_HOME leaked into codex MCP entry: "
f"{env.get('HERMES_HOME')!r}"
)
def test_real_hermes_home_propagates(self, monkeypatch, tmp_path):
"""A legitimate HERMES_HOME (not a tempdir path) DOES propagate so the
MCP subprocess sees the same config as the parent CLI."""
# Use a path that looks real — under /Users or /home, not /var/folders.
# We can't easily create one in the test, so just use a stable path
# outside any tempdir-detector needle. The detector checks for tempdir
# markers, not for path existence.
real_path = "/Users/alice/.hermes"
monkeypatch.setenv("HERMES_HOME", real_path)
entry = _build_hermes_tools_mcp_entry()
env = entry.get("env", {})
assert env.get("HERMES_HOME") == real_path
def test_unset_hermes_home_omits_env_key(self, monkeypatch):
"""When HERMES_HOME is unset in the environment, the MCP entry MUST
NOT bake in a resolved-default path. The codex subprocess should
inherit whatever HERMES_HOME its launcher (systemd, gateway, shell)
sets at runtime, rather than being pinned to migrate-time defaults.
Regression guard for issue #26250 follow-up review."""
monkeypatch.delenv("HERMES_HOME", raising=False)
entry = _build_hermes_tools_mcp_entry()
env = entry.get("env", {})
assert "HERMES_HOME" not in env, (
f"HERMES_HOME should not be set when env var is unset, got: "
f"{env.get('HERMES_HOME')!r}"
)
@@ -0,0 +1,238 @@
"""Tests for the /codex-runtime slash-command shared logic.
These cover the pure-Python state machine; CLI and gateway handlers are
tested separately because they involve config persistence and prompt
formatting that's surface-specific."""
from __future__ import annotations
from unittest.mock import patch
import pytest
from hermes_cli import codex_runtime_switch as crs
class TestParseArgs:
@pytest.mark.parametrize("arg,expected", [
("", None),
(" ", None),
("auto", "auto"),
("codex_app_server", "codex_app_server"),
("on", "codex_app_server"),
("off", "auto"),
("codex", "codex_app_server"),
("default", "auto"),
("hermes", "auto"),
("ENABLE", "codex_app_server"), # case-insensitive
("DiSaBlE", "auto"),
])
def test_valid_args(self, arg, expected):
value, errors = crs.parse_args(arg)
assert errors == []
assert value == expected
def test_invalid_arg_returns_error(self):
value, errors = crs.parse_args("turbo")
assert value is None
assert errors and "Unknown runtime" in errors[0]
class TestGetCurrentRuntime:
def test_default_when_unset(self):
assert crs.get_current_runtime({}) == "auto"
assert crs.get_current_runtime({"model": {}}) == "auto"
assert crs.get_current_runtime({"model": {"openai_runtime": ""}}) == "auto"
def test_unrecognized_falls_back_to_auto(self):
assert crs.get_current_runtime(
{"model": {"openai_runtime": "garbage"}}
) == "auto"
def test_explicit_codex(self):
assert crs.get_current_runtime(
{"model": {"openai_runtime": "codex_app_server"}}
) == "codex_app_server"
def test_handles_non_dict_config(self):
assert crs.get_current_runtime(None) == "auto" # type: ignore[arg-type]
assert crs.get_current_runtime("notadict") == "auto" # type: ignore[arg-type]
assert crs.get_current_runtime({"model": "notadict"}) == "auto"
class TestSetRuntime:
def test_creates_model_section_if_missing(self):
cfg = {}
old = crs.set_runtime(cfg, "codex_app_server")
assert old == "auto"
assert cfg["model"]["openai_runtime"] == "codex_app_server"
def test_returns_previous_value(self):
cfg = {"model": {"openai_runtime": "codex_app_server"}}
old = crs.set_runtime(cfg, "auto")
assert old == "codex_app_server"
assert cfg["model"]["openai_runtime"] == "auto"
def test_invalid_value_raises(self):
with pytest.raises(ValueError):
crs.set_runtime({}, "garbage")
class TestApply:
def test_read_only_call_reports_state(self):
cfg = {"model": {"openai_runtime": "codex_app_server"}}
with patch.object(crs, "check_codex_binary_ok",
return_value=(True, "0.130.0")):
r = crs.apply(cfg, None)
assert r.success
assert r.new_value == "codex_app_server"
assert r.old_value == "codex_app_server"
assert "codex_app_server" in r.message
assert "0.130.0" in r.message
def test_no_change_when_already_set(self):
cfg = {"model": {"openai_runtime": "auto"}}
r = crs.apply(cfg, "auto")
assert r.success
assert r.message == "openai_runtime already set to auto"
def test_enable_blocked_when_codex_missing(self):
cfg = {}
with patch.object(crs, "check_codex_binary_ok",
return_value=(False, "codex not found")):
r = crs.apply(cfg, "codex_app_server")
assert r.success is False
assert "Cannot enable" in r.message
assert "npm i -g @openai/codex" in r.message
# Config NOT mutated on failure
assert cfg.get("model", {}).get("openai_runtime") in {None, ""}
def test_enable_succeeds_when_codex_present(self):
cfg = {}
persisted = {}
def persist(c):
persisted.update(c)
# Patch migrate so this test doesn't reach into the user's real
# ~/.codex/config.toml. See issue #26250 Bug C — without this patch,
# crs.apply() invokes the real migrate() which writes to
# Path.home() / ".codex" using whatever HERMES_HOME the running pytest
# session has set, leaking pytest tempdir paths into the user's
# codex config.
with patch.object(crs, "check_codex_binary_ok",
return_value=(True, "0.130.0")), \
patch("hermes_cli.codex_runtime_plugin_migration.migrate"):
r = crs.apply(cfg, "codex_app_server", persist_callback=persist)
assert r.success
assert r.new_value == "codex_app_server"
assert r.old_value == "auto"
assert r.requires_new_session is True
assert "via MCP" in r.message # hermes-tools callback message
assert cfg["model"]["openai_runtime"] == "codex_app_server"
assert persisted["model"]["openai_runtime"] == "codex_app_server"
def test_disable_does_not_check_binary(self):
cfg = {"model": {"openai_runtime": "codex_app_server"}}
with patch.object(crs, "check_codex_binary_ok") as bin_check:
r = crs.apply(cfg, "auto")
assert r.success
# Binary check is irrelevant when disabling — should not be called
# with the codex_app_server enable-gate signature.
assert r.new_value == "auto"
assert r.old_value == "codex_app_server"
def test_persist_callback_failure_reported(self):
cfg = {}
def persist_boom(c):
raise IOError("disk full")
with patch.object(crs, "check_codex_binary_ok",
return_value=(True, "0.130.0")):
r = crs.apply(cfg, "codex_app_server", persist_callback=persist_boom)
assert r.success is False
assert "persist failed" in r.message
assert "disk full" in r.message
def test_enable_triggers_mcp_migration(self):
"""Enabling codex_app_server should auto-migrate Hermes mcp_servers
to ~/.codex/config.toml so the spawned subprocess sees them."""
cfg = {
"mcp_servers": {
"filesystem": {"command": "npx", "args": ["-y", "fs-server"]},
}
}
with patch.object(crs, "check_codex_binary_ok",
return_value=(True, "0.130.0")), \
patch("hermes_cli.codex_runtime_plugin_migration.migrate") as mig:
mig.return_value.migrated = ["filesystem", "hermes-tools"]
mig.return_value.migrated_plugins = []
mig.return_value.plugin_query_error = None
mig.return_value.wrote_permissions_default = ":workspace"
mig.return_value.errors = []
mig.return_value.target_path = "/fake/.codex/config.toml"
r = crs.apply(cfg, "codex_app_server")
assert r.success
assert mig.called # migration was triggered
# User MCP servers are reported (excluding internal hermes-tools)
assert "Migrated 1 MCP server" in r.message
assert "filesystem" in r.message
# Permissions default surfaces
assert "Default sandbox: :workspace" in r.message
# Hermes tool callback announcement
assert "via MCP" in r.message
def test_disable_does_not_trigger_migration(self):
"""Switching back to auto must not write to ~/.codex/."""
cfg = {
"model": {"openai_runtime": "codex_app_server"},
"mcp_servers": {"x": {"command": "y"}},
}
with patch("hermes_cli.codex_runtime_plugin_migration.migrate") as mig:
r = crs.apply(cfg, "auto")
assert r.success
assert not mig.called # disabling does not migrate
def test_migration_failure_does_not_block_enable(self):
"""If MCP migration raises, the runtime change still proceeds —
users can manually re-run migration later."""
cfg = {"mcp_servers": {"x": {"command": "y"}}}
with patch.object(crs, "check_codex_binary_ok",
return_value=(True, "0.130.0")), \
patch("hermes_cli.codex_runtime_plugin_migration.migrate",
side_effect=RuntimeError("disk full")):
r = crs.apply(cfg, "codex_app_server")
assert r.success # change still applied
assert r.new_value == "codex_app_server"
assert "MCP migration skipped" in r.message
assert "disk full" in r.message
def test_binary_check_cached_within_apply(self):
"""check_codex_binary_ok is invoked at most once per apply() call.
The enable path has three sites that need the version (state report,
enable gate, success message). Without caching, a single
/codex-runtime invocation spawns `codex --version` three times.
Regression guard against a refactor that drops the cache.
"""
cfg = {}
with patch.object(crs, "check_codex_binary_ok",
return_value=(True, "0.130.0")) as bin_check, \
patch("hermes_cli.codex_runtime_plugin_migration.migrate"):
r = crs.apply(cfg, "codex_app_server")
assert r.success
assert bin_check.call_count == 1, (
f"check_codex_binary_ok was called {bin_check.call_count} time(s); "
"should be cached and called exactly once per apply()"
)
def test_binary_check_cached_on_read_only_call(self):
"""Read-only call (new_value=None) calls the binary check exactly
once and reuses the result for the message."""
cfg = {"model": {"openai_runtime": "codex_app_server"}}
with patch.object(crs, "check_codex_binary_ok",
return_value=(True, "0.130.0")) as bin_check:
crs.apply(cfg, None)
assert bin_check.call_count == 1
File diff suppressed because it is too large Load Diff
+319
View File
@@ -0,0 +1,319 @@
"""Tests for hermes_cli/completion.py — shell completion script generation."""
import argparse
import os
import re
import shutil
import subprocess
import tempfile
import pytest
from hermes_cli.completion import _walk, generate_bash, generate_zsh, generate_fish
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_parser() -> argparse.ArgumentParser:
"""Build a minimal parser that mirrors the real hermes structure."""
p = argparse.ArgumentParser(prog="hermes")
p.add_argument("--version", "-V", action="store_true")
p.add_argument("-p", "--profile", help="Profile name")
sub = p.add_subparsers(dest="command")
chat = sub.add_parser("chat", help="Interactive chat with the agent")
chat.add_argument("-q", "--query")
chat.add_argument("-m", "--model")
gw = sub.add_parser("gateway", help="Messaging gateway management")
gw_sub = gw.add_subparsers(dest="gateway_command")
gw_sub.add_parser("start", help="Start service")
gw_sub.add_parser("stop", help="Stop service")
gw_sub.add_parser("status", help="Show status")
# alias — should NOT appear as a duplicate in completions
gw_sub.add_parser("run", aliases=["foreground"], help="Run in foreground")
sess = sub.add_parser("sessions", help="Manage session history")
sess_sub = sess.add_subparsers(dest="sessions_action")
sess_sub.add_parser("list", help="List sessions")
sess_sub.add_parser("delete", help="Delete a session")
prof = sub.add_parser("profile", help="Manage profiles")
prof_sub = prof.add_subparsers(dest="profile_command")
prof_sub.add_parser("list", help="List profiles")
prof_sub.add_parser("use", help="Switch to a profile")
prof_sub.add_parser("create", help="Create a new profile")
prof_sub.add_parser("delete", help="Delete a profile")
prof_sub.add_parser("show", help="Show profile details")
prof_sub.add_parser("alias", help="Set profile alias")
prof_sub.add_parser("rename", help="Rename a profile")
prof_sub.add_parser("export", help="Export a profile")
sub.add_parser("version", help="Show version")
return p
# ---------------------------------------------------------------------------
# 1. Parser extraction
# ---------------------------------------------------------------------------
class TestWalk:
def test_top_level_subcommands_extracted(self):
tree = _walk(_make_parser())
assert set(tree["subcommands"].keys()) == {"chat", "gateway", "sessions", "profile", "version"}
def test_nested_subcommands_extracted(self):
tree = _walk(_make_parser())
gw_subs = set(tree["subcommands"]["gateway"]["subcommands"].keys())
assert {"start", "stop", "status", "run"}.issubset(gw_subs)
def test_aliases_not_duplicated(self):
"""'foreground' is an alias of 'run' — must not appear as separate entry."""
tree = _walk(_make_parser())
gw_subs = tree["subcommands"]["gateway"]["subcommands"]
assert "foreground" not in gw_subs
def test_flags_extracted(self):
tree = _walk(_make_parser())
chat_flags = tree["subcommands"]["chat"]["flags"]
assert "-q" in chat_flags or "--query" in chat_flags
def test_help_text_captured(self):
tree = _walk(_make_parser())
assert tree["subcommands"]["chat"]["help"] != ""
assert tree["subcommands"]["gateway"]["help"] != ""
# ---------------------------------------------------------------------------
# 2. Bash output
# ---------------------------------------------------------------------------
class TestGenerateBash:
def test_contains_completion_function_and_register(self):
out = generate_bash(_make_parser())
assert "_hermes_completion()" in out
assert "complete -F _hermes_completion hermes" in out
def test_top_level_commands_present(self):
out = generate_bash(_make_parser())
for cmd in ("chat", "gateway", "sessions", "version"):
assert cmd in out
def test_nested_subcommands_in_case(self):
out = generate_bash(_make_parser())
assert "start" in out
assert "stop" in out
def test_valid_bash_syntax(self):
"""Script must pass `bash -n` syntax check."""
out = generate_bash(_make_parser())
with tempfile.NamedTemporaryFile(mode="w", suffix=".bash", delete=False) as f:
f.write(out)
path = f.name
try:
result = subprocess.run(["bash", "-n", path], capture_output=True)
assert result.returncode == 0, result.stderr.decode()
finally:
os.unlink(path)
# ---------------------------------------------------------------------------
# 3. Zsh output
# ---------------------------------------------------------------------------
class TestGenerateZsh:
def test_contains_compdef_header(self):
out = generate_zsh(_make_parser())
assert "#compdef hermes" in out
def test_top_level_commands_present(self):
out = generate_zsh(_make_parser())
for cmd in ("chat", "gateway", "sessions", "version"):
assert cmd in out
def test_nested_describe_blocks(self):
out = generate_zsh(_make_parser())
assert "_describe" in out
# gateway has subcommands so a _cmds array must be generated
assert "gateway_cmds" in out
def test_registers_compdef_instead_of_invoking_completion_function(self):
out = generate_zsh(_make_parser())
assert 'compdef _hermes hermes' in out
assert '_hermes "$@"' not in out
def test_preserves_valid_zsh_arguments_alias_syntax(self):
out = generate_zsh(_make_parser())
assert "'(-)'{-h,--help}'[Show help and exit]'" in out
assert "'(-)'{-V,--version}'[Show version and exit]'" in out
assert "'(-)'{-p,--profile}'[Profile name]:profile:_hermes_profiles'" in out
assert "'(-h --help){-h,--help}[Show help and exit]'" not in out
assert '"(-h --help)"{-h,--help}"[Show help and exit]"' not in out
def test_valid_zsh_syntax(self):
if not shutil.which("zsh"):
pytest.skip("zsh not installed")
out = generate_zsh(_make_parser())
with tempfile.NamedTemporaryFile(mode="w", suffix=".zsh", delete=False) as f:
f.write(out)
path = f.name
try:
result = subprocess.run(["zsh", "-n", path], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
finally:
os.unlink(path)
def test_zsh_eval_style_source_registers_after_compinit(self):
if not shutil.which("zsh"):
pytest.skip("zsh not installed")
out = generate_zsh(_make_parser())
with tempfile.NamedTemporaryFile(mode="w", suffix=".zsh", delete=False) as f:
f.write(out)
path = f.name
try:
result = subprocess.run(
[
"zsh",
"-fc",
f"autoload -Uz compinit && compinit -D; source {path}; [[ ${{_comps[hermes]}} == _hermes ]]",
],
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
assert result.stderr == ""
finally:
os.unlink(path)
# ---------------------------------------------------------------------------
# 4. Fish output
# ---------------------------------------------------------------------------
class TestGenerateFish:
def test_disables_file_completion(self):
out = generate_fish(_make_parser())
assert "complete -c hermes -f" in out
def test_top_level_commands_present(self):
out = generate_fish(_make_parser())
for cmd in ("chat", "gateway", "sessions", "version"):
assert cmd in out
def test_subcommand_guard_present(self):
out = generate_fish(_make_parser())
assert "__fish_seen_subcommand_from" in out
def test_valid_fish_syntax(self):
"""Script must be accepted by fish without errors."""
if not shutil.which("fish"):
pytest.skip("fish not installed")
out = generate_fish(_make_parser())
with tempfile.NamedTemporaryFile(mode="w", suffix=".fish", delete=False) as f:
f.write(out)
path = f.name
try:
result = subprocess.run(["fish", path], capture_output=True)
assert result.returncode == 0, result.stderr.decode()
finally:
os.unlink(path)
# ---------------------------------------------------------------------------
# 5. Subcommand drift prevention
# ---------------------------------------------------------------------------
class TestSubcommandDrift:
def test_SUBCOMMANDS_covers_required_commands(self):
"""_SUBCOMMANDS must include all known top-level commands so that
multi-word session names after -c/-r are never accidentally split.
"""
import inspect
from hermes_cli.main import _coalesce_session_name_args
source = inspect.getsource(_coalesce_session_name_args)
match = re.search(r'_SUBCOMMANDS\s*=\s*\{([^}]+)\}', source, re.DOTALL)
assert match, "_SUBCOMMANDS block not found in _coalesce_session_name_args()"
defined = set(re.findall(r'"(\w+)"', match.group(1)))
required = {
"chat", "model", "gateway", "setup", "login", "logout", "auth",
"status", "cron", "config", "sessions", "version", "update",
"uninstall", "profile", "skills", "tools", "mcp", "plugins",
"acp", "claw", "honcho", "completion", "logs",
}
missing = required - defined
assert not missing, f"Missing from _SUBCOMMANDS: {missing}"
# ---------------------------------------------------------------------------
# 6. Profile completion (regression prevention)
# ---------------------------------------------------------------------------
class TestProfileCompletion:
"""Ensure profile name completion is present in all shell outputs."""
def test_bash_has_profiles_helper(self):
out = generate_bash(_make_parser())
assert "_hermes_profiles()" in out
assert 'profiles_dir="$HOME/.hermes/profiles"' in out
def test_bash_completes_profiles_after_p_flag(self):
out = generate_bash(_make_parser())
assert '"-p"' in out or "== \"-p\"" in out
assert '"--profile"' in out or '== "--profile"' in out
assert "_hermes_profiles" in out
def test_bash_profile_subcommand_has_action_completion(self):
out = generate_bash(_make_parser())
assert "use|delete|show|alias|rename|export)" in out
def test_bash_profile_actions_complete_profile_names(self):
"""After 'hermes profile use', complete with profile names."""
out = generate_bash(_make_parser())
# The profile case should have _hermes_profiles for name-taking actions
lines = out.split("\n")
in_profile_case = False
has_profiles_in_action = False
for line in lines:
if "profile)" in line:
in_profile_case = True
if in_profile_case and "_hermes_profiles" in line:
has_profiles_in_action = True
break
assert has_profiles_in_action, "profile actions should complete with _hermes_profiles"
def test_zsh_has_profiles_helper(self):
out = generate_zsh(_make_parser())
assert "_hermes_profiles()" in out
assert "$HOME/.hermes/profiles" in out
def test_zsh_has_profile_flag_completion(self):
out = generate_zsh(_make_parser())
assert "--profile" in out
assert "_hermes_profiles" in out
def test_zsh_profile_actions_complete_names(self):
out = generate_zsh(_make_parser())
assert "use|delete|show|alias|rename|export)" in out
def test_fish_has_profiles_helper(self):
out = generate_fish(_make_parser())
assert "__hermes_profiles" in out
assert "$HOME/.hermes/profiles" in out
def test_fish_has_profile_flag_completion(self):
out = generate_fish(_make_parser())
assert "-s p -l profile" in out
assert "(__hermes_profiles)" in out
def test_fish_profile_actions_complete_names(self):
out = generate_fish(_make_parser())
# Should have profile name completion for actions like use, delete, etc.
assert "__hermes_profiles" in out
count = out.count("(__hermes_profiles)")
# At least the -p flag + the profile action completions
assert count >= 2, f"Expected >=2 profile completion entries, got {count}"
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
"""Regression tests for removed dead config keys.
This file guards against accidental re-introduction of config keys that were
documented or declared at some point but never actually wired up to read code.
Future dead-config regressions can accumulate here.
"""
import inspect
def test_delegation_default_toolsets_removed_from_cli_config():
"""delegation.default_toolsets was dead config — never read by
_load_config() or anywhere else. Removed.
Guards against accidental re-introduction in cli.py's CLI_CONFIG default
dict. If this test fails, someone re-added the key without wiring it up
to _load_config() in tools/delegate_tool.py.
We inspect the source of load_cli_config() instead of asserting on the
runtime CLI_CONFIG dict because CLI_CONFIG is populated by deep-merging
the user's ~/.hermes/config.yaml over the defaults (cli.py:359-366).
A contributor who still has the legacy key set in their own config
would cause a false failure, and HERMES_HOME patching via conftest
doesn't help because cli._hermes_home is frozen at module import time
(cli.py:76) before any autouse fixture can fire. Source inspection
sidesteps all of that: it tests the defaults literal directly.
"""
from cli import load_cli_config
source = inspect.getsource(load_cli_config)
assert '"default_toolsets"' not in source, (
"delegation.default_toolsets was removed because it was never read. "
"Do not re-add it to cli.py's CLI_CONFIG default dict; "
"use tools/delegate_tool.py's DEFAULT_TOOLSETS module constant or "
"wire a new config key through _load_config()."
)
@@ -0,0 +1,133 @@
"""Tests for ${ENV_VAR} substitution in config.yaml values."""
import pytest
from hermes_cli.config import _expand_env_vars, load_config
class TestExpandEnvVars:
def test_simple_substitution(self):
with pytest.MonkeyPatch().context() as mp:
mp.setenv("MY_KEY", "secret123")
assert _expand_env_vars("${MY_KEY}") == "secret123"
def test_missing_var_kept_verbatim(self):
with pytest.MonkeyPatch().context() as mp:
mp.delenv("UNDEFINED_VAR_XYZ", raising=False)
assert _expand_env_vars("${UNDEFINED_VAR_XYZ}") == "${UNDEFINED_VAR_XYZ}"
def test_no_placeholder_unchanged(self):
assert _expand_env_vars("plain-value") == "plain-value"
def test_dict_recursive(self):
with pytest.MonkeyPatch().context() as mp:
mp.setenv("TOKEN", "tok-abc")
result = _expand_env_vars({"key": "${TOKEN}", "other": "literal"})
assert result == {"key": "tok-abc", "other": "literal"}
def test_nested_dict(self):
with pytest.MonkeyPatch().context() as mp:
mp.setenv("API_KEY", "sk-xyz")
result = _expand_env_vars({"model": {"api_key": "${API_KEY}"}})
assert result["model"]["api_key"] == "sk-xyz"
def test_list_items(self):
with pytest.MonkeyPatch().context() as mp:
mp.setenv("VAL", "hello")
result = _expand_env_vars(["${VAL}", "literal", 42])
assert result == ["hello", "literal", 42]
def test_non_string_values_untouched(self):
assert _expand_env_vars(42) == 42
assert _expand_env_vars(3.14) == 3.14
assert _expand_env_vars(True) is True
assert _expand_env_vars(None) is None
def test_multiple_placeholders_in_one_string(self):
with pytest.MonkeyPatch().context() as mp:
mp.setenv("HOST", "localhost")
mp.setenv("PORT", "5432")
assert _expand_env_vars("${HOST}:${PORT}") == "localhost:5432"
def test_dict_keys_not_expanded(self):
with pytest.MonkeyPatch().context() as mp:
mp.setenv("KEY", "value")
result = _expand_env_vars({"${KEY}": "no-expand-key"})
assert "${KEY}" in result
class TestLoadConfigExpansion:
def test_load_config_expands_env_vars(self, tmp_path, monkeypatch):
config_yaml = (
"model:\n"
" api_key: ${GOOGLE_API_KEY}\n"
"platforms:\n"
" telegram:\n"
" token: ${TELEGRAM_BOT_TOKEN}\n"
"plain: no-substitution\n"
)
config_file = tmp_path / "config.yaml"
config_file.write_text(config_yaml)
monkeypatch.setenv("GOOGLE_API_KEY", "gsk-test-key")
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "1234567:ABC-token")
# Patch the imported function's own globals. Other tests may reload
# hermes_cli.config, making string-target monkeypatches hit a different
# module object than this collection-time imported load_config().
monkeypatch.setitem(load_config.__globals__, "get_config_path", lambda: config_file)
config = load_config()
assert config["model"]["api_key"] == "gsk-test-key"
assert config["platforms"]["telegram"]["token"] == "1234567:ABC-token"
assert config["plain"] == "no-substitution"
def test_load_config_unresolved_kept_verbatim(self, tmp_path, monkeypatch):
config_yaml = "model:\n api_key: ${NOT_SET_XYZ_123}\n"
config_file = tmp_path / "config.yaml"
config_file.write_text(config_yaml)
monkeypatch.delenv("NOT_SET_XYZ_123", raising=False)
monkeypatch.setitem(load_config.__globals__, "get_config_path", lambda: config_file)
config = load_config()
assert config["model"]["api_key"] == "${NOT_SET_XYZ_123}"
class TestLoadCliConfigExpansion:
"""Verify that load_cli_config() also expands ${VAR} references."""
def test_cli_config_expands_auxiliary_api_key(self, tmp_path, monkeypatch):
config_yaml = (
"auxiliary:\n"
" vision:\n"
" api_key: ${TEST_VISION_KEY_XYZ}\n"
)
config_file = tmp_path / "config.yaml"
config_file.write_text(config_yaml)
monkeypatch.setenv("TEST_VISION_KEY_XYZ", "vis-key-123")
# Patch the hermes home so load_cli_config finds our test config
monkeypatch.setattr("cli._hermes_home", tmp_path)
from cli import load_cli_config
config = load_cli_config()
assert config["auxiliary"]["vision"]["api_key"] == "vis-key-123"
def test_cli_config_unresolved_kept_verbatim(self, tmp_path, monkeypatch):
config_yaml = (
"auxiliary:\n"
" vision:\n"
" api_key: ${UNSET_CLI_VAR_ABC}\n"
)
config_file = tmp_path / "config.yaml"
config_file.write_text(config_yaml)
monkeypatch.delenv("UNSET_CLI_VAR_ABC", raising=False)
monkeypatch.setattr("cli._hermes_home", tmp_path)
from cli import load_cli_config
config = load_cli_config()
assert config["auxiliary"]["vision"]["api_key"] == "${UNSET_CLI_VAR_ABC}"
+169
View File
@@ -0,0 +1,169 @@
import textwrap
from hermes_cli.config import load_config, save_config
def _write_config(tmp_path, body: str):
(tmp_path / "config.yaml").write_text(textwrap.dedent(body), encoding="utf-8")
def _read_config(tmp_path) -> str:
return (tmp_path / "config.yaml").read_text(encoding="utf-8")
def test_save_config_preserves_env_refs_on_unrelated_change(monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("TU_ZI_API_KEY", "sk-realsecret")
monkeypatch.setenv("ALT_SECRET", "alt-secret")
_write_config(
tmp_path,
"""\
custom_providers:
- name: tuzi
base_url: https://api.tu-zi.com
api_key: ${TU_ZI_API_KEY}
headers:
Authorization: Bearer ${ALT_SECRET}
model: claude-opus-4-6
model:
default: claude-opus-4-6
""",
)
config = load_config()
config["model"]["default"] = "doubao-pro"
save_config(config)
saved = _read_config(tmp_path)
assert "api_key: ${TU_ZI_API_KEY}" in saved
assert "Authorization: Bearer ${ALT_SECRET}" in saved
assert "sk-realsecret" not in saved
assert "alt-secret" not in saved
def test_save_config_preserves_unresolved_env_refs(monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.delenv("MISSING_SECRET", raising=False)
_write_config(
tmp_path,
"""\
custom_providers:
- name: unresolved
api_key: ${MISSING_SECRET}
model: claude-opus-4-6
model:
default: claude-opus-4-6
""",
)
config = load_config()
config["display"]["compact"] = True
save_config(config)
assert "api_key: ${MISSING_SECRET}" in _read_config(tmp_path)
def test_save_config_allows_intentional_secret_value_change(monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("TU_ZI_API_KEY", "sk-old-secret")
_write_config(
tmp_path,
"""\
custom_providers:
- name: tuzi
api_key: ${TU_ZI_API_KEY}
model: claude-opus-4-6
model:
default: claude-opus-4-6
""",
)
config = load_config()
config["custom_providers"][0]["api_key"] = "sk-new-secret"
save_config(config)
saved = _read_config(tmp_path)
assert "api_key: sk-new-secret" in saved
assert "${TU_ZI_API_KEY}" not in saved
def test_save_config_preserves_template_when_env_rotates_after_load(monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("TU_ZI_API_KEY", "sk-old-secret")
_write_config(
tmp_path,
"""\
custom_providers:
- name: tuzi
api_key: ${TU_ZI_API_KEY}
model: claude-opus-4-6
model:
default: claude-opus-4-6
""",
)
config = load_config()
monkeypatch.setenv("TU_ZI_API_KEY", "sk-rotated-secret")
config["model"]["default"] = "doubao-pro"
save_config(config)
saved = _read_config(tmp_path)
assert "api_key: ${TU_ZI_API_KEY}" in saved
assert "sk-old-secret" not in saved
assert "sk-rotated-secret" not in saved
def test_save_config_keeps_edited_partial_template_strings_literal(monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("ALT_SECRET", "alt-secret")
_write_config(
tmp_path,
"""\
custom_providers:
- name: tuzi
headers:
Authorization: Bearer ${ALT_SECRET}
model: claude-opus-4-6
model:
default: claude-opus-4-6
""",
)
config = load_config()
config["custom_providers"][0]["headers"]["Authorization"] = "Token alt-secret"
save_config(config)
saved = _read_config(tmp_path)
assert "Authorization: Token alt-secret" in saved
assert "Authorization: Bearer ${ALT_SECRET}" not in saved
def test_save_config_falls_back_to_positional_matching_for_duplicate_names(monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("FIRST_SECRET", "first-secret")
monkeypatch.setenv("SECOND_SECRET", "second-secret")
_write_config(
tmp_path,
"""\
custom_providers:
- name: duplicate
api_key: ${FIRST_SECRET}
model: claude-opus-4-6
- name: duplicate
api_key: ${SECOND_SECRET}
model: doubao-pro
model:
default: claude-opus-4-6
""",
)
config = load_config()
config["display"]["compact"] = True
save_config(config)
saved = _read_config(tmp_path)
assert saved.count("name: duplicate") == 2
assert "api_key: ${FIRST_SECRET}" in saved
assert "api_key: ${SECOND_SECRET}" in saved
assert "first-secret" not in saved
assert "second-secret" not in saved
+207
View File
@@ -0,0 +1,207 @@
"""Tests for config.yaml structure validation (validate_config_structure)."""
from hermes_cli.config import validate_config_structure, ConfigIssue
class TestCustomProvidersValidation:
"""custom_providers must be a YAML list, not a dict."""
def test_dict_instead_of_list(self):
"""The exact Discord user scenario — custom_providers as flat dict."""
issues = validate_config_structure({
"custom_providers": {
"name": "Generativelanguage.googleapis.com",
"base_url": "https://generativelanguage.googleapis.com/v1beta",
"api_key": "xxx",
"model": "models/gemini-2.5-flash",
"rate_limit_delay": 2.0,
"fallback_model": {
"provider": "openrouter",
"model": "qwen/qwen3.6-plus:free",
},
},
"fallback_providers": [],
})
errors = [i for i in issues if i.severity == "error"]
assert any("dict" in i.message and "list" in i.message for i in errors), (
"Should detect custom_providers as dict instead of list"
)
def test_dict_detects_misplaced_fields(self):
"""When custom_providers is a dict, detect fields that look misplaced."""
issues = validate_config_structure({
"custom_providers": {
"name": "test",
"base_url": "https://example.com",
"api_key": "xxx",
},
})
warnings = [i for i in issues if i.severity == "warning"]
# Should flag base_url, api_key as looking like custom_providers entry fields
misplaced = [i for i in warnings if "custom_providers entry fields" in i.message]
assert len(misplaced) == 1
def test_dict_detects_nested_fallback(self):
"""When fallback_model gets swallowed into custom_providers dict."""
issues = validate_config_structure({
"custom_providers": {
"name": "test",
"fallback_model": {"provider": "openrouter", "model": "test"},
},
})
errors = [i for i in issues if i.severity == "error"]
assert any("fallback_model" in i.message and "inside" in i.message for i in errors)
def test_valid_list_no_issues(self):
"""Properly formatted custom_providers should produce no issues."""
issues = validate_config_structure({
"custom_providers": [
{"name": "gemini", "base_url": "https://example.com/v1"},
],
"model": {"provider": "custom", "default": "test"},
})
assert len(issues) == 0
def test_list_entry_missing_name(self):
"""List entry without name should warn."""
issues = validate_config_structure({
"custom_providers": [{"base_url": "https://example.com/v1"}],
"model": {"provider": "custom"},
})
assert any("missing 'name'" in i.message for i in issues)
def test_list_entry_missing_base_url(self):
"""List entry without base_url should warn."""
issues = validate_config_structure({
"custom_providers": [{"name": "test"}],
"model": {"provider": "custom"},
})
assert any("missing 'base_url'" in i.message for i in issues)
def test_list_entry_not_dict(self):
"""Non-dict list entries should warn."""
issues = validate_config_structure({
"custom_providers": ["not-a-dict"],
"model": {"provider": "custom"},
})
assert any("not a dict" in i.message for i in issues)
def test_none_custom_providers_no_issues(self):
"""No custom_providers at all should be fine."""
issues = validate_config_structure({
"model": {"provider": "openrouter"},
})
assert len(issues) == 0
class TestFallbackModelValidation:
"""fallback_model should be a top-level dict with provider + model."""
def test_missing_provider(self):
issues = validate_config_structure({
"fallback_model": {"model": "anthropic/claude-sonnet-4"},
})
assert any("missing 'provider'" in i.message for i in issues)
def test_missing_model(self):
issues = validate_config_structure({
"fallback_model": {"provider": "openrouter"},
})
assert any("missing 'model'" in i.message for i in issues)
def test_valid_fallback(self):
issues = validate_config_structure({
"fallback_model": {
"provider": "openrouter",
"model": "anthropic/claude-sonnet-4",
},
})
# Only fallback-related issues should be absent
fb_issues = [i for i in issues if "fallback" in i.message.lower()]
assert len(fb_issues) == 0
def test_non_dict_fallback(self):
issues = validate_config_structure({
"fallback_model": "openrouter:anthropic/claude-sonnet-4",
})
assert any("should be a dict" in i.message for i in issues)
def test_empty_fallback_dict_no_issues(self):
"""Empty fallback_model dict means disabled — no warnings needed."""
issues = validate_config_structure({
"fallback_model": {},
})
fb_issues = [i for i in issues if "fallback" in i.message.lower()]
assert len(fb_issues) == 0
def test_valid_fallback_list(self):
"""List-form fallback_model (chain) should validate when every entry has provider+model."""
issues = validate_config_structure({
"fallback_model": [
{"provider": "openrouter", "model": "anthropic/claude-sonnet-4"},
{"provider": "anthropic", "model": "claude-sonnet-4-6"},
],
})
fb_issues = [i for i in issues if "fallback" in i.message.lower()]
assert len(fb_issues) == 0
def test_fallback_list_entry_missing_provider(self):
issues = validate_config_structure({
"fallback_model": [
{"provider": "openrouter", "model": "anthropic/claude-sonnet-4"},
{"model": "claude-sonnet-4-6"},
],
})
assert any("fallback_model[1]" in i.message and "provider" in i.message for i in issues)
def test_fallback_list_entry_missing_model(self):
issues = validate_config_structure({
"fallback_model": [
{"provider": "openrouter"},
],
})
assert any("fallback_model[0]" in i.message and "model" in i.message for i in issues)
def test_fallback_list_entry_not_a_dict(self):
issues = validate_config_structure({
"fallback_model": ["openrouter:anthropic/claude-sonnet-4"],
})
assert any("fallback_model[0]" in i.message and "should be a dict" in i.message for i in issues)
class TestMissingModelSection:
"""Warn when custom_providers exists but model section is missing."""
def test_custom_providers_without_model(self):
issues = validate_config_structure({
"custom_providers": [
{"name": "test", "base_url": "https://example.com/v1"},
],
})
assert any("no 'model' section" in i.message for i in issues)
def test_custom_providers_with_model(self):
issues = validate_config_structure({
"custom_providers": [
{"name": "test", "base_url": "https://example.com/v1"},
],
"model": {"provider": "custom", "default": "test-model"},
})
# Should not warn about missing model section
assert not any("no 'model' section" in i.message for i in issues)
class TestConfigIssueDataclass:
"""ConfigIssue should be a proper dataclass."""
def test_fields(self):
issue = ConfigIssue(severity="error", message="test msg", hint="test hint")
assert issue.severity == "error"
assert issue.message == "test msg"
assert issue.hint == "test hint"
def test_equality(self):
a = ConfigIssue("error", "msg", "hint")
b = ConfigIssue("error", "msg", "hint")
assert a == b
@@ -0,0 +1,303 @@
"""Tests for container-aware CLI routing (NixOS container mode).
When container.enable = true in the NixOS module, the activation script
writes a .container-mode metadata file. The host CLI detects this and
execs into the container instead of running locally.
"""
import os
import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from hermes_cli.config import (
get_container_exec_info,
)
# =============================================================================
# get_container_exec_info
# =============================================================================
@pytest.fixture
def container_env(tmp_path, monkeypatch):
"""Set up a fake HERMES_HOME with .container-mode file."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("HERMES_DEV", raising=False)
container_mode = hermes_home / ".container-mode"
container_mode.write_text(
"# Written by NixOS activation script. Do not edit manually.\n"
"backend=podman\n"
"container_name=hermes-agent\n"
"exec_user=hermes\n"
"hermes_bin=/data/current-package/bin/hermes\n"
)
return hermes_home
def test_get_container_exec_info_returns_metadata(container_env):
"""Reads .container-mode and returns all fields including exec_user."""
with patch("hermes_constants.is_container", return_value=False):
info = get_container_exec_info()
assert info is not None
assert info["backend"] == "podman"
assert info["container_name"] == "hermes-agent"
assert info["exec_user"] == "hermes"
assert info["hermes_bin"] == "/data/current-package/bin/hermes"
def test_get_container_exec_info_none_inside_container(container_env):
"""Returns None when we're already inside a container."""
with patch("hermes_constants.is_container", return_value=True):
info = get_container_exec_info()
assert info is None
def test_get_container_exec_info_none_without_file(tmp_path, monkeypatch):
"""Returns None when .container-mode doesn't exist (native mode)."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("HERMES_DEV", raising=False)
with patch("hermes_constants.is_container", return_value=False):
info = get_container_exec_info()
assert info is None
def test_get_container_exec_info_skipped_when_hermes_dev(container_env, monkeypatch):
"""Returns None when HERMES_DEV=1 is set (dev mode bypass)."""
monkeypatch.setenv("HERMES_DEV", "1")
with patch("hermes_constants.is_container", return_value=False):
info = get_container_exec_info()
assert info is None
def test_get_container_exec_info_not_skipped_when_hermes_dev_zero(container_env, monkeypatch):
"""HERMES_DEV=0 does NOT trigger bypass — only '1' does."""
monkeypatch.setenv("HERMES_DEV", "0")
with patch("hermes_constants.is_container", return_value=False):
info = get_container_exec_info()
assert info is not None
def test_get_container_exec_info_defaults():
"""Falls back to defaults for missing keys."""
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
hermes_home = Path(tmpdir) / ".hermes"
hermes_home.mkdir()
(hermes_home / ".container-mode").write_text(
"# minimal file with no keys\n"
)
with patch("hermes_constants.is_container", return_value=False), \
patch.dict(get_container_exec_info.__globals__, {"get_hermes_home": lambda: hermes_home}), \
patch.dict(os.environ, {}, clear=False):
os.environ.pop("HERMES_DEV", None)
info = get_container_exec_info()
assert info is not None
assert info["backend"] == "docker"
assert info["container_name"] == "hermes-agent"
assert info["exec_user"] == "hermes"
assert info["hermes_bin"] == "/data/current-package/bin/hermes"
def test_get_container_exec_info_docker_backend(container_env):
"""Correctly reads docker backend with custom exec_user."""
(container_env / ".container-mode").write_text(
"backend=docker\n"
"container_name=hermes-custom\n"
"exec_user=myuser\n"
"hermes_bin=/opt/hermes/bin/hermes\n"
)
with patch("hermes_constants.is_container", return_value=False):
info = get_container_exec_info()
assert info["backend"] == "docker"
assert info["container_name"] == "hermes-custom"
assert info["exec_user"] == "myuser"
assert info["hermes_bin"] == "/opt/hermes/bin/hermes"
def test_get_container_exec_info_crashes_on_permission_error(container_env):
"""PermissionError propagates instead of being silently swallowed."""
with patch("hermes_constants.is_container", return_value=False), \
patch("builtins.open", side_effect=PermissionError("permission denied")):
with pytest.raises(PermissionError):
get_container_exec_info()
# =============================================================================
# _exec_in_container
# =============================================================================
@pytest.fixture
def docker_container_info():
return {
"backend": "docker",
"container_name": "hermes-agent",
"exec_user": "hermes",
"hermes_bin": "/data/current-package/bin/hermes",
}
@pytest.fixture
def podman_container_info():
return {
"backend": "podman",
"container_name": "hermes-agent",
"exec_user": "hermes",
"hermes_bin": "/data/current-package/bin/hermes",
}
def test_exec_in_container_calls_execvp(docker_container_info):
"""Verifies os.execvp is called with correct args: runtime, tty flags,
user, env vars, container name, binary, and CLI args."""
from hermes_cli.main import _exec_in_container
with patch("shutil.which", return_value="/usr/bin/docker"), \
patch("subprocess.run") as mock_run, \
patch("sys.stdin") as mock_stdin, \
patch("os.execvp") as mock_execvp, \
patch.dict(os.environ, {"TERM": "xterm-256color", "LANG": "en_US.UTF-8"},
clear=False):
mock_stdin.isatty.return_value = True
mock_run.return_value = MagicMock(returncode=0)
_exec_in_container(docker_container_info, ["chat", "-m", "opus"])
mock_execvp.assert_called_once()
cmd = mock_execvp.call_args[0][1]
assert cmd[0] == "/usr/bin/docker"
assert cmd[1] == "exec"
assert "-it" in cmd
idx_u = cmd.index("-u")
assert cmd[idx_u + 1] == "hermes"
e_indices = [i for i, v in enumerate(cmd) if v == "-e"]
e_values = [cmd[i + 1] for i in e_indices]
assert "TERM=xterm-256color" in e_values
assert "LANG=en_US.UTF-8" in e_values
assert "hermes-agent" in cmd
assert "/data/current-package/bin/hermes" in cmd
assert "chat" in cmd
def test_exec_in_container_non_tty_uses_i_only(docker_container_info):
"""Non-TTY mode uses -i instead of -it."""
from hermes_cli.main import _exec_in_container
with patch("shutil.which", return_value="/usr/bin/docker"), \
patch("subprocess.run") as mock_run, \
patch("sys.stdin") as mock_stdin, \
patch("os.execvp") as mock_execvp:
mock_stdin.isatty.return_value = False
mock_run.return_value = MagicMock(returncode=0)
_exec_in_container(docker_container_info, ["sessions", "list"])
cmd = mock_execvp.call_args[0][1]
assert "-i" in cmd
assert "-it" not in cmd
def test_exec_in_container_no_runtime_hard_fails(podman_container_info):
"""Hard fails when runtime not found (no fallback)."""
from hermes_cli.main import _exec_in_container
with patch("shutil.which", return_value=None), \
patch("subprocess.run") as mock_run, \
patch("os.execvp") as mock_execvp, \
pytest.raises(SystemExit) as exc_info:
_exec_in_container(podman_container_info, ["chat"])
mock_run.assert_not_called()
mock_execvp.assert_not_called()
assert exc_info.value.code != 0
def test_exec_in_container_sudo_probe_sets_prefix(podman_container_info):
"""When first probe fails and sudo probe succeeds, execvp is called
with sudo -n prefix."""
from hermes_cli.main import _exec_in_container
def which_side_effect(name):
if name == "podman":
return "/usr/bin/podman"
if name == "sudo":
return "/usr/bin/sudo"
return None
with patch("shutil.which", side_effect=which_side_effect), \
patch("subprocess.run") as mock_run, \
patch("sys.stdin") as mock_stdin, \
patch("os.execvp") as mock_execvp:
mock_stdin.isatty.return_value = True
mock_run.side_effect = [
MagicMock(returncode=1), # direct probe fails
MagicMock(returncode=0), # sudo probe succeeds
]
_exec_in_container(podman_container_info, ["chat"])
mock_execvp.assert_called_once()
cmd = mock_execvp.call_args[0][1]
assert cmd[0] == "/usr/bin/sudo"
assert cmd[1] == "-n"
assert cmd[2] == "/usr/bin/podman"
assert cmd[3] == "exec"
def test_exec_in_container_probe_timeout_prints_message(docker_container_info):
"""TimeoutExpired from probe produces a human-readable error, not a
raw traceback."""
from hermes_cli.main import _exec_in_container
with patch("shutil.which", return_value="/usr/bin/docker"), \
patch("subprocess.run", side_effect=subprocess.TimeoutExpired(
cmd=["docker", "inspect"], timeout=15)), \
patch("os.execvp") as mock_execvp, \
pytest.raises(SystemExit) as exc_info:
_exec_in_container(docker_container_info, ["chat"])
mock_execvp.assert_not_called()
assert exc_info.value.code == 1
def test_exec_in_container_container_not_running_no_sudo(docker_container_info):
"""When runtime exists but container not found and no sudo available,
prints helpful error about root containers."""
from hermes_cli.main import _exec_in_container
def which_side_effect(name):
if name == "docker":
return "/usr/bin/docker"
return None
with patch("shutil.which", side_effect=which_side_effect), \
patch("subprocess.run") as mock_run, \
patch("os.execvp") as mock_execvp, \
pytest.raises(SystemExit) as exc_info:
mock_run.return_value = MagicMock(returncode=1)
_exec_in_container(docker_container_info, ["chat"])
mock_execvp.assert_not_called()
assert exc_info.value.code == 1
+665
View File
@@ -0,0 +1,665 @@
"""Tests for hermes_cli.container_boot — the cont-init.d-time
reconciliation that recreates per-profile gateway s6 service slots
from the persistent profiles directory.
These tests run against a fake $HERMES_HOME under tmp_path; no real
s6 supervision tree is required. The in-container integration test
covering end-to-end "docker restart" survival lives in
tests/docker/test_container_restart.py.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from hermes_cli.container_boot import (
ReconcileAction,
reconcile_profile_gateways,
)
# ---------------------------------------------------------------------------
# Fixtures + helpers
# ---------------------------------------------------------------------------
def _make_profile(
hermes_home: Path,
name: str,
*,
state: str | None,
with_pid: bool = False,
config: bool = True,
) -> Path:
"""Create a fake profile directory under hermes_home/profiles/<name>/."""
p = hermes_home / "profiles" / name
p.mkdir(parents=True)
if config:
# SOUL.md is what the reconciler keys on — it's always seeded by
# `hermes profile create`. See container_boot._render_run_script.
(p / "SOUL.md").write_text("# fake profile\n")
if state is not None:
(p / "gateway_state.json").write_text(json.dumps({
"gateway_state": state, "timestamp": 1234567890,
}))
if with_pid:
(p / "gateway.pid").write_text(json.dumps(
{"pid": 99999, "host": "old-container"},
))
(p / "processes.json").write_text("[]")
return p
def _seed_default_root(
hermes_home: Path,
*,
state: str | None = None,
with_pid: bool = False,
) -> None:
"""Populate gateway_state.json / stale runtime files at the
HERMES_HOME root (the implicit default profile)."""
if state is not None:
(hermes_home / "gateway_state.json").write_text(json.dumps({
"gateway_state": state, "timestamp": 1234567890,
}))
if with_pid:
(hermes_home / "gateway.pid").write_text(json.dumps(
{"pid": 99999, "host": "old-container"},
))
(hermes_home / "processes.json").write_text("[]")
def _named_actions(actions: list[ReconcileAction]) -> list[ReconcileAction]:
"""Drop the always-present default-profile action so tests that
only care about named profiles can assert against a clean list."""
return [a for a in actions if a.profile != "default"]
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_running_profile_is_registered_and_autostarted(tmp_path: Path) -> None:
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(tmp_path, "coder", state="running")
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
assert _named_actions(actions) == [ReconcileAction(
profile="coder", prior_state="running", action="started",
)]
svc = scandir / "gateway-coder"
assert (svc / "run").exists()
assert (svc / "run").stat().st_mode & 0o111 # executable
assert (svc / "type").read_text().strip() == "longrun"
# Auto-start means no down-marker.
assert not (svc / "down").exists()
def test_stopped_profile_is_registered_but_not_started(tmp_path: Path) -> None:
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(tmp_path, "writer", state="stopped")
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
assert _named_actions(actions) == [ReconcileAction(
profile="writer", prior_state="stopped", action="registered",
)]
# down marker tells s6-svscan to NOT start the service.
assert (scandir / "gateway-writer" / "down").exists()
def test_startup_failed_does_not_autostart(tmp_path: Path) -> None:
"""Avoid crash-loop on restart when the gateway was failing to boot."""
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(tmp_path, "broken", state="startup_failed")
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
named = _named_actions(actions)
assert named[0].action == "registered"
assert (scandir / "gateway-broken" / "down").exists()
def test_starting_state_does_not_autostart(tmp_path: Path) -> None:
"""`starting` means the gateway died mid-boot last time; treat as
failed, not as a candidate for auto-restart."""
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(tmp_path, "unlucky", state="starting")
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
named = _named_actions(actions)
assert named[0].action == "registered"
def test_stale_runtime_files_are_removed(tmp_path: Path) -> None:
scandir = tmp_path / "run-service"; scandir.mkdir()
profile = _make_profile(tmp_path, "coder", state="running", with_pid=True)
assert (profile / "gateway.pid").exists()
assert (profile / "processes.json").exists()
reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
assert not (profile / "gateway.pid").exists()
assert not (profile / "processes.json").exists()
def test_profile_without_state_file_is_registered_but_not_started(
tmp_path: Path,
) -> None:
"""A freshly-created profile that's never been started: register
its slot but don't auto-start."""
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(tmp_path, "fresh", state=None)
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
assert _named_actions(actions) == [ReconcileAction(
profile="fresh", prior_state=None, action="registered",
)]
assert (scandir / "gateway-fresh" / "down").exists()
def test_directory_without_marker_file_is_skipped(tmp_path: Path) -> None:
"""A stray dir under profiles/ that isn't actually a profile (no
SOUL.md the marker the reconciler keys on) should be skipped."""
scandir = tmp_path / "run-service"; scandir.mkdir()
# Create a profile dir but without SOUL.md
(tmp_path / "profiles" / "stray").mkdir(parents=True)
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
assert _named_actions(actions) == []
assert not (scandir / "gateway-stray").exists()
def test_corrupt_state_file_treated_as_no_prior_state(tmp_path: Path) -> None:
"""If gateway_state.json is malformed JSON, don't blow up the whole
reconciliation register the slot in the down state."""
scandir = tmp_path / "run-service"; scandir.mkdir()
profile = _make_profile(tmp_path, "junk", state="running")
(profile / "gateway_state.json").write_text("{ not valid json")
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
named = _named_actions(actions)
assert named[0].action == "registered" # not "started"
assert (scandir / "gateway-junk" / "down").exists()
def test_reconcile_log_is_written(tmp_path: Path) -> None:
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(tmp_path, "a", state="running")
_make_profile(tmp_path, "b", state="stopped")
reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
log = (tmp_path / "logs" / "container-boot.log").read_text()
assert "profile=a" in log
assert "action=started" in log
assert "profile=b" in log
assert "action=registered" in log
def test_reconcile_log_rotates_when_size_exceeded(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When container-boot.log exceeds _LOG_ROTATE_BYTES, the existing
file is rotated to .1 before the new entries are appended."""
from hermes_cli import container_boot
# Tighten the threshold so we don't have to write 256 KiB.
monkeypatch.setattr(container_boot, "_LOG_ROTATE_BYTES", 200)
log_path = tmp_path / "logs" / "container-boot.log"
log_path.parent.mkdir()
log_path.write_text("X" * 300) # already over the threshold
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(tmp_path, "coder", state="running")
reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
rotated = tmp_path / "logs" / "container-boot.log.1"
assert rotated.exists(), "expected previous log to be rotated to .1"
assert rotated.read_text().startswith("X" * 300)
# The new entries land in a fresh container-boot.log (no leftover Xs).
new_contents = log_path.read_text()
assert "X" not in new_contents
assert "profile=coder" in new_contents
def test_reconcile_log_does_not_rotate_below_threshold(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A small existing log is appended to in place; no .1 is created."""
from hermes_cli import container_boot
monkeypatch.setattr(container_boot, "_LOG_ROTATE_BYTES", 10_000_000)
log_path = tmp_path / "logs" / "container-boot.log"
log_path.parent.mkdir()
log_path.write_text("previous entry\n")
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(tmp_path, "coder", state="running")
reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
assert not (tmp_path / "logs" / "container-boot.log.1").exists()
contents = log_path.read_text()
assert contents.startswith("previous entry\n")
assert "profile=coder" in contents
def test_reconcile_log_rotation_overwrites_existing_dot1(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Rotating again replaces the prior .1 — we keep at most one
rotated file (soft cap of ~2 × threshold)."""
from hermes_cli import container_boot
monkeypatch.setattr(container_boot, "_LOG_ROTATE_BYTES", 200)
log_dir = tmp_path / "logs"; log_dir.mkdir()
(log_dir / "container-boot.log.1").write_text("OLD ROTATION")
(log_dir / "container-boot.log").write_text("Y" * 300)
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(tmp_path, "coder", state="running")
reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
# .1 now contains the previous .log (Ys), not OLD ROTATION.
rotated = (log_dir / "container-boot.log.1").read_text()
assert "OLD ROTATION" not in rotated
assert rotated.startswith("Y" * 300)
def test_dry_run_makes_no_filesystem_changes(tmp_path: Path) -> None:
scandir = tmp_path / "run-service"; scandir.mkdir()
profile = _make_profile(tmp_path, "coder", state="running", with_pid=True)
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=True,
)
# The action list is still produced...
assert _named_actions(actions) == [ReconcileAction(
profile="coder", prior_state="running", action="started",
)]
# ...but nothing on disk was touched.
assert (profile / "gateway.pid").exists() # not removed under dry_run
assert not (scandir / "gateway-coder").exists()
assert not (tmp_path / "logs" / "container-boot.log").exists()
def test_missing_profiles_root_still_registers_default_slot(
tmp_path: Path,
) -> None:
"""When $HERMES_HOME/profiles doesn't exist (fresh install), the
reconciliation should still register a gateway-default slot for
the root profile and return without raising. Previously this
returned an empty list; the default slot is now always present
so `hermes gateway start` (no -p) has somewhere to land."""
scandir = tmp_path / "run-service"; scandir.mkdir()
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
assert actions == [ReconcileAction(
profile="default", prior_state=None, action="registered",
)]
assert (scandir / "gateway-default").is_dir()
assert (scandir / "gateway-default" / "down").exists()
def test_invalid_profile_name_in_directory_raises(tmp_path: Path) -> None:
"""A profile dir whose name doesn't match validate_profile_name's
rules (uppercase, etc.) must surface as a hard error rather than
silently produce an invalid s6 service dir."""
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(tmp_path, "BadName", state="running")
with pytest.raises(ValueError):
reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
def test_register_service_publishes_atomically(tmp_path: Path) -> None:
"""The reconciler should build the new service dir in a sibling
tmp directory and rename it into place never leaving a half-
populated slot visible to a concurrent s6-svscan rescan.
We verify the invariant indirectly: after a clean reconcile, the
target directory exists with all required files, and no sibling
.tmp leftovers remain. (Atomic publication is the only way to
achieve both with mkdir + write.)
"""
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(tmp_path, "coder", state="running")
reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
# No leftover tmp dir.
leftover = list(scandir.glob("*.tmp"))
assert leftover == [], f"leftover tmp directories: {leftover}"
# Target is fully populated.
svc = scandir / "gateway-coder"
assert (svc / "type").exists()
assert (svc / "run").exists()
assert (svc / "log" / "run").exists()
def test_register_service_overwrites_existing_slot(tmp_path: Path) -> None:
"""A second reconciliation pass cleanly replaces an existing
slot (the tmp+rename publication overwrites the previous one)."""
scandir = tmp_path / "run-service"; scandir.mkdir()
profile = _make_profile(tmp_path, "coder", state="running")
# First pass.
reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
first_run = (scandir / "gateway-coder" / "run").read_text()
# Mutate the profile state so the run-script changes (extra_env
# rendering would differ if we wired profile config through, but
# for now just exercise the overwrite path).
(profile / "gateway_state.json").write_text(
'{"gateway_state": "stopped"}',
)
reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
# Slot still exists, no .tmp remnants.
assert (scandir / "gateway-coder" / "run").read_text() == first_run
assert list(scandir.glob("*.tmp")) == []
# Down marker now present (state went from running → stopped).
assert (scandir / "gateway-coder" / "down").exists()
def test_register_service_cleans_up_stale_tmp_dir(tmp_path: Path) -> None:
"""If a previous interrupted run left a .tmp sibling directory,
a fresh reconcile must clean it up rather than failing on mkdir."""
scandir = tmp_path / "run-service"; scandir.mkdir()
# Simulate a leftover from an interrupted run.
stale_tmp = scandir / "gateway-coder.tmp"
stale_tmp.mkdir()
(stale_tmp / "stale-file").write_text("garbage")
_make_profile(tmp_path, "coder", state="running")
reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
assert not stale_tmp.exists()
assert (scandir / "gateway-coder" / "run").exists()
# ---------------------------------------------------------------------------
# Default-profile slot — always registered (PR #30136 review item I1)
# ---------------------------------------------------------------------------
def test_default_slot_always_registered_on_empty_home(tmp_path: Path) -> None:
"""Bare HERMES_HOME with nothing under it still produces a
gateway-default slot (down state)."""
scandir = tmp_path / "run-service"; scandir.mkdir()
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
assert actions == [ReconcileAction(
profile="default", prior_state=None, action="registered",
)]
svc = scandir / "gateway-default"
assert svc.is_dir()
assert (svc / "run").exists()
assert (svc / "down").exists()
def test_default_slot_run_script_omits_profile_flag(tmp_path: Path) -> None:
"""The default slot's run script must NOT pass `-p default` —
that would resolve to $HERMES_HOME/profiles/default/ instead of
the root profile. It must call `hermes gateway run` directly."""
scandir = tmp_path / "run-service"; scandir.mkdir()
reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
run = (scandir / "gateway-default" / "run").read_text()
assert "hermes gateway run" in run
assert "-p default" not in run
assert "-p 'default'" not in run
def test_default_slot_autostarts_when_root_state_running(tmp_path: Path) -> None:
"""gateway_state.json at the HERMES_HOME root with state=running
means the default slot auto-starts on container boot."""
scandir = tmp_path / "run-service"; scandir.mkdir()
_seed_default_root(tmp_path, state="running")
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
default_action = next(a for a in actions if a.profile == "default")
assert default_action.prior_state == "running"
assert default_action.action == "started"
assert not (scandir / "gateway-default" / "down").exists()
@pytest.mark.parametrize(
"container_argv",
[
("gateway", "run"),
("/init", "/opt/hermes/docker/main-wrapper.sh", "gateway", "run"),
],
)
def test_legacy_gateway_run_cmd_seeds_default_running_state(
tmp_path: Path,
container_argv: tuple[str, ...],
) -> None:
"""Pre-s6 Docker users often ran `gateway run` as the container
command. With no persisted gateway_state.json yet, s6 reconciliation
must migrate that legacy intent into a running default gateway slot."""
scandir = tmp_path / "run-service"; scandir.mkdir()
actions = reconcile_profile_gateways(
hermes_home=tmp_path,
scandir=scandir,
dry_run=False,
container_argv=container_argv,
)
default_action = next(a for a in actions if a.profile == "default")
assert default_action.prior_state == "running"
assert default_action.action == "started"
assert not (scandir / "gateway-default" / "down").exists()
state = json.loads((tmp_path / "gateway_state.json").read_text())
assert state["gateway_state"] == "running"
assert state["migrated_from"] == "legacy-container-cmd"
@pytest.mark.parametrize(
"container_argv",
[
("gateway", "run", "--no-supervise"),
("/init", "/opt/hermes/docker/main-wrapper.sh", "gateway", "run", "--no-supervise"),
],
)
def test_legacy_gateway_run_no_supervise_does_not_seed_s6_state(
tmp_path: Path,
container_argv: tuple[str, ...],
) -> None:
"""`gateway run --no-supervise` is an explicit opt-out from s6 migration."""
scandir = tmp_path / "run-service"; scandir.mkdir()
actions = reconcile_profile_gateways(
hermes_home=tmp_path,
scandir=scandir,
dry_run=False,
container_argv=container_argv,
)
default_action = next(a for a in actions if a.profile == "default")
assert default_action.prior_state is None
assert default_action.action == "registered"
assert (scandir / "gateway-default" / "down").exists()
assert not (tmp_path / "gateway_state.json").exists()
def test_legacy_gateway_run_env_no_supervise_does_not_seed_s6_state(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Env opt-out matches the CLI `--no-supervise` flag."""
scandir = tmp_path / "run-service"; scandir.mkdir()
monkeypatch.setenv("HERMES_GATEWAY_NO_SUPERVISE", "1")
actions = reconcile_profile_gateways(
hermes_home=tmp_path,
scandir=scandir,
dry_run=False,
container_argv=("gateway", "run"),
)
default_action = next(a for a in actions if a.profile == "default")
assert default_action.prior_state is None
assert default_action.action == "registered"
assert (scandir / "gateway-default" / "down").exists()
assert not (tmp_path / "gateway_state.json").exists()
def test_default_slot_does_not_autostart_when_root_state_stopped(
tmp_path: Path,
) -> None:
scandir = tmp_path / "run-service"; scandir.mkdir()
_seed_default_root(tmp_path, state="stopped")
actions = reconcile_profile_gateways(
hermes_home=tmp_path,
scandir=scandir,
dry_run=False,
container_argv=("gateway", "run"),
)
default_action = next(a for a in actions if a.profile == "default")
assert default_action.action == "registered"
assert (scandir / "gateway-default" / "down").exists()
state = json.loads((tmp_path / "gateway_state.json").read_text())
assert state["gateway_state"] == "stopped"
def test_default_slot_does_not_autostart_when_root_state_startup_failed(
tmp_path: Path,
) -> None:
"""Crash-loop guard applies to the default slot too."""
scandir = tmp_path / "run-service"; scandir.mkdir()
_seed_default_root(tmp_path, state="startup_failed")
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
default_action = next(a for a in actions if a.profile == "default")
assert default_action.action == "registered"
def test_default_slot_cleans_up_stale_runtime_files_at_root(
tmp_path: Path,
) -> None:
"""gateway.pid and processes.json at the HERMES_HOME root (left
over from the previous container's default gateway) must be
swept the same way as for named profiles."""
scandir = tmp_path / "run-service"; scandir.mkdir()
_seed_default_root(tmp_path, state="running", with_pid=True)
assert (tmp_path / "gateway.pid").exists()
reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
assert not (tmp_path / "gateway.pid").exists()
assert not (tmp_path / "processes.json").exists()
def test_default_slot_appears_before_named_profiles(tmp_path: Path) -> None:
"""The action list is ordered: default first, then named profiles
in directory order. Operators and the boot-log reader rely on
this ordering being stable."""
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(tmp_path, "z-last-alphabetically", state="stopped")
_make_profile(tmp_path, "a-first-alphabetically", state="stopped")
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
assert [a.profile for a in actions] == [
"default",
"a-first-alphabetically",
"z-last-alphabetically",
]
def test_profiles_default_subdir_is_skipped_with_warning(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A user-created profiles/default/ collides with the reserved
root-profile slot the named entry is skipped (with a warning)
so we don't double-register gateway-default."""
import logging
caplog.set_level(logging.WARNING)
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(tmp_path, "default", state="running")
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
# Only the root-profile default slot appears — not the colliding
# named profile.
default_actions = [a for a in actions if a.profile == "default"]
assert len(default_actions) == 1
# And the warning surfaces so operators know the named profile
# was ignored.
assert any(
"profiles/default/" in record.message for record in caplog.records
)
+201
View File
@@ -0,0 +1,201 @@
"""Tests for hermes_cli.copilot_auth — Copilot token validation and resolution."""
import pytest
from unittest.mock import patch
class TestTokenValidation:
"""Token type validation."""
def test_classic_pat_rejected(self):
from hermes_cli.copilot_auth import validate_copilot_token
valid, msg = validate_copilot_token("ghp_abcdefghijklmnop1234")
assert valid is False
assert "Classic Personal Access Tokens" in msg
assert "ghp_" in msg
def test_oauth_token_accepted(self):
from hermes_cli.copilot_auth import validate_copilot_token
valid, msg = validate_copilot_token("gho_abcdefghijklmnop1234")
assert valid is True
def test_fine_grained_pat_accepted(self):
from hermes_cli.copilot_auth import validate_copilot_token
valid, msg = validate_copilot_token("github_pat_abcdefghijklmnop1234")
assert valid is True
def test_github_app_token_accepted(self):
from hermes_cli.copilot_auth import validate_copilot_token
valid, msg = validate_copilot_token("ghu_abcdefghijklmnop1234")
assert valid is True
def test_empty_token_rejected(self):
from hermes_cli.copilot_auth import validate_copilot_token
valid, msg = validate_copilot_token("")
assert valid is False
class TestResolveToken:
"""Token resolution with env var priority."""
def test_copilot_github_token_first_priority(self, monkeypatch):
from hermes_cli.copilot_auth import resolve_copilot_token
monkeypatch.setenv("COPILOT_GITHUB_TOKEN", "gho_copilot_first")
monkeypatch.setenv("GH_TOKEN", "gho_gh_second")
monkeypatch.setenv("GITHUB_TOKEN", "gho_github_third")
token, source = resolve_copilot_token()
assert token == "gho_copilot_first"
assert source == "COPILOT_GITHUB_TOKEN"
def test_gh_token_second_priority(self, monkeypatch):
from hermes_cli.copilot_auth import resolve_copilot_token
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
monkeypatch.setenv("GH_TOKEN", "gho_gh_second")
monkeypatch.setenv("GITHUB_TOKEN", "gho_github_third")
token, source = resolve_copilot_token()
assert token == "gho_gh_second"
assert source == "GH_TOKEN"
def test_github_token_third_priority(self, monkeypatch):
from hermes_cli.copilot_auth import resolve_copilot_token
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
monkeypatch.delenv("GH_TOKEN", raising=False)
monkeypatch.setenv("GITHUB_TOKEN", "gho_github_third")
token, source = resolve_copilot_token()
assert token == "gho_github_third"
assert source == "GITHUB_TOKEN"
def test_classic_pat_in_env_skipped(self, monkeypatch):
"""Classic PATs in env vars should be skipped, not returned."""
from hermes_cli.copilot_auth import resolve_copilot_token
monkeypatch.setenv("COPILOT_GITHUB_TOKEN", "ghp_classic_pat_nope")
monkeypatch.delenv("GH_TOKEN", raising=False)
monkeypatch.setenv("GITHUB_TOKEN", "gho_valid_oauth")
token, source = resolve_copilot_token()
# Should skip the ghp_ token and find the gho_ one
assert token == "gho_valid_oauth"
assert source == "GITHUB_TOKEN"
def test_gh_cli_fallback(self, monkeypatch):
from hermes_cli.copilot_auth import resolve_copilot_token
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
monkeypatch.delenv("GH_TOKEN", raising=False)
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
with patch("hermes_cli.copilot_auth._try_gh_cli_token", return_value="gho_from_cli"):
token, source = resolve_copilot_token()
assert token == "gho_from_cli"
assert source == "gh auth token"
def test_gh_cli_classic_pat_raises(self, monkeypatch):
from hermes_cli.copilot_auth import resolve_copilot_token
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
monkeypatch.delenv("GH_TOKEN", raising=False)
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
with patch("hermes_cli.copilot_auth._try_gh_cli_token", return_value="ghp_classic"):
with pytest.raises(ValueError, match="classic PAT"):
resolve_copilot_token()
def test_no_token_returns_empty(self, monkeypatch):
from hermes_cli.copilot_auth import resolve_copilot_token
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
monkeypatch.delenv("GH_TOKEN", raising=False)
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
with patch("hermes_cli.copilot_auth._try_gh_cli_token", return_value=None):
token, source = resolve_copilot_token()
assert token == ""
assert source == ""
class TestRequestHeaders:
"""Copilot API header generation."""
def test_default_headers_include_openai_intent(self):
from hermes_cli.copilot_auth import copilot_request_headers
headers = copilot_request_headers()
assert headers["Openai-Intent"] == "conversation-edits"
assert headers["User-Agent"] == "HermesAgent/1.0"
assert "Editor-Version" in headers
def test_agent_turn_sets_initiator(self):
from hermes_cli.copilot_auth import copilot_request_headers
headers = copilot_request_headers(is_agent_turn=True)
assert headers["x-initiator"] == "agent"
def test_user_turn_sets_initiator(self):
from hermes_cli.copilot_auth import copilot_request_headers
headers = copilot_request_headers(is_agent_turn=False)
assert headers["x-initiator"] == "user"
def test_vision_header(self):
from hermes_cli.copilot_auth import copilot_request_headers
headers = copilot_request_headers(is_vision=True)
assert headers["Copilot-Vision-Request"] == "true"
def test_no_vision_header_by_default(self):
from hermes_cli.copilot_auth import copilot_request_headers
headers = copilot_request_headers()
assert "Copilot-Vision-Request" not in headers
class TestCopilotDefaultHeaders:
"""The models.py copilot_default_headers uses copilot_auth."""
def test_includes_openai_intent(self):
from hermes_cli.models import copilot_default_headers
headers = copilot_default_headers()
assert "Openai-Intent" in headers
assert headers["Openai-Intent"] == "conversation-edits"
def test_includes_x_initiator(self):
from hermes_cli.models import copilot_default_headers
headers = copilot_default_headers()
assert "x-initiator" in headers
class TestApiModeSelection:
"""API mode selection matching opencode's shouldUseCopilotResponsesApi."""
def test_gpt5_uses_responses(self):
from hermes_cli.models import _should_use_copilot_responses_api
assert _should_use_copilot_responses_api("gpt-5.4") is True
assert _should_use_copilot_responses_api("gpt-5.4-mini") is True
assert _should_use_copilot_responses_api("gpt-5.3-codex") is True
assert _should_use_copilot_responses_api("gpt-5.2-codex") is True
assert _should_use_copilot_responses_api("gpt-5.2") is True
assert _should_use_copilot_responses_api("gpt-5.1-codex-max") is True
def test_gpt5_mini_excluded(self):
from hermes_cli.models import _should_use_copilot_responses_api
assert _should_use_copilot_responses_api("gpt-5-mini") is False
def test_gpt4_uses_chat(self):
from hermes_cli.models import _should_use_copilot_responses_api
assert _should_use_copilot_responses_api("gpt-4.1") is False
assert _should_use_copilot_responses_api("gpt-4o") is False
assert _should_use_copilot_responses_api("gpt-4o-mini") is False
def test_non_gpt_uses_chat(self):
from hermes_cli.models import _should_use_copilot_responses_api
assert _should_use_copilot_responses_api("claude-sonnet-4.6") is False
assert _should_use_copilot_responses_api("claude-opus-4.6") is False
assert _should_use_copilot_responses_api("gemini-2.5-pro") is False
assert _should_use_copilot_responses_api("grok-code-fast-1") is False
class TestEnvVarOrder:
"""PROVIDER_REGISTRY has correct env var order."""
def test_copilot_env_vars_include_copilot_github_token(self):
from hermes_cli.auth import PROVIDER_REGISTRY
copilot = PROVIDER_REGISTRY["copilot"]
assert "COPILOT_GITHUB_TOKEN" in copilot.api_key_env_vars
# COPILOT_GITHUB_TOKEN should be first
assert copilot.api_key_env_vars[0] == "COPILOT_GITHUB_TOKEN"
def test_copilot_env_vars_order_matches_docs(self):
from hermes_cli.auth import PROVIDER_REGISTRY
copilot = PROVIDER_REGISTRY["copilot"]
assert copilot.api_key_env_vars == (
"COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"
)
@@ -0,0 +1,157 @@
"""Catalog-API-key fallback for the Copilot ``/model`` picker.
Regression for #16708: when the user's only Copilot credential is a
``gho_*`` token (typically obtained via device-code login) stored in
``auth.json`` under ``credential_pool.copilot[]`` placed there by
``hermes auth add copilot`` or by ``_seed_from_env`` when the env var
is set in ``~/.hermes/.env`` the picker was silently dropping back to
a stale hardcoded list because ``_resolve_copilot_catalog_api_key``
only consulted env vars / ``gh auth token`` and never read the
credential pool.
"""
from unittest.mock import patch
from hermes_cli.models import _resolve_copilot_catalog_api_key
class TestCopilotCatalogApiKeyResolution:
def test_env_var_token_wins_over_pool(self):
"""Env-resolved token still short-circuits the pool fallback."""
with patch(
"hermes_cli.auth.resolve_api_key_provider_credentials",
return_value={"api_key": "env-token"},
), patch(
"hermes_cli.auth.read_credential_pool",
) as mock_pool:
assert _resolve_copilot_catalog_api_key() == "env-token"
mock_pool.assert_not_called()
def test_falls_back_to_pool_oauth_token(self):
"""Empty env → walk credential_pool.copilot[] for an OAuth access_token."""
with patch(
"hermes_cli.auth.resolve_api_key_provider_credentials",
return_value={"api_key": ""},
), patch(
"hermes_cli.auth.read_credential_pool",
return_value=[{"access_token": "gho_abc123"}],
), patch(
"hermes_cli.copilot_auth.exchange_copilot_token",
return_value=("tid_exchanged_xyz", 1234567890.0),
):
assert _resolve_copilot_catalog_api_key() == "tid_exchanged_xyz"
def test_falls_back_when_env_resolution_raises(self):
"""Env path raising an exception still falls through to the pool."""
with patch(
"hermes_cli.auth.resolve_api_key_provider_credentials",
side_effect=RuntimeError("auth.json corrupt"),
), patch(
"hermes_cli.auth.read_credential_pool",
return_value=[{"access_token": "gho_xyz"}],
), patch(
"hermes_cli.copilot_auth.exchange_copilot_token",
return_value=("tid_exchanged_xyz", 1234567890.0),
):
assert _resolve_copilot_catalog_api_key() == "tid_exchanged_xyz"
def test_skips_classic_pat_in_pool(self):
"""Classic PATs (``ghp_…``) are unsupported by the Copilot API — skip them."""
with patch(
"hermes_cli.auth.resolve_api_key_provider_credentials",
return_value={"api_key": ""},
), patch(
"hermes_cli.auth.read_credential_pool",
return_value=[{"access_token": "ghp_classic_pat"}],
), patch(
"hermes_cli.copilot_auth.exchange_copilot_token",
) as mock_exchange:
assert _resolve_copilot_catalog_api_key() == ""
mock_exchange.assert_not_called()
def test_skips_invalid_pool_entries_until_first_exchangeable(self):
"""Non-dict entries and entries without an ``access_token`` are skipped."""
with patch(
"hermes_cli.auth.resolve_api_key_provider_credentials",
return_value={"api_key": ""},
), patch(
"hermes_cli.auth.read_credential_pool",
return_value=[
"not-a-dict",
{"label": "no-token-here"},
{"access_token": ""},
{"access_token": "gho_first_real_token"},
{"access_token": "gho_should_not_reach"},
],
), patch(
"hermes_cli.copilot_auth.exchange_copilot_token",
return_value=("tid_from_first", 1234567890.0),
) as mock_exchange:
assert _resolve_copilot_catalog_api_key() == "tid_from_first"
mock_exchange.assert_called_once_with("gho_first_real_token")
def test_skips_pool_entry_that_fails_to_exchange(self):
"""If the first entry won't exchange, try the next — an unsupported pool[0]
must not wedge a later valid entry (Copilot review #16868 finding)."""
attempts: list[str] = []
def fake_exchange(raw_token: str):
attempts.append(raw_token)
if raw_token == "gho_unsupported_account":
raise ValueError("Copilot token exchange failed: HTTP 401")
return ("tid_from_second", 1234567890.0)
with patch(
"hermes_cli.auth.resolve_api_key_provider_credentials",
return_value={"api_key": ""},
), patch(
"hermes_cli.auth.read_credential_pool",
return_value=[
{"access_token": "gho_unsupported_account"},
{"access_token": "gho_valid_token"},
],
), patch(
"hermes_cli.copilot_auth.exchange_copilot_token",
side_effect=fake_exchange,
):
assert _resolve_copilot_catalog_api_key() == "tid_from_second"
assert attempts == ["gho_unsupported_account", "gho_valid_token"]
def test_all_pool_entries_fail_exchange_returns_empty(self):
"""All exchanges fail → return "" so the caller falls back to curated."""
with patch(
"hermes_cli.auth.resolve_api_key_provider_credentials",
return_value={"api_key": ""},
), patch(
"hermes_cli.auth.read_credential_pool",
return_value=[
{"access_token": "gho_expired_a"},
{"access_token": "gho_expired_b"},
],
), patch(
"hermes_cli.copilot_auth.exchange_copilot_token",
side_effect=ValueError("Copilot token exchange failed"),
):
assert _resolve_copilot_catalog_api_key() == ""
def test_returns_empty_string_when_no_credentials_anywhere(self):
"""No env, no pool → empty string (caller falls back to curated list)."""
with patch(
"hermes_cli.auth.resolve_api_key_provider_credentials",
return_value={"api_key": ""},
), patch(
"hermes_cli.auth.read_credential_pool",
return_value=[],
):
assert _resolve_copilot_catalog_api_key() == ""
def test_pool_failure_returns_empty_string(self):
"""If the pool read itself raises, swallow and return ""."""
with patch(
"hermes_cli.auth.resolve_api_key_provider_credentials",
return_value={"api_key": ""},
), patch(
"hermes_cli.auth.read_credential_pool",
side_effect=RuntimeError("auth.json locked"),
):
assert _resolve_copilot_catalog_api_key() == ""
+134
View File
@@ -0,0 +1,134 @@
"""Tests for Copilot live /models context-window resolution."""
from __future__ import annotations
import time
from unittest.mock import patch
import pytest
from hermes_cli.models import get_copilot_model_context
# Sample catalog items mimicking the Copilot /models API response
_SAMPLE_CATALOG = [
{
"id": "claude-opus-4.6-1m",
"capabilities": {
"type": "chat",
"limits": {"max_prompt_tokens": 1000000, "max_output_tokens": 64000},
},
},
{
"id": "gpt-4.1",
"capabilities": {
"type": "chat",
"limits": {"max_prompt_tokens": 128000, "max_output_tokens": 32768},
},
},
{
"id": "claude-sonnet-4",
"capabilities": {
"type": "chat",
"limits": {"max_prompt_tokens": 200000, "max_output_tokens": 64000},
},
},
{
"id": "model-without-limits",
"capabilities": {"type": "chat"},
},
{
"id": "model-zero-limit",
"capabilities": {
"type": "chat",
"limits": {"max_prompt_tokens": 0},
},
},
]
@pytest.fixture(autouse=True)
def _clear_cache():
"""Reset module-level cache before each test."""
import hermes_cli.models as mod
mod._copilot_context_cache = {}
mod._copilot_context_cache_time = 0.0
yield
mod._copilot_context_cache = {}
mod._copilot_context_cache_time = 0.0
class TestGetCopilotModelContext:
"""Tests for get_copilot_model_context()."""
@patch("hermes_cli.models.fetch_github_model_catalog", return_value=_SAMPLE_CATALOG)
def test_returns_max_prompt_tokens(self, mock_fetch):
assert get_copilot_model_context("claude-opus-4.6-1m") == 1_000_000
assert get_copilot_model_context("gpt-4.1") == 128_000
@patch("hermes_cli.models.fetch_github_model_catalog", return_value=_SAMPLE_CATALOG)
def test_returns_none_for_unknown_model(self, mock_fetch):
assert get_copilot_model_context("nonexistent-model") is None
@patch("hermes_cli.models.fetch_github_model_catalog", return_value=_SAMPLE_CATALOG)
def test_skips_models_without_limits(self, mock_fetch):
assert get_copilot_model_context("model-without-limits") is None
@patch("hermes_cli.models.fetch_github_model_catalog", return_value=_SAMPLE_CATALOG)
def test_skips_zero_limit(self, mock_fetch):
assert get_copilot_model_context("model-zero-limit") is None
@patch("hermes_cli.models.fetch_github_model_catalog", return_value=_SAMPLE_CATALOG)
def test_caches_results(self, mock_fetch):
get_copilot_model_context("gpt-4.1")
get_copilot_model_context("claude-sonnet-4")
# Only one API call despite two lookups
assert mock_fetch.call_count == 1
@patch("hermes_cli.models.fetch_github_model_catalog", return_value=_SAMPLE_CATALOG)
def test_cache_expires(self, mock_fetch):
import hermes_cli.models as mod
get_copilot_model_context("gpt-4.1")
assert mock_fetch.call_count == 1
# Expire the cache
mod._copilot_context_cache_time = time.time() - 7200
get_copilot_model_context("gpt-4.1")
assert mock_fetch.call_count == 2
@patch("hermes_cli.models.fetch_github_model_catalog", return_value=None)
def test_returns_none_when_catalog_unavailable(self, mock_fetch):
assert get_copilot_model_context("gpt-4.1") is None
@patch("hermes_cli.models.fetch_github_model_catalog", return_value=[])
def test_returns_none_for_empty_catalog(self, mock_fetch):
assert get_copilot_model_context("gpt-4.1") is None
class TestModelMetadataCopilotIntegration:
"""Test that get_model_context_length() uses Copilot live API for copilot provider."""
@patch("hermes_cli.models.fetch_github_model_catalog", return_value=_SAMPLE_CATALOG)
def test_copilot_provider_uses_live_api(self, mock_fetch):
from agent.model_metadata import get_model_context_length
ctx = get_model_context_length("claude-opus-4.6-1m", provider="copilot")
assert ctx == 1_000_000
@patch("hermes_cli.models.fetch_github_model_catalog", return_value=_SAMPLE_CATALOG)
def test_copilot_acp_provider_uses_live_api(self, mock_fetch):
from agent.model_metadata import get_model_context_length
ctx = get_model_context_length("claude-sonnet-4", provider="copilot-acp")
assert ctx == 200_000
@patch("hermes_cli.models.fetch_github_model_catalog", return_value=None)
def test_falls_through_when_catalog_unavailable(self, mock_fetch):
from agent.model_metadata import get_model_context_length
# Should not raise, should fall through to models.dev or defaults
ctx = get_model_context_length("gpt-4.1", provider="copilot")
assert isinstance(ctx, int)
assert ctx > 0
@@ -0,0 +1,22 @@
"""Tests for GitHub Copilot entries shown in the /model picker."""
import os
from unittest.mock import patch
from hermes_cli.model_switch import list_authenticated_providers
@patch.dict(os.environ, {"GH_TOKEN": "test-key"}, clear=False)
def test_copilot_picker_uses_live_catalog_when_available():
live_models = ["gpt-5.4", "claude-sonnet-4.6", "gemini-3.1-pro-preview"]
with patch("agent.models_dev.fetch_models_dev", return_value={}), \
patch("hermes_cli.models._resolve_copilot_catalog_api_key", return_value="gh-token"), \
patch("hermes_cli.models._fetch_github_models", return_value=live_models):
providers = list_authenticated_providers(current_provider="openrouter", max_models=50)
copilot = next((p for p in providers if p["slug"] == "copilot"), None)
assert copilot is not None
assert copilot["models"] == live_models
assert copilot["total_models"] == len(live_models)
@@ -0,0 +1,159 @@
"""Tests for Copilot token exchange (raw GitHub token → Copilot API token)."""
from __future__ import annotations
import json
import time
from unittest.mock import MagicMock, patch
import pytest
@pytest.fixture(autouse=True)
def _clear_jwt_cache():
"""Reset the module-level JWT cache before each test."""
import hermes_cli.copilot_auth as mod
mod._jwt_cache.clear()
yield
mod._jwt_cache.clear()
class TestExchangeCopilotToken:
"""Tests for exchange_copilot_token()."""
def _mock_urlopen(self, token="tid=abc;exp=123;sku=copilot_individual", expires_at=None):
"""Create a mock urlopen context manager returning a token response."""
if expires_at is None:
expires_at = time.time() + 1800
resp_data = json.dumps({"token": token, "expires_at": expires_at}).encode()
mock_resp = MagicMock()
mock_resp.read.return_value = resp_data
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
mock_resp.__exit__ = MagicMock(return_value=False)
return mock_resp
@patch("urllib.request.urlopen")
def test_exchanges_token_successfully(self, mock_urlopen):
from hermes_cli.copilot_auth import exchange_copilot_token
mock_urlopen.return_value = self._mock_urlopen(token="tid=abc;exp=999")
api_token, expires_at = exchange_copilot_token("gho_test123")
assert api_token == "tid=abc;exp=999"
assert isinstance(expires_at, float)
# Verify request was made with correct headers
call_args = mock_urlopen.call_args
req = call_args[0][0]
assert req.get_header("Authorization") == "token gho_test123"
assert "GitHubCopilotChat" in req.get_header("User-agent")
@patch("urllib.request.urlopen")
def test_caches_result(self, mock_urlopen):
from hermes_cli.copilot_auth import exchange_copilot_token
future = time.time() + 1800
mock_urlopen.return_value = self._mock_urlopen(expires_at=future)
exchange_copilot_token("gho_test123")
exchange_copilot_token("gho_test123")
assert mock_urlopen.call_count == 1
@patch("urllib.request.urlopen")
def test_refreshes_expired_cache(self, mock_urlopen):
from hermes_cli.copilot_auth import exchange_copilot_token, _jwt_cache, _token_fingerprint
# Seed cache with expired entry
fp = _token_fingerprint("gho_test123")
_jwt_cache[fp] = ("old_token", time.time() - 10)
mock_urlopen.return_value = self._mock_urlopen(
token="new_token", expires_at=time.time() + 1800
)
api_token, _ = exchange_copilot_token("gho_test123")
assert api_token == "new_token"
assert mock_urlopen.call_count == 1
@patch("urllib.request.urlopen")
def test_raises_on_empty_token(self, mock_urlopen):
from hermes_cli.copilot_auth import exchange_copilot_token
resp_data = json.dumps({"token": "", "expires_at": 0}).encode()
mock_resp = MagicMock()
mock_resp.read.return_value = resp_data
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
mock_resp.__exit__ = MagicMock(return_value=False)
mock_urlopen.return_value = mock_resp
with pytest.raises(ValueError, match="empty token"):
exchange_copilot_token("gho_test123")
@patch("urllib.request.urlopen", side_effect=Exception("network error"))
def test_raises_on_network_error(self, mock_urlopen):
from hermes_cli.copilot_auth import exchange_copilot_token
with pytest.raises(ValueError, match="network error"):
exchange_copilot_token("gho_test123")
class TestGetCopilotApiToken:
"""Tests for get_copilot_api_token() — the fallback wrapper."""
@patch("hermes_cli.copilot_auth.exchange_copilot_token")
def test_returns_exchanged_token(self, mock_exchange):
from hermes_cli.copilot_auth import get_copilot_api_token
mock_exchange.return_value = ("exchanged_jwt", time.time() + 1800)
assert get_copilot_api_token("gho_raw") == "exchanged_jwt"
@patch("hermes_cli.copilot_auth.exchange_copilot_token", side_effect=ValueError("fail"))
def test_falls_back_to_raw_token(self, mock_exchange):
from hermes_cli.copilot_auth import get_copilot_api_token
assert get_copilot_api_token("gho_raw") == "gho_raw"
def test_empty_token_passthrough(self):
from hermes_cli.copilot_auth import get_copilot_api_token
assert get_copilot_api_token("") == ""
class TestTokenFingerprint:
"""Tests for _token_fingerprint()."""
def test_consistent(self):
from hermes_cli.copilot_auth import _token_fingerprint
fp1 = _token_fingerprint("gho_abc123")
fp2 = _token_fingerprint("gho_abc123")
assert fp1 == fp2
def test_different_tokens_different_fingerprints(self):
from hermes_cli.copilot_auth import _token_fingerprint
fp1 = _token_fingerprint("gho_abc123")
fp2 = _token_fingerprint("gho_xyz789")
assert fp1 != fp2
def test_length(self):
from hermes_cli.copilot_auth import _token_fingerprint
assert len(_token_fingerprint("gho_test")) == 16
class TestCallerIntegration:
"""Test that callers correctly use token exchange."""
@patch("hermes_cli.copilot_auth.resolve_copilot_token", return_value=("gho_raw", "GH_TOKEN"))
@patch("hermes_cli.copilot_auth.get_copilot_api_token", return_value="exchanged_jwt")
def test_auth_resolve_uses_exchange(self, mock_exchange, mock_resolve):
from hermes_cli.auth import _resolve_api_key_provider_secret
# Create a minimal pconfig mock
pconfig = MagicMock()
token, source = _resolve_api_key_provider_secret("copilot", pconfig)
assert token == "exchanged_jwt"
assert source == "GH_TOKEN"
mock_exchange.assert_called_once_with("gho_raw")
+123
View File
@@ -0,0 +1,123 @@
"""Tests for hermes_cli.cron command handling."""
from argparse import Namespace
import pytest
from cron.jobs import create_job, get_job, list_jobs
from hermes_cli.cron import cron_command
@pytest.fixture()
def tmp_cron_dir(tmp_path, monkeypatch):
monkeypatch.setattr("cron.jobs.CRON_DIR", tmp_path / "cron")
monkeypatch.setattr("cron.jobs.JOBS_FILE", tmp_path / "cron" / "jobs.json")
monkeypatch.setattr("cron.jobs.OUTPUT_DIR", tmp_path / "cron" / "output")
return tmp_path
class TestCronCommandLifecycle:
def test_pause_resume_run(self, tmp_cron_dir, capsys):
job = create_job(prompt="Check server status", schedule="every 1h")
cron_command(Namespace(cron_command="pause", job_id=job["id"]))
paused = get_job(job["id"])
assert paused["state"] == "paused"
cron_command(Namespace(cron_command="resume", job_id=job["id"]))
resumed = get_job(job["id"])
assert resumed["state"] == "scheduled"
cron_command(Namespace(cron_command="run", job_id=job["id"]))
triggered = get_job(job["id"])
assert triggered["state"] == "scheduled"
out = capsys.readouterr().out
assert "Paused job" in out
assert "Resumed job" in out
assert "Triggered job" in out
def test_edit_can_replace_and_clear_skills(self, tmp_cron_dir, capsys):
job = create_job(
prompt="Combine skill outputs",
schedule="every 1h",
skill="blogwatcher",
)
cron_command(
Namespace(
cron_command="edit",
job_id=job["id"],
schedule="every 2h",
prompt="Revised prompt",
name="Edited Job",
deliver=None,
repeat=None,
skill=None,
skills=["maps", "blogwatcher"],
clear_skills=False,
)
)
updated = get_job(job["id"])
assert updated["skills"] == ["maps", "blogwatcher"]
assert updated["name"] == "Edited Job"
assert updated["prompt"] == "Revised prompt"
assert updated["schedule_display"] == "every 120m"
cron_command(
Namespace(
cron_command="edit",
job_id=job["id"],
schedule=None,
prompt=None,
name=None,
deliver=None,
repeat=None,
skill=None,
skills=None,
clear_skills=True,
)
)
cleared = get_job(job["id"])
assert cleared["skills"] == []
assert cleared["skill"] is None
out = capsys.readouterr().out
assert "Updated job" in out
def test_create_with_multiple_skills(self, tmp_cron_dir, capsys):
cron_command(
Namespace(
cron_command="create",
schedule="every 1h",
prompt="Use both skills",
name="Skill combo",
deliver=None,
repeat=None,
skill=None,
skills=["blogwatcher", "maps"],
)
)
out = capsys.readouterr().out
assert "Created job" in out
jobs = list_jobs()
assert len(jobs) == 1
assert jobs[0]["skills"] == ["blogwatcher", "maps"]
assert jobs[0]["name"] == "Skill combo"
def test_list_does_not_crash_when_repeat_is_null(self, tmp_cron_dir, capsys):
"""A one-shot job can be persisted with ``"repeat": null``. `cron
list` must render it as rather than crashing on .get(...)\\.get."""
from cron.jobs import load_jobs, save_jobs
create_job(prompt="One shot", schedule="every 1h")
# Force the present-but-null shape that .get("repeat", {}) mishandles.
jobs = load_jobs()
jobs[0]["repeat"] = None
save_jobs(jobs)
cron_command(Namespace(cron_command="list", all=True))
out = capsys.readouterr().out
assert "Repeat: ∞" in out
@@ -0,0 +1,85 @@
"""Unit tests for the extracted ``hermes cron`` parser builder.
Confirms ``build_cron_parser`` wires up the same subactions, aliases, options,
and ``func=cmd_cron`` dispatch that lived inline in ``main()`` before the
god-file Phase 2 extraction.
"""
from __future__ import annotations
import argparse
from hermes_cli.subcommands.cron import build_cron_parser
def _sentinel_handler(args): # pragma: no cover - only identity is asserted
return "cron-handler"
def _build():
parser = argparse.ArgumentParser(prog="hermes")
subparsers = parser.add_subparsers(dest="command")
build_cron_parser(subparsers, cmd_cron=_sentinel_handler)
return parser
def test_cron_subactions_present():
parser = _build()
for action in ("list", "create", "edit", "pause", "resume", "run", "remove", "status", "tick"):
ns = parser.parse_args(["cron", action] if action in ("list", "status", "tick")
else ["cron", action, "jobid"] if action in ("pause", "resume", "run", "remove", "edit")
else ["cron", "create", "30m"])
assert ns.command == "cron"
assert ns.cron_command == action
def test_cron_aliases():
parser = _build()
# create has alias "add"
ns = parser.parse_args(["cron", "add", "30m"])
assert ns.cron_command == "add"
# remove has aliases rm / delete
for alias in ("rm", "delete"):
ns = parser.parse_args(["cron", alias, "jid"])
assert ns.cron_command == alias
def test_cron_create_options():
parser = _build()
ns = parser.parse_args([
"cron", "create", "0 9 * * *", "daily task prompt",
"--name", "daily", "--deliver", "origin", "--repeat", "3",
"--skill", "a", "--skill", "b", "--no-agent",
"--workdir", "/tmp/x",
])
assert ns.schedule == "0 9 * * *"
assert ns.prompt == "daily task prompt"
assert ns.name == "daily"
assert ns.deliver == "origin"
assert ns.repeat == 3
assert ns.skills == ["a", "b"]
assert ns.no_agent is True
assert ns.workdir == "/tmp/x"
def test_cron_edit_no_agent_tristate():
parser = _build()
# --no-agent -> True, --agent -> False, neither -> None
assert parser.parse_args(["cron", "edit", "j", "--no-agent"]).no_agent is True
assert parser.parse_args(["cron", "edit", "j", "--agent"]).no_agent is False
assert parser.parse_args(["cron", "edit", "j"]).no_agent is None
def test_cron_dispatch_func_is_injected_handler():
parser = _build()
ns = parser.parse_args(["cron", "list"])
assert ns.func is _sentinel_handler
def test_cron_accept_hooks_flag_on_run_and_tick():
parser = _build()
# --accept-hooks is suppressed-default; present only when passed.
ns = parser.parse_args(["cron", "run", "jid", "--accept-hooks"])
assert ns.accept_hooks is True
ns2 = parser.parse_args(["cron", "tick", "--accept-hooks"])
assert ns2.accept_hooks is True
@@ -0,0 +1,265 @@
"""Tests for `hermes curator archive` and `hermes curator prune`.
Covers:
- archive refuses pinned skills with an `unpin` hint
- archive returns 0/1 based on archive_skill() success
- prune filters pinned and already-archived, applies --days threshold
- prune falls back to created_at when last_activity_at is null
- prune --dry-run makes no state changes
- prune --yes skips confirmation
- prune --days validation
"""
from __future__ import annotations
from types import SimpleNamespace
def _ns(**kwargs):
return SimpleNamespace(**kwargs)
# ─── archive ────────────────────────────────────────────────────────────────
def test_archive_refuses_pinned(monkeypatch, capsys):
import hermes_cli.curator as curator_cli
import tools.skill_usage as skill_usage
monkeypatch.setattr(skill_usage, "get_record", lambda name: {"pinned": True})
called = []
monkeypatch.setattr(
skill_usage, "archive_skill",
lambda name: called.append(name) or (True, "should not get here"),
)
rc = curator_cli._cmd_archive(_ns(skill="pinned-skill"))
assert rc == 1
assert called == []
out = capsys.readouterr().out
assert "pinned" in out.lower()
assert "hermes curator unpin" in out
def test_archive_calls_archive_skill(monkeypatch, capsys):
import hermes_cli.curator as curator_cli
import tools.skill_usage as skill_usage
monkeypatch.setattr(skill_usage, "get_record", lambda name: {"pinned": False})
monkeypatch.setattr(
skill_usage, "archive_skill",
lambda name: (True, f"archived to .archive/{name}"),
)
rc = curator_cli._cmd_archive(_ns(skill="my-skill"))
assert rc == 0
assert "archived to .archive/my-skill" in capsys.readouterr().out
def test_archive_reports_failure(monkeypatch, capsys):
import hermes_cli.curator as curator_cli
import tools.skill_usage as skill_usage
monkeypatch.setattr(skill_usage, "get_record", lambda name: {"pinned": False})
monkeypatch.setattr(
skill_usage, "archive_skill",
lambda name: (False, f"skill '{name}' is bundled or hub-installed; never archive"),
)
rc = curator_cli._cmd_archive(_ns(skill="hub-slug"))
assert rc == 1
assert "bundled or hub-installed" in capsys.readouterr().out
# ─── prune ──────────────────────────────────────────────────────────────────
def _mk_record(name, *, idle_days=0, pinned=False, state="active", created_idle_days=None):
import datetime as _dt
now = _dt.datetime.now(_dt.timezone.utc)
last_activity = (now - _dt.timedelta(days=idle_days)).isoformat() if idle_days else None
created_delta = created_idle_days if created_idle_days is not None else idle_days
created = (now - _dt.timedelta(days=created_delta)).isoformat()
return {
"name": name,
"state": state,
"pinned": pinned,
"last_activity_at": last_activity,
"created_at": created,
"activity_count": 0 if idle_days == 0 and last_activity is None else 1,
}
def test_prune_days_validation(monkeypatch, capsys):
import hermes_cli.curator as curator_cli
rc = curator_cli._cmd_prune(_ns(days=0, yes=True, dry_run=False))
assert rc == 2
err = capsys.readouterr().err
assert "--days must be >= 1" in err
def test_prune_nothing_to_do(monkeypatch, capsys):
import hermes_cli.curator as curator_cli
import tools.skill_usage as skill_usage
monkeypatch.setattr(skill_usage, "agent_created_report", lambda: [])
rc = curator_cli._cmd_prune(_ns(days=30, yes=True, dry_run=False))
assert rc == 0
assert "nothing to prune" in capsys.readouterr().out
def test_prune_filters_pinned_and_archived(monkeypatch, capsys):
import hermes_cli.curator as curator_cli
import tools.skill_usage as skill_usage
rows = [
_mk_record("old-pinned", idle_days=200, pinned=True),
_mk_record("old-archived", idle_days=200, state="archived"),
_mk_record("recent", idle_days=10),
_mk_record("old-active", idle_days=200),
]
monkeypatch.setattr(skill_usage, "agent_created_report", lambda: rows)
archived = []
monkeypatch.setattr(
skill_usage, "archive_skill",
lambda name: archived.append(name) or (True, f"archived {name}"),
)
rc = curator_cli._cmd_prune(_ns(days=30, yes=True, dry_run=False))
assert rc == 0
assert archived == ["old-active"]
out = capsys.readouterr().out
assert "old-active" in out
assert "old-pinned" not in out
assert "old-archived" not in out
assert "recent" not in out
assert "archived 1/1" in out
def test_prune_falls_back_to_created_at_when_never_used(monkeypatch, capsys):
"""Never-used skills must be prunable via created_at — otherwise immortal."""
import hermes_cli.curator as curator_cli
import tools.skill_usage as skill_usage
rows = [_mk_record("never-used", idle_days=0, created_idle_days=200)]
# Force last_activity_at to None explicitly
rows[0]["last_activity_at"] = None
monkeypatch.setattr(skill_usage, "agent_created_report", lambda: rows)
archived = []
monkeypatch.setattr(
skill_usage, "archive_skill",
lambda name: archived.append(name) or (True, "ok"),
)
rc = curator_cli._cmd_prune(_ns(days=90, yes=True, dry_run=False))
assert rc == 0
assert archived == ["never-used"]
def test_prune_dry_run_makes_no_changes(monkeypatch, capsys):
import hermes_cli.curator as curator_cli
import tools.skill_usage as skill_usage
rows = [_mk_record("old-skill", idle_days=200)]
monkeypatch.setattr(skill_usage, "agent_created_report", lambda: rows)
archived = []
monkeypatch.setattr(
skill_usage, "archive_skill",
lambda name: archived.append(name) or (True, "ok"),
)
rc = curator_cli._cmd_prune(_ns(days=30, yes=True, dry_run=True))
assert rc == 0
assert archived == []
out = capsys.readouterr().out
assert "old-skill" in out
assert "dry run" in out
def test_prune_prompts_without_yes(monkeypatch, capsys):
import hermes_cli.curator as curator_cli
import tools.skill_usage as skill_usage
rows = [_mk_record("old-skill", idle_days=200)]
monkeypatch.setattr(skill_usage, "agent_created_report", lambda: rows)
archived = []
monkeypatch.setattr(
skill_usage, "archive_skill",
lambda name: archived.append(name) or (True, "ok"),
)
monkeypatch.setattr("builtins.input", lambda _prompt: "n")
rc = curator_cli._cmd_prune(_ns(days=30, yes=False, dry_run=False))
assert rc == 1
assert archived == []
assert "aborted" in capsys.readouterr().out
def test_prune_confirms_with_y(monkeypatch, capsys):
import hermes_cli.curator as curator_cli
import tools.skill_usage as skill_usage
rows = [_mk_record("old-skill", idle_days=200)]
monkeypatch.setattr(skill_usage, "agent_created_report", lambda: rows)
archived = []
monkeypatch.setattr(
skill_usage, "archive_skill",
lambda name: archived.append(name) or (True, "ok"),
)
monkeypatch.setattr("builtins.input", lambda _prompt: "y")
rc = curator_cli._cmd_prune(_ns(days=30, yes=False, dry_run=False))
assert rc == 0
assert archived == ["old-skill"]
def test_prune_reports_partial_failure(monkeypatch, capsys):
import hermes_cli.curator as curator_cli
import tools.skill_usage as skill_usage
rows = [
_mk_record("ok-skill", idle_days=200),
_mk_record("bad-skill", idle_days=200),
]
monkeypatch.setattr(skill_usage, "agent_created_report", lambda: rows)
def fake_archive(name):
if name == "bad-skill":
return False, "disk full"
return True, "ok"
monkeypatch.setattr(skill_usage, "archive_skill", fake_archive)
rc = curator_cli._cmd_prune(_ns(days=30, yes=True, dry_run=False))
assert rc == 1
out = capsys.readouterr().out
assert "archived 1/2" in out
assert "bad-skill: disk full" in out
# ─── argparse wiring ────────────────────────────────────────────────────────
def test_archive_and_prune_registered():
import argparse
import hermes_cli.curator as curator_cli
parser = argparse.ArgumentParser(prog="hermes curator")
curator_cli.register_cli(parser)
args = parser.parse_args(["archive", "my-skill"])
assert args.skill == "my-skill"
assert args.func.__name__ == "_cmd_archive"
args = parser.parse_args(["prune", "--days", "45", "--yes", "--dry-run"])
assert args.days == 45
assert args.yes is True
assert args.dry_run is True
assert args.func.__name__ == "_cmd_prune"
def test_prune_defaults():
import argparse
import hermes_cli.curator as curator_cli
parser = argparse.ArgumentParser(prog="hermes curator")
curator_cli.register_cli(parser)
args = parser.parse_args(["prune"])
assert args.days == 90
assert args.yes is False
assert args.dry_run is False
@@ -0,0 +1,162 @@
"""Tests for `_print_curator_recent_run_notice`.
The notice prints the most recent curator run summary on `hermes update`,
exactly once per run. Show-once is enforced by stamping
`last_run_summary_shown_at` in curator state after printing.
Why this matters: the curator runs in the background (gateway tick + CLI
session start) so users normally never see the rename map. `hermes update`
is the high-attention surface where consolidations should land.
"""
from __future__ import annotations
import importlib
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
@pytest.fixture
def curator_env(tmp_path, monkeypatch, capsys):
home = tmp_path / ".hermes"
home.mkdir()
(home / "skills").mkdir()
(home / "logs").mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
import hermes_constants
importlib.reload(hermes_constants)
from agent import curator
importlib.reload(curator)
from hermes_cli import main as hermes_main
importlib.reload(hermes_main)
yield {
"curator": curator,
"main": hermes_main,
"capsys": capsys,
}
def _set_state(curator_mod, **fields):
state = curator_mod.load_state()
state.update(fields)
curator_mod.save_state(state)
def test_silent_when_no_curator_run_yet(curator_env):
"""First-run notice handles this case; recent-run notice stays silent."""
curator_env["main"]._print_curator_recent_run_notice()
out = curator_env["capsys"].readouterr().out
assert "Skill curator — last run" not in out
def test_silent_when_summary_is_single_line(curator_env):
"""No archives = no rename map = nothing to surface. But still stamps shown."""
now = datetime.now(timezone.utc).isoformat()
_set_state(
curator_env["curator"],
last_run_at=now,
last_run_summary="auto: no changes; llm: no change",
)
curator_env["main"]._print_curator_recent_run_notice()
out = curator_env["capsys"].readouterr().out
assert "Skill curator — last run" not in out
# Should still mark shown so we don't reconsider on every update.
state = curator_env["curator"].load_state()
assert state["last_run_summary_shown_at"] == now
def test_prints_multiline_summary_with_rename_map(curator_env):
"""Multi-line summary (rename map appended) prints with timestamp + footer."""
now = datetime.now(timezone.utc).isoformat()
summary = (
"auto: 1 marked stale; llm: consolidated 2 into 1\n"
"archived 2 skill(s):\n"
" • pdf-extraction → document-tools\n"
" • docx-extraction → document-tools\n"
"full report: hermes curator status"
)
_set_state(
curator_env["curator"],
last_run_at=now,
last_run_summary=summary,
)
curator_env["main"]._print_curator_recent_run_notice()
out = curator_env["capsys"].readouterr().out
assert "Skill curator — last run" in out
assert "pdf-extraction → document-tools" in out
assert "docx-extraction → document-tools" in out
assert "shows once per curator run" in out
def test_show_once_semantics(curator_env):
"""Calling twice prints once; second call is silent until a new run lands."""
now = datetime.now(timezone.utc).isoformat()
summary = (
"auto: no changes; llm: consolidated 1 into 1\n"
"archived 1 skill(s):\n"
" • old → new\n"
"full report: hermes curator status"
)
_set_state(
curator_env["curator"],
last_run_at=now,
last_run_summary=summary,
)
curator_env["main"]._print_curator_recent_run_notice()
first = curator_env["capsys"].readouterr().out
assert "old → new" in first
curator_env["main"]._print_curator_recent_run_notice()
second = curator_env["capsys"].readouterr().out
assert second == "", "second call must be silent (already shown)"
def test_new_run_resets_show_once(curator_env):
"""A newer curator run with rename data prints again, even though one was already shown."""
older = (datetime.now(timezone.utc) - timedelta(hours=8)).isoformat()
_set_state(
curator_env["curator"],
last_run_at=older,
last_run_summary=(
"auto: no changes; llm: consolidated 1 into 1\n"
"archived 1 skill(s):\n"
" • thing-a → umbrella\n"
"full report: hermes curator status"
),
)
curator_env["main"]._print_curator_recent_run_notice()
curator_env["capsys"].readouterr() # drain
# New run lands.
newer = datetime.now(timezone.utc).isoformat()
_set_state(
curator_env["curator"],
last_run_at=newer,
last_run_summary=(
"auto: no changes; llm: consolidated 1 into 1\n"
"archived 1 skill(s):\n"
" • thing-b → umbrella\n"
"full report: hermes curator status"
),
)
curator_env["main"]._print_curator_recent_run_notice()
out = curator_env["capsys"].readouterr().out
assert "thing-b → umbrella" in out
assert "thing-a" not in out # only the newer run shows
def test_format_time_ago_buckets(curator_env):
"""Smoke test the time formatter — drives the `last run Xh ago` line."""
fmt = curator_env["main"]._format_time_ago
now = datetime.now(timezone.utc)
assert fmt((now - timedelta(seconds=10)).isoformat()) == "just now"
assert fmt((now - timedelta(minutes=5)).isoformat()) == "5m ago"
assert fmt((now - timedelta(hours=3)).isoformat()) == "3h ago"
assert fmt((now - timedelta(days=2)).isoformat()) == "2d ago"
assert fmt("not-a-real-iso-string") == "recently"
+87
View File
@@ -0,0 +1,87 @@
"""Tests for `hermes curator run` CLI behavior."""
from __future__ import annotations
from types import SimpleNamespace
def _args(**kwargs):
values = {
"dry_run": False,
"synchronous": False,
"background": False,
}
values.update(kwargs)
return SimpleNamespace(**values)
def test_run_defaults_to_synchronous(monkeypatch, capsys):
import agent.curator as curator_state
import hermes_cli.curator as curator_cli
calls = []
monkeypatch.setattr(curator_state, "is_enabled", lambda: True)
monkeypatch.setattr(
curator_state,
"run_curator_review",
lambda **kwargs: calls.append(kwargs) or {"auto_transitions": {}},
)
assert curator_cli._cmd_run(_args()) == 0
assert calls[0]["synchronous"] is True
assert calls[0]["dry_run"] is False
assert "background" not in capsys.readouterr().out
def test_run_background_opts_into_async(monkeypatch, capsys):
import agent.curator as curator_state
import hermes_cli.curator as curator_cli
calls = []
monkeypatch.setattr(curator_state, "is_enabled", lambda: True)
monkeypatch.setattr(
curator_state,
"run_curator_review",
lambda **kwargs: calls.append(kwargs) or {"auto_transitions": {}},
)
assert curator_cli._cmd_run(_args(background=True)) == 0
assert calls[0]["synchronous"] is False
assert "llm pass running in background" in capsys.readouterr().out
def test_run_sync_wins_over_background(monkeypatch):
import agent.curator as curator_state
import hermes_cli.curator as curator_cli
calls = []
monkeypatch.setattr(curator_state, "is_enabled", lambda: True)
monkeypatch.setattr(
curator_state,
"run_curator_review",
lambda **kwargs: calls.append(kwargs) or {"auto_transitions": {}},
)
assert curator_cli._cmd_run(_args(synchronous=True, background=True)) == 0
assert calls[0]["synchronous"] is True
def test_dry_run_default_reports_synchronous_wording(monkeypatch, capsys):
import agent.curator as curator_state
import hermes_cli.curator as curator_cli
monkeypatch.setattr(curator_state, "is_enabled", lambda: True)
monkeypatch.setattr(
curator_state,
"run_curator_review",
lambda **kwargs: {"auto_transitions": {}},
)
assert curator_cli._cmd_run(_args(dry_run=True)) == 0
out = capsys.readouterr().out
assert "When the report lands" not in out
assert "Read the report with `hermes curator status`" in out
+202
View File
@@ -0,0 +1,202 @@
"""Tests for `hermes curator status` output.
Covers:
- y0shualee's "least recently active" semantic (view/patch/use all count as activity).
- The most-used / least-used rankings by activity_count so users can see which
skills actually get exercised.
"""
from __future__ import annotations
import io
from argparse import Namespace
from contextlib import redirect_stdout
from pathlib import Path
from types import SimpleNamespace
import pytest
def test_status_uses_last_activity_not_only_last_used(monkeypatch, capsys):
import agent.curator as curator_state
import hermes_cli.curator as curator_cli
import tools.skill_usage as skill_usage
monkeypatch.setattr(curator_state, "load_state", lambda: {
"paused": False,
"last_run_at": None,
"last_run_summary": "(none)",
"run_count": 0,
})
monkeypatch.setattr(curator_state, "is_enabled", lambda: True)
monkeypatch.setattr(curator_state, "get_interval_hours", lambda: 168)
monkeypatch.setattr(curator_state, "get_stale_after_days", lambda: 30)
monkeypatch.setattr(curator_state, "get_archive_after_days", lambda: 90)
monkeypatch.setattr(skill_usage, "agent_created_report", lambda: [
{
"name": "recently-viewed",
"state": "active",
"pinned": False,
"use_count": 0,
"view_count": 3,
"patch_count": 1,
"created_at": "2026-01-01T00:00:00+00:00",
"last_used_at": None,
"last_viewed_at": "2026-04-30T10:00:00+00:00",
"last_patched_at": "2026-04-30T11:00:00+00:00",
"last_activity_at": "2026-04-30T11:00:00+00:00",
"activity_count": 4,
}
])
assert curator_cli._cmd_status(SimpleNamespace()) == 0
out = capsys.readouterr().out
assert "least recently active" in out
assert "activity= 4" in out
assert "last_activity=never" not in out
assert "last_used=never" not in out
@pytest.fixture
def curator_status_env(tmp_path, monkeypatch):
"""Isolated HERMES_HOME with real agent-created skills on disk."""
home = tmp_path / ".hermes"
skills = home / "skills"
skills.mkdir(parents=True)
(home / "logs").mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
import importlib
import hermes_constants
importlib.reload(hermes_constants)
from tools import skill_usage
importlib.reload(skill_usage)
from agent import curator
importlib.reload(curator)
from hermes_cli import curator as curator_cli
importlib.reload(curator_cli)
def _write_skill(name: str) -> None:
d = skills / name
d.mkdir()
(d / "SKILL.md").write_text(
"---\n"
f"name: {name}\n"
"description: test\n"
"version: 1.0.0\n"
"metadata:\n"
" hermes:\n"
" agent_created: true\n"
"---\n"
f"# {name}\n"
)
return {
"home": home,
"skills": skills,
"make_skill": _write_skill,
"skill_usage": skill_usage,
"curator_cli": curator_cli,
}
def _capture_status(curator_cli) -> str:
buf = io.StringIO()
with redirect_stdout(buf):
rc = curator_cli._cmd_status(Namespace())
assert rc == 0
return buf.getvalue()
def test_status_shows_most_and_least_used_sections(curator_status_env):
env = curator_status_env
env["make_skill"]("top-dog")
env["make_skill"]("middling")
env["make_skill"]("never-used")
# Mark all three as agent-created so they enter the curator's catalog.
# Under the provenance-marker semantics, skills must be explicitly opted
# into curator management (normally via the background-review fork when
# it creates a skill through skill_manage).
for n in ("top-dog", "middling", "never-used"):
env["skill_usage"].mark_agent_created(n)
# Bump use_count differentially. All three counters (use/view/patch) feed
# into activity_count, so bumping use alone is enough to make activity
# diverge between skills.
for _ in range(10):
env["skill_usage"].bump_use("top-dog")
for _ in range(2):
env["skill_usage"].bump_use("middling")
out = _capture_status(env["curator_cli"])
# Both new sections present
assert "most active (top 5):" in out
assert "least active (top 5):" in out
# y0shualee's section preserved
assert "least recently active (top 5):" in out
# most-active lists top-dog FIRST (highest activity_count)
most_section = out.split("most active (top 5):")[1].split("\n\n")[0]
top_line = most_section.strip().split("\n")[0]
assert "top-dog" in top_line
assert "activity= 10" in top_line
# least-active lists never-used FIRST (activity=0)
least_section = out.split("least active (top 5):")[1].split("\n\n")[0]
bottom_line = least_section.strip().split("\n")[0]
assert "never-used" in bottom_line
assert "activity= 0" in bottom_line
def test_status_hides_most_active_when_all_zero(curator_status_env):
"""If no skills have any activity, skip the most-active block — it's noise.
Least-active still shows so the user sees their catalog."""
env = curator_status_env
env["make_skill"]("a")
env["make_skill"]("b")
# Mark both as agent-created so the catalog lists them. No bumps.
env["skill_usage"].mark_agent_created("a")
env["skill_usage"].mark_agent_created("b")
out = _capture_status(env["curator_cli"])
# most-active section is hidden because the top is 0
assert "most active (top 5):" not in out
# least-active still renders — it's part of the catalog overview
assert "least active (top 5):" in out
def test_status_no_skills_produces_clean_empty_output(curator_status_env):
env = curator_status_env
out = _capture_status(env["curator_cli"])
assert "no agent-created skills" in out
# None of the ranking sections render
assert "most active" not in out
assert "least active" not in out
def test_status_marks_missing_last_report_path(monkeypatch, capsys, tmp_path):
import agent.curator as curator_state
import hermes_cli.curator as curator_cli
import tools.skill_usage as skill_usage
missing_report = tmp_path / "stale-report"
monkeypatch.setattr(curator_state, "load_state", lambda: {
"paused": False,
"last_run_at": None,
"last_run_summary": "auto: no changes",
"run_count": 1,
"last_report_path": str(missing_report),
})
monkeypatch.setattr(curator_state, "is_enabled", lambda: True)
monkeypatch.setattr(curator_state, "get_interval_hours", lambda: 168)
monkeypatch.setattr(curator_state, "get_stale_after_days", lambda: 30)
monkeypatch.setattr(curator_state, "get_archive_after_days", lambda: 90)
monkeypatch.setattr(skill_usage, "agent_created_report", lambda: [])
assert curator_cli._cmd_status(SimpleNamespace()) == 0
out = capsys.readouterr().out
assert f"last report: {missing_report} (missing)" in out
+109
View File
@@ -0,0 +1,109 @@
"""Regression tests for arrow-key decoding in the curses menus.
Root cause these guard against: on many terminals/terminfo entries, cursor
keys are delivered to ``getch()`` as raw CSI/SS3 escape byte sequences
(``27, 91, 66`` for arrow-down) even when ``keypad(True)`` is set. The menus
used to treat the leading ``27`` as ESC/cancel, which dumped the setup wizard's
provider/model picker into its numbered "Select [1-N]" fallback the instant a
user pressed up or down.
"""
import sys
import pytest
# curses (and its _curses C extension) is Unix-only; skip the whole module on Windows.
if sys.platform == "win32":
pytest.skip("curses is not available on Windows", allow_module_level=True)
import curses
from hermes_cli.curses_ui import (
NAV_CANCEL,
NAV_DOWN,
NAV_NONE,
NAV_SELECT,
NAV_UP,
read_menu_key,
)
class FakeStdscr:
"""Minimal stdscr stand-in that replays a queue of getch() byte returns.
``getch`` pops from ``keys``; an empty queue yields ``-1`` (matching curses
non-blocking behavior). ``timeout`` is recorded but otherwise inert.
"""
def __init__(self, keys):
self.keys = list(keys)
self.timeouts = []
def getch(self):
return self.keys.pop(0) if self.keys else -1
def timeout(self, ms):
self.timeouts.append(ms)
def test_raw_csi_arrow_down_decodes_to_down():
# ESC [ B -> down, NOT cancel
assert read_menu_key(FakeStdscr([27, ord("["), ord("B")])) == NAV_DOWN
def test_raw_csi_arrow_up_decodes_to_up():
# ESC [ A -> up
assert read_menu_key(FakeStdscr([27, ord("["), ord("A")])) == NAV_UP
def test_raw_ss3_arrow_keys_decode():
# Application cursor mode: ESC O B / ESC O A
assert read_menu_key(FakeStdscr([27, ord("O"), ord("B")])) == NAV_DOWN
assert read_menu_key(FakeStdscr([27, ord("O"), ord("A")])) == NAV_UP
def test_translated_key_constants_still_work():
assert read_menu_key(FakeStdscr([curses.KEY_DOWN])) == NAV_DOWN
assert read_menu_key(FakeStdscr([curses.KEY_UP])) == NAV_UP
def test_vim_keys():
assert read_menu_key(FakeStdscr([ord("j")])) == NAV_DOWN
assert read_menu_key(FakeStdscr([ord("k")])) == NAV_UP
def test_lone_escape_is_cancel():
# ESC with no continuation byte (getch returns -1) -> genuine cancel.
assert read_menu_key(FakeStdscr([27])) == NAV_CANCEL
def test_q_is_cancel():
assert read_menu_key(FakeStdscr([ord("q")])) == NAV_CANCEL
def test_enter_variants_select():
assert read_menu_key(FakeStdscr([10])) == NAV_SELECT
assert read_menu_key(FakeStdscr([13])) == NAV_SELECT
assert read_menu_key(FakeStdscr([curses.KEY_ENTER])) == NAV_SELECT
def test_unhandled_csi_sequence_is_consumed_and_ignored():
# Delete key (ESC [ 3 ~): must be swallowed whole and map to NAV_NONE so
# its tail bytes don't leak into a subsequent input() call.
fake = FakeStdscr([27, ord("["), ord("3"), ord("~"), ord("X")])
assert read_menu_key(fake) == NAV_NONE
# The trailing 'X' (a genuinely separate keypress) must remain unconsumed.
assert fake.keys == [ord("X")]
def test_home_end_csi_sequences_ignored():
# ESC [ H (Home) and ESC [ F (End) -> NAV_NONE, fully consumed.
assert read_menu_key(FakeStdscr([27, ord("["), ord("H")])) == NAV_NONE
assert read_menu_key(FakeStdscr([27, ord("["), ord("F")])) == NAV_NONE
def test_escape_uses_short_timeout_then_restores_blocking():
fake = FakeStdscr([27, ord("["), ord("B")])
read_menu_key(fake)
# A short positive timeout is set to wait for the continuation byte, then
# blocking mode (-1) is restored.
assert fake.timeouts[0] > 0
assert fake.timeouts[-1] == -1
@@ -0,0 +1,137 @@
"""Tests for curses color compatibility on low-color terminals (Docker).
Regression test for #13688: ``hermes plugins`` crashes with
``curses.error: init_pair() : color number is greater than COLORS-1``
in Docker containers where curses.COLORS == 8 (only colors 0-7 exist).
The bug was ``curses.init_pair(4, 8, -1)`` using raw color 8 ("bright
black" / dim gray) which does not exist on 8-color terminals. The fix
clamps with ``min(8, curses.COLORS - 1)``.
"""
import sys
import pytest
# curses (and its _curses C extension) is Unix-only; skip the whole module on Windows.
if sys.platform == "win32":
pytest.skip("curses is not available on Windows", allow_module_level=True)
import curses
import re
from pathlib import Path
from unittest.mock import patch, MagicMock
# Path to the source files under test
_SRC_ROOT = Path(__file__).parent.parent.parent / "hermes_cli"
class TestInitPairClampingBehavior:
"""Simulate curses color initialization on low-color terminals.
Patches curses.COLORS to 8 (Docker default) and verifies that
init_pair is never called with a color >= COLORS.
"""
def _collect_init_pair_calls(self, draw_fn, colors_value):
"""Run a curses draw function with a mock stdscr and patched COLORS.
Returns list of (pair_number, fg, bg) tuples from init_pair calls.
"""
calls = []
real_init_pair = curses.init_pair
def tracking_init_pair(pair, fg, bg):
calls.append((pair, fg, bg))
mock_stdscr = MagicMock()
mock_stdscr.getmaxyx.return_value = (24, 80)
mock_stdscr.getch.return_value = 27 # ESC to exit
with patch("curses.COLORS", colors_value, create=True), \
patch("curses.init_pair", side_effect=tracking_init_pair), \
patch("curses.has_colors", return_value=True), \
patch("curses.start_color"), \
patch("curses.use_default_colors"), \
patch("curses.curs_set"):
try:
draw_fn(mock_stdscr)
except (SystemExit, StopIteration, Exception):
pass # draw functions loop until keypress
return calls
def test_8_color_terminal_no_color_exceeds_limit(self):
"""On an 8-color terminal (Docker), no init_pair fg color >= 8."""
# Simulate the color init pattern from plugins_cmd.py
def _simulated_color_init(stdscr):
if curses.has_colors():
curses.start_color()
curses.use_default_colors()
curses.init_pair(1, curses.COLOR_GREEN, -1)
curses.init_pair(2, curses.COLOR_YELLOW, -1)
curses.init_pair(3, curses.COLOR_CYAN, -1)
curses.init_pair(4, 8 if curses.COLORS > 8 else curses.COLOR_WHITE, -1)
calls = self._collect_init_pair_calls(_simulated_color_init, 8)
for pair, fg, bg in calls:
assert fg < 8, (
f"init_pair({pair}, {fg}, {bg}) uses color {fg} which "
f"does not exist on an 8-color terminal (valid: 0-7)"
)
def test_256_color_terminal_uses_color_8(self):
"""On a 256-color terminal, color 8 (dim gray) should be used."""
def _simulated_color_init(stdscr):
if curses.has_colors():
curses.start_color()
curses.use_default_colors()
curses.init_pair(4, 8 if curses.COLORS > 8 else curses.COLOR_WHITE, -1)
calls = self._collect_init_pair_calls(_simulated_color_init, 256)
assert any(fg == 8 for _, fg, _ in calls), (
"On 256-color terminals, color 8 (dim gray) should be used"
)
def test_16_color_terminal_uses_color_8(self):
"""On a 16-color terminal, color 8 should be available."""
def _simulated_color_init(stdscr):
if curses.has_colors():
curses.start_color()
curses.use_default_colors()
curses.init_pair(4, 8 if curses.COLORS > 8 else curses.COLOR_WHITE, -1)
calls = self._collect_init_pair_calls(_simulated_color_init, 16)
assert any(fg == 8 for _, fg, _ in calls)
class TestSourceCodeGuardrails:
"""Regression guardrails: raw color 8 must not reappear in source.
These complement the behavioral tests above they catch regressions
introduced by copy-paste of the old pattern.
"""
_RAW_COLOR_8_PATTERN = re.compile(r'init_pair\(\d+,\s*8\s*,')
def test_no_raw_color_8_in_plugins_cmd(self):
source = (_SRC_ROOT / "plugins_cmd.py").read_text()
matches = self._RAW_COLOR_8_PATTERN.findall(source)
assert not matches, (
f"plugins_cmd.py contains unclamped color 8: {matches}"
)
def test_no_raw_color_8_in_main(self):
source = (_SRC_ROOT / "main.py").read_text()
matches = self._RAW_COLOR_8_PATTERN.findall(source)
assert not matches, (
f"main.py contains unclamped color 8: {matches}"
)
def test_no_raw_color_8_in_curses_ui(self):
source = (_SRC_ROOT / "curses_ui.py").read_text()
matches = self._RAW_COLOR_8_PATTERN.findall(source)
assert not matches, (
f"curses_ui.py contains unclamped color 8: {matches}"
)
@@ -0,0 +1,127 @@
"""Tests for the ranked fuzzy scorer used by the searchable curses pickers."""
from hermes_cli.curses_ui import (
_SearchState,
_filter_indices,
_fuzzy_score,
_handle_active_search_key,
_is_boundary,
_token_score,
)
class _FakeCurses:
KEY_BACKSPACE = 263
KEY_DOWN = 258
KEY_ENTER = 343
def test_fuzzy_score_matches_subsequence():
assert _fuzzy_score("gpt-4o", "g4o") is not None
assert _fuzzy_score("gpt-4o", "4o") is not None
assert _fuzzy_score("gpt-4o", "o4g") is None
assert _fuzzy_score("gpt-4o", "xyz") is None
def test_scorer_matches_typescript_reference():
"""Score parity with ui-tui/web fuzzy.ts. These exact values are produced
by the TS fuzzyScoreMulti for the same inputs (verified via a cross-language
harness); keep the Python port byte-identical so all three surfaces rank
consistently. If you change the scoring constants, update the TS copies too.
"""
cases = {
("gpt-4o", "g4o"): 15.94,
("gpt-4o", "gpt"): 28.94,
("claude-sonnet-4", "sonnet"): 33.85,
("claude-sonnet-4", "clad snnt"): 30.70,
("GptO", "gpto"): 57.96, # camelCase boundary on the original-case 'O'
}
for (label, query), expected in cases.items():
score = _fuzzy_score(label, query)
assert score is not None
assert round(score, 2) == expected, f"{label!r}/{query!r}: {score} != {expected}"
def test_is_boundary_camelcase_and_separators():
assert _is_boundary("gpt-4o", 0) is True # start
assert _is_boundary("gpt-4o", 4) is True # after '-'
assert _is_boundary("gpt-4o", 2) is False # mid-word
assert _is_boundary("GptO", 3) is True # lower->upper transition
def test_token_score_takes_orig_and_lower():
# Exact match (lower == token) earns the +20 bonus over a prefix.
exact = _token_score("sonnet", "sonnet", "sonnet")
prefix = _token_score("sonnet-x", "sonnet-x", "sonnet")
assert exact is not None and prefix is not None
assert exact > prefix
def test_esc_clears_query_and_signals_changed():
# Esc during active search clears the filter (restores full list) and
# signals `changed` so the driver resets scroll/cursor.
search = _SearchState(active=True, query="gpt")
handled, confirm, changed = _handle_active_search_key(_FakeCurses, 27, search)
assert (handled, confirm, changed) == (True, False, True)
assert search.active is False
assert search.query == ""
# Esc with no query: still stops search, but nothing changed.
search2 = _SearchState(active=True, query="")
assert _handle_active_search_key(_FakeCurses, 27, search2) == (True, False, False)
def test_high_byte_keys_ignored():
# Bytes 128-255 must NOT append Latin-1 mojibake to the query.
search = _SearchState(active=True, query="ab")
handled, _, changed = _handle_active_search_key(_FakeCurses, 200, search)
assert (handled, changed) == (False, False)
assert search.query == "ab"
def test_fuzzy_score_empty_query_is_zero():
assert _fuzzy_score("anything", "") == 0
assert _fuzzy_score("anything", " ") == 0
def test_fuzzy_score_prefix_beats_scattered():
prefix = _fuzzy_score("gpt-4o-mini", "gpt")
scattered = _fuzzy_score("a-g-p-t", "gpt")
assert prefix is not None and scattered is not None
assert prefix > scattered
def test_fuzzy_score_exact_and_shorter_rank_higher():
exact = _fuzzy_score("sonnet", "sonnet")
longer = _fuzzy_score("sonnet-extended", "sonnet")
assert exact is not None and longer is not None
# Same prefix match, but the shorter id wins on the length tiebreak.
assert exact > longer
def test_filter_indices_ranks_best_first():
models = ["gpt-4o", "gpt-4o-mini", "claude-sonnet-4", "claude-haiku", "o1-preview"]
# g4o matches both gpt-4o variants; the shorter exact-ish one ranks first.
ranked = _filter_indices(models, "g4o")
assert [models[i] for i in ranked] == ["gpt-4o", "gpt-4o-mini"]
# son4 surfaces the sonnet model.
assert [models[i] for i in _filter_indices(models, "son4")] == ["claude-sonnet-4"]
# Multi-token AND.
assert [models[i] for i in _filter_indices(models, "clad snnt")] == ["claude-sonnet-4"]
# No match drops everything.
assert _filter_indices(models, "zzz") == []
def test_filter_indices_blank_query_preserves_order():
models = ["b", "a", "c"]
assert _filter_indices(models, "") == [0, 1, 2]
assert _filter_indices(models, " ") == [0, 1, 2]
def test_filter_indices_stable_for_equal_scores():
# Identical labels score identically; original order is the tiebreak.
items = ["ab", "ab", "ab"]
assert _filter_indices(items, "ab") == [0, 1, 2]
+68
View File
@@ -0,0 +1,68 @@
from hermes_cli.curses_ui import (
_SearchState,
_filter_indices,
_handle_active_search_key,
_move_filtered_cursor,
_reconcile_cursor,
)
class _FakeCurses:
KEY_BACKSPACE = 263
KEY_DOWN = 258
KEY_ENTER = 343
def test_filter_indices_keeps_all_items_for_blank_query():
assert _filter_indices(["Anthropic", "OpenAI"], "") == [0, 1]
assert _filter_indices(["Anthropic", "OpenAI"], " ") == [0, 1]
def test_filter_indices_matches_subsequences():
items = ["claude-opus-4-7", "gpt-5.4-codex", "deepseek-v4"]
assert _filter_indices(items, "co47") == [0]
assert _filter_indices(items, "gpt5") == [1]
def test_filter_indices_requires_all_tokens():
items = ["OpenAI Codex", "OpenAI Chat Completions", "Anthropic Claude"]
assert _filter_indices(items, "open cod") == [0]
def test_reconcile_cursor_moves_to_first_visible_match():
assert _reconcile_cursor([2, 4], 0) == (2, 0)
assert _reconcile_cursor([2, 4], 4) == (4, 1)
def test_move_filtered_cursor_wraps_within_matches():
filtered = [2, 4, 7]
assert _move_filtered_cursor(filtered, 2, 0, -1) == 7
assert _move_filtered_cursor(filtered, 7, 2, 1) == 2
def test_active_search_allows_navigation_keys_to_reach_menu_loop():
search = _SearchState(active=True, query="opus")
assert _handle_active_search_key(_FakeCurses, _FakeCurses.KEY_DOWN, search) == (
False,
False,
False,
)
assert search.active is True
assert search.query == "opus"
def test_active_search_consumes_query_editing_and_confirm_keys():
search = _SearchState(active=True, query="op")
assert _handle_active_search_key(_FakeCurses, ord("u"), search) == (True, False, True)
assert search.query == "opu"
assert _handle_active_search_key(_FakeCurses, _FakeCurses.KEY_ENTER, search) == (
True,
True,
False,
)
@@ -0,0 +1,240 @@
"""Regression tests for custom_providers per-model context_length resolution.
Covers the fix for #15779 — mid-session /model switch to a named custom
provider must honor ``custom_providers[].models.<id>.context_length`` the
same way startup already does.
"""
from __future__ import annotations
from unittest.mock import patch
from hermes_cli.config import get_custom_provider_context_length
class TestGetCustomProviderContextLength:
def test_returns_override_for_matching_entry(self):
custom = [
{
"name": "my-endpoint",
"base_url": "https://example.invalid/v1",
"models": {"gpt-5.5": {"context_length": 1_050_000}},
}
]
assert (
get_custom_provider_context_length(
"gpt-5.5", "https://example.invalid/v1", custom
)
== 1_050_000
)
def test_trailing_slash_insensitive(self):
custom = [
{
"base_url": "https://example.invalid/v1/",
"models": {"m": {"context_length": 500_000}},
}
]
# config has trailing slash, runtime doesn't — must match
assert (
get_custom_provider_context_length(
"m", "https://example.invalid/v1", custom
)
== 500_000
)
# and the reverse
custom2 = [
{
"base_url": "https://example.invalid/v1",
"models": {"m": {"context_length": 500_000}},
}
]
assert (
get_custom_provider_context_length(
"m", "https://example.invalid/v1/", custom2
)
== 500_000
)
def test_returns_none_when_url_does_not_match(self):
custom = [
{
"base_url": "https://example.invalid/v1",
"models": {"m": {"context_length": 400_000}},
}
]
assert (
get_custom_provider_context_length(
"m", "https://other.invalid/v1", custom
)
is None
)
def test_returns_none_when_model_does_not_match(self):
custom = [
{
"base_url": "https://example.invalid/v1",
"models": {"gpt-5.5": {"context_length": 400_000}},
}
]
assert (
get_custom_provider_context_length(
"different-model", "https://example.invalid/v1", custom
)
is None
)
def test_returns_none_for_string_value(self):
"""'256K' string is not a valid int — skip silently.
(The inline startup path still emits a user-visible warning; the
helper itself returns None so downstream fallbacks can run.)
"""
custom = [
{
"base_url": "https://example.invalid/v1",
"models": {"m": {"context_length": "256K"}},
}
]
assert (
get_custom_provider_context_length(
"m", "https://example.invalid/v1", custom
)
is None
)
def test_returns_none_for_zero_or_negative(self):
for bad in (0, -1, -100):
custom = [
{
"base_url": "https://example.invalid/v1",
"models": {"m": {"context_length": bad}},
}
]
assert (
get_custom_provider_context_length(
"m", "https://example.invalid/v1", custom
)
is None
), f"value {bad!r} should be rejected"
def test_empty_inputs_return_none(self):
assert get_custom_provider_context_length("", "http://x", [{"base_url": "http://x", "models": {"": {"context_length": 1}}}]) is None
assert get_custom_provider_context_length("m", "", [{"base_url": "", "models": {"m": {"context_length": 1}}}]) is None
assert get_custom_provider_context_length("m", "http://x", None) is None
assert get_custom_provider_context_length("m", "http://x", []) is None
def test_ignores_non_dict_entries(self):
"""Malformed entries must not crash the lookup."""
custom = [
"not a dict",
None,
{"base_url": "https://example.invalid/v1", "models": "not a dict"},
{"base_url": "https://example.invalid/v1", "models": {"m": "not a dict"}},
{
"base_url": "https://example.invalid/v1",
"models": {"m": {"context_length": 400_000}},
},
]
assert (
get_custom_provider_context_length(
"m", "https://example.invalid/v1", custom
)
== 400_000
)
class TestGetModelContextLengthHonorsOverride:
"""agent.model_metadata.get_model_context_length must honor the
custom_providers override at step 0b before any probe, cache hit,
or models.dev lookup can override it.
"""
def _mock_all_probes(self):
"""Context manager that disables every downstream resolution step."""
from agent import model_metadata as _mm
return [
patch.object(_mm, "get_cached_context_length", return_value=None),
patch.object(_mm, "fetch_endpoint_model_metadata", return_value={}),
patch.object(_mm, "fetch_model_metadata", return_value={}),
patch.object(_mm, "is_local_endpoint", return_value=False),
patch.object(_mm, "_is_known_provider_base_url", return_value=False),
]
def test_custom_providers_override_wins_over_default_fallback(self):
from agent.model_metadata import get_model_context_length
custom = [
{
"base_url": "https://example.invalid/v1",
"models": {"gpt-5.5": {"context_length": 1_050_000}},
}
]
patches = self._mock_all_probes()
for p in patches:
p.start()
try:
ctx = get_model_context_length(
"gpt-5.5",
base_url="https://example.invalid/v1",
provider="custom",
custom_providers=custom,
)
finally:
for p in patches:
p.stop()
assert ctx == 1_050_000
def test_explicit_config_context_length_still_wins(self):
"""Top-level model.context_length (step 0) outranks custom_providers (step 0b).
Users who set both should see the top-level value that's the
documented precedence and matches the long-standing step-0 behavior.
"""
from agent.model_metadata import get_model_context_length
custom = [
{
"base_url": "https://example.invalid/v1",
"models": {"m": {"context_length": 1_050_000}},
}
]
ctx = get_model_context_length(
"m",
base_url="https://example.invalid/v1",
provider="custom",
config_context_length=500_000, # explicit top-level wins
custom_providers=custom,
)
assert ctx == 500_000
def test_no_override_falls_through_to_default(self):
"""With custom_providers=None and all probes disabled, resolver
returns DEFAULT_FALLBACK_CONTEXT (256K after the stepdown bump).
"""
from agent.model_metadata import get_model_context_length, DEFAULT_FALLBACK_CONTEXT
patches = self._mock_all_probes()
for p in patches:
p.start()
try:
ctx = get_model_context_length(
"unknown-model",
base_url="https://example.invalid/v1",
provider="custom",
custom_providers=None,
)
finally:
for p in patches:
p.stop()
assert ctx == DEFAULT_FALLBACK_CONTEXT
class TestContextProbeTiers:
def test_256k_is_top_tier_and_default(self):
"""The stepdown probe starts at 256K and 256K is the new default."""
from agent.model_metadata import CONTEXT_PROBE_TIERS, DEFAULT_FALLBACK_CONTEXT
assert CONTEXT_PROBE_TIERS[0] == 256_000
assert DEFAULT_FALLBACK_CONTEXT == 256_000
# Tiers still descend monotonically
for a, b in zip(CONTEXT_PROBE_TIERS, CONTEXT_PROBE_TIERS[1:]):
assert a > b, f"tiers must strictly descend, got {a} then {b}"
# 128K is still a tier (users relying on it probe-down get there)
assert 128_000 in CONTEXT_PROBE_TIERS
@@ -0,0 +1,99 @@
"""Unit tests for find_custom_provider_identity (base_url → custom:<name>).
Reverse lookup used by tui_gateway session persistence to recover a named
``providers:`` / ``custom_providers:`` entry from the only durable fact the
session row keeps once the provider has been resolved to the literal string
"custom": the endpoint URL. See
tests/tui_gateway/test_custom_provider_session_persistence.py for the
end-to-end persist/resume round-trip.
"""
import hermes_cli.runtime_provider as rp
def test_matches_legacy_custom_providers_list(monkeypatch):
monkeypatch.setattr(
rp,
"load_config",
lambda: {
"custom_providers": [
{"name": "MiMo v2.5 Pro", "base_url": "https://api.mimo.example/v1"}
]
},
)
assert (
rp.find_custom_provider_identity("https://api.mimo.example/v1")
== "custom:mimo-v2.5-pro"
)
def test_matches_providers_dict_by_key(monkeypatch):
monkeypatch.setattr(
rp,
"load_config",
lambda: {"providers": {"local": {"api": "http://127.0.0.1:8000/v1"}}},
)
assert (
rp.find_custom_provider_identity("http://127.0.0.1:8000/v1")
== "custom:local"
)
def test_match_ignores_trailing_slash_and_case(monkeypatch):
monkeypatch.setattr(
rp,
"load_config",
lambda: {
"custom_providers": [
{"name": "local", "base_url": "http://Localhost:8000/v1/"}
]
},
)
assert (
rp.find_custom_provider_identity("http://localhost:8000/v1")
== "custom:local"
)
def test_no_match_returns_none(monkeypatch):
monkeypatch.setattr(
rp,
"load_config",
lambda: {
"custom_providers": [
{"name": "other", "base_url": "https://elsewhere.example/v1"}
]
},
)
assert rp.find_custom_provider_identity("https://api.mimo.example/v1") is None
def test_empty_base_url_returns_none(monkeypatch):
monkeypatch.setattr(
rp, "load_config", lambda: {"custom_providers": [{"name": "x"}]}
)
assert rp.find_custom_provider_identity("") is None
assert rp.find_custom_provider_identity(None) is None
def test_identity_resolves_back_through_named_lookup(monkeypatch):
"""The returned slug must be accepted by _get_named_custom_provider —
that is the whole point of persisting it."""
config = {
"custom_providers": [
{
"name": "mimo-v2.5-pro",
"base_url": "https://api.mimo.example/v1",
"api_key": "sk-entry",
}
]
}
monkeypatch.setattr(rp, "load_config", lambda: config)
slug = rp.find_custom_provider_identity("https://api.mimo.example/v1")
assert slug == "custom:mimo-v2.5-pro"
entry = rp._get_named_custom_provider(slug)
assert entry is not None
assert entry["base_url"] == "https://api.mimo.example/v1"
assert entry["api_key"] == "sk-entry"
@@ -0,0 +1,695 @@
"""Tests that `hermes model` always shows the model selection menu for custom
providers, even when a model is already saved.
Regression test for the bug where _model_flow_named_custom() returned
immediately when provider_info had a saved ``model`` field, making it
impossible to switch models on multi-model endpoints.
"""
from unittest.mock import patch
import pytest
@pytest.fixture
def config_home(tmp_path, monkeypatch):
"""Isolated HERMES_HOME with a minimal config."""
home = tmp_path / "hermes"
home.mkdir()
config_yaml = home / "config.yaml"
config_yaml.write_text("model: old-model\ncustom_providers: []\n")
env_file = home / ".env"
env_file.write_text("")
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.delenv("HERMES_MODEL", raising=False)
monkeypatch.delenv("LLM_MODEL", raising=False)
monkeypatch.delenv("HERMES_INFERENCE_PROVIDER", raising=False)
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
return home
class TestCustomProviderModelSwitch:
"""Ensure _model_flow_named_custom always probes and shows menu."""
def test_saved_model_still_probes_endpoint(self, config_home):
"""When a model is already saved, the function must still call
fetch_api_models to probe the endpoint not skip with early return."""
from hermes_cli.main import _model_flow_named_custom
provider_info = {
"name": "My vLLM",
"base_url": "https://vllm.example.com/v1",
"api_key": "sk-test",
"model": "model-A", # already saved
}
with patch("hermes_cli.models.fetch_api_models", return_value=["model-A", "model-B"]) as mock_fetch, \
patch("hermes_cli.curses_ui.curses_radiolist", side_effect=ImportError), \
patch("builtins.input", return_value="2"), \
patch("builtins.print"):
_model_flow_named_custom({}, provider_info)
# fetch_api_models MUST be called even though model was saved
mock_fetch.assert_called_once_with(
"sk-test",
"https://vllm.example.com/v1",
timeout=8.0,
)
def test_can_switch_to_different_model(self, config_home):
"""User selects a different model than the saved one."""
import yaml
from hermes_cli.main import _model_flow_named_custom
provider_info = {
"name": "My vLLM",
"base_url": "https://vllm.example.com/v1",
"api_key": "sk-test",
"model": "model-A",
}
with patch("hermes_cli.models.fetch_api_models", return_value=["model-A", "model-B"]), \
patch("hermes_cli.curses_ui.curses_radiolist", side_effect=ImportError), \
patch("builtins.input", return_value="2"), \
patch("builtins.print"):
_model_flow_named_custom({}, provider_info)
config = yaml.safe_load((config_home / "config.yaml").read_text()) or {}
model = config.get("model")
assert isinstance(model, dict)
assert model["default"] == "model-B"
def test_probe_failure_falls_back_to_saved(self, config_home):
"""When endpoint probe fails and user presses Enter, saved model is used."""
import yaml
from hermes_cli.main import _model_flow_named_custom
provider_info = {
"name": "My vLLM",
"base_url": "https://vllm.example.com/v1",
"api_key": "sk-test",
"model": "model-A",
}
# fetch returns empty list (probe failed), user presses Enter (empty input)
with patch("hermes_cli.models.fetch_api_models", return_value=[]), \
patch("builtins.input", return_value=""), \
patch("builtins.print"):
_model_flow_named_custom({}, provider_info)
config = yaml.safe_load((config_home / "config.yaml").read_text()) or {}
model = config.get("model")
assert isinstance(model, dict)
assert model["default"] == "model-A"
def test_no_saved_model_still_works(self, config_home):
"""First-time flow (no saved model) still works as before."""
import yaml
from hermes_cli.main import _model_flow_named_custom
provider_info = {
"name": "My vLLM",
"base_url": "https://vllm.example.com/v1",
"api_key": "sk-test",
# no "model" key
}
with patch("hermes_cli.models.fetch_api_models", return_value=["model-X"]), \
patch("hermes_cli.curses_ui.curses_radiolist", side_effect=ImportError), \
patch("builtins.input", return_value="1"), \
patch("builtins.print"):
_model_flow_named_custom({}, provider_info)
config = yaml.safe_load((config_home / "config.yaml").read_text()) or {}
model = config.get("model")
assert isinstance(model, dict)
assert model["default"] == "model-X"
def test_api_mode_set_from_provider_info(self, config_home):
"""When custom_providers entry has api_mode, it should be applied."""
import yaml
from hermes_cli.main import _model_flow_named_custom
provider_info = {
"name": "Anthropic Proxy",
"base_url": "https://proxy.example.com/anthropic",
"api_key": "***",
"model": "claude-3",
"api_mode": "anthropic_messages",
}
with patch("hermes_cli.models.fetch_api_models", return_value=["claude-3"]) as mock_fetch, \
patch("hermes_cli.curses_ui.curses_radiolist", side_effect=ImportError), \
patch("builtins.input", return_value="1"), \
patch("builtins.print"):
_model_flow_named_custom({}, provider_info)
mock_fetch.assert_called_once_with(
"***",
"https://proxy.example.com/anthropic",
timeout=8.0,
api_mode="anthropic_messages",
)
config = yaml.safe_load((config_home / "config.yaml").read_text()) or {}
model = config.get("model")
assert isinstance(model, dict)
assert model.get("api_mode") == "anthropic_messages"
def test_api_mode_cleared_when_not_specified(self, config_home):
"""When custom_providers entry has no api_mode, stale api_mode is removed."""
import yaml
from hermes_cli.main import _model_flow_named_custom
# Pre-seed a stale api_mode in config
config_path = config_home / "config.yaml"
config_path.write_text(yaml.dump({"model": {"api_mode": "anthropic_messages"}}))
provider_info = {
"name": "My vLLM",
"base_url": "https://vllm.example.com/v1",
"api_key": "***",
"model": "llama-3",
}
with patch("hermes_cli.models.fetch_api_models", return_value=["llama-3"]), \
patch("hermes_cli.curses_ui.curses_radiolist", side_effect=ImportError), \
patch("builtins.input", return_value="1"), \
patch("builtins.print"):
_model_flow_named_custom({}, provider_info)
config = yaml.safe_load((config_home / "config.yaml").read_text()) or {}
model = config.get("model")
assert isinstance(model, dict)
assert "api_mode" not in model, "Stale api_mode should be removed"
def test_env_template_api_key_is_preserved_in_model_config(self, config_home, monkeypatch):
"""Selecting an env-backed custom provider must not inline the secret."""
import yaml
from hermes_cli.main import _model_flow_named_custom
config_path = config_home / "config.yaml"
config_path.write_text(
"model:\n"
" default: old-model\n"
" provider: openrouter\n"
"custom_providers:\n"
"- name: Example Provider\n"
" base_url: https://api.example-provider.test/v1\n"
" api_key: ${EXAMPLE_PROVIDER_API_KEY}\n"
" model: qwen3.6-35b-fast\n"
)
monkeypatch.setenv("EXAMPLE_PROVIDER_API_KEY", "sk-live-example-provider")
provider_info = {
"name": "Example Provider",
"base_url": "https://api.example-provider.test/v1",
"api_key": "sk-live-example-provider",
"api_key_ref": "${EXAMPLE_PROVIDER_API_KEY}",
"model": "qwen3.6-35b-fast",
}
with patch("hermes_cli.models.fetch_api_models", return_value=["qwen3.6-35b-fast"]) as mock_fetch, \
patch("hermes_cli.curses_ui.curses_radiolist", side_effect=ImportError), \
patch("builtins.input", return_value="1"), \
patch("builtins.print"):
_model_flow_named_custom({}, provider_info)
mock_fetch.assert_called_once_with(
"sk-live-example-provider",
"https://api.example-provider.test/v1",
timeout=8.0,
)
config = yaml.safe_load(config_path.read_text()) or {}
assert config["model"]["api_key"] == "${EXAMPLE_PROVIDER_API_KEY}"
assert config["custom_providers"][0]["api_key"] == "${EXAMPLE_PROVIDER_API_KEY}"
assert "sk-live-example-provider" not in config_path.read_text()
def test_key_env_custom_provider_persists_reference_not_secret(self, config_home, monkeypatch):
"""key_env custom providers should also avoid writing plaintext keys."""
import yaml
from hermes_cli.main import _model_flow_named_custom
config_path = config_home / "config.yaml"
config_path.write_text(
"model:\n"
" default: old-model\n"
"custom_providers:\n"
"- name: Example Provider\n"
" base_url: https://api.example-provider.test/v1\n"
" key_env: EXAMPLE_PROVIDER_API_KEY\n"
" model: qwen3.6-35b-fast\n"
)
monkeypatch.setenv("EXAMPLE_PROVIDER_API_KEY", "sk-live-example-provider")
provider_info = {
"name": "Example Provider",
"base_url": "https://api.example-provider.test/v1",
"api_key": "",
"key_env": "EXAMPLE_PROVIDER_API_KEY",
"model": "qwen3.6-35b-fast",
}
with patch("hermes_cli.models.fetch_api_models", return_value=["qwen3.6-35b-fast"]), \
patch("hermes_cli.curses_ui.curses_radiolist", side_effect=ImportError), \
patch("builtins.input", return_value="1"), \
patch("builtins.print"):
_model_flow_named_custom({}, provider_info)
config = yaml.safe_load(config_path.read_text()) or {}
assert config["model"]["api_key"] == "${EXAMPLE_PROVIDER_API_KEY}"
assert config["custom_providers"][0]["key_env"] == "EXAMPLE_PROVIDER_API_KEY"
assert "sk-live-example-provider" not in config_path.read_text()
def test_env_ref_base_url_preserves_api_key_ref_through_picker(
self, config_home, monkeypatch
):
"""Integration regression: when BOTH ``base_url`` and ``api_key`` use
``${VAR}`` templates (the Discord-reported NeuralWatt case), the picker
must still preserve the env reference in ``model.api_key``.
The earlier lookup went through ``get_compatible_custom_providers``
which dropped entries whose ``base_url`` was an env-ref template
(``urlparse("${NEURALWATT_API_BASE}")`` has no scheme/netloc), causing
``api_key_ref`` to stay empty and the resolved secret to be written to
``config.yaml``. This test drives the real picker-callsite code path.
"""
import yaml
from hermes_cli.main import select_provider_and_model
config_path = config_home / "config.yaml"
config_path.write_text(
"model:\n"
" default: old-model\n"
" provider: openrouter\n"
"custom_providers:\n"
"- name: NeuralWatt\n"
" base_url: ${NEURALWATT_API_BASE}\n"
" api_key: ${NEURALWATT_API_KEY}\n"
" model: qwen3.6-35b-fast\n"
" models: []\n"
)
monkeypatch.setenv("NEURALWATT_API_BASE", "https://api.neuralwatt.com/v1")
monkeypatch.setenv("NEURALWATT_API_KEY", "sk-live-neuralwatt-secret")
# Exercise the real picker: select "custom:neuralwatt" from the
# provider menu. ``select_provider_and_model`` prompts for a provider
# choice (returns an index), then hands off to
# ``_model_flow_named_custom`` with the provider_info built by
# ``_named_custom_provider_map``.
def _pick_neuralwatt(labels, default=0):
for i, label in enumerate(labels):
if "NeuralWatt" in label:
return i
raise AssertionError(
f"NeuralWatt entry missing from provider menu: {labels}"
)
with patch("hermes_cli.main._prompt_provider_choice",
side_effect=_pick_neuralwatt), \
patch("hermes_cli.models.fetch_api_models",
return_value=["qwen3.6-35b-fast"]) as mock_fetch, \
patch("hermes_cli.curses_ui.curses_radiolist", side_effect=ImportError), \
patch("builtins.input", return_value="1"), \
patch("builtins.print"):
select_provider_and_model()
# The live probe must still use the resolved secret.
mock_fetch.assert_called_once()
probe_args, probe_kwargs = mock_fetch.call_args
assert probe_args[0] == "sk-live-neuralwatt-secret"
# But config.yaml must keep the env reference, not the plaintext secret.
saved = config_path.read_text()
config = yaml.safe_load(saved) or {}
assert config["model"]["api_key"] == "${NEURALWATT_API_KEY}"
assert config["custom_providers"][0]["api_key"] == "${NEURALWATT_API_KEY}"
assert "sk-live-neuralwatt-secret" not in saved
def test_bare_custom_current_provider_matches_env_base_url_before_first_fallback(
self, config_home, monkeypatch
):
"""`hermes model` must mark the custom provider matching model.base_url
as current instead of falling back to the first saved custom provider.
Regression: with ``model.provider: custom`` and multiple
``custom_providers`` entries, the CLI resolved bare ``custom`` through
``resolve_custom_provider()``, whose compatibility fallback returns the
first entry. A config with Cerebras first and NeuralWatt active then
showed Cerebras as current.
"""
from hermes_cli.main import select_provider_and_model
config_path = config_home / "config.yaml"
config_path.write_text(
"model:\n"
" default: kimi-k2.6-fast\n"
" provider: custom\n"
" base_url: ${NEURALWATT_API_BASE}\n"
" api_key: ${NEURALWATT_API_KEY}\n"
"providers: {}\n"
"custom_providers:\n"
"- name: Cerebras.ai\n"
" base_url: ${CEREBRAS_API_BASE}\n"
" api_key: ${CEREBRAS_API_KEY}\n"
" model: qwen-3-235b-a22b-instruct-2507\n"
" models: []\n"
"- name: NeuralWatt\n"
" base_url: ${NEURALWATT_API_BASE}\n"
" api_key: ${NEURALWATT_API_KEY}\n"
" model: kimi-k2.6-fast\n"
" models: []\n"
)
monkeypatch.setenv("CEREBRAS_API_BASE", "https://api.cerebras.ai/v1")
monkeypatch.setenv("CEREBRAS_API_KEY", "sk-live-cerebras-secret")
monkeypatch.setenv("NEURALWATT_API_BASE", "https://api.neuralwatt.com/v1")
monkeypatch.setenv("NEURALWATT_API_KEY", "sk-live-neuralwatt-secret")
captured: dict = {}
def _capture_and_cancel(labels, default=0):
captured["labels"] = labels
captured["default"] = default
return len(labels) - 1 # Leave unchanged
with patch("hermes_cli.main._prompt_provider_choice",
side_effect=_capture_and_cancel), \
patch("builtins.print"):
select_provider_and_model()
labels = captured["labels"]
default_label = labels[captured["default"]]
assert "NeuralWatt" in default_label
assert "currently active" in default_label
assert "Cerebras.ai" not in default_label
assert not any(
"Cerebras.ai" in label and "currently active" in label
for label in labels
)
def test_named_custom_provider_selection_preserves_base_url_env_ref(
self, config_home, monkeypatch
):
"""Selecting an env-backed custom provider should not expand its
``base_url`` template into ``model.base_url`` on disk."""
import yaml
from hermes_cli.main import select_provider_and_model
config_path = config_home / "config.yaml"
config_path.write_text(
"model:\n"
" default: old-model\n"
" provider: openrouter\n"
"custom_providers:\n"
"- name: NeuralWatt\n"
" base_url: ${NEURALWATT_API_BASE}\n"
" api_key: ${NEURALWATT_API_KEY}\n"
" model: qwen3.6-35b-fast\n"
" models: []\n"
)
monkeypatch.setenv("NEURALWATT_API_BASE", "https://api.neuralwatt.com/v1")
monkeypatch.setenv("NEURALWATT_API_KEY", "sk-live-neuralwatt-secret")
def _pick_neuralwatt(labels, default=0):
for i, label in enumerate(labels):
if "NeuralWatt" in label:
return i
raise AssertionError(
f"NeuralWatt entry missing from provider menu: {labels}"
)
with patch("hermes_cli.main._prompt_provider_choice",
side_effect=_pick_neuralwatt), \
patch("hermes_cli.models.fetch_api_models",
return_value=["qwen3.6-35b-fast"]) as mock_fetch, \
patch("hermes_cli.curses_ui.curses_radiolist", side_effect=ImportError), \
patch("builtins.input", return_value="1"), \
patch("builtins.print"):
select_provider_and_model()
mock_fetch.assert_called_once()
probe_args, _ = mock_fetch.call_args
assert probe_args[1] == "https://api.neuralwatt.com/v1"
saved = config_path.read_text()
config = yaml.safe_load(saved) or {}
assert config["model"]["base_url"] == "${NEURALWATT_API_BASE}"
assert config["model"]["api_key"] == "${NEURALWATT_API_KEY}"
assert "https://api.neuralwatt.com/v1" not in saved
assert "sk-live-neuralwatt-secret" not in saved
def test_key_env_providers_dict_entry_does_not_add_api_key(
self, config_home, monkeypatch
):
"""Regression for #15803: a ``providers:`` (keyed-schema) entry that
relies on ``key_env`` must not gain an ``api_key`` field after the
model picker runs.
Before the fix, ``_model_flow_named_custom`` synthesized
``api_key: ${KEY_ENV}`` from the resolved secret and wrote it to the
``providers.<key>`` entry, cluttering configs that intentionally keep
credentials out of ``config.yaml``. The entry already carries
``key_env``; the runtime resolves it directly, so no inline
``api_key`` belongs on disk.
"""
import yaml
from hermes_cli.main import _model_flow_named_custom
config_path = config_home / "config.yaml"
config_path.write_text(
"providers:\n"
" crs-henkee:\n"
" name: CRS Henkee\n"
" base_url: http://127.0.0.1:3000/api/v1\n"
" key_env: HERMES_CRS_HENKEE_KEY\n"
" transport: anthropic_messages\n"
" model: claude-opus-4-7\n"
" default_model: claude-opus-4-7\n"
"custom_providers: []\n"
)
monkeypatch.setenv("HERMES_CRS_HENKEE_KEY", "cr_live_secret_xyz")
# provider_info as built by _named_custom_provider_map for a
# ``providers:`` entry that has key_env but no inline api_key.
provider_info = {
"name": "CRS Henkee",
"base_url": "http://127.0.0.1:3000/api/v1",
"api_key": "",
"key_env": "HERMES_CRS_HENKEE_KEY",
"model": "claude-opus-4-7",
"api_mode": "anthropic_messages",
"provider_key": "crs-henkee",
"api_key_ref": "",
}
with patch(
"hermes_cli.models.fetch_api_models",
return_value=["claude-opus-4-7"],
) as mock_fetch, \
patch("hermes_cli.curses_ui.curses_radiolist", side_effect=ImportError), \
patch("builtins.input", return_value="1"), \
patch("builtins.print"):
_model_flow_named_custom({}, provider_info)
# The /models probe must resolve the secret from the env var.
mock_fetch.assert_called_once()
probe_args, _ = mock_fetch.call_args
assert probe_args[0] == "cr_live_secret_xyz"
# The providers entry must NOT gain an api_key field — neither the
# plaintext secret nor a synthesized ${KEY_ENV} template.
saved_text = config_path.read_text()
saved = yaml.safe_load(saved_text) or {}
entry = saved["providers"]["crs-henkee"]
assert "api_key" not in entry, (
f"providers.crs-henkee gained an api_key field: {entry.get('api_key')!r}"
)
assert entry["key_env"] == "HERMES_CRS_HENKEE_KEY"
assert entry["default_model"] == "claude-opus-4-7"
# And the plaintext secret must never appear anywhere on disk.
assert "cr_live_secret_xyz" not in saved_text
# The synthesized template is also redundant here — key_env owns it.
assert "${HERMES_CRS_HENKEE_KEY}" not in saved_text
def test_key_env_providers_dict_preserves_existing_api_key(
self, config_home, monkeypatch
):
"""A ``providers:`` entry that already has an inline ``api_key``
template must keep it untouched. Only entries that never declared
an ``api_key`` should skip the write."""
import yaml
from hermes_cli.main import _model_flow_named_custom
config_path = config_home / "config.yaml"
config_path.write_text(
"providers:\n"
" crs-henkee:\n"
" name: CRS Henkee\n"
" base_url: http://127.0.0.1:3000/api/v1\n"
" api_key: ${HERMES_CRS_HENKEE_KEY}\n"
" key_env: HERMES_CRS_HENKEE_KEY\n"
" transport: anthropic_messages\n"
" model: claude-opus-4-7\n"
" default_model: claude-opus-4-7\n"
"custom_providers: []\n"
)
monkeypatch.setenv("HERMES_CRS_HENKEE_KEY", "cr_live_secret_xyz")
provider_info = {
"name": "CRS Henkee",
"base_url": "http://127.0.0.1:3000/api/v1",
"api_key": "cr_live_secret_xyz", # expanded by load_config
"key_env": "HERMES_CRS_HENKEE_KEY",
"model": "claude-opus-4-7",
"api_mode": "anthropic_messages",
"provider_key": "crs-henkee",
"api_key_ref": "${HERMES_CRS_HENKEE_KEY}", # raw template preserved
}
with patch(
"hermes_cli.models.fetch_api_models",
return_value=["claude-opus-4-7"],
), \
patch("hermes_cli.curses_ui.curses_radiolist", side_effect=ImportError), \
patch("builtins.input", return_value="1"), \
patch("builtins.print"):
_model_flow_named_custom({}, provider_info)
saved_text = config_path.read_text()
saved = yaml.safe_load(saved_text) or {}
entry = saved["providers"]["crs-henkee"]
# Existing api_key template must survive (the resolved secret must not
# clobber it via _preserve_env_ref_templates).
assert entry["api_key"] == "${HERMES_CRS_HENKEE_KEY}"
assert "cr_live_secret_xyz" not in saved_text
class TestCustomProviderDiscoverModels:
"""#18726: honor ``discover_models: false`` in the terminal ``hermes model``
named-custom flow so the picker shows the configured ``models:`` subset
instead of the endpoint's full live catalog."""
def test_discover_false_uses_configured_list_and_skips_probe(self, config_home):
"""discover_models: false + configured models → no live probe, the
configured list is used verbatim."""
from hermes_cli.main import _model_flow_named_custom
provider_info = {
"name": "Baidu Coding",
"base_url": "https://qianfan.baidubce.com/v2/coding",
"api_key": "sk-test",
"discover_models": False,
"models": {"kimi-k2.5": {}, "glm-5": {}},
"model": "kimi-k2.5",
}
with patch("hermes_cli.models.fetch_api_models") as mock_fetch, \
patch("hermes_cli.curses_ui.curses_radiolist", side_effect=ImportError), \
patch("builtins.input", return_value="2"), \
patch("builtins.print"):
_model_flow_named_custom({}, provider_info)
# The live /models endpoint must NOT be probed when discovery is off.
mock_fetch.assert_not_called()
def test_discover_false_saves_choice_from_configured_list(self, config_home):
"""User picks the 2nd configured model; it persists, list-driven."""
import yaml
from hermes_cli.main import _model_flow_named_custom
provider_info = {
"name": "Baidu Coding",
"base_url": "https://qianfan.baidubce.com/v2/coding",
"api_key": "sk-test",
"discover_models": False,
"models": {"kimi-k2.5": {}, "glm-5": {}},
"model": "kimi-k2.5",
}
with patch("hermes_cli.models.fetch_api_models") as mock_fetch, \
patch("hermes_cli.curses_ui.curses_radiolist", side_effect=ImportError), \
patch("builtins.input", return_value="2"), \
patch("builtins.print"):
_model_flow_named_custom({}, provider_info)
mock_fetch.assert_not_called()
config = yaml.safe_load((config_home / "config.yaml").read_text()) or {}
model = config.get("model")
assert isinstance(model, dict)
assert model["default"] == "glm-5"
def test_default_still_probes_when_discover_unset(self, config_home):
"""Default (discover_models unset → True) keeps live-probe behaviour
even when a models: list is configured Option B opt-out semantics."""
from hermes_cli.main import _model_flow_named_custom
provider_info = {
"name": "My Gateway",
"base_url": "https://gw.example.com/v1",
"api_key": "sk-test",
"models": {"subset-a": {}}, # configured, but discovery NOT disabled
"model": "subset-a",
}
with patch(
"hermes_cli.models.fetch_api_models",
return_value=["live-a", "live-b", "live-c"],
) as mock_fetch, \
patch("hermes_cli.curses_ui.curses_radiolist", side_effect=ImportError), \
patch("builtins.input", return_value="1"), \
patch("builtins.print"):
_model_flow_named_custom({}, provider_info)
# Probe MUST still run — configured models: alone does not whitelist.
mock_fetch.assert_called_once_with(
"sk-test",
"https://gw.example.com/v1",
timeout=8.0,
)
def test_probe_empty_falls_back_to_configured_list(self, config_home):
"""When discovery is on but the probe returns nothing, fall back to the
configured models: list instead of forcing manual entry."""
import yaml
from hermes_cli.main import _model_flow_named_custom
provider_info = {
"name": "My Gateway",
"base_url": "https://gw.example.com/v1",
"api_key": "sk-test",
"models": {"fallback-a": {}, "fallback-b": {}},
"model": "fallback-a",
}
with patch("hermes_cli.models.fetch_api_models", return_value=[]), \
patch("hermes_cli.curses_ui.curses_radiolist", side_effect=ImportError), \
patch("builtins.input", return_value="2"), \
patch("builtins.print"):
_model_flow_named_custom({}, provider_info)
config = yaml.safe_load((config_home / "config.yaml").read_text()) or {}
model = config.get("model")
assert isinstance(model, dict)
assert model["default"] == "fallback-b"
def test_discover_false_string_is_normalised(self, config_home):
"""String 'false' (hand-edited configs) disables discovery too."""
from hermes_cli.main import _model_flow_named_custom
provider_info = {
"name": "Baidu Coding",
"base_url": "https://qianfan.baidubce.com/v2/coding",
"api_key": "sk-test",
"discover_models": "false",
"models": {"kimi-k2.5": {}, "glm-5": {}},
"model": "kimi-k2.5",
}
with patch("hermes_cli.models.fetch_api_models") as mock_fetch, \
patch("hermes_cli.curses_ui.curses_radiolist", side_effect=ImportError), \
patch("builtins.input", return_value="1"), \
patch("builtins.print"):
_model_flow_named_custom({}, provider_info)
mock_fetch.assert_not_called()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,723 @@
"""Phase 6 — 401 re-auth + ``next=`` propagation tests.
Verifies the contract documented in Phase 6 v2 of the plan:
- API 401 responses carry ``{"error", "login_url", ...}`` so the SPA
fetch wrapper can ``window.location.assign(body.login_url)``.
- The ``login_url`` embeds a ``next=<original-path>`` query string so
re-auth lands the user back where they were.
- HTML redirects ALSO carry ``next=``.
- ``next=`` validation: protocol-relative paths, absolute URLs, and
loops back to ``/login`` / ``/auth/*`` are dropped.
- Invalid/expired cookies are cleared on 401 so the browser doesn't
keep replaying them.
- ``set_session_cookies(refresh_token="")`` does NOT emit the
``hermes_session_rt`` cookie (contract V1: no RT to persist).
- ``/auth/callback?next=`` honours the same-origin landing path.
"""
from __future__ import annotations
from urllib.parse import quote
import pytest
# Phase 5 / Phase 6: these tests mutate ``web_server.app.state.auth_required``
# at module level. Run them in the same xdist worker so they don't race
# against each other (and against any other file that also touches
# ``app.state``) — the marker name is shared across all dashboard-auth test
# files that gate the app.
pytestmark = pytest.mark.xdist_group("dashboard_auth_app_state")
from fastapi import FastAPI
from fastapi.responses import Response
from fastapi.testclient import TestClient
from hermes_cli import web_server
from hermes_cli.dashboard_auth import clear_providers, register_provider
from hermes_cli.dashboard_auth.cookies import (
SESSION_AT_COOKIE,
SESSION_RT_COOKIE,
clear_session_cookies,
set_session_cookies,
)
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def gated_app():
clear_providers()
register_provider(StubAuthProvider())
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
prev_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.bound_host = "fly-app.fly.dev"
web_server.app.state.bound_port = 443
web_server.app.state.auth_required = True
client = TestClient(web_server.app, base_url="https://fly-app.fly.dev")
yield client
clear_providers()
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
web_server.app.state.auth_required = prev_required
# ---------------------------------------------------------------------------
# set_session_cookies(refresh_token="") skips the RT cookie
# ---------------------------------------------------------------------------
class TestRefreshTokenCookieDeprecation:
def _build_app(self, *, refresh_token: str):
app = FastAPI()
@app.get("/set")
def _set():
r = Response("ok")
set_session_cookies(
r, access_token="AT", refresh_token=refresh_token,
access_token_expires_in=3600, use_https=True,
)
return r
return app
def test_empty_refresh_token_does_not_emit_rt_cookie(self):
client = TestClient(self._build_app(refresh_token=""))
r = client.get("/set")
cookies = r.headers.get_list("set-cookie")
rt_cookies = [c for c in cookies if SESSION_RT_COOKIE in c]
assert rt_cookies == []
# AT cookie still set (whichever variant the request resolves to).
at_cookies = [c for c in cookies if SESSION_AT_COOKIE in c]
assert len(at_cookies) == 1
def test_present_refresh_token_still_emits_rt_cookie(self):
client = TestClient(self._build_app(refresh_token="forward-compat"))
r = client.get("/set")
cookies = r.headers.get_list("set-cookie")
rt_cookies = [c for c in cookies if SESSION_RT_COOKIE in c]
assert len(rt_cookies) == 1
assert "forward-compat" in rt_cookies[0]
def test_clear_session_cookies_still_emits_rt_deletion(self):
"""Even when we never wrote the RT cookie, logout/clear should
emit a Max-Age=0 deletion to flush stale cookies from old
deployments."""
app = FastAPI()
@app.get("/clear")
def _clear():
r = Response("ok")
clear_session_cookies(r)
return r
client = TestClient(app)
r = client.get("/clear")
cookies = r.headers.get_list("set-cookie")
assert any(
SESSION_RT_COOKIE in c and "Max-Age=0" in c
for c in cookies
)
# ---------------------------------------------------------------------------
# Gate middleware: 401 envelope + next= propagation
# ---------------------------------------------------------------------------
class TestApi401Envelope:
# NOTE: probe a gated route (``/api/sessions``) here rather than
# ``/api/status`` — status is in the shared ``PUBLIC_API_PATHS``
# allowlist (portal liveness probe) so it would 200 even without a
# cookie and never exercise the 401-envelope code path.
def test_no_cookie_returns_unauthenticated_envelope(self, gated_app):
r = gated_app.get("/api/sessions")
assert r.status_code == 401
body = r.json()
assert body["error"] == "unauthenticated"
assert "login_url" in body
assert body["login_url"].startswith("/login")
def test_invalid_cookie_returns_session_expired_envelope(self, gated_app):
gated_app.cookies.set(SESSION_AT_COOKIE, "garbage")
r = gated_app.get("/api/sessions")
assert r.status_code == 401
body = r.json()
assert body["error"] == "session_expired"
assert body["login_url"].startswith("/login")
def test_invalid_cookie_clears_dead_cookie(self, gated_app):
"""Dead-cookie cleanup — Phase 6 requirement so the browser
doesn't keep replaying the stale token on every request."""
gated_app.cookies.set(SESSION_AT_COOKIE, "garbage")
r = gated_app.get("/api/sessions")
set_cookies = r.headers.get_list("set-cookie")
assert any(
c.startswith(f"{SESSION_AT_COOKIE}=") and "Max-Age=0" in c
for c in set_cookies
)
def test_login_url_drops_next_for_deep_api_path(self, gated_app):
"""Bug fix: ``/api/*`` paths must NOT round-trip into ``next=``.
Before the fix, an unauthenticated SPA fetch like ``GET
/api/analytics/models?days=30`` from ModelsPage round-tripped
through the OAuth dance and landed the user on the raw JSON
endpoint instead of the dashboard. The gate now drops API paths
from ``next=`` entirely; the SPA's own ``hermes.lastLocation``
fallback in ``web/src/lib/api.ts`` covers the deep-link case.
"""
r = gated_app.get("/api/sessions?page=2")
body = r.json()
# ``login_url`` is the bare ``/login`` (no ``next=``) — the
# post-callback landing falls back to "/" rather than the API
# URL.
assert body["login_url"] == "/login"
assert "next=" not in body["login_url"]
def test_login_url_drops_next_for_analytics_path(self, gated_app):
"""Specific repro for the ``/api/analytics/models?days=30``
case Ben reported: page on /models, session expires, SPA fires
getModelsAnalytics(), 401 envelope carries ``next=``, user ends
up staring at JSON post-callback."""
r = gated_app.get("/api/analytics/models?days=30")
body = r.json()
assert body["login_url"] == "/login"
assert "next=" not in body["login_url"]
class TestTransparentRefreshOnAccessTokenEviction:
"""Regression: an expired access token whose cookie the browser has
ALREADY EVICTED must still transparently refresh via the RT cookie
not bounce to /login.
This is the common-path expiry bug, not an edge case. The access-token
cookie is set with ``Max-Age = access_token_expires_in`` (~15 min), so
the browser deletes ``hermes_session_at`` the instant the token lapses,
while ``hermes_session_rt`` lives for 30 days. From that moment the
browser sends ONLY the refresh-token cookie. The original gate bailed at
``if not at: return _unauth_response(...)`` bouncing the user to
/login on every single expiry despite holding a perfectly good refresh
token, defeating the entire transparent-refresh feature. The fix lets a
request carrying only the RT flow into the refresh path.
Discrimination: under the pre-fix code, scenario 1 (AT cookie absent,
RT present) returned 401/302 to login with NO rotated cookies and NO
REFRESH_SUCCESS the refresh code never ran. With the fix it returns
200 and rotates both cookies.
"""
def _build_rt_only_app(self):
"""Gate over the real app with a Stub provider whose RT is live
(default_ttl>0 so refresh succeeds). Mint a valid signed RT
directly (the stub's refresh_session only checks the RT's
signature + exp), then send ONLY that RT cookie.
"""
import time as _t
from tests.hermes_cli.conftest_dashboard_auth import _sign
clear_providers()
provider = StubAuthProvider(default_ttl=900)
register_provider(provider)
valid_rt = _sign(
{"sub": "stub-user-1", "kind": "refresh", "exp": int(_t.time()) + 30 * 86400}
)
return provider, valid_rt
def test_at_evicted_rt_present_refreshes_transparently(self, gated_app):
provider, valid_rt = self._build_rt_only_app()
# Browser sends ONLY the RT cookie — the AT cookie has aged out.
gated_app.cookies.clear()
gated_app.cookies.set(SESSION_RT_COOKIE, valid_rt)
r = gated_app.get("/api/sessions", follow_redirects=False)
# Transparent refresh — request served, NOT bounced.
assert r.status_code == 200, (
f"expected 200 (transparent refresh) got {r.status_code} "
f"— the AT-evicted/RT-present case bounced to login"
)
# Both cookies rotated onto the response.
set_cookies = r.headers.get_list("set-cookie")
assert any(
c.startswith(SESSION_AT_COOKIE) or f"-{SESSION_AT_COOKIE}" in c
for c in set_cookies
), f"no rotated AT cookie in {set_cookies!r}"
assert any(
c.startswith(SESSION_RT_COOKIE) or f"-{SESSION_RT_COOKIE}" in c
for c in set_cookies
), f"no rotated RT cookie in {set_cookies!r}"
def test_no_cookies_at_all_still_bounces(self, gated_app):
"""Guard the fix didn't over-reach: a request with NEITHER cookie
must still 401 to login (nothing to verify or refresh)."""
self._build_rt_only_app()
gated_app.cookies.clear()
r = gated_app.get("/api/sessions")
assert r.status_code == 401
assert r.json()["error"] == "unauthenticated"
def test_dead_rt_only_bounces_to_login(self, gated_app):
"""An RT-only request whose RT is dead/expired must bounce (the
refresh raises RefreshExpiredError clear + relogin), not 500."""
clear_providers()
# default_ttl=0 → the stub treats the minted RT as born-expired,
# so refresh_session raises RefreshExpiredError.
provider = StubAuthProvider(default_ttl=0)
register_provider(provider)
gated_app.cookies.clear()
# A syntactically-real but expired RT (signed with exp<=now).
import time as _t
from tests.hermes_cli.conftest_dashboard_auth import _sign
dead_rt = _sign({"sub": "u", "kind": "refresh", "exp": int(_t.time()) - 1})
gated_app.cookies.set(SESSION_RT_COOKIE, dead_rt)
r = gated_app.get("/api/sessions")
assert r.status_code == 401
assert r.json()["error"] == "session_expired"
class TestHtmlRedirectNext:
def test_deep_html_path_redirects_with_next(self, gated_app):
r = gated_app.get("/sessions", follow_redirects=False)
assert r.status_code == 302
assert r.headers["location"] == "/login?next=%2Fsessions"
def test_root_path_redirects_with_next(self, gated_app):
r = gated_app.get("/", follow_redirects=False)
assert r.headers["location"] in ("/login", "/login?next=%2F")
def test_login_loop_avoided(self, gated_app):
"""A request to /login itself must not produce ``?next=/login``
because that'd be a loop after re-auth."""
# /login is on the public allowlist so it doesn't go through the
# 401 path. But sanity: the page renders.
r = gated_app.get("/login")
assert r.status_code == 200
def test_auth_loop_avoided(self, gated_app):
"""A failed cookie on /auth/me (auth-required path) must drop
the next= rather than risk a /login?next=/api/auth/me loop."""
# /api/auth/me requires auth. Without cookie → 401 with login_url
# but next= must NOT point at /api/auth/.
r = gated_app.get("/api/auth/me")
assert r.status_code == 401
body = r.json()
assert "next=" not in body["login_url"]
# ---------------------------------------------------------------------------
# Gate middleware: same-origin next= validation
# ---------------------------------------------------------------------------
class TestNextSameOriginValidation:
def test_protocol_relative_path_dropped(self, gated_app):
# `//evil.com/foo` parses to a protocol-relative URL — browser
# would treat as cross-origin. We drop it at the gate; the path
# we redirect to should NOT contain `//evil.com`.
r = gated_app.get("//evil.com", follow_redirects=False)
# Starlette likely normalizes the path before we see it, so the
# gate may see "/evil.com" — either way the encoded value
# in next= must be safe to feed to window.location.assign.
# Just assert no protocol-relative form survives.
assert r.status_code == 302
location = r.headers["location"]
assert "%2F%2Fevil" not in location # urlencoded // form
assert "//evil" not in location
def test_safe_next_validator_accepts_same_origin(self):
from hermes_cli.dashboard_auth.middleware import _safe_next_target
class FakeRequest:
def __init__(self, path, query=""):
self.url = type("URL", (), {"path": path, "query": query})()
assert _safe_next_target(FakeRequest("/sessions")) == "%2Fsessions"
assert (
_safe_next_target(FakeRequest("/sessions", "page=2"))
== "%2Fsessions%3Fpage%3D2"
)
def test_safe_next_validator_rejects_protocol_relative(self):
from hermes_cli.dashboard_auth.middleware import _safe_next_target
class FakeRequest:
def __init__(self, path):
self.url = type("URL", (), {"path": path, "query": ""})()
assert _safe_next_target(FakeRequest("//evil.com")) == ""
def test_safe_next_validator_rejects_login_loop(self):
from hermes_cli.dashboard_auth.middleware import _safe_next_target
class FakeRequest:
def __init__(self, path):
self.url = type("URL", (), {"path": path, "query": ""})()
assert _safe_next_target(FakeRequest("/login")) == ""
assert _safe_next_target(FakeRequest("/auth/login")) == ""
assert _safe_next_target(FakeRequest("/api/auth/me")) == ""
def test_safe_next_validator_rejects_api_paths(self):
"""``/api/*`` paths must not round-trip through ``next=``.
Any API URL is a JSON endpoint; landing the browser there after
OAuth shows raw JSON instead of the dashboard. This is the bug
fix that closes the analytics-page redirect mishap.
"""
from hermes_cli.dashboard_auth.middleware import _safe_next_target
class FakeRequest:
def __init__(self, path, query=""):
self.url = type("URL", (), {"path": path, "query": query})()
assert _safe_next_target(FakeRequest("/api/analytics/models")) == ""
assert (
_safe_next_target(FakeRequest("/api/analytics/models", "days=30"))
== ""
)
assert _safe_next_target(FakeRequest("/api/sessions")) == ""
assert _safe_next_target(FakeRequest("/api/config")) == ""
assert _safe_next_target(FakeRequest("/api/status")) == ""
# Exact ``/api`` (no trailing slash) also rejected — the dashboard
# has no such SPA route, but pinning the boundary keeps the rule
# crisp.
assert _safe_next_target(FakeRequest("/api")) == ""
def test_safe_next_validator_does_not_reject_api_prefix_lookalikes(self):
"""Negative guard: ``/api-docs`` or ``/apis`` aren't ``/api/*``
and must remain valid landing targets."""
from hermes_cli.dashboard_auth.middleware import _safe_next_target
class FakeRequest:
def __init__(self, path):
self.url = type("URL", (), {"path": path, "query": ""})()
# ``/apidocs`` or ``/api-keys`` lookalike SPA routes — we must
# only match the ``/api/`` prefix or exact ``/api``.
assert _safe_next_target(FakeRequest("/apidocs")) == "%2Fapidocs"
assert _safe_next_target(FakeRequest("/api-keys")) == "%2Fapi-keys"
# ---------------------------------------------------------------------------
# /auth/callback honours next= and validates it
# ---------------------------------------------------------------------------
class TestAuthCallbackNext:
"""End-to-end next= propagation through a full OAuth round trip.
These tests drive the real flow exactly as the gate produces it:
1. unauth GET /sessions 302 /login?next=%2Fsessions
2. GET /login?next=%2Fsessions HTML with provider buttons that
carry next=%2Fsessions in their hrefs
3. GET /auth/login?provider=stub&next=%2Fsessions 302 to IDP +
PKCE cookie carrying provider/state/verifier/next
4. IDP returns to /auth/callback?code=...&state=... (NO next on
the callback URL real IDPs only echo back code+state)
5. /auth/callback reads next from the PKCE cookie, validates it,
and redirects there.
Discrimination: each test drives the flow without smuggling
``next=`` onto the callback URL. Under the pre-fix code paths
(/login ignored next=, /auth/login dropped it, /auth/callback read
it from the wrong place), the callback always lands on ``/``. Only
PKCE-cookie carriage produces the correct landing.
"""
def _drive_oauth_via_login(
self, gated_app, *, next_path: str = "",
expect_next_in_button: bool = True,
):
"""Walk /login → /auth/login → IDP-bounce → /auth/callback like
a real browser. ``next_path`` is the path the gate would have
encoded for the user; nothing about the callback URL is
smuggled. ``expect_next_in_button`` controls whether the
rendered /login page is expected to thread next= into the
provider button False for cases where the same-origin
validator drops the value (e.g. //evil.com, /login)."""
login_path = "/login"
if next_path:
login_path = f"/login?next={quote(next_path, safe='')}"
r_login = gated_app.get(login_path, follow_redirects=False)
assert r_login.status_code == 200
# Click the stub provider button. Real browsers parse the HTML;
# we extract the href the page emitted, so a regression that
# forgets to thread next= through the button will surface here.
body = r_login.text
# Each provider button is emitted as an <a class="provider-btn"
# href="/auth/login?provider=stub..."> line.
marker = 'href="'
i = body.find('class="provider-btn"')
assert i != -1, "no provider button in /login HTML"
h = body.find(marker, i) + len(marker)
j = body.find('"', h)
href = body[h:j]
# Critical: the href must carry next= when /login was given
# next= AND the validator accepted it. (This is the property the
# pre-fix render_login_html didn't satisfy.) For rejected
# next= values, the validator drops them at the /login boundary
# and the button href must NOT carry the rogue value.
if next_path and expect_next_in_button:
assert "next=" in href, (
f"login button dropped next= (href={href!r})"
)
if next_path and not expect_next_in_button:
assert "next=" not in href, (
f"login button leaked rejected next= "
f"(next_path={next_path!r}, href={href!r})"
)
r_to_idp = gated_app.get(href, follow_redirects=False)
assert r_to_idp.status_code == 302
# Stub IDP "returns" code+state on the callback URL — same shape
# as a real IDP. Critical: we do NOT append next= here.
state = r_to_idp.headers["location"].split("state=")[1]
return gated_app.get(
f"/auth/callback?code=stub_code&state={state}",
follow_redirects=False,
)
def test_callback_without_next_lands_at_root(self, gated_app):
r = self._drive_oauth_via_login(gated_app)
assert r.status_code == 302
assert r.headers["location"] == "/"
def test_callback_with_safe_next_lands_there(self, gated_app):
r = self._drive_oauth_via_login(gated_app, next_path="/sessions")
assert r.status_code == 302
assert r.headers["location"] == "/sessions"
def test_callback_with_query_string_in_next(self, gated_app):
r = self._drive_oauth_via_login(
gated_app, next_path="/sessions?page=2"
)
assert r.status_code == 302
assert r.headers["location"] == "/sessions?page=2"
def test_callback_rejects_open_redirect(self, gated_app):
# Attacker tries to inject ``next=//evil.com`` at the /login
# boundary, hoping it survives to the callback redirect. The
# /login validator drops it before it reaches the button href
# (and therefore the cookie), so the callback never sees it and
# the user lands at "/".
r = self._drive_oauth_via_login(
gated_app, next_path="//evil.com/steal",
expect_next_in_button=False,
)
assert r.status_code == 302
assert r.headers["location"] == "/"
def test_callback_rejects_login_loop(self, gated_app):
r = self._drive_oauth_via_login(
gated_app, next_path="/login",
expect_next_in_button=False,
)
assert r.status_code == 302
assert r.headers["location"] == "/"
def test_attacker_callback_next_param_is_ignored(self, gated_app):
"""Hardening: even if an attacker crafts a callback URL with a
rogue ``next=`` query parameter, the server reads from the PKCE
cookie (server-set) and ignores the URL value. This pins the
fix against a regression that re-introduces the URL read."""
# Drive a clean login with no next=.
r_login = gated_app.get("/login", follow_redirects=False)
assert r_login.status_code == 200
r_to_idp = gated_app.get(
"/auth/login?provider=stub", follow_redirects=False
)
state = r_to_idp.headers["location"].split("state=")[1]
# Attacker appends next=/internal-admin to the callback URL.
r = gated_app.get(
f"/auth/callback?code=stub_code&state={state}"
f"&next={quote('/internal-admin', safe='')}",
follow_redirects=False,
)
assert r.status_code == 302
# No next= was in the PKCE cookie, so landing must be "/" —
# NOT /internal-admin.
assert r.headers["location"] == "/"
def test_callback_with_api_next_lands_at_root(self, gated_app):
"""End-to-end repro of the analytics-redirect bug.
Drive ``/auth/login?next=/api/analytics/models?days=30``
exactly what the pre-fix gate would have stamped after a
ModelsPage 401. The validator at /auth/login MUST now drop
``/api/*`` so the PKCE cookie never carries the API path, AND
the callback's ``_validate_post_login_target`` MUST drop it as
second-line defence. Either layer alone is enough; both means
a regression in one is caught by the other.
Discrimination: under the pre-fix code, both validators
accepted ``/api/*`` and the callback redirected to the raw
JSON endpoint. With the fix, the callback redirects to "/".
"""
api_next = "/api/analytics/models?days=30"
r_to_idp = gated_app.get(
f"/auth/login?provider=stub&next={quote(api_next, safe='')}",
follow_redirects=False,
)
state = r_to_idp.headers["location"].split("state=")[1]
r = gated_app.get(
f"/auth/callback?code=stub_code&state={state}",
follow_redirects=False,
)
assert r.status_code == 302
# Landing falls back to "/" — NOT the API URL.
assert r.headers["location"] == "/"
# ---------------------------------------------------------------------------
# Unit-level coverage: _validate_post_login_target on the callback boundary
# ---------------------------------------------------------------------------
class TestValidatePostLoginTarget:
"""Cover ``_validate_post_login_target`` directly — it's the second
half of the next= validator pair (the callback boundary). The gate
side has matching coverage in ``TestNextSameOriginValidation``.
"""
def test_accepts_same_origin_paths(self):
from hermes_cli.dashboard_auth.routes import _validate_post_login_target
assert _validate_post_login_target("/sessions") == "/sessions"
# URL-encoded form (as the cookie carries it) round-trips through
# the validator's unquote step.
assert (
_validate_post_login_target("%2Fsessions%3Fpage%3D2")
== "/sessions?page=2"
)
def test_rejects_protocol_relative(self):
from hermes_cli.dashboard_auth.routes import _validate_post_login_target
assert _validate_post_login_target("//evil.com") == ""
assert _validate_post_login_target("%2F%2Fevil.com") == ""
def test_rejects_login_loop(self):
from hermes_cli.dashboard_auth.routes import _validate_post_login_target
assert _validate_post_login_target("/login") == ""
assert _validate_post_login_target("/auth/login") == ""
assert _validate_post_login_target("/api/auth/me") == ""
def test_rejects_api_paths(self):
"""Bug fix: any ``/api/*`` target is dropped at the callback
boundary. Pin both the exact match and the trailing-slash forms
plus a few realistic SPA-API endpoints."""
from hermes_cli.dashboard_auth.routes import _validate_post_login_target
assert _validate_post_login_target("/api") == ""
assert _validate_post_login_target("/api/analytics/models") == ""
assert _validate_post_login_target("/api/analytics/models?days=30") == ""
assert _validate_post_login_target("/api/sessions") == ""
assert _validate_post_login_target("/api/config") == ""
# URL-encoded form — what the cookie actually carries.
assert (
_validate_post_login_target(
"%2Fapi%2Fanalytics%2Fmodels%3Fdays%3D30"
) == ""
)
def test_does_not_reject_api_prefix_lookalikes(self):
from hermes_cli.dashboard_auth.routes import _validate_post_login_target
# SPA route lookalikes — must NOT be dropped.
assert _validate_post_login_target("/apidocs") == "/apidocs"
assert _validate_post_login_target("/api-keys") == "/api-keys"
# ---------------------------------------------------------------------------
# Unit-level coverage: render_login_html threads next= into provider buttons
# ---------------------------------------------------------------------------
class TestRenderLoginHtmlNext:
"""Cover ``render_login_html`` directly so a regression that drops
the ``next_path`` parameter is caught at the function boundary, not
only via the full integration walk."""
def setup_method(self):
clear_providers()
register_provider(StubAuthProvider())
def teardown_method(self):
clear_providers()
def test_no_next_emits_plain_button(self):
from hermes_cli.dashboard_auth.login_page import render_login_html
html_out = render_login_html()
assert 'href="/auth/login?provider=stub"' in html_out
assert "next=" not in html_out
def test_next_threaded_url_encoded(self):
from hermes_cli.dashboard_auth.login_page import render_login_html
html_out = render_login_html(next_path="/sessions?page=2")
# next= is URL-encoded — quote(safe='') turns "/" into "%2F",
# "?" into "%3F", "=" into "%3D". The encoded value never
# contains an "&" so the raw "&" separator in the href is
# unambiguous.
assert "next=%2Fsessions%3Fpage%3D2" in html_out
assert "provider=stub&next=" in html_out
def test_next_with_html_metacharacters_is_escaped(self):
"""Defence in depth: even though the caller validates next_path,
we still HTML-escape the rendered value so a regression in the
caller can't trivially produce an HTML-injection sink."""
from hermes_cli.dashboard_auth.login_page import render_login_html
# `"` in a path is already URL-encoded by quote() to %22, so it
# never reaches the HTML escaper as a raw quote. This test pins
# both layers: quote() does its job AND escape() does its.
html_out = render_login_html(next_path='/x"injected')
assert '"injected' not in html_out
assert "%22injected" in html_out
# ---------------------------------------------------------------------------
# Unit-level coverage: /auth/login persists next= into the PKCE cookie
# ---------------------------------------------------------------------------
class TestAuthLoginPkceCookieNext:
"""Cover the ``/auth/login`` route's PKCE cookie payload directly.
The cookie is the round-trip carrier for ``next=``; if /auth/login
forgets to encode it, the callback has no path to honour even when
everything else is wired correctly.
"""
def test_no_next_query_omits_next_segment(self, gated_app):
r = gated_app.get(
"/auth/login?provider=stub", follow_redirects=False
)
assert r.status_code == 302
cookies = r.headers.get_list("set-cookie")
pkce = next(c for c in cookies if "hermes_session_pkce" in c)
assert "next=" not in pkce
def test_safe_next_query_encoded_into_cookie(self, gated_app):
r = gated_app.get(
f"/auth/login?provider=stub&next={quote('/sessions', safe='')}",
follow_redirects=False,
)
cookies = r.headers.get_list("set-cookie")
pkce = next(c for c in cookies if "hermes_session_pkce" in c)
# ``next=`` segment present, URL-encoded.
assert "next=%2Fsessions" in pkce
def test_unsafe_next_query_dropped_from_cookie(self, gated_app):
"""The validator at /auth/login refuses //evil.com BEFORE
storing it. Defence in depth: even if a regression leaks next=
through /login's button rendering, /auth/login is the second
boundary."""
r = gated_app.get(
f"/auth/login?provider=stub&next={quote('//evil.com/x', safe='')}",
follow_redirects=False,
)
cookies = r.headers.get_list("set-cookie")
pkce = next(c for c in cookies if "hermes_session_pkce" in c)
assert "next=" not in pkce
@@ -0,0 +1,81 @@
"""Audit log for dashboard-auth events.
Profile-aware location: ``$HERMES_HOME/logs/dashboard-auth.log``.
Format: one JSON object per line. Token-like kwargs are dropped before
serialisation so we never leak refresh tokens or JWTs to disk.
"""
from __future__ import annotations
import json
import pytest
from hermes_cli.dashboard_auth.audit import audit_log, AuditEvent
@pytest.fixture
def profile_home(tmp_path, monkeypatch):
"""Redirect $HERMES_HOME and ~ to a tmp dir for the duration of the test."""
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
# Some code paths fall back to Path.home() — patch that too.
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
return home
def test_audit_writes_jsonlines(profile_home):
audit_log(AuditEvent.LOGIN_START, provider="nous", ip="1.2.3.4")
audit_log(
AuditEvent.LOGIN_SUCCESS,
provider="nous", user_id="u1",
email="a@b.com", ip="1.2.3.4",
)
path = profile_home / "logs" / "dashboard-auth.log"
assert path.exists(), f"audit log not created at {path}"
lines = path.read_text().strip().splitlines()
assert len(lines) == 2
second = json.loads(lines[1])
assert second["event"] == "login_success"
assert second["provider"] == "nous"
assert second["user_id"] == "u1"
assert second["email"] == "a@b.com"
assert "ts" in second # ISO-8601 timestamp
def test_audit_redacts_token_like_fields(profile_home):
audit_log(
AuditEvent.LOGIN_SUCCESS,
provider="nous", access_token="should-not-appear",
refresh_token="also-not", code="not-this", state="nope",
)
raw = (profile_home / "logs" / "dashboard-auth.log").read_text()
for forbidden in ("should-not-appear", "also-not", "not-this", "nope"):
assert forbidden not in raw, f"token-like value leaked into audit log: {forbidden}"
def test_audit_all_event_types_have_string_values():
for ev in AuditEvent:
assert isinstance(ev.value, str)
assert ev.value
def test_audit_write_failure_does_not_raise(monkeypatch, tmp_path):
"""A broken audit log must not crash auth."""
# Point HERMES_HOME at a file (not a dir) so mkdir/open will fail.
broken = tmp_path / "not-a-dir"
broken.write_text("blocking file")
monkeypatch.setenv("HERMES_HOME", str(broken))
# Should NOT raise.
audit_log(AuditEvent.LOGIN_FAILURE, provider="nous", reason="x")
def test_audit_creates_logs_dir_if_missing(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
# logs/ deliberately does not exist
audit_log(AuditEvent.LOGIN_START, provider="nous")
assert (home / "logs").is_dir()
assert (home / "logs" / "dashboard-auth.log").exists()
@@ -0,0 +1,233 @@
"""Tests for the dashboard-auth cookie helpers."""
from __future__ import annotations
from fastapi import FastAPI
from fastapi.responses import Response
from fastapi.testclient import TestClient
from starlette.requests import Request
from hermes_cli.dashboard_auth.cookies import (
PKCE_COOKIE,
SESSION_AT_COOKIE,
SESSION_RT_COOKIE,
clear_pkce_cookie,
clear_session_cookies,
read_pkce_cookie,
read_session_cookies,
set_pkce_cookie,
set_session_cookies,
)
def _build_app(use_https: bool = True, prefix: str = ""):
app = FastAPI()
@app.get("/set")
def set_endpoint():
r = Response("ok")
set_session_cookies(
r, access_token="AT", refresh_token="RT",
access_token_expires_in=3600, use_https=use_https,
prefix=prefix,
)
return r
@app.get("/set-pkce")
def set_pkce():
r = Response("ok")
set_pkce_cookie(r, payload="provider=stub;state=s;verifier=v",
use_https=use_https, prefix=prefix)
return r
@app.get("/clear")
def clear():
r = Response("ok")
clear_session_cookies(r, prefix=prefix)
clear_pkce_cookie(r, prefix=prefix)
return r
return app
# Cookie name resolution helpers used throughout — the bare name resolves
# to a request-shape-dependent variant (__Host- / __Secure- / bare).
# Tests pin a specific shape so a regression in the name-resolution
# logic fails loudly rather than silently breaking sessions.
def test_session_cookies_use_host_prefix_on_https_direct():
"""HTTPS + no proxy prefix → __Host- prefix (strongest spec
hardening: bound to exact origin, requires Path=/, requires Secure)."""
client = TestClient(_build_app(use_https=True, prefix=""))
r = client.get("/set")
cookies = r.headers.get_list("set-cookie")
at = next(c for c in cookies if c.startswith(f"__Host-{SESSION_AT_COOKIE}="))
rt = next(c for c in cookies if c.startswith(f"__Host-{SESSION_RT_COOKIE}="))
for c in (at, rt):
assert "HttpOnly" in c
assert "samesite=lax" in c.lower()
assert "Secure" in c
assert "Path=/" in c
def test_session_cookies_use_secure_prefix_when_proxied():
"""HTTPS + /hermes prefix → __Secure- prefix (__Host- forbids
Path != "/"; __Secure- keeps the Secure-required hardening)."""
client = TestClient(_build_app(use_https=True, prefix="/hermes"))
r = client.get("/set")
cookies = r.headers.get_list("set-cookie")
at = next(c for c in cookies if c.startswith(f"__Secure-{SESSION_AT_COOKIE}="))
assert "Path=/hermes" in at
assert "Secure" in at
# __Host- variant must NOT be emitted on the prefix path.
assert not any(
c.startswith(f"__Host-{SESSION_AT_COOKIE}=") for c in cookies
)
def test_session_cookies_use_bare_name_on_http():
"""Loopback HTTP dev: __Host- / __Secure- both require Secure, which
we can't set on HTTP. Use bare cookie names."""
client = TestClient(_build_app(use_https=False))
r = client.get("/set")
cookies = r.headers.get_list("set-cookie")
# Bare name present; no __Host- / __Secure- variant emitted.
assert any(c.startswith(f"{SESSION_AT_COOKIE}=") for c in cookies)
assert not any(
c.startswith(f"__Host-{SESSION_AT_COOKIE}=")
or c.startswith(f"__Secure-{SESSION_AT_COOKIE}=")
for c in cookies
)
# No Secure flag (HTTP).
at = next(c for c in cookies if c.startswith(f"{SESSION_AT_COOKIE}="))
assert "Secure" not in at
def test_session_cookies_have_30day_rt_and_token_ttl_at():
client = TestClient(_build_app(use_https=True))
r = client.get("/set")
cookies = r.headers.get_list("set-cookie")
at = next(c for c in cookies if c.startswith(f"__Host-{SESSION_AT_COOKIE}="))
rt = next(c for c in cookies if c.startswith(f"__Host-{SESSION_RT_COOKIE}="))
assert "Max-Age=3600" in at
assert "Max-Age=2592000" in rt # 30 days = 30 * 86400
def test_clear_session_cookies_emits_expired_at_and_rt():
"""``clear_session_cookies`` emits Max-Age=0 deletions for every
plausible cookie-name variant under the active prefix so we flush
stale cookies that an older deploy may have set under a different
prefix."""
client = TestClient(_build_app())
r = client.get("/clear")
cookies = r.headers.get_list("set-cookie")
# At least one variant of each session cookie should be deleted.
assert any(
SESSION_AT_COOKIE in c and "Max-Age=0" in c for c in cookies
)
assert any(
SESSION_RT_COOKIE in c and "Max-Age=0" in c for c in cookies
)
def test_pkce_cookie_short_ttl_and_path_root():
client = TestClient(_build_app(use_https=True))
r = client.get("/set-pkce")
pkce = next(
c for c in r.headers.get_list("set-cookie")
if PKCE_COOKIE in c
)
assert "HttpOnly" in pkce
assert "Max-Age=600" in pkce # 10 minutes
assert "Path=/" in pkce
assert "Secure" in pkce
def test_read_session_cookies_from_request_bare_name():
"""Reader accepts the bare name (loopback) by default."""
scope = {
"type": "http",
"method": "GET",
"path": "/",
"headers": [(
b"cookie",
f"{SESSION_AT_COOKIE}=at_value; {SESSION_RT_COOKIE}=rt_value".encode(),
)],
}
req = Request(scope)
at, rt = read_session_cookies(req)
assert at == "at_value"
assert rt == "rt_value"
def test_read_session_cookies_from_request_host_prefix():
"""Reader also finds cookies set with the __Host- variant
(HTTPS direct deploy)."""
scope = {
"type": "http",
"method": "GET",
"path": "/",
"headers": [(
b"cookie",
f"__Host-{SESSION_AT_COOKIE}=at_value; "
f"__Host-{SESSION_RT_COOKIE}=rt_value".encode(),
)],
}
req = Request(scope)
at, rt = read_session_cookies(req)
assert at == "at_value"
assert rt == "rt_value"
def test_read_session_cookies_from_request_secure_prefix():
"""Reader also finds cookies set with the __Secure- variant
(HTTPS behind a proxy prefix)."""
scope = {
"type": "http",
"method": "GET",
"path": "/",
"headers": [(
b"cookie",
f"__Secure-{SESSION_AT_COOKIE}=at_value; "
f"__Secure-{SESSION_RT_COOKIE}=rt_value".encode(),
)],
}
req = Request(scope)
at, rt = read_session_cookies(req)
assert at == "at_value"
assert rt == "rt_value"
def test_read_session_cookies_missing_returns_none():
req = Request({"type": "http", "method": "GET", "path": "/", "headers": []})
assert read_session_cookies(req) == (None, None)
def test_read_pkce_cookie_round_trip():
scope = {
"type": "http",
"method": "GET",
"path": "/",
"headers": [(b"cookie", f"{PKCE_COOKIE}=state=s;verifier=v".encode())],
}
req = Request(scope)
assert read_pkce_cookie(req) == "state=s" # NB: cookie value stops at ';'
def test_detect_https_via_scheme():
"""``detect_https`` reads from request.url.scheme.
Under uvicorn proxy_headers=True the scheme is rewritten from
``X-Forwarded-Proto``; that's an integration concern, not unit.
"""
from hermes_cli.dashboard_auth.cookies import detect_https
http_req = Request({
"type": "http", "method": "GET", "path": "/", "scheme": "http",
"headers": [], "server": ("x", 80),
})
https_req = Request({
"type": "http", "method": "GET", "path": "/", "scheme": "https",
"headers": [], "server": ("x", 443),
})
assert detect_https(http_req) is False
assert detect_https(https_req) is True
@@ -0,0 +1,302 @@
"""Regression harness for the dashboard auth gate.
Phase 0 establish a baseline pin on the current (pre-OAuth) behavior so
later phases can prove they didn't break loopback mode.
"""
import pytest
# Phase 5 / Phase 6: these tests mutate ``web_server.app.state.auth_required``
# at module level. Run them in the same xdist worker so they don't race
# against each other (and against any other file that also touches
# ``app.state``) — the marker name is shared across all dashboard-auth test
# files that gate the app.
pytestmark = pytest.mark.xdist_group("dashboard_auth_app_state")
from fastapi.testclient import TestClient
from hermes_cli import web_server
@pytest.fixture
def client_loopback():
# Pin the bound-host state for host_header_middleware so requests with
# default Host: testclient pass the DNS-rebinding check. TestClient
# sends Host: testserver by default, but our middleware accepts the
# loopback aliases when bound_host is loopback.
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
web_server.app.state.bound_host = "127.0.0.1"
web_server.app.state.bound_port = 9119
client = TestClient(web_server.app, base_url="http://127.0.0.1:9119")
yield client
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
def test_loopback_status_is_public(client_loopback):
"""`/api/status` must remain reachable without a token in loopback mode."""
r = client_loopback.get("/api/status")
assert r.status_code == 200
body = r.json()
assert "version" in body
def test_loopback_protected_route_requires_token(client_loopback):
"""Any non-public /api/ route must require the session token."""
# /api/sessions exists and is auth-gated by auth_middleware.
r = client_loopback.get("/api/sessions")
assert r.status_code == 401
def test_loopback_protected_route_accepts_session_token(client_loopback):
"""The injected SPA token unlocks protected /api/ routes."""
r = client_loopback.get(
"/api/sessions",
headers={"X-Hermes-Session-Token": web_server._SESSION_TOKEN},
)
# 200 or 404 (no sessions yet) both prove the auth layer let it through.
# 500 is also acceptable if there's a downstream issue unrelated to auth.
assert r.status_code != 401, (
f"Expected auth to succeed but got 401; body: {r.text}"
)
def test_loopback_index_injects_session_token(client_loopback):
"""Loopback mode keeps injecting the SPA token into index.html.
This is the property that the new auth gate MUST disable once a gated
bind is detected. Phase 3 will add an inverse test for the gated path.
"""
r = client_loopback.get("/")
if r.status_code == 404:
pytest.skip("WEB_DIST not built in this env")
assert "__HERMES_SESSION_TOKEN__" in r.text
def test_loopback_host_header_validation_still_enforced(client_loopback):
"""DNS-rebinding protection: a foreign Host header is rejected."""
r = client_loopback.get("/api/status", headers={"Host": "evil.test"})
assert r.status_code == 400
# ---------------------------------------------------------------------------
# should_require_auth predicate (Task 0.2)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("host,allow_public,expected", [
("127.0.0.1", False, False),
("127.0.0.1", True, False),
("localhost", False, False),
("::1", False, False),
("0.0.0.0", True, False), # --insecure escape hatch
("0.0.0.0", False, True),
("192.168.1.5", False, True),
("10.0.0.1", True, False),
("100.64.0.1", False, True), # Tailscale CGNAT — treated as public
("hermes-agent-prod-abc.fly.dev", False, True),
])
def test_should_require_auth_truth_table(host, allow_public, expected):
from hermes_cli.web_server import should_require_auth
assert should_require_auth(host, allow_public) is expected
# ---------------------------------------------------------------------------
# start_server stashes auth_required on app.state (Task 0.3)
# ---------------------------------------------------------------------------
def _stub_uvicorn_run(monkeypatch):
"""Replace uvicorn.Config/Server with no-op fakes so start_server
returns immediately (rather than blocking on the event loop). Returns the dict
that will capture the keyword args.
"""
import asyncio
import contextlib
import uvicorn
captured: dict = {"kwargs": {}}
class _FakeConfig:
loaded = True
host = "127.0.0.1"
port = 8000
def __init__(self, *args, **kwargs):
captured["kwargs"] = kwargs
def load(self):
pass
class lifespan_class:
should_exit = False
state: dict = {}
def __init__(self, *a, **kw):
pass
async def startup(self):
pass
async def shutdown(self):
pass
class _FakeServer:
should_exit = False
started = True
servers: list = []
lifespan = None
@staticmethod
def capture_signals():
return contextlib.nullcontext()
async def startup(self, sockets=None):
pass
async def main_loop(self):
pass
async def shutdown(self, sockets=None):
pass
monkeypatch.setattr(uvicorn, "Config", _FakeConfig)
monkeypatch.setattr(uvicorn, "Server", lambda config: _FakeServer())
return captured
def test_start_server_loopback_sets_auth_required_false(monkeypatch):
"""Loopback bind: app.state.auth_required is False after start_server."""
_stub_uvicorn_run(monkeypatch)
# Force a fresh state to detect that start_server actually set it.
web_server.app.state.auth_required = None
web_server.start_server(
host="127.0.0.1", port=9119,
open_browser=False, allow_public=False,
)
assert web_server.app.state.auth_required is False
def test_start_server_insecure_public_sets_auth_required_false(monkeypatch):
"""``--insecure`` (allow_public=True) on a public host: gate stays OFF."""
_stub_uvicorn_run(monkeypatch)
web_server.app.state.auth_required = None
web_server.start_server(
host="0.0.0.0", port=9119,
open_browser=False, allow_public=True,
)
assert web_server.app.state.auth_required is False
def test_start_server_public_without_insecure_records_auth_required(monkeypatch):
"""Public bind without --insecure: the gate engages and auth_required=True.
With no providers registered, this fails closed with SystemExit. The
flag-stashing happens BEFORE the exit so the rest of the system can
branch on it. (See task 3.5 tests below for the with-provider path.)
"""
from hermes_cli.dashboard_auth import clear_providers
clear_providers()
_stub_uvicorn_run(monkeypatch)
web_server.app.state.auth_required = None
with pytest.raises(SystemExit):
web_server.start_server(
host="0.0.0.0", port=9119,
open_browser=False, allow_public=False,
)
assert web_server.app.state.auth_required is True
# ---------------------------------------------------------------------------
# Task 3.5: start_server fail-closed + proxy_headers + index-token suppression
# ---------------------------------------------------------------------------
def test_start_server_gate_with_provider_proceeds_and_sets_proxy_headers(monkeypatch):
"""With at least one provider, public bind + no --insecure starts the server.
The SystemExit-refusing-to-bind guard is REPLACED in gated mode by
"the gate engages", so as long as a provider is registered the bind
succeeds. uvicorn is called with proxy_headers=True so X-Forwarded-Proto
from Fly's TLS terminator is honoured for cookie Secure-flag decisions.
"""
from hermes_cli.dashboard_auth import clear_providers, register_provider
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
clear_providers()
register_provider(StubAuthProvider())
captured = _stub_uvicorn_run(monkeypatch)
try:
web_server.app.state.auth_required = None
web_server.start_server(
host="0.0.0.0", port=9119,
open_browser=False, allow_public=False,
)
assert web_server.app.state.auth_required is True
assert captured["kwargs"].get("host") == "0.0.0.0"
assert captured["kwargs"].get("proxy_headers") is True
finally:
clear_providers()
def test_start_server_gate_without_provider_fails_closed(monkeypatch):
"""No providers + gate would activate → SystemExit with a clear message."""
from hermes_cli.dashboard_auth import clear_providers
clear_providers()
_stub_uvicorn_run(monkeypatch)
web_server.app.state.auth_required = None
with pytest.raises(SystemExit, match=r"no auth providers"):
web_server.start_server(
host="0.0.0.0", port=9119,
open_browser=False, allow_public=False,
)
def test_start_server_surfaces_nous_skip_reason_when_unconfigured(monkeypatch):
"""When the bundled Nous plugin loaded but skipped registration (no
env vars set), the gate's fail-closed message should surface the
plugin's LAST_SKIP_REASON so the operator knows the config fix is
'set HERMES_DASHBOARD_OAUTH_CLIENT_ID', not 'install a plugin'."""
from hermes_cli.dashboard_auth import clear_providers
from plugins.dashboard_auth import nous as nous_plugin
# Simulate the plugin running and skipping for "no client_id".
clear_providers()
_stub_uvicorn_run(monkeypatch)
monkeypatch.delenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", raising=False)
monkeypatch.delenv("HERMES_DASHBOARD_PORTAL_URL", raising=False)
from unittest.mock import MagicMock
nous_plugin.register(MagicMock()) # populates LAST_SKIP_REASON
assert "HERMES_DASHBOARD_OAUTH_CLIENT_ID" in nous_plugin.LAST_SKIP_REASON
web_server.app.state.auth_required = None
with pytest.raises(SystemExit) as exc_info:
web_server.start_server(
host="0.0.0.0", port=9119,
open_browser=False, allow_public=False,
)
# The error message embeds the plugin's specific skip reason rather
# than the generic "Install the default Nous provider" boilerplate.
msg = str(exc_info.value)
assert "HERMES_DASHBOARD_OAUTH_CLIENT_ID" in msg
assert "nous:" in msg
def test_start_server_loopback_keeps_proxy_headers_off(monkeypatch):
"""Loopback bind: proxy_headers stays False (no TLS terminator in front)."""
captured = _stub_uvicorn_run(monkeypatch)
web_server.start_server(
host="127.0.0.1", port=9119,
open_browser=False, allow_public=False,
)
assert captured["kwargs"].get("proxy_headers") is False
def test_start_server_insecure_keeps_proxy_headers_off(monkeypatch):
"""--insecure: gate stays off, proxy_headers stays off."""
captured = _stub_uvicorn_run(monkeypatch)
web_server.start_server(
host="0.0.0.0", port=9119,
open_browser=False, allow_public=True,
)
assert web_server.app.state.auth_required is False
assert captured["kwargs"].get("proxy_headers") is False
@@ -0,0 +1,571 @@
"""End-to-end behavioural tests for the dashboard auth gate.
Uses ``StubAuthProvider`` so the OAuth round trip can complete in-process
without any external IDP. Exercises:
* `/api/status` flips from public (loopback) to gated (auth_required)
* `/` redirects to /login when no cookie present
* `/api/auth/providers` is the public bootstrap endpoint
* `/login` renders HTML listing all providers
* /assets/* still passes through unauthenticated
* Full /auth/login /auth/callback / round trip with the stub
* Invalid / missing cookies return 401 (api) or 302 (html)
* Zero-providers + gate-on fails closed
"""
from __future__ import annotations
import pytest
# Phase 5 / Phase 6: these tests mutate ``web_server.app.state.auth_required``
# at module level. Run them in the same xdist worker so they don't race
# against each other (and against any other file that also touches
# ``app.state``) — the marker name is shared across all dashboard-auth test
# files that gate the app.
pytestmark = pytest.mark.xdist_group("dashboard_auth_app_state")
from fastapi.testclient import TestClient
from hermes_cli import web_server
from hermes_cli.dashboard_auth import clear_providers, register_provider
from hermes_cli.dashboard_auth.cookies import SESSION_AT_COOKIE
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
@pytest.fixture
def gated_app():
"""Configure web_server.app for gated mode + register the stub provider."""
clear_providers()
register_provider(StubAuthProvider())
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
prev_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.bound_host = "fly-app.fly.dev"
web_server.app.state.bound_port = 443
web_server.app.state.auth_required = True
# Use https base_url so cookies pick up Secure flag and host_header
# matches the bound interface.
client = TestClient(web_server.app, base_url="https://fly-app.fly.dev")
yield client
clear_providers()
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
web_server.app.state.auth_required = prev_required
# ---------------------------------------------------------------------------
# Allowlist (public) routes
# ---------------------------------------------------------------------------
def test_gated_status_is_public(gated_app):
"""``/api/status`` MUST be public under the OAuth gate.
Regression guard for the wildcard-subdomain rollout: NAS
(``fly-provider.ts`` ``getInstanceRuntimeStatus``) hits
``/api/status`` without a cookie as its sole liveness probe. A 401
here surfaces every healthy agent as STARTING/down in the portal
UI. The endpoint returns only version + gateway/auth-gate metadata
(no user data, no session content), so it stays in the shared
``PUBLIC_API_PATHS`` allowlist under both the legacy ``_SESSION_TOKEN``
gate and the OAuth gate.
The body also reports the gate's shape (``auth_required``,
``auth_providers``) so the SPA's StatusPage and external monitors
can distinguish loopback / gated / no-providers without a separate
round trip.
"""
r = gated_app.get("/api/status")
assert r.status_code == 200, (
f"Expected 200, got {r.status_code}: {r.text}"
)
body = r.json()
assert body["auth_required"] is True
assert "version" in body
assert "gateway_state" in body
@pytest.mark.parametrize("path", [
"/api/config/defaults",
"/api/config/schema",
"/api/model/info",
"/api/dashboard/themes",
"/api/dashboard/plugins",
])
def test_other_public_api_paths_are_public_under_gate(gated_app, path):
"""The remaining ``PUBLIC_API_PATHS`` entries must also bypass the
gate. They're documented as non-sensitive read-only endpoints that
the SPA pre-loads before login (themes, config schema, model
metadata). A 401 / 302-to-login here would block the dashboard
shell from rendering pre-auth.
Accept any non-auth-failure status: 200 when the route succeeds,
or any route-specific error (e.g. 400 / 404 / 500 from a missing
dependency) but NEVER 401, and NEVER a 302 to ``/login``.
"""
r = gated_app.get(path, follow_redirects=False)
assert r.status_code != 401, (
f"{path} returned 401 under the OAuth gate — should be public"
)
if r.status_code == 302:
location = r.headers.get("location", "")
assert "/login" not in location, (
f"{path} redirected to {location} — should be public, "
"not bounced to /login"
)
def test_gated_html_redirects_to_login(gated_app):
r = gated_app.get("/", follow_redirects=False)
assert r.status_code == 302
# Phase 6: gate carries a ``next=`` so post-login bounces back to /.
assert r.headers["location"] in ("/login", "/login?next=%2F")
def test_gated_auth_providers_is_public(gated_app):
r = gated_app.get("/api/auth/providers")
assert r.status_code == 200
body = r.json()
assert any(p["name"] == "stub" for p in body["providers"])
assert body["providers"][0]["display_name"] == "Stub IdP (test only)"
def test_gated_login_html_is_public_and_lists_providers(gated_app):
r = gated_app.get("/login")
assert r.status_code == 200
assert r.headers["content-type"].startswith("text/html")
assert "Stub IdP" in r.text
assert 'href="/auth/login?provider=stub"' in r.text
def test_gated_static_asset_path_is_public(gated_app):
"""``/assets/*`` is allowlisted so the SPA's CSS/JS loads pre-login."""
r = gated_app.get("/assets/_nonexistent.css")
# 404 not 401 — proves middleware let the request through to the
# static-files mount, which then 404'd because the file isn't there.
assert r.status_code == 404
# ---------------------------------------------------------------------------
# OAuth round trip
# ---------------------------------------------------------------------------
def test_full_login_round_trip_unlocks_gated_api(gated_app):
# 1) Click "Sign in with Stub IdP" — /auth/login redirects to the stub
# with a PKCE cookie on the response.
r1 = gated_app.get("/auth/login?provider=stub", follow_redirects=False)
assert r1.status_code == 302
pkce = next(
(c for c in r1.headers.get_list("set-cookie")
if "hermes_session_pkce" in c),
None,
)
assert pkce and "HttpOnly" in pkce
redirect = r1.headers["location"]
# Stub bounces back to {redirect_uri}?code=stub_code&state=<s>
assert "code=stub_code" in redirect
assert "state=" in redirect
state = redirect.split("state=")[1]
# 2) The browser would now follow the redirect to /auth/callback.
# TestClient automatically carries the PKCE cookie forward.
r2 = gated_app.get(
f"/auth/callback?code=stub_code&state={state}",
follow_redirects=False,
)
assert r2.status_code == 302
assert r2.headers["location"] == "/"
set_cookies = r2.headers.get_list("set-cookie")
assert any("hermes_session_at" in c for c in set_cookies)
assert any("hermes_session_rt" in c for c in set_cookies)
# 3) A gated API route (``/api/sessions``) now succeeds because we
# have a valid session cookie. (We deliberately don't probe
# ``/api/status`` here — it's in the shared PUBLIC_API_PATHS
# allowlist and would 200 even without a login, so it can't
# distinguish "logged in" from "gate accidentally disabled".)
r3 = gated_app.get("/api/sessions")
assert r3.status_code == 200, (
f"Expected 200 for /api/sessions post-login, got {r3.status_code}: "
f"{r3.text}"
)
def _complete_stub_login(client) -> None:
"""Walk the stub OAuth round trip so ``client`` carries a valid session.
TestClient persists Set-Cookie across calls, so after this returns the
client's cookie jar holds ``hermes_session_at`` / ``hermes_session_rt``
and subsequent gated requests authenticate.
"""
r1 = client.get("/auth/login?provider=stub", follow_redirects=False)
assert r1.status_code == 302
state = r1.headers["location"].split("state=")[1]
r2 = client.get(
f"/auth/callback?code=stub_code&state={state}",
follow_redirects=False,
)
assert r2.status_code == 302
def test_gated_require_token_endpoint_accepts_cookie_session(gated_app):
"""Regression: ``_require_token`` endpoints must work under the OAuth gate.
In gated mode the legacy ``_SESSION_TOKEN`` is NOT injected into the SPA
(it authenticates with the session cookie). Endpoints that call
``_require_token`` directly plugin install/enable/disable,
``/api/dashboard/plugins/hub``, and others used to re-check the absent
token and 401 every cookie-authenticated request, making them permanently
unreachable behind the gate (the dashboard surfaced a
``401: {"detail":"Unauthorized"}`` popup on plugin install). The fix makes
``_require_token`` defer to the gate, which has already verified the cookie
and attached ``request.state.session`` before the handler runs.
We POST a deliberately invalid plugin identifier: a passing auth layer
lets the request reach the handler, which rejects the identifier with a
400. The assertion is simply "not 401" proving auth succeeded without
coupling to the validation message.
"""
_complete_stub_login(gated_app)
r = gated_app.post(
"/api/dashboard/agent-plugins/install",
json={"identifier": "definitely not a valid identifier",
"force": False, "enable": False},
)
assert r.status_code != 401, (
"A _require_token endpoint 401'd a cookie-authenticated request under "
f"the OAuth gate (the install-popup bug). Body: {r.text}"
)
# And specifically: it reached the handler's own validation.
assert r.status_code == 400, (
f"Expected the install handler's 400 (bad identifier), got "
f"{r.status_code}: {r.text}"
)
def test_gated_require_token_endpoint_still_rejects_no_cookie(gated_app):
"""The gate must still 401 a ``_require_token`` endpoint with no session.
The fix defers to the gate it does not make these endpoints public. A
request with no cookie is rejected by ``gated_auth_middleware`` before the
handler runs, so the install endpoint stays protected.
"""
r = gated_app.post(
"/api/dashboard/agent-plugins/install",
json={"identifier": "owner/repo", "force": False, "enable": False},
)
assert r.status_code == 401, (
f"Expected 401 for an unauthenticated install POST under the gate, "
f"got {r.status_code}: {r.text}"
)
# A representative spread of the OTHER ``_require_token`` endpoints (there are
# 14 in total). The install popup was just the reported symptom; the same bug
# made API-key reveal, provider validation, the OAuth-provider connect flow,
# and the rest of plugin management unreachable behind the gate. Each entry is
# (method, path, json_body); we assert only that a logged-in request is NOT
# 401'd — i.e. it cleared the auth layer and reached the handler. The
# handler's own status (400/404/429/etc.) is route-specific and not asserted.
_GATED_REQUIRE_TOKEN_ROUTES = [
("get", "/api/dashboard/plugins/hub", None),
("post", "/api/env/reveal", {"key": "NONEXISTENT_ENV_VAR_FOR_TEST"}),
("post", "/api/providers/validate", {"key": "OPENAI_API_KEY", "value": ""}),
("delete", "/api/providers/oauth/__not_a_real_provider__", None),
("post", "/api/dashboard/agent-plugins/__nope__/enable", None),
]
@pytest.mark.parametrize("method,path,body", _GATED_REQUIRE_TOKEN_ROUTES)
def test_gated_require_token_routes_accept_cookie_session(
gated_app, method, path, body
):
"""Every ``_require_token`` route must clear auth for a logged-in caller.
Same root cause and fix as
``test_gated_require_token_endpoint_accepts_cookie_session`` this just
proves the fix covers the whole class, not only ``agent-plugins/install``.
"""
_complete_stub_login(gated_app)
kwargs = {"json": body} if body is not None else {}
r = gated_app.request(method.upper(), path, **kwargs)
assert r.status_code != 401, (
f"{method.upper()} {path} 401'd a cookie-authenticated request under "
f"the OAuth gate — _require_token still rejecting a valid session. "
f"Body: {r.text}"
)
def test_login_unknown_provider_returns_404(gated_app):
r = gated_app.get("/auth/login?provider=nonexistent", follow_redirects=False)
assert r.status_code == 404
def test_callback_without_pkce_cookie_returns_400(gated_app):
# No prior /auth/login → no PKCE cookie.
r = gated_app.get(
"/auth/callback?code=stub_code&state=anything",
follow_redirects=False,
)
assert r.status_code == 400
def test_callback_state_mismatch_returns_400(gated_app):
# Walk through /auth/login first to plant the PKCE cookie.
r1 = gated_app.get("/auth/login?provider=stub", follow_redirects=False)
# ...then pretend the IDP returned a different state.
r2 = gated_app.get(
"/auth/callback?code=stub_code&state=WRONG",
follow_redirects=False,
)
assert r2.status_code == 400
def test_callback_invalid_code_returns_400(gated_app):
r1 = gated_app.get("/auth/login?provider=stub", follow_redirects=False)
state = r1.headers["location"].split("state=")[1]
r2 = gated_app.get(
f"/auth/callback?code=BAD_CODE&state={state}",
follow_redirects=False,
)
assert r2.status_code == 400
# ---------------------------------------------------------------------------
# Cookie validation
# ---------------------------------------------------------------------------
def test_invalid_cookie_returns_401_on_api(gated_app):
gated_app.cookies.set(SESSION_AT_COOKIE, "garbage-not-a-real-token")
r = gated_app.get("/api/sessions")
assert r.status_code == 401
def test_invalid_cookie_redirects_on_html(gated_app):
gated_app.cookies.set(SESSION_AT_COOKIE, "garbage")
r = gated_app.get("/", follow_redirects=False)
assert r.status_code == 302
# Phase 6: gate carries a ``next=`` so post-login bounces back to /.
assert r.headers["location"] in ("/login", "/login?next=%2F")
def test_logout_clears_cookies_and_redirects_to_login(gated_app):
# First log in.
r1 = gated_app.get("/auth/login?provider=stub", follow_redirects=False)
state = r1.headers["location"].split("state=")[1]
gated_app.get(
f"/auth/callback?code=stub_code&state={state}",
follow_redirects=False,
)
# Now log out.
r = gated_app.post("/auth/logout", follow_redirects=False)
assert r.status_code == 302
assert r.headers["location"] == "/login"
set_cookies = r.headers.get_list("set-cookie")
assert any(
c.startswith("hermes_session_at=") and "Max-Age=0" in c
for c in set_cookies
)
assert any(
c.startswith("hermes_session_rt=") and "Max-Age=0" in c
for c in set_cookies
)
# ---------------------------------------------------------------------------
# Identity probe
# ---------------------------------------------------------------------------
def test_api_auth_me_returns_session_after_login(gated_app):
r1 = gated_app.get("/auth/login?provider=stub", follow_redirects=False)
state = r1.headers["location"].split("state=")[1]
gated_app.get(
f"/auth/callback?code=stub_code&state={state}",
follow_redirects=False,
)
r = gated_app.get("/api/auth/me")
assert r.status_code == 200
body = r.json()
assert body["user_id"] == "stub-user-1"
assert body["email"] == "stub@example.test"
assert body["display_name"] == "Stub User"
assert body["provider"] == "stub"
assert body["org_id"] == "stub-org-1"
assert "expires_at" in body
def test_api_auth_me_requires_auth(gated_app):
# No cookies.
r = gated_app.get("/api/auth/me")
assert r.status_code == 401
# ---------------------------------------------------------------------------
# Zero-providers fail-closed
# ---------------------------------------------------------------------------
def test_gated_zero_providers_fails_closed_on_api_auth_providers():
"""If gate is on but no providers are registered, /api/auth/providers 503s."""
clear_providers()
prev_required = getattr(web_server.app.state, "auth_required", None)
prev_host = getattr(web_server.app.state, "bound_host", None)
web_server.app.state.bound_host = "fly-app.fly.dev"
web_server.app.state.auth_required = True
try:
client = TestClient(web_server.app, base_url="https://fly-app.fly.dev")
r = client.get("/api/auth/providers")
assert r.status_code == 503
assert "no auth providers" in r.text.lower()
finally:
web_server.app.state.auth_required = prev_required
web_server.app.state.bound_host = prev_host
def test_gated_zero_providers_login_page_renders_help_text():
clear_providers()
prev_required = getattr(web_server.app.state, "auth_required", None)
prev_host = getattr(web_server.app.state, "bound_host", None)
web_server.app.state.bound_host = "fly-app.fly.dev"
web_server.app.state.auth_required = True
try:
client = TestClient(web_server.app, base_url="https://fly-app.fly.dev")
r = client.get("/login")
assert r.status_code == 200
# Empty-provider HTML mentions the fix-up path. (HTML wraps text
# so we can't grep for the exact phrase; check for the canonical
# fragments instead.)
text = r.text.lower()
assert "sign-in unavailable" in text
assert "no authentication" in text
assert "providers are installed" in text
assert "--insecure" in text
finally:
web_server.app.state.auth_required = prev_required
web_server.app.state.bound_host = prev_host
# ---------------------------------------------------------------------------
# Multi-provider verify: a ProviderError from one provider must not abort the
# chain when another provider can verify the token.
# ---------------------------------------------------------------------------
class _UnreachableProvider(StubAuthProvider):
"""A provider whose IDP is unreachable: verify_session always raises.
Models the real-world bug a self-hosted-OIDC session hits the ``nous``
provider first, which tries to reach Nous Portal's JWKS; if that's
unreachable ``nous`` raises ProviderError. The gate must keep trying the
remaining providers rather than 503-ing the whole request.
"""
name = "unreachable"
display_name = "Unreachable IdP (test only)"
def verify_session(self, *, access_token: str):
from hermes_cli.dashboard_auth.base import ProviderError
raise ProviderError("simulated: IDP/JWKS unreachable")
def refresh_session(self, *, refresh_token: str):
from hermes_cli.dashboard_auth.base import ProviderError
raise ProviderError("simulated: IDP/JWKS unreachable")
def _mint_stub_at(stub: StubAuthProvider) -> str:
"""Mint a valid access-token cookie value from a StubAuthProvider via its
own login round trip (so the HMAC signature matches what verify expects)."""
ls = stub.start_login(redirect_uri="https://fly-app.fly.dev/auth/callback")
state = dict(
seg.split("=", 1)
for seg in ls.cookie_payload["hermes_session_pkce"].split(";")
if "=" in seg
)["state"]
verifier = dict(
seg.split("=", 1)
for seg in ls.cookie_payload["hermes_session_pkce"].split(";")
if "=" in seg
)["verifier"]
session = stub.complete_login(
code="stub_code",
state=state,
code_verifier=verifier,
redirect_uri="https://fly-app.fly.dev/auth/callback",
)
return session.access_token
@pytest.fixture
def _gated_state():
"""Bare gated app-state setup WITHOUT registering any provider, so each
test controls provider registration order itself. Yields a factory that
builds the TestClient after providers are registered."""
clear_providers()
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
prev_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.bound_host = "fly-app.fly.dev"
web_server.app.state.bound_port = 443
web_server.app.state.auth_required = True
def _client() -> TestClient:
return TestClient(web_server.app, base_url="https://fly-app.fly.dev")
yield _client
clear_providers()
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
web_server.app.state.auth_required = prev_required
def test_unreachable_first_provider_does_not_block_second(_gated_state):
"""An unreachable provider registered FIRST must not 503 a request whose
token a later provider can verify.
Regression for the stacked-provider bug: the verify loop used to return
503 on the first provider's ProviderError, before the working provider
ever got a turn. Now it logs, continues, and the working provider wins.
"""
working = StubAuthProvider()
register_provider(_UnreachableProvider()) # registered first → tried first
register_provider(working) # the one that can verify
at = _mint_stub_at(working)
client = _gated_state()
client.cookies.set(SESSION_AT_COOKIE, at)
r = client.get("/api/auth/me")
assert r.status_code == 200, (
f"Expected the working provider to verify the session despite the "
f"unreachable one being tried first; got {r.status_code}: {r.text}"
)
body = r.json()
assert body["provider"] == "stub"
assert body["user_id"] == "stub-user-1"
def test_all_providers_unreachable_returns_503(_gated_state):
"""If NO provider can verify the token AND at least one was unreachable,
surface 503 (transient outage) rather than forcing a needless re-login."""
register_provider(_UnreachableProvider())
client = _gated_state()
# Any non-empty cookie — the unreachable provider raises before parsing.
client.cookies.set(SESSION_AT_COOKIE, "some-opaque-token")
r = client.get("/api/auth/me")
assert r.status_code == 503
assert "unreachable" in r.text.lower()
def test_unverifiable_token_with_reachable_providers_redirects(_gated_state):
"""When every provider is REACHABLE but none recognises the token (all
return None, none raises), the gate falls through to re-login NOT 503."""
register_provider(StubAuthProvider())
client = _gated_state()
client.cookies.set(SESSION_AT_COOKIE, "garbage-not-a-real-token")
# API path → 401; HTML would 302. Either way, NOT 503.
r = client.get("/api/auth/me")
assert r.status_code == 401
assert "unreachable" not in r.text.lower()
@@ -0,0 +1,448 @@
"""Tests for the password (non-redirect) dashboard-auth login flow.
Covers the protocol extension (``supports_password`` +
``complete_password_login``), the ``/auth/password-login`` route end-to-end
through the REAL ``gated_auth_middleware`` (session-cookie mint
authenticated request transparent refresh), the login-page credential
form rendering, and the route's rate limiter.
The E2E harness mirrors ``test_dashboard_auth_401_reauth.py``: register a
provider, flip ``app.state.auth_required = True``, drive a ``TestClient``.
"""
from __future__ import annotations
import time
import pytest
# These tests mutate ``web_server.app.state.auth_required`` at module level,
# so they share the dashboard-auth app-state xdist group to avoid racing
# other gate tests.
pytestmark = pytest.mark.xdist_group("dashboard_auth_app_state")
from fastapi.testclient import TestClient
from hermes_cli import web_server
from hermes_cli.dashboard_auth import (
DashboardAuthProvider,
InvalidCredentialsError,
ProviderError,
Session,
assert_protocol_compliance,
clear_providers,
register_provider,
)
from hermes_cli.dashboard_auth.cookies import SESSION_AT_COOKIE, SESSION_RT_COOKIE
from hermes_cli.dashboard_auth.login_page import render_login_html
from hermes_cli.dashboard_auth.routes import _reset_password_rate_limit
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
# ---------------------------------------------------------------------------
# Test password provider — minimal, in-memory, signed tokens.
# ---------------------------------------------------------------------------
def _sign(secret: bytes, sub: str, kind: str, ttl: int) -> str:
import base64
import hashlib
import hmac
import json
raw = json.dumps(
{"sub": sub, "kind": kind, "exp": int(time.time()) + ttl},
separators=(",", ":"),
).encode()
sig = hmac.new(secret, raw, hashlib.sha256).digest()
return base64.urlsafe_b64encode(raw + sig).decode()
def _unsign(secret: bytes, token: str):
import base64
import hashlib
import hmac
import json
try:
blob = base64.urlsafe_b64decode(token.encode())
raw, sig = blob[:-32], blob[-32:]
if not hmac.compare_digest(
sig, hmac.new(secret, raw, hashlib.sha256).digest()
):
return None
return json.loads(raw)
except Exception:
return None
class PasswordProvider(DashboardAuthProvider):
"""In-test username/password provider (admin / hunter2)."""
name = "testpw"
display_name = "Test Password"
supports_password = True
def __init__(self, *, ttl: int = 3600, secret: bytes = b"test-secret-1234567890"):
self._ttl = ttl
self._secret = secret
self.unreachable = False # flip to simulate a ProviderError
def start_login(self, *, redirect_uri: str):
raise NotImplementedError
def complete_login(self, **kwargs):
raise NotImplementedError
def complete_password_login(self, *, username: str, password: str) -> Session:
if self.unreachable:
raise ProviderError("backing store down")
if username != "admin" or password != "hunter2":
raise InvalidCredentialsError("bad creds")
exp = int(time.time()) + self._ttl
return Session(
user_id="admin",
email="",
display_name="admin",
org_id="",
provider=self.name,
expires_at=exp,
access_token=_sign(self._secret, "admin", "access", self._ttl),
refresh_token=_sign(self._secret, "admin", "refresh", 30 * 86400),
)
def verify_session(self, *, access_token: str):
p = _unsign(self._secret, access_token)
if not p or p.get("kind") != "access" or p["exp"] <= int(time.time()):
return None
return Session(
user_id=p["sub"], email="", display_name=p["sub"], org_id="",
provider=self.name, expires_at=p["exp"],
access_token=access_token, refresh_token="",
)
def refresh_session(self, *, refresh_token: str) -> Session:
from hermes_cli.dashboard_auth import RefreshExpiredError
p = _unsign(self._secret, refresh_token)
if not p or p.get("kind") != "refresh" or p["exp"] <= int(time.time()):
raise RefreshExpiredError("dead rt")
exp = int(time.time()) + self._ttl
return Session(
user_id=p["sub"], email="", display_name=p["sub"], org_id="",
provider=self.name, expires_at=exp,
access_token=_sign(self._secret, p["sub"], "access", self._ttl),
refresh_token=_sign(self._secret, p["sub"], "refresh", 30 * 86400),
)
def revoke_session(self, *, refresh_token: str) -> None:
return None
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def pw_provider():
return PasswordProvider()
@pytest.fixture
def gated_app(pw_provider):
clear_providers()
register_provider(pw_provider)
_reset_password_rate_limit()
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
prev_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.bound_host = "fly-app.fly.dev"
web_server.app.state.bound_port = 443
web_server.app.state.auth_required = True
client = TestClient(web_server.app, base_url="https://fly-app.fly.dev")
yield client
clear_providers()
_reset_password_rate_limit()
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
web_server.app.state.auth_required = prev_required
# ---------------------------------------------------------------------------
# Protocol extension
# ---------------------------------------------------------------------------
class TestProtocolExtension:
def test_password_provider_is_protocol_compliant(self):
assert assert_protocol_compliance(PasswordProvider) is None
def test_default_supports_password_is_false(self):
# OAuth providers (the Stub) inherit the False default.
assert StubAuthProvider.supports_password is False
def test_default_complete_password_login_raises_not_implemented(self):
# A provider that doesn't override the method (the Stub) raises,
# rather than silently accepting any credentials.
with pytest.raises(NotImplementedError):
StubAuthProvider().complete_password_login(
username="x", password="y"
)
# ---------------------------------------------------------------------------
# /api/auth/providers exposes the supports_password flag
# ---------------------------------------------------------------------------
class TestProviderListFlag:
def test_providers_endpoint_reports_supports_password(self, gated_app):
resp = gated_app.get("/api/auth/providers")
assert resp.status_code == 200
prov = {p["name"]: p for p in resp.json()["providers"]}
assert prov["testpw"]["supports_password"] is True
def test_oauth_provider_reports_false(self):
clear_providers()
register_provider(StubAuthProvider())
prev = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.auth_required = True
try:
client = TestClient(
web_server.app, base_url="https://fly-app.fly.dev"
)
resp = client.get("/api/auth/providers")
prov = {p["name"]: p for p in resp.json()["providers"]}
assert prov["stub"]["supports_password"] is False
finally:
clear_providers()
web_server.app.state.auth_required = prev
# ---------------------------------------------------------------------------
# /auth/password-login — end-to-end through the real middleware
# ---------------------------------------------------------------------------
class TestPasswordLoginRoute:
def test_valid_credentials_set_session_cookies_and_return_next(
self, gated_app
):
resp = gated_app.post(
"/auth/password-login",
json={
"provider": "testpw",
"username": "admin",
"password": "hunter2",
"next": "/sessions",
},
)
assert resp.status_code == 200
assert resp.json() == {"ok": True, "next": "/sessions"}
set_cookie = resp.headers.get("set-cookie", "")
# HTTPS request → __Host- prefixed access-token cookie is set.
assert SESSION_AT_COOKIE in set_cookie
assert SESSION_RT_COOKIE in set_cookie
def test_session_cookie_then_grants_authenticated_access(self, gated_app):
# Log in, then hit an auth-required endpoint with the cookie jar
# the TestClient retains — proving the minted session is accepted
# by the real gated_auth_middleware.
login = gated_app.post(
"/auth/password-login",
json={"provider": "testpw", "username": "admin", "password": "hunter2"},
)
assert login.status_code == 200
me = gated_app.get("/api/auth/me")
assert me.status_code == 200
assert me.json()["user_id"] == "admin"
assert me.json()["provider"] == "testpw"
def test_wrong_password_returns_generic_401(self, gated_app):
resp = gated_app.post(
"/auth/password-login",
json={"provider": "testpw", "username": "admin", "password": "WRONG"},
)
assert resp.status_code == 401
# Generic detail — no user-vs-password distinction.
assert resp.json()["detail"] == "Invalid credentials"
assert "set-cookie" not in {k.lower() for k in resp.headers}
def test_unknown_user_returns_same_generic_401(self, gated_app):
resp = gated_app.post(
"/auth/password-login",
json={"provider": "testpw", "username": "ghost", "password": "hunter2"},
)
assert resp.status_code == 401
assert resp.json()["detail"] == "Invalid credentials"
def test_unknown_provider_returns_404(self, gated_app):
resp = gated_app.post(
"/auth/password-login",
json={"provider": "nope", "username": "admin", "password": "hunter2"},
)
assert resp.status_code == 404
def test_oauth_provider_rejects_password_login_with_404(self):
# An OAuth-only provider (supports_password False) must not be
# reachable via the password route — same 404 as unknown, so the
# endpoint isn't a provider-capability oracle.
clear_providers()
register_provider(StubAuthProvider())
_reset_password_rate_limit()
prev = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.auth_required = True
try:
client = TestClient(
web_server.app, base_url="https://fly-app.fly.dev"
)
resp = client.post(
"/auth/password-login",
json={"provider": "stub", "username": "x", "password": "y"},
)
assert resp.status_code == 404
finally:
clear_providers()
_reset_password_rate_limit()
web_server.app.state.auth_required = prev
def test_provider_unreachable_returns_503(self, gated_app, pw_provider):
pw_provider.unreachable = True
resp = gated_app.post(
"/auth/password-login",
json={"provider": "testpw", "username": "admin", "password": "hunter2"},
)
assert resp.status_code == 503
def test_open_redirect_next_is_dropped(self, gated_app):
resp = gated_app.post(
"/auth/password-login",
json={
"provider": "testpw",
"username": "admin",
"password": "hunter2",
"next": "https://evil.example/phish",
},
)
assert resp.status_code == 200
# Malicious absolute URL dropped → lands at root.
assert resp.json()["next"] == "/"
def test_route_is_public_unauthenticated(self, gated_app):
# The login route itself must be reachable without a session —
# otherwise you could never log in.
resp = gated_app.post(
"/auth/password-login",
json={"provider": "testpw", "username": "admin", "password": "hunter2"},
)
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# Transparent refresh — expired access token, live refresh token
# ---------------------------------------------------------------------------
class TestPasswordSessionRefresh:
def test_expired_access_token_refreshes_via_rt_cookie(self):
# TTL=0 → access token born expired; the RT cookie should drive a
# transparent refresh on the next request (the same machinery the
# OAuth provider uses).
clear_providers()
provider = PasswordProvider(ttl=0)
register_provider(provider)
_reset_password_rate_limit()
prev = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.auth_required = True
try:
client = TestClient(
web_server.app, base_url="https://fly-app.fly.dev"
)
login = client.post(
"/auth/password-login",
json={"provider": "testpw", "username": "admin", "password": "hunter2"},
)
assert login.status_code == 200
# Give the provider a live TTL so the refreshed token verifies.
provider._ttl = 3600
me = client.get("/api/auth/me")
assert me.status_code == 200
assert me.json()["user_id"] == "admin"
finally:
clear_providers()
_reset_password_rate_limit()
web_server.app.state.auth_required = prev
# ---------------------------------------------------------------------------
# Rate limiter
# ---------------------------------------------------------------------------
class TestRateLimit:
def test_repeated_failures_eventually_429(self, gated_app):
# The limiter caps attempts per IP per window (default 10). After
# the budget is exhausted, even a VALID credential gets 429.
last = None
for _ in range(15):
last = gated_app.post(
"/auth/password-login",
json={"provider": "testpw", "username": "admin", "password": "WRONG"},
)
assert last.status_code == 429
# Even correct creds are throttled once the window is saturated.
good = gated_app.post(
"/auth/password-login",
json={"provider": "testpw", "username": "admin", "password": "hunter2"},
)
assert good.status_code == 429
# ---------------------------------------------------------------------------
# Login page rendering
# ---------------------------------------------------------------------------
class TestLoginPageRender:
def test_password_provider_renders_credential_form_and_script(self):
clear_providers()
register_provider(PasswordProvider())
try:
html = render_login_html(next_path="/sessions")
assert '<form class="provider-form" data-provider="testpw"' in html
assert 'name="username"' in html
assert 'name="password"' in html
assert 'value="/sessions"' in html
assert "<script>" in html
assert "/auth/password-login" in html
finally:
clear_providers()
def test_oauth_only_page_stays_script_free(self):
clear_providers()
register_provider(StubAuthProvider())
try:
html = render_login_html()
assert "provider-btn" in html
assert "<script>" not in html
# No password FORM element rendered (the .provider-form CSS
# rule lives in the template's <style> block unconditionally;
# what must be absent is an actual rendered form + its script).
assert '<form class="provider-form"' not in html
assert "/auth/password-login" not in html
finally:
clear_providers()
def test_mixed_providers_render_both(self):
clear_providers()
register_provider(StubAuthProvider())
register_provider(PasswordProvider())
try:
html = render_login_html()
# OAuth redirect button AND a password form, both present.
assert "/auth/login?provider=stub" in html
assert 'data-provider="testpw"' in html
assert "<script>" in html
finally:
clear_providers()
@@ -0,0 +1,90 @@
"""The plugin context exposes register_dashboard_auth_provider.
Mirrors the image-gen / memory-provider hooks (see plugins.py:531 for prior
art).
"""
from __future__ import annotations
import pytest
from hermes_cli.dashboard_auth import clear_providers, get_provider
from hermes_cli.dashboard_auth.base import (
DashboardAuthProvider, LoginStart, Session,
)
from hermes_cli.plugins import PluginContext, PluginManifest
class _Stub(DashboardAuthProvider):
name = "stub"
display_name = "Stub IdP"
def start_login(self, *, redirect_uri):
return LoginStart(redirect_url="x", cookie_payload={})
def complete_login(self, *, code, state, code_verifier, redirect_uri):
return Session("u", "e", "n", "o", "stub", 0, "a", "r")
def verify_session(self, *, access_token):
return None
def refresh_session(self, *, refresh_token):
return Session("u", "e", "n", "o", "stub", 0, "a", "r")
def revoke_session(self, *, refresh_token):
return None
class _MinimalManager:
"""The fixture only needs whatever PluginContext touches at register-time.
We don't import the real PluginManager because it pulls in the full
plugin-discovery surface. The hook we're testing only reads from
``ctx.manifest``, so the manager attributes don't matter — but we set
the few that other PluginContext methods touch defensively.
"""
_cli_ref = None
_context_engine = None
_tools: dict = {}
@pytest.fixture(autouse=True)
def _isolated_registry():
clear_providers()
yield
clear_providers()
def _make_ctx(name: str = "dashboard-auth-stub") -> PluginContext:
manifest = PluginManifest(name=name, version="0.0.1", description="stub")
return PluginContext(manifest=manifest, manager=_MinimalManager()) # type: ignore[arg-type]
def test_plugin_ctx_exposes_register_dashboard_auth_provider():
ctx = _make_ctx()
assert hasattr(ctx, "register_dashboard_auth_provider")
def test_plugin_ctx_register_dashboard_auth_provider_happy_path():
ctx = _make_ctx()
ctx.register_dashboard_auth_provider(_Stub())
p = get_provider("stub")
assert p is not None
assert p.display_name == "Stub IdP"
def test_plugin_ctx_silently_ignores_non_provider(caplog):
"""Mirror image_gen behaviour: log warning, leave registry empty.
We do NOT raise a misbehaving plugin must not crash the host.
"""
import logging
ctx = _make_ctx("dashboard-auth-bad")
with caplog.at_level(logging.WARNING):
ctx.register_dashboard_auth_provider("not a provider") # type: ignore[arg-type]
assert get_provider("stub") is None
assert any(
"dashboard-auth-bad" in rec.message
and "DashboardAuthProvider" in rec.message
for rec in caplog.records
)
@@ -0,0 +1,643 @@
"""Path-prefix (X-Forwarded-Prefix) awareness for the dashboard-auth gate.
Mission-control style deployments reverse-proxy the dashboard at a path
prefix (e.g. ``mission-control.tilos.com/hermes/*`` -> local Caddy ->
:9119), injecting ``X-Forwarded-Prefix: /hermes`` on every request.
The dashboard already honours this for the SPA bundle (rewriting asset
URLs and the bootstrap ``__HERMES_BASE_PATH__``). The OAuth gate must
honour it too:
1. The gate's ``Location:`` redirect to /login (in
``_unauth_response``) needs to be ``/hermes/login`` so the browser
follows it through the proxy.
2. The 401 JSON envelope's ``login_url`` needs the same prefix so the
SPA's full-page navigation lands at the proxied login page.
3. ``_redirect_uri`` (the OAuth callback URL handed to the IDP) must
reconstruct the public URL including the prefix, otherwise the IDP
redirects back to ``/auth/callback`` instead of
``/hermes/auth/callback`` and the user gets 404.
4. Cookies must use ``Path=/hermes`` when behind a prefix so they
don't leak to other apps on the same origin AND so they get sent
back to the dashboard on subsequent requests under the prefix.
5. The ``__Host-`` cookie prefix requires ``Path=/`` when behind an
X-Forwarded-Prefix we use ``__Secure-`` instead (matches every
hardening property except scope, which the explicit ``Path``
covers).
These tests document the wire-level contract so a regression in any of
those rules surfaces before a Mission Control deploy.
"""
from __future__ import annotations
import pytest
# Same xdist group as the other dashboard-auth tests — they all mutate
# web_server.app.state.auth_required at module level.
pytestmark = pytest.mark.xdist_group("dashboard_auth_app_state")
from fastapi.testclient import TestClient
from hermes_cli import web_server
from hermes_cli.dashboard_auth import clear_providers, register_provider
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
@pytest.fixture
def gated_app_proxied():
"""web_server.app configured for gated mode with proxy_headers + a
public Host that simulates the Mission Control reverse proxy.
The ``base_url`` sets ``host:scheme`` defaults so we don't have to
pass them on every request. ``X-Forwarded-Prefix`` is passed
per-request because the TestClient doesn't have a way to default
request headers.
"""
clear_providers()
register_provider(StubAuthProvider())
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
prev_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.bound_host = "mission-control.tilos.com"
web_server.app.state.bound_port = 443
web_server.app.state.auth_required = True
client = TestClient(
web_server.app,
base_url="https://mission-control.tilos.com",
)
yield client
clear_providers()
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
web_server.app.state.auth_required = prev_required
@pytest.fixture
def gated_app_direct():
"""web_server.app configured for gated mode WITHOUT a proxy prefix,
for the Fly-direct deploy shape (no path mounting).
"""
clear_providers()
register_provider(StubAuthProvider())
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
prev_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.bound_host = "fly-app.fly.dev"
web_server.app.state.bound_port = 443
web_server.app.state.auth_required = True
client = TestClient(
web_server.app,
base_url="https://fly-app.fly.dev",
)
yield client
clear_providers()
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
web_server.app.state.auth_required = prev_required
# ---------------------------------------------------------------------------
# Gate middleware: Location: header and 401 envelope respect prefix
# ---------------------------------------------------------------------------
class TestGateRedirectsCarryPrefix:
def test_html_redirect_to_login_carries_prefix(self, gated_app_proxied):
r = gated_app_proxied.get(
"/sessions",
headers={"x-forwarded-prefix": "/hermes"},
follow_redirects=False,
)
assert r.status_code == 302
# /login redirect must include the prefix or the browser will
# follow it to mission-control.tilos.com/login (which the proxy
# doesn't route to the dashboard).
assert r.headers["location"].startswith("/hermes/login"), (
f"Location header lost prefix: {r.headers['location']!r}"
)
def test_api_401_envelope_login_url_carries_prefix(self, gated_app_proxied):
r = gated_app_proxied.get(
"/api/sessions",
headers={"x-forwarded-prefix": "/hermes"},
follow_redirects=False,
)
assert r.status_code == 401
body = r.json()
# SPA does window.location.assign(body.login_url); this MUST
# include the prefix.
assert body["login_url"].startswith("/hermes/login"), (
f"401 envelope login_url lost prefix: {body['login_url']!r}"
)
def test_no_prefix_header_keeps_unprefixed_paths(self, gated_app_direct):
"""When no X-Forwarded-Prefix is sent, the Location header must
NOT gain a phantom prefix the Fly-direct deploy shape has no
proxy at all."""
r = gated_app_direct.get("/sessions", follow_redirects=False)
assert r.status_code == 302
assert r.headers["location"] == "/login?next=%2Fsessions"
def test_malformed_prefix_header_is_ignored(self, gated_app_proxied):
"""A hostile proxy injects ``X-Forwarded-Prefix: <script>``;
the normaliser rejects it and the gate falls back to unprefixed
URLs. Defence against header-injection HTML inside Location."""
r = gated_app_proxied.get(
"/sessions",
headers={"x-forwarded-prefix": "<script>alert(1)</script>"},
follow_redirects=False,
)
assert r.status_code == 302
assert "<script>" not in r.headers["location"]
assert r.headers["location"].startswith("/login")
# ---------------------------------------------------------------------------
# /auth/login: the OAuth redirect_uri reflects the proxy prefix
# ---------------------------------------------------------------------------
class TestOAuthRedirectUriRespectsPrefix:
def test_redirect_uri_includes_prefix_in_authorize_url(
self, gated_app_proxied
):
"""The IDP returns the user to the redirect_uri we sent. If we
don't include the prefix, the IDP redirects to
``https://mission-control.tilos.com/auth/callback`` instead of
``https://mission-control.tilos.com/hermes/auth/callback`` the
former routes to the MC frontend, not the dashboard, so the
user gets 404."""
r = gated_app_proxied.get(
"/auth/login?provider=stub",
headers={"x-forwarded-prefix": "/hermes"},
follow_redirects=False,
)
assert r.status_code == 302
location = r.headers["location"]
# The stub IDP's redirect_url echoes the redirect_uri back. The
# real IDP would consume it and later use it to redirect the
# user, so the byte-exact value MUST include the prefix.
from urllib.parse import urlparse
# Stub returns ``{redirect_uri}?code=stub_code&state=...`` — so
# we read up to the first ``?``.
redirect_uri = location.split("?", 1)[0]
# Absolute https URL including prefix.
parsed = urlparse(redirect_uri)
assert parsed.scheme == "https"
assert parsed.netloc == "mission-control.tilos.com"
assert parsed.path == "/hermes/auth/callback", (
f"redirect_uri dropped prefix: {redirect_uri!r}"
)
def test_redirect_uri_no_prefix_when_direct_deploy(
self, gated_app_direct
):
r = gated_app_direct.get(
"/auth/login?provider=stub", follow_redirects=False
)
assert r.status_code == 302
redirect_uri = r.headers["location"].split("?", 1)[0]
from urllib.parse import urlparse
parsed = urlparse(redirect_uri)
assert parsed.netloc == "fly-app.fly.dev"
assert parsed.path == "/auth/callback"
# ---------------------------------------------------------------------------
# HERMES_DASHBOARD_PUBLIC_URL / dashboard.public_url override
# ---------------------------------------------------------------------------
class TestPublicUrlOverride:
"""``dashboard.public_url`` (env override:
``HERMES_DASHBOARD_PUBLIC_URL``) lets an operator force the absolute
base URL the OAuth ``redirect_uri`` is built from.
When set, it is the *complete authority* scheme + host + optional
path prefix. ``X-Forwarded-Prefix`` is ignored on that code path
because the operator has explicitly declared the public URL and we
no longer need to guess from proxy headers. This is the relief
valve for deploys behind reverse proxies that don't set
``X-Forwarded-Host`` / ``X-Forwarded-Proto`` / ``X-Forwarded-Prefix``
correctly (or at all) manual nginx setups, on-prem ingresses,
Fly.io deploys with custom domains where the proxy header chain is
incomplete.
When unset, the existing ``proxy_headers=True`` + X-Forwarded-Prefix
reconstruction path runs untouched. Existing Fly.io deploys
continue to work without configuration.
Precedence (mirrors ``client_id``):
env (non-empty) > config.yaml > reconstructed from request
"""
@pytest.fixture
def patch_config(self, monkeypatch):
"""Replace ``hermes_cli.config.load_config`` with a stub
returning the given ``public_url``. Pass ``None`` to set no
config-side value."""
def _set(public_url) -> None:
cfg = {}
if public_url is not None:
cfg = {"dashboard": {"public_url": public_url}}
monkeypatch.setattr(
"hermes_cli.config.load_config", lambda: cfg
)
return _set
def _redirect_uri(self, gated_app, *, headers=None) -> str:
"""Drive /auth/login and read the redirect_uri the IDP saw."""
r = gated_app.get(
"/auth/login?provider=stub",
headers=headers or {},
follow_redirects=False,
)
assert r.status_code == 302, r.text
# Stub IDP echoes redirect_uri back as the prefix of the
# Location header (`{redirect_uri}?code=stub_code&state=…`).
return r.headers["location"].split("?", 1)[0]
def test_public_url_env_overrides_request_reconstruction(
self, gated_app_direct, patch_config, monkeypatch
):
"""``HERMES_DASHBOARD_PUBLIC_URL`` wins over the URL the
request would otherwise reconstruct to. Critical for deploys
whose proxy headers don't match the public URL."""
patch_config(None)
monkeypatch.setenv(
"HERMES_DASHBOARD_PUBLIC_URL", "https://custom.example",
)
redirect_uri = self._redirect_uri(gated_app_direct)
assert redirect_uri == "https://custom.example/auth/callback", (
f"public_url env var didn't override reconstruction "
f"(got {redirect_uri!r})"
)
def test_public_url_config_yaml_used_when_env_unset(
self, gated_app_direct, patch_config, monkeypatch
):
monkeypatch.delenv("HERMES_DASHBOARD_PUBLIC_URL", raising=False)
patch_config("https://from-config.example")
redirect_uri = self._redirect_uri(gated_app_direct)
assert redirect_uri == "https://from-config.example/auth/callback"
def test_env_overrides_config_public_url(
self, gated_app_direct, patch_config, monkeypatch
):
"""Precedence pin — env wins over config.yaml. Fly.io / CI
secret injection depends on this ordering."""
monkeypatch.setenv(
"HERMES_DASHBOARD_PUBLIC_URL", "https://from-env.example",
)
patch_config("https://from-config.example")
redirect_uri = self._redirect_uri(gated_app_direct)
assert redirect_uri == "https://from-env.example/auth/callback", (
"env var must override config.yaml — Fly secret injection "
"depends on this precedence"
)
def test_public_url_with_path_prefix_baked_in(
self, gated_app_direct, patch_config, monkeypatch
):
"""When public_url already carries a path prefix
(``https://example.com/hermes``), the OAuth callback URL is
the path appended verbatim. The operator is declaring the
whole authority; we trust them."""
patch_config(None)
monkeypatch.setenv(
"HERMES_DASHBOARD_PUBLIC_URL", "https://example.com/hermes",
)
redirect_uri = self._redirect_uri(gated_app_direct)
assert redirect_uri == "https://example.com/hermes/auth/callback"
def test_public_url_ignores_x_forwarded_prefix(
self, gated_app_proxied, patch_config, monkeypatch
):
"""X-Forwarded-Prefix is the auto-reconstruction signal; when
public_url is set we no longer need to guess, and stacking the
prefix on top would double-prefix in the common case where
the operator already baked their prefix into public_url."""
patch_config(None)
monkeypatch.setenv(
"HERMES_DASHBOARD_PUBLIC_URL", "https://example.com/already-prefixed",
)
redirect_uri = self._redirect_uri(
gated_app_proxied,
headers={"x-forwarded-prefix": "/should-be-ignored"},
)
assert (
redirect_uri == "https://example.com/already-prefixed/auth/callback"
), (
f"public_url should suppress X-Forwarded-Prefix layering, "
f"got {redirect_uri!r}"
)
def test_public_url_strips_trailing_slash(
self, gated_app_direct, patch_config, monkeypatch
):
"""``https://example.com/`` and ``https://example.com`` must
produce identical results no ``//auth/callback`` double slash."""
patch_config(None)
monkeypatch.setenv(
"HERMES_DASHBOARD_PUBLIC_URL", "https://example.com/",
)
redirect_uri = self._redirect_uri(gated_app_direct)
assert redirect_uri == "https://example.com/auth/callback"
def test_malformed_public_url_falls_through_to_reconstruction(
self, gated_app_direct, patch_config, monkeypatch
):
"""Defence against header injection: a public_url that doesn't
parse as ``http(s)://host[/path]`` is dropped and we fall back
to request reconstruction. The login flow continues to work
rather than dispatching the user to a hostile URL."""
from urllib.parse import urlparse
patch_config(None)
for bad in [
"javascript:alert(1)",
"ftp://example.com",
"example.com", # missing scheme
"https://", # missing host
'https://example.com/"injected', # quote char
"https://example.com/\nhttps://evil", # CRLF injection
]:
monkeypatch.setenv("HERMES_DASHBOARD_PUBLIC_URL", bad)
redirect_uri = self._redirect_uri(gated_app_direct)
# Fell through to request reconstruction — netloc is the
# bound host, NOT the hostile value.
parsed = urlparse(redirect_uri)
assert parsed.netloc == "fly-app.fly.dev", (
f"malformed public_url={bad!r} leaked into redirect_uri: "
f"{redirect_uri!r}"
)
assert parsed.path == "/auth/callback"
def test_empty_public_url_env_treated_as_unset(
self, gated_app_direct, patch_config, monkeypatch
):
"""Same defensive behaviour as the other env vars in this
plugin an empty env var doesn't shadow a valid config.yaml
entry."""
monkeypatch.setenv("HERMES_DASHBOARD_PUBLIC_URL", "")
patch_config("https://from-config.example")
redirect_uri = self._redirect_uri(gated_app_direct)
assert redirect_uri == "https://from-config.example/auth/callback"
def test_scheme_less_public_url_env_warns_operator(
self, patch_config, monkeypatch, caplog
):
"""A non-empty env var that's missing its scheme (the #1 cause
of "I set HERMES_DASHBOARD_PUBLIC_URL but the callback is still
http://") must emit an operator-facing WARNING rather than being
silently discarded. Regression for #42780."""
import logging
from hermes_cli.dashboard_auth import prefix as prefix_mod
# Reset the per-value dedup cache so the warning fires in-test
# regardless of test ordering.
prefix_mod._warned_malformed_public_urls.clear()
patch_config(None)
monkeypatch.setenv("HERMES_DASHBOARD_PUBLIC_URL", "hermes.domain.com")
with caplog.at_level(logging.WARNING, logger=prefix_mod.__name__):
result = prefix_mod.resolve_public_url()
assert result == "" # scheme-less value is still rejected
warnings = [
r.getMessage()
for r in caplog.records
if r.levelno == logging.WARNING
]
assert any(
"HERMES_DASHBOARD_PUBLIC_URL" in m
and "hermes.domain.com" in m
and "scheme" in m
for m in warnings
), f"expected a scheme warning, got: {warnings!r}"
def test_scheme_less_public_url_warning_is_deduplicated(
self, patch_config, monkeypatch, caplog
):
"""resolve_public_url runs per-request; the malformed-value
warning must fire at most once per distinct value so a
misconfigured deploy doesn't flood the logs."""
import logging
from hermes_cli.dashboard_auth import prefix as prefix_mod
prefix_mod._warned_malformed_public_urls.clear()
patch_config(None)
monkeypatch.setenv("HERMES_DASHBOARD_PUBLIC_URL", "hermes.domain.com")
with caplog.at_level(logging.WARNING, logger=prefix_mod.__name__):
for _ in range(5):
prefix_mod.resolve_public_url()
scheme_warnings = [
r
for r in caplog.records
if r.levelno == logging.WARNING
and "hermes.domain.com" in r.getMessage()
]
assert len(scheme_warnings) == 1, (
f"expected exactly one warning across 5 calls, "
f"got {len(scheme_warnings)}"
)
def test_valid_public_url_emits_no_warning(
self, patch_config, monkeypatch, caplog
):
"""A correctly-formed value must not produce a spurious warning."""
import logging
from hermes_cli.dashboard_auth import prefix as prefix_mod
prefix_mod._warned_malformed_public_urls.clear()
patch_config(None)
monkeypatch.setenv(
"HERMES_DASHBOARD_PUBLIC_URL", "https://hermes.domain.com"
)
with caplog.at_level(logging.WARNING, logger=prefix_mod.__name__):
result = prefix_mod.resolve_public_url()
assert result == "https://hermes.domain.com"
assert not [
r for r in caplog.records if r.levelno == logging.WARNING
]
# ---------------------------------------------------------------------------
# Cookies: Path attribute + __Host- / __Secure- prefix rules
# ---------------------------------------------------------------------------
class TestCookiePathRespectsPrefix:
"""Cookies must use ``Path=<prefix>`` when behind a proxy so they:
a) get sent back to the dashboard on subsequent requests (browser
only sends a cookie if the request path starts with the cookie's
Path attribute);
b) don't leak to other apps mounted alongside the dashboard
(e.g. ``mission-control.tilos.com/billing/...``).
When the cookie's Path can be ``/`` (no prefix, Fly-direct), we use
the ``__Host-`` cookie prefix for additional hardening it binds
the cookie to the exact host (no Domain attribute) and requires Secure.
"""
def test_pkce_cookie_uses_prefix_path(self, gated_app_proxied):
r = gated_app_proxied.get(
"/auth/login?provider=stub",
headers={"x-forwarded-prefix": "/hermes"},
follow_redirects=False,
)
cookies = r.headers.get_list("set-cookie")
pkce = next(c for c in cookies if "hermes_session_pkce" in c)
# Browser only sends cookie back if the request path is under
# the cookie's Path attribute, so we need /hermes here. Bare
# /-rooted cookies would still be sent but would also be sent
# to /billing/... etc.
assert "Path=/hermes" in pkce, (
f"PKCE cookie has wrong Path: {pkce!r}"
)
def test_pkce_cookie_uses_secure_prefix_when_proxied(
self, gated_app_proxied
):
"""Behind a proxy with Path != /, ``__Host-`` is disallowed
(the spec requires Path=/). Fall back to ``__Secure-``, which
carries the same Secure-required guarantee but allows any Path.
"""
r = gated_app_proxied.get(
"/auth/login?provider=stub",
headers={"x-forwarded-prefix": "/hermes"},
follow_redirects=False,
)
cookies = r.headers.get_list("set-cookie")
# The PKCE cookie name carries the __Secure- prefix.
pkce_candidates = [
c for c in cookies
if c.startswith("__Secure-hermes_session_pkce=")
]
assert pkce_candidates, (
f"PKCE cookie missing __Secure- prefix: {cookies!r}"
)
def test_pkce_cookie_uses_host_prefix_when_direct(
self, gated_app_direct
):
"""Fly-direct deploy: Path=/ is available, so we can use the
stricter ``__Host-`` prefix. This binds the cookie to the
exact origin (no Domain attribute) best practice for
single-host single-app deploys."""
r = gated_app_direct.get(
"/auth/login?provider=stub", follow_redirects=False
)
cookies = r.headers.get_list("set-cookie")
pkce_candidates = [
c for c in cookies
if c.startswith("__Host-hermes_session_pkce=")
]
assert pkce_candidates, (
f"PKCE cookie missing __Host- prefix on direct deploy: "
f"{cookies!r}"
)
# __Host- requires Path=/ and Secure (cookies spec); both must
# be present even if a regression flips one off.
pkce = pkce_candidates[0]
assert "Path=/" in pkce
assert "Secure" in pkce
def test_loopback_cookies_unprefixed(self):
"""Loopback HTTP dev: no Secure, no __Host- / __Secure-.
The bare cookie name is the right choice neither prefix is
spec-compatible without Secure."""
from fastapi import FastAPI
from fastapi.responses import Response
from hermes_cli.dashboard_auth.cookies import set_pkce_cookie
app = FastAPI()
@app.get("/set")
def _set():
r = Response("ok")
set_pkce_cookie(r, payload="x", use_https=False)
return r
client = TestClient(app)
r = client.get("/set")
cookies = r.headers.get_list("set-cookie")
# Bare cookie name, no prefix.
assert any(c.startswith("hermes_session_pkce=") for c in cookies), (
f"Loopback cookie should be bare-named: {cookies!r}"
)
# And no __Host- / __Secure- variant accidentally emitted.
assert not any(
c.startswith("__Host-") or c.startswith("__Secure-")
for c in cookies
)
def test_cookies_read_back_round_trip_through_prefix(
self, gated_app_proxied
):
"""The end-to-end property: after a successful OAuth round
trip via the proxy, the session-AT cookie carries the
__Secure- prefix AND Path=/hermes, so the next request under
the same prefix is authenticated.
Note on TestClient semantics: starlette's TestClient sees the
literal request path (``/auth/login``, ``/auth/callback``)
not the public path the proxy displays to the browser
(``/hermes/auth/login``, ``/hermes/auth/callback``). A cookie
set with ``Path=/hermes`` would therefore NOT be sent back on
the second request through TestClient even though it WOULD be
sent by a real browser hitting ``/hermes/auth/callback``. To
avoid baking that mismatch into the test, we inspect the
``Set-Cookie`` header on the callback's response WITHOUT
depending on the PKCE cookie round-tripping through
TestClient's jar — we drive /auth/callback with an explicit
Cookie header that carries the PKCE value from /auth/login.
"""
# /auth/login sets the PKCE cookie. Capture it from Set-Cookie.
r1 = gated_app_proxied.get(
"/auth/login?provider=stub",
headers={"x-forwarded-prefix": "/hermes"},
follow_redirects=False,
)
pkce_set = next(
c for c in r1.headers.get_list("set-cookie")
if "hermes_session_pkce" in c
)
# Parse "__Secure-hermes_session_pkce=...; HttpOnly; ...".
pkce_kv = pkce_set.split(";", 1)[0] # "__Secure-hermes_session_pkce=value"
state = r1.headers["location"].split("state=")[1]
# Round-trip the cookie by hand because TestClient's jar won't
# automatically send a Path=/hermes cookie to a /auth/callback
# request path.
r2 = gated_app_proxied.get(
f"/auth/callback?code=stub_code&state={state}",
headers={
"x-forwarded-prefix": "/hermes",
"cookie": pkce_kv,
},
follow_redirects=False,
)
assert r2.status_code == 302, r2.text
cookies = r2.headers.get_list("set-cookie")
at_cookies = [
c for c in cookies
if c.startswith("__Secure-hermes_session_at=")
]
assert at_cookies, (
f"session_at missing __Secure- prefix: {cookies!r}"
)
assert "Path=/hermes" in at_cookies[0]
assert "Secure" in at_cookies[0]
assert "HttpOnly" in at_cookies[0]
@@ -0,0 +1,182 @@
"""Contract test for DashboardAuthProvider implementations.
Every provider plugin should call ``assert_protocol_compliance`` on its
provider class in its own unit test. This module tests the abstract base
itself: dataclass fields, ABC rejection of partial impls, and the
protocol-compliance helper.
"""
from __future__ import annotations
import pytest
from hermes_cli.dashboard_auth.base import (
DashboardAuthProvider,
Session,
LoginStart,
assert_protocol_compliance,
)
# ---------------------------------------------------------------------------
# Dataclasses
# ---------------------------------------------------------------------------
def test_session_has_required_fields():
s = Session(
user_id="u1",
email="a@b.com",
display_name="A",
org_id="org_1",
provider="test",
expires_at=1234567890,
access_token="at",
refresh_token="rt",
)
assert s.user_id == "u1"
assert s.provider == "test"
assert s.expires_at == 1234567890
def test_login_start_has_redirect_and_state():
ls = LoginStart(
redirect_url="https://portal/authorize?...",
cookie_payload={"hermes_session_pkce": "verifier=abc;state=xyz"},
)
assert ls.redirect_url.startswith("https://")
assert "hermes_session_pkce" in ls.cookie_payload
# ---------------------------------------------------------------------------
# ABC enforcement
# ---------------------------------------------------------------------------
def test_abstract_provider_cannot_be_instantiated():
with pytest.raises(TypeError):
DashboardAuthProvider() # type: ignore[abstract]
class _BrokenProvider(DashboardAuthProvider):
name = "broken"
display_name = "Broken"
# Deliberately missing all the methods.
def test_assert_protocol_compliance_rejects_partial_impl():
with pytest.raises(TypeError):
assert_protocol_compliance(_BrokenProvider)
class _CompliantProvider(DashboardAuthProvider):
name = "ok"
display_name = "OK"
def start_login(self, *, redirect_uri: str) -> LoginStart:
return LoginStart(redirect_url="x", cookie_payload={})
def complete_login(self, *, code, state, code_verifier, redirect_uri) -> Session:
return Session(
user_id="u", email="x", display_name="x", org_id="o",
provider=self.name, expires_at=0,
access_token="a", refresh_token="r",
)
def verify_session(self, *, access_token: str):
return None
def refresh_session(self, *, refresh_token: str) -> Session:
return Session(
user_id="u", email="x", display_name="x", org_id="o",
provider=self.name, expires_at=0,
access_token="a", refresh_token="r",
)
def revoke_session(self, *, refresh_token: str) -> None:
return None
def test_assert_protocol_compliance_accepts_full_impl():
# Returns None on success; the helper raises on failure.
assert assert_protocol_compliance(_CompliantProvider) is None
def test_assert_protocol_compliance_rejects_missing_name_attr():
class NoName(_CompliantProvider):
name = "" # empty is treated as missing
with pytest.raises(TypeError, match="name"):
assert_protocol_compliance(NoName)
def test_assert_protocol_compliance_rejects_missing_display_name():
class NoDisplay(_CompliantProvider):
display_name = ""
with pytest.raises(TypeError, match="display_name"):
assert_protocol_compliance(NoDisplay)
# ---------------------------------------------------------------------------
# Registry (Task 1.2)
# ---------------------------------------------------------------------------
from hermes_cli.dashboard_auth import ( # noqa: E402 (after-imports for clarity)
register_provider,
get_provider,
list_providers,
clear_providers,
)
@pytest.fixture(autouse=True)
def _isolated_registry():
"""Every test starts with an empty registry and leaves it empty."""
clear_providers()
yield
clear_providers()
def test_registry_register_and_get():
p = _CompliantProvider()
register_provider(p)
assert get_provider("ok") is p
def test_registry_get_missing_returns_none():
assert get_provider("nope") is None
def test_registry_lists_in_registration_order():
class A(_CompliantProvider):
name = "a"
display_name = "A"
class B(_CompliantProvider):
name = "b"
display_name = "B"
register_provider(A())
register_provider(B())
names = [p.name for p in list_providers()]
assert names == ["a", "b"]
def test_registry_rejects_non_compliant_provider():
with pytest.raises(TypeError):
register_provider(_BrokenProvider()) # type: ignore[abstract]
def test_registry_rejects_duplicate_name():
register_provider(_CompliantProvider())
with pytest.raises(ValueError, match="already registered"):
register_provider(_CompliantProvider())
def test_registry_clear_drops_all():
register_provider(_CompliantProvider())
assert get_provider("ok") is not None
clear_providers()
assert get_provider("ok") is None
assert list_providers() == []
@@ -0,0 +1,135 @@
"""Phase 7 — /api/status exposes auth-gate state + AuthWidget integration.
The dashboard's status endpoint now reports ``auth_required`` and
``auth_providers`` so the AuthWidget + StatusPage can render the
correct "gated / loopback" badge without a separate round trip. This
test asserts both shapes (gated and loopback).
The AuthWidget itself is .tsx no Python test here. The widget's
behaviour (renders nothing on 401, shows truncated user_id, etc.) is
documented in AuthWidget.tsx; covered manually via the Phase 4.2
smoke test against staging Portal.
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from hermes_cli import web_server
from hermes_cli.dashboard_auth import clear_providers, register_provider
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
# These tests mutate ``web_server.app.state.auth_required`` so they share
# the same xdist group as the other dashboard-auth gated_app tests.
pytestmark = pytest.mark.xdist_group("dashboard_auth_app_state")
@pytest.fixture
def gated_client():
clear_providers()
register_provider(StubAuthProvider())
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
prev_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.bound_host = "fly-app.fly.dev"
web_server.app.state.bound_port = 443
web_server.app.state.auth_required = True
client = TestClient(web_server.app, base_url="https://fly-app.fly.dev")
yield client
clear_providers()
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
web_server.app.state.auth_required = prev_required
@pytest.fixture
def loopback_client():
clear_providers()
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
prev_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.bound_host = "127.0.0.1"
web_server.app.state.bound_port = 8080
web_server.app.state.auth_required = False
client = TestClient(web_server.app, base_url="http://127.0.0.1:8080")
yield client
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
web_server.app.state.auth_required = prev_required
def test_status_reports_auth_required_in_gated_mode(gated_client):
# No ``_login()`` call — ``/api/status`` is in the shared
# ``PUBLIC_API_PATHS`` allowlist precisely so external probes (and
# the SPA's pre-login bootstrap) can read the gate's shape without
# a cookie. Hit it cold.
r = gated_client.get("/api/status")
assert r.status_code == 200
body = r.json()
assert body["auth_required"] is True
assert body["auth_providers"] == ["stub"]
def test_status_reports_auth_disabled_in_loopback_mode(loopback_client):
r = loopback_client.get("/api/status")
assert r.status_code == 200
body = r.json()
assert body["auth_required"] is False
# Loopback mode has no registered providers (the Nous plugin's env
# vars aren't set in test).
assert body["auth_providers"] == []
def test_status_preserves_existing_fields(loopback_client):
"""Defence-in-depth: adding auth_required/auth_providers must not
have dropped any previous field (the dashboard's React StatusPage
relies on the full payload shape)."""
r = loopback_client.get("/api/status")
body = r.json()
expected_keys = {
"version", "release_date", "hermes_home", "config_path", "env_path",
"config_version", "latest_config_version", "gateway_running",
"gateway_pid", "gateway_health_url", "gateway_state",
"gateway_platforms", "gateway_exit_reason", "gateway_updated_at",
"active_sessions", "auth_required", "auth_providers",
}
missing = expected_keys - set(body.keys())
assert not missing, f"/api/status dropped fields: {missing}"
# Host-local detail (absolute paths, PID, internal gateway URL) is deployment
# recon a liveness probe never needs. ``/api/status`` bypasses dashboard auth
# (it is in ``PUBLIC_API_PATHS``), so on a network-exposed bind it must not
# leak that detail to anonymous callers.
_HOST_DETAIL_FIELDS = frozenset({
"hermes_home", "config_path", "env_path", "gateway_pid",
"gateway_health_url",
})
def test_status_withholds_host_detail_in_gated_mode(gated_client):
"""On a gated (non-loopback) bind, the public ``/api/status`` probe must
expose only the liveness + auth-gate shape never absolute host paths,
the gateway PID, or the internal gateway health URL. The endpoint
bypasses dashboard auth, so anyone who can reach the host hits it cold."""
r = gated_client.get("/api/status")
assert r.status_code == 200
body = r.json()
# Liveness / auth-gate shape stays public.
for key in ("version", "gateway_state", "auth_required", "auth_providers"):
assert key in body, f"liveness field {key!r} must stay public"
# Deployment recon must be withheld from the anonymous public probe.
leaked = _HOST_DETAIL_FIELDS & set(body.keys())
assert not leaked, f"/api/status leaked host detail under the gate: {leaked}"
def test_status_includes_host_detail_in_loopback_mode(loopback_client):
"""Counterpart to the gated case: a loopback bind is local-only, so the
full payload (including host paths and PID) is still served preserving
the StatusPage / ``hermes status`` experience for local operators."""
r = loopback_client.get("/api/status")
assert r.status_code == 200
body = r.json()
missing = _HOST_DETAIL_FIELDS - set(body.keys())
assert not missing, f"loopback /api/status should keep host detail: {missing}"
@@ -0,0 +1,150 @@
"""Contract test for the StubAuthProvider used in dashboard-auth E2E tests.
Phase 2 of the dashboard-OAuth plan. Validates the stub against the
provider protocol so subsequent phases that depend on its behavior
have a guarantee.
"""
from __future__ import annotations
import pytest
from hermes_cli.dashboard_auth.base import (
InvalidCodeError, RefreshExpiredError, assert_protocol_compliance,
)
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
def _pkce_payload(ls) -> dict:
"""Parse ``state=...;verifier=...`` out of the LoginStart cookie payload."""
return dict(
item.split("=", 1)
for item in ls.cookie_payload["hermes_session_pkce"].split(";")
)
def test_stub_complies_with_protocol():
assert assert_protocol_compliance(StubAuthProvider) is None
def test_stub_start_login_returns_callback_redirect():
p = StubAuthProvider()
ls = p.start_login(redirect_uri="https://x.fly.dev/auth/callback")
assert "code=stub_code" in ls.redirect_url
assert "state=" in ls.redirect_url
assert "hermes_session_pkce" in ls.cookie_payload
def test_stub_complete_login_with_matching_state_succeeds():
p = StubAuthProvider()
ls = p.start_login(redirect_uri="https://x.fly.dev/auth/callback")
payload = _pkce_payload(ls)
sess = p.complete_login(
code="stub_code",
state=payload["state"],
code_verifier=payload["verifier"],
redirect_uri="https://x.fly.dev/auth/callback",
)
assert sess.user_id == "stub-user-1"
assert sess.email == "stub@example.test"
assert sess.display_name == "Stub User"
assert sess.org_id == "stub-org-1"
assert sess.provider == "stub"
assert sess.access_token and sess.refresh_token
def test_stub_complete_login_rejects_mismatched_state():
p = StubAuthProvider()
p.start_login(redirect_uri="https://x.fly.dev/auth/callback")
with pytest.raises(InvalidCodeError):
p.complete_login(
code="stub_code",
state="WRONG",
code_verifier="anything",
redirect_uri="https://x.fly.dev/auth/callback",
)
def test_stub_complete_login_rejects_wrong_code():
p = StubAuthProvider()
ls = p.start_login(redirect_uri="https://x.fly.dev/auth/callback")
payload = _pkce_payload(ls)
with pytest.raises(InvalidCodeError):
p.complete_login(
code="BAD",
state=payload["state"],
code_verifier=payload["verifier"],
redirect_uri="https://x.fly.dev/auth/callback",
)
def test_stub_verify_session_round_trips():
p = StubAuthProvider()
ls = p.start_login(redirect_uri="https://x.fly.dev/auth/callback")
payload = _pkce_payload(ls)
sess = p.complete_login(
code="stub_code",
state=payload["state"],
code_verifier=payload["verifier"],
redirect_uri="https://x.fly.dev/auth/callback",
)
verified = p.verify_session(access_token=sess.access_token)
assert verified is not None
assert verified.user_id == "stub-user-1"
assert verified.org_id == "stub-org-1"
def test_stub_verify_expired_session_returns_none():
p = StubAuthProvider(default_ttl=0)
ls = p.start_login(redirect_uri="https://x/auth/callback")
payload = _pkce_payload(ls)
sess = p.complete_login(
code="stub_code",
state=payload["state"],
code_verifier=payload["verifier"],
redirect_uri="https://x/auth/callback",
)
# default_ttl=0 means the access token is born already expired
# (verify uses ``<=`` so exp == now counts as expired).
assert p.verify_session(access_token=sess.access_token) is None
def test_stub_verify_tampered_token_returns_none():
p = StubAuthProvider()
assert p.verify_session(access_token="garbage-not-a-real-token") is None
def test_stub_refresh_round_trips():
p = StubAuthProvider()
ls = p.start_login(redirect_uri="https://x/auth/callback")
payload = _pkce_payload(ls)
sess = p.complete_login(
code="stub_code",
state=payload["state"],
code_verifier=payload["verifier"],
redirect_uri="https://x/auth/callback",
)
refreshed = p.refresh_session(refresh_token=sess.refresh_token)
# Refresh must return a valid Session for the same identity. (Tokens
# may compare equal byte-for-byte if the refresh happens within the
# same wall-clock second as the original — payload contents are
# otherwise identical and HMAC is deterministic. The behavioural
# invariant is just "refresh succeeds and identity survives".)
assert refreshed.user_id == "stub-user-1"
assert refreshed.access_token # non-empty
assert refreshed.refresh_token # non-empty
# And the refreshed access_token is still verifiable.
verified = p.verify_session(access_token=refreshed.access_token)
assert verified is not None
assert verified.user_id == "stub-user-1"
def test_stub_refresh_expired_raises():
p = StubAuthProvider()
with pytest.raises(RefreshExpiredError):
p.refresh_session(refresh_token="garbage")
def test_stub_revoke_is_silent():
p = StubAuthProvider()
# Best-effort; must never raise.
p.revoke_session(refresh_token="anything")
@@ -0,0 +1,569 @@
"""Tests for the WS-upgrade auth helper (Phase 5 task 5.2).
The dashboard's four WS endpoints (``/api/pty``, ``/api/ws``, ``/api/pub``,
``/api/events``) share an auth gate: ``_ws_auth_ok``. In loopback mode it
accepts ``?token=<_SESSION_TOKEN>``; in gated mode it accepts a single-use
``?ticket=`` minted by ``POST /api/auth/ws-ticket``.
These tests exercise the helper at the unit level (no actual WS upgrade)
plus the ticket-mint endpoint under realistic gated-mode setup. We don't
test the full WS upgrade because the starlette TestClient WS path has a
pre-existing regression unrelated to dashboard-auth.
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
# Phase 5 / Phase 6: these tests mutate ``web_server.app.state.auth_required``
# at module level. Run them in the same xdist worker so they don't race
# against each other (and against any other file that also touches
# ``app.state``) — the marker name is shared across all dashboard-auth test
# files that gate the app.
pytestmark = pytest.mark.xdist_group("dashboard_auth_app_state")
from fastapi.testclient import TestClient
from hermes_cli import web_server
from hermes_cli.dashboard_auth import clear_providers, register_provider
from hermes_cli.dashboard_auth.ws_tickets import (
_reset_for_tests,
consume_internal_credential,
internal_ws_credential,
mint_ticket,
)
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def gated_app():
"""web_server.app configured for gated mode + stub provider registered."""
_reset_for_tests()
clear_providers()
register_provider(StubAuthProvider())
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
prev_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.bound_host = "fly-app.fly.dev"
web_server.app.state.bound_port = 443
web_server.app.state.auth_required = True
client = TestClient(web_server.app, base_url="https://fly-app.fly.dev")
yield client
clear_providers()
_reset_for_tests()
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
web_server.app.state.auth_required = prev_required
@pytest.fixture
def loopback_app():
"""web_server.app configured for loopback mode (gate OFF)."""
_reset_for_tests()
clear_providers()
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
prev_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.bound_host = "127.0.0.1"
web_server.app.state.bound_port = 8080
web_server.app.state.auth_required = False
client = TestClient(web_server.app, base_url="http://127.0.0.1:8080")
yield client
_reset_for_tests()
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
web_server.app.state.auth_required = prev_required
@pytest.fixture
def insecure_public_app():
"""web_server.app configured for all-interfaces insecure mode."""
_reset_for_tests()
clear_providers()
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
prev_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.bound_host = "0.0.0.0"
web_server.app.state.bound_port = 9120
web_server.app.state.auth_required = False
client = TestClient(web_server.app, base_url="http://192.168.0.222:9120")
yield client
_reset_for_tests()
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
web_server.app.state.auth_required = prev_required
def _logged_in(client: TestClient) -> None:
"""Drive the stub OAuth round trip so the client holds session cookies."""
r1 = client.get("/auth/login?provider=stub", follow_redirects=False)
assert r1.status_code == 302
state = r1.headers["location"].split("state=")[1]
r2 = client.get(
f"/auth/callback?code=stub_code&state={state}", follow_redirects=False
)
assert r2.status_code == 302
# ---------------------------------------------------------------------------
# POST /api/auth/ws-ticket — the mint endpoint
# ---------------------------------------------------------------------------
class TestWsTicketEndpoint:
def test_authenticated_session_can_mint(self, gated_app):
_logged_in(gated_app)
r = gated_app.post("/api/auth/ws-ticket")
assert r.status_code == 200
body = r.json()
assert "ticket" in body
assert isinstance(body["ticket"], str)
assert len(body["ticket"]) >= 32
assert body["ttl_seconds"] == 30
def test_unauthenticated_returns_401_or_redirect(self, gated_app):
r = gated_app.post("/api/auth/ws-ticket", follow_redirects=False)
# gated_auth_middleware short-circuits before the route — it
# returns either 401 or 302. Either is fine.
assert r.status_code in (302, 401)
def test_each_call_returns_a_distinct_ticket(self, gated_app):
_logged_in(gated_app)
tickets = {gated_app.post("/api/auth/ws-ticket").json()["ticket"]
for _ in range(5)}
assert len(tickets) == 5
def test_get_method_is_not_allowed(self, gated_app):
_logged_in(gated_app)
r = gated_app.get("/api/auth/ws-ticket", follow_redirects=False)
# GET must not mint a ticket (which would be cookie-replayable via
# <img src=…> from a malicious origin). Accepted responses:
# 401 — gated middleware allowlist-miss
# 404 — SPA catch-all swallowed it
# 405 — Method Not Allowed (route only registered for POST)
# 200 — SPA index.html was served (catch-all caught the path)
# In every case the JSON body of a successful ticket mint must
# NOT be present. The assertion below holds even when the SPA
# shell happens to serve a 200.
body = r.text
assert "ticket" not in body or '"ttl_seconds"' not in body, (
f"GET /api/auth/ws-ticket leaked a ticket (status={r.status_code}, "
f"body[:200]={body[:200]!r})"
)
# ---------------------------------------------------------------------------
# _ws_auth_ok — unit-level (synthetic WebSocket-shaped object)
# ---------------------------------------------------------------------------
@pytest.fixture
def insecure_explicit_host_app():
"""web_server.app bound to an explicit non-loopback host (--insecure).
Models `--host 100.64.0.10 --insecure` (e.g. a Tailscale IP behind
`tailscale serve`) a specific address rather than the all-interfaces
0.0.0.0 wildcard.
"""
_reset_for_tests()
clear_providers()
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
prev_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.bound_host = "100.64.0.10"
web_server.app.state.bound_port = 9119
web_server.app.state.auth_required = False
client = TestClient(web_server.app, base_url="http://100.64.0.10:9119")
yield client
_reset_for_tests()
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
web_server.app.state.auth_required = prev_required
def _fake_ws(*, query: dict, client_host: str = "127.0.0.1", path: str = "/api/pty"):
"""Build a stand-in for starlette.WebSocket good enough for _ws_auth_ok."""
class _QP:
def __init__(self, q):
self._q = q
def get(self, k, default=""):
return self._q.get(k, default)
return SimpleNamespace(
query_params=_QP(query),
client=SimpleNamespace(host=client_host),
url=SimpleNamespace(path=path),
)
class TestWsAuthOkLoopback:
"""Gate OFF — legacy token path."""
def test_correct_token_accepted(self, loopback_app):
ws = _fake_ws(query={"token": web_server._SESSION_TOKEN})
assert web_server._ws_auth_ok(ws) is True
def test_wrong_token_rejected(self, loopback_app):
ws = _fake_ws(query={"token": "not-the-real-token"})
assert web_server._ws_auth_ok(ws) is False
def test_missing_token_rejected(self, loopback_app):
ws = _fake_ws(query={})
assert web_server._ws_auth_ok(ws) is False
def test_ticket_param_ignored_in_loopback(self, loopback_app):
# Even if someone sneaks a ticket through, loopback mode only
# cares about ?token=. A naked ticket isn't a token.
ticket = mint_ticket(user_id="u1", provider="stub")
ws = _fake_ws(query={"ticket": ticket})
assert web_server._ws_auth_ok(ws) is False
class TestWsAuthOkGated:
"""Gate ON — ticket path only."""
def test_valid_ticket_accepted(self, gated_app):
ticket = mint_ticket(user_id="u1", provider="stub")
ws = _fake_ws(query={"ticket": ticket})
assert web_server._ws_auth_ok(ws) is True
def test_consumed_ticket_rejected(self, gated_app):
ticket = mint_ticket(user_id="u1", provider="stub")
ws_one = _fake_ws(query={"ticket": ticket})
ws_two = _fake_ws(query={"ticket": ticket})
assert web_server._ws_auth_ok(ws_one) is True
# Single-use — second consumption fails.
assert web_server._ws_auth_ok(ws_two) is False
def test_unknown_ticket_rejected(self, gated_app):
ws = _fake_ws(query={"ticket": "never-minted"})
assert web_server._ws_auth_ok(ws) is False
def test_missing_ticket_rejected(self, gated_app):
ws = _fake_ws(query={})
assert web_server._ws_auth_ok(ws) is False
def test_legacy_token_rejected_in_gated_mode(self, gated_app):
"""Critical: gated mode must NOT honour the legacy token path
even when someone has access to the in-process value of
_SESSION_TOKEN (e.g. a leaked log line)."""
ws = _fake_ws(query={"token": web_server._SESSION_TOKEN})
assert web_server._ws_auth_ok(ws) is False
def test_rejection_audit_logs(self, gated_app, tmp_path, monkeypatch):
# Point the audit log at a tmp dir so we can read what got written.
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
from hermes_cli.dashboard_auth import audit as audit_mod
# The log path is resolved lazily on the first audit_log() call;
# bust any cached handler so it re-resolves.
if hasattr(audit_mod, "_LOGGER"):
monkeypatch.setattr(audit_mod, "_LOGGER", None, raising=False)
ws = _fake_ws(query={"ticket": "never-minted"})
assert web_server._ws_auth_ok(ws) is False
log_file = tmp_path / "logs" / "dashboard-auth.log"
# The audit module may write asynchronously through stdlib logging,
# but flush is synchronous. If the file doesn't exist yet, the
# logger may not have been initialized in this process — that's
# acceptable as long as the rejection path didn't crash.
if log_file.exists():
content = log_file.read_text()
assert "ws_ticket_rejected" in content
def test_internal_credential_accepted(self, gated_app):
"""Server-spawned children present the process-lifetime internal
credential via ?internal= and are accepted in gated mode."""
cred = internal_ws_credential()
ws = _fake_ws(query={"internal": cred})
assert web_server._ws_auth_ok(ws) is True
def test_internal_credential_is_multi_use(self, gated_app):
"""Unlike single-use tickets, the internal credential survives
repeated use so the child can reconnect."""
cred = internal_ws_credential()
for _ in range(3):
ws = _fake_ws(query={"internal": cred})
assert web_server._ws_auth_ok(ws) is True
def test_wrong_internal_credential_rejected(self, gated_app):
# Mint the real one so the store is non-empty, then present a bogus value.
internal_ws_credential()
ws = _fake_ws(query={"internal": "not-the-internal-credential"})
assert web_server._ws_auth_ok(ws) is False
def test_internal_credential_not_accepted_in_loopback(self, loopback_app):
"""Outside gated mode, ?internal= is meaningless — only ?token= works.
A naked internal credential must not authenticate."""
cred = internal_ws_credential()
ws = _fake_ws(query={"internal": cred})
assert web_server._ws_auth_ok(ws) is False
class TestWsRequestIsAllowedGated:
"""Bug fix: in gated mode, the WS peer-IP loopback check must be
bypassed.
When the OAuth gate is active, ``start_server`` runs uvicorn with
``proxy_headers=True`` so the dashboard can honour
``X-Forwarded-Proto`` from Fly's TLS terminator. A side effect is that
``ws.client.host`` is rewritten to the X-Forwarded-For value the
real internet client IP, never loopback. The loopback peer guard
(intended only for unauthenticated loopback dev) must not also reject
those upgrades: the OAuth gate + single-use ticket is the auth.
Regression coverage: every WS endpoint (``/api/pty``, ``/api/ws``,
``/api/pub``, ``/api/events``) calls ``_ws_request_is_allowed`` after
``_ws_auth_ok``. If the peer-IP check rejects gated mode, the chat
tab + sidebar tool feed silently fail to connect even after a
successful OAuth login.
"""
def test_non_loopback_peer_allowed_in_gated_mode(self, gated_app):
ws = _fake_ws(query={}, client_host="203.0.113.7")
# Host header matches the bound host so the DNS-rebinding guard
# passes; only the peer-IP check is under test.
ws.headers = {"host": "fly-app.fly.dev"}
assert web_server._ws_request_is_allowed(ws) is True
def test_non_loopback_peer_rejected_in_loopback_mode(self, loopback_app):
"""Loopback mode still enforces the peer-IP guard — the legacy
token path is the only auth and we don't want random LAN hosts
guessing it."""
ws = _fake_ws(query={}, client_host="192.168.1.42")
ws.headers = {"host": "127.0.0.1:8080"}
assert web_server._ws_request_is_allowed(ws) is False
def test_loopback_peer_allowed_in_loopback_mode(self, loopback_app):
ws = _fake_ws(query={}, client_host="127.0.0.1")
ws.headers = {"host": "127.0.0.1:8080"}
assert web_server._ws_request_is_allowed(ws) is True
def test_non_loopback_peer_allowed_in_insecure_public_mode(self, insecure_public_app):
"""`--host 0.0.0.0 --insecure` is an explicit LAN/public opt-in.
Regression coverage for the dashboard `/chat` breakage where the
HTML shell loaded on 9120 but every WebSocket upgrade was rejected
with 403 because the loopback-only peer guard still ran even though
the operator intentionally exposed the dashboard on all interfaces.
"""
ws = _fake_ws(query={}, client_host="192.168.0.55")
ws.headers = {
"host": "192.168.0.222:9120",
"origin": "http://192.168.0.222:9120",
}
assert web_server._ws_request_is_allowed(ws) is True
def test_peer_allowed_on_explicit_non_loopback_bind(self, insecure_explicit_host_app):
"""`--host 100.64.0.10 --insecure` (Tailscale/LAN IP) is an explicit
non-loopback opt-in too not just the 0.0.0.0 wildcard.
Regression coverage: the merged 0.0.0.0/:: fix did not cover binding
directly to a specific tailnet/LAN address, so `/chat` HTML loaded but
WS upgrades were still rejected by the loopback-only peer guard.
"""
ws = _fake_ws(query={}, client_host="100.64.0.99")
ws.headers = {
"host": "100.64.0.10:9119",
"origin": "http://100.64.0.10:9119",
}
assert web_server._ws_request_is_allowed(ws) is True
def test_rebinding_host_rejected_on_explicit_non_loopback_bind(
self, insecure_explicit_host_app
):
"""Lifting the peer-IP gate for an explicit bind must NOT lift the
DNS-rebinding Host guard: a mismatched Host header is still rejected,
because an explicit non-loopback bind requires an exact Host match in
`_is_accepted_host` (unlike the 0.0.0.0 wildcard, which accepts any).
"""
ws = _fake_ws(query={}, client_host="100.64.0.99")
ws.headers = {"host": "evil.example.com"}
assert web_server._ws_request_is_allowed(ws) is False
def test_host_origin_guard_still_runs_in_gated_mode(self, gated_app):
"""Bypassing the peer-IP check must not bypass the DNS-rebinding
Host header guard that one still protects against attacker
sites resolving DNS to the public IP."""
ws = _fake_ws(query={}, client_host="203.0.113.7")
ws.headers = {"host": "evil.example.com"}
assert web_server._ws_request_is_allowed(ws) is False
class TestWsHostOriginGuardOrigins:
"""The WS Origin guard must let the packaged desktop shell connect.
Electron loads the packaged renderer over ``file://``, so its WebSocket
handshake carries ``Origin: file://`` (or the opaque ``null``, or a custom
``app://`` scheme). The DNS-rebinding guard only needs to block cross-site
http(s) origins a malicious web page can never forge a non-web origin.
This guard runs only AFTER ``_ws_auth_ok`` has validated the WS credential
(session token on loopback / ``--insecure`` binds, single-use ``?ticket=``
on OAuth-gated binds), so a non-web origin is trusted in every mode: the
credential is the real gate, and a ``file://`` / ``null`` origin cannot
originate a DNS-rebinding browser attack. ``http(s)`` origins are still
match-checked against the bound host.
"""
def _ws(self, *, origin, host):
ws = _fake_ws(query={}, path="/api/ws")
ws.headers = {"host": host, "origin": origin}
return ws
def test_loopback_file_origin_allowed(self, loopback_app):
ws = self._ws(origin="file://", host="127.0.0.1:8080")
assert web_server._ws_host_origin_is_allowed(ws) is True
def test_loopback_null_origin_allowed(self, loopback_app):
ws = self._ws(origin="null", host="127.0.0.1:8080")
assert web_server._ws_host_origin_is_allowed(ws) is True
def test_loopback_app_scheme_origin_allowed(self, loopback_app):
ws = self._ws(origin="app://hermes", host="127.0.0.1:8080")
assert web_server._ws_host_origin_is_allowed(ws) is True
def test_loopback_matching_http_origin_allowed(self, loopback_app):
# The dev renderer (vite) loads over http://127.0.0.1:<port>.
ws = self._ws(origin="http://127.0.0.1:5174", host="127.0.0.1:8080")
assert web_server._ws_host_origin_is_allowed(ws) is True
def test_loopback_cross_site_http_origin_rejected(self, loopback_app):
# DNS-rebinding / cross-site: a real web attacker can only present an
# http(s) origin, and that must still be rejected.
ws = self._ws(origin="http://evil.test", host="127.0.0.1:8080")
assert web_server._ws_host_origin_is_allowed(ws) is False
def test_explicit_non_loopback_file_origin_allowed(self, insecure_explicit_host_app):
"""Packaged Hermes Desktop also uses file:// when connecting to a
Tailscale/LAN dashboard bind.
The WebSocket route calls _ws_auth_ok before this guard, so in
non-gated mode the legacy session token remains the auth boundary.
"""
ws = self._ws(origin="file://", host="100.64.0.10:9119")
assert web_server._ws_host_origin_is_allowed(ws) is True
def test_explicit_non_loopback_null_origin_allowed(self, insecure_explicit_host_app):
ws = self._ws(origin="null", host="100.64.0.10:9119")
assert web_server._ws_host_origin_is_allowed(ws) is True
def test_explicit_non_loopback_cross_site_http_origin_rejected(
self, insecure_explicit_host_app
):
ws = self._ws(origin="http://localhost:9119", host="100.64.0.10:9119")
assert web_server._ws_host_origin_is_allowed(ws) is False
def test_gated_file_origin_allowed(self, gated_app):
# The packaged desktop app drives a remote OAuth-GATED gateway over a
# file:// renderer origin. The WS route validates the single-use
# ?ticket= in _ws_auth_ok before this guard runs, and a file:// origin
# can't be a DNS-rebinding browser attack, so the Origin guard must let
# it through. This is the regression that broke desktop → hosted
# gateway connections — every WS upgrade got HTTP 403 even with a valid
# ticket.
ws = self._ws(origin="file://", host="fly-app.fly.dev")
assert web_server._ws_host_origin_is_allowed(ws) is True
def test_gated_null_origin_allowed(self, gated_app):
ws = self._ws(origin="null", host="fly-app.fly.dev")
assert web_server._ws_host_origin_is_allowed(ws) is True
def test_gated_app_scheme_origin_allowed(self, gated_app):
ws = self._ws(origin="app://.", host="fly-app.fly.dev")
assert web_server._ws_host_origin_is_allowed(ws) is True
def test_gated_cross_site_http_origin_still_host_checked(self, gated_app):
# An http(s) origin is still subjected to the same-host check even on a
# gated bind: a cross-site http origin whose netloc doesn't match the
# bound host is rejected. Real browser DNS-rebinding defence unchanged.
ws = self._ws(origin="https://evil.test", host="fly-app.fly.dev")
assert web_server._ws_host_origin_is_allowed(ws) is False
def test_gated_same_host_https_origin_allowed(self, gated_app):
ws = self._ws(origin="https://fly-app.fly.dev", host="fly-app.fly.dev")
assert web_server._ws_host_origin_is_allowed(ws) is True
class TestSidecarUrl:
def test_loopback_uses_session_token(self, loopback_app):
url = web_server._build_sidecar_url("ch-1")
assert url is not None
assert f"token={web_server._SESSION_TOKEN}" in url
assert "ticket=" not in url
def test_gated_uses_internal_credential(self, gated_app):
url = web_server._build_sidecar_url("ch-1")
assert url is not None
assert "token=" not in url
assert "ticket=" not in url
assert "internal=" in url
# The value should be the live process-lifetime internal credential,
# multi-use so the child can reconnect /api/pub.
cred = url.split("internal=")[1].split("&")[0]
info = consume_internal_credential(cred)
assert info["user_id"] == "server-internal"
assert info["provider"] == "server-internal"
# Multi-use: a second consume still succeeds (unlike a ticket).
assert consume_internal_credential(cred)["provider"] == "server-internal"
def test_no_bound_host_returns_none(self, gated_app):
web_server.app.state.bound_host = None
try:
assert web_server._build_sidecar_url("ch") is None
finally:
web_server.app.state.bound_host = "fly-app.fly.dev"
# ---------------------------------------------------------------------------
# _build_gateway_ws_url — the TUI child's primary JSON-RPC backend WS.
# Loopback uses ?token=; gated mode uses the multi-use internal credential
# (NOT a single-use ticket — the child reuses this URL across reconnects).
# ---------------------------------------------------------------------------
class TestGatewayWsUrl:
def test_loopback_uses_session_token(self, loopback_app):
url = web_server._build_gateway_ws_url()
assert url is not None
assert "/api/ws?" in url
assert f"token={web_server._SESSION_TOKEN}" in url
assert "internal=" not in url
def test_gated_uses_internal_credential(self, gated_app):
url = web_server._build_gateway_ws_url()
assert url is not None
assert "/api/ws?" in url
assert "token=" not in url
assert "ticket=" not in url
assert "internal=" in url
cred = url.split("internal=")[1].split("&")[0]
# The credential authenticates against _ws_auth_ok in gated mode.
ws = _fake_ws(query={"internal": cred})
assert web_server._ws_auth_ok(ws) is True
def test_gated_credential_matches_sidecar(self, gated_app):
"""Both server-internal builders share one process credential, so a
single value authenticates /api/ws and /api/pub alike."""
gw = web_server._build_gateway_ws_url()
sc = web_server._build_sidecar_url("ch-1")
assert gw is not None and sc is not None
gw_cred = gw.split("internal=")[1].split("&")[0]
sc_cred = sc.split("internal=")[1].split("&")[0]
assert gw_cred == sc_cred
def test_no_bound_host_returns_none(self, gated_app):
web_server.app.state.bound_host = None
try:
assert web_server._build_gateway_ws_url() is None
finally:
web_server.app.state.bound_host = "fly-app.fly.dev"
@@ -0,0 +1,231 @@
"""Tests for the WS-upgrade ticket store (Phase 5 task 5.1).
The store is process-local and threading-safe. Tests run with xdist so
each worker has its own module instance no cross-worker bleed but we
call ``_reset_for_tests`` between tests to keep things deterministic.
"""
from __future__ import annotations
import threading
import pytest
from hermes_cli.dashboard_auth import ws_tickets
from hermes_cli.dashboard_auth.ws_tickets import (
TTL_SECONDS,
TicketInvalid,
_reset_for_tests,
consume_ticket,
mint_ticket,
)
@pytest.fixture(autouse=True)
def _reset():
_reset_for_tests()
yield
_reset_for_tests()
# ---------------------------------------------------------------------------
# Happy path
# ---------------------------------------------------------------------------
class TestMintAndConsume:
def test_round_trip(self):
ticket = mint_ticket(user_id="u1", provider="nous")
info = consume_ticket(ticket)
assert info["user_id"] == "u1"
assert info["provider"] == "nous"
assert "minted_at" in info
def test_ticket_has_minimum_length(self):
# ``secrets.token_urlsafe(32)`` produces ~43 chars; enforce a floor
# so a future refactor can't accidentally shrink the entropy.
ticket = mint_ticket(user_id="u1", provider="nous")
assert len(ticket) >= 32
def test_ticket_values_are_unique(self):
seen = {mint_ticket(user_id="u1", provider="x") for _ in range(50)}
assert len(seen) == 50
# ---------------------------------------------------------------------------
# Single-use
# ---------------------------------------------------------------------------
class TestSingleUse:
def test_second_consume_raises(self):
ticket = mint_ticket(user_id="u1", provider="stub")
consume_ticket(ticket)
with pytest.raises(TicketInvalid, match="unknown"):
consume_ticket(ticket)
def test_unknown_ticket_rejected(self):
with pytest.raises(TicketInvalid, match="unknown"):
consume_ticket("nope-never-minted")
def test_empty_ticket_rejected(self):
with pytest.raises(TicketInvalid):
consume_ticket("")
# ---------------------------------------------------------------------------
# TTL
# ---------------------------------------------------------------------------
class TestTTL:
def test_constant_is_30_seconds(self):
# Pinned so a refactor that doubled the lifetime would surface here.
assert TTL_SECONDS == 30
def test_expired_ticket_rejected(self, monkeypatch):
# Mock time inside the ws_tickets module so mint and consume see
# different clocks. We have to patch the symbol the module actually
# binds; ``time`` is module-level there.
clock = {"now": 1_000_000}
def fake_time():
return clock["now"]
monkeypatch.setattr(ws_tickets.time, "time", fake_time)
ticket = mint_ticket(user_id="u1", provider="stub")
clock["now"] += TTL_SECONDS + 1
with pytest.raises(TicketInvalid, match="expired"):
consume_ticket(ticket)
def test_at_exact_ttl_boundary_still_valid(self, monkeypatch):
clock = {"now": 1_000_000}
monkeypatch.setattr(ws_tickets.time, "time", lambda: clock["now"])
ticket = mint_ticket(user_id="u1", provider="stub")
clock["now"] += TTL_SECONDS # exactly at boundary; expires_at == now
# Implementation: ``expires_at < now`` (strict), so == passes.
info = consume_ticket(ticket)
assert info["user_id"] == "u1"
# ---------------------------------------------------------------------------
# Truncated value in error message (secret hygiene)
# ---------------------------------------------------------------------------
class TestErrorMessages:
def test_unknown_ticket_error_truncates_value(self):
long_value = "a" * 100
with pytest.raises(TicketInvalid) as exc_info:
consume_ticket(long_value)
# Never log more than the first 8 chars of an opaque ticket.
message = str(exc_info.value)
assert long_value not in message
assert long_value[:8] in message
# ---------------------------------------------------------------------------
# Thread safety: mint + consume from many threads doesn't deadlock or
# return duplicates.
# ---------------------------------------------------------------------------
class TestConcurrency:
def test_mint_and_consume_concurrent(self):
results: list[dict] = []
errors: list[Exception] = []
lock = threading.Lock()
def worker(i: int):
try:
t = mint_ticket(user_id=f"u{i}", provider="stub")
info = consume_ticket(t)
with lock:
results.append(info)
except Exception as exc: # noqa: BLE001 — collect for assert
with lock:
errors.append(exc)
threads = [threading.Thread(target=worker, args=(i,)) for i in range(20)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=5.0)
assert not t.is_alive(), "thread deadlocked"
assert errors == []
assert len(results) == 20
# Every consume returns a distinct user_id (no cross-thread bleed).
assert {r["user_id"] for r in results} == {f"u{i}" for i in range(20)}
# ---------------------------------------------------------------------------
# Process-lifetime internal credential (server-spawned PTY child auth).
# Direct unit coverage for internal_ws_credential / consume_internal_credential
# — _ws_auth_ok exercises these indirectly, but the mint-once, unminted, and
# empty-value branches are only reachable via direct calls.
# ---------------------------------------------------------------------------
class TestInternalCredential:
def test_minted_once_is_stable(self):
"""Successive calls return the same process-lifetime value."""
first = ws_tickets.internal_ws_credential()
second = ws_tickets.internal_ws_credential()
assert first == second
assert len(first) >= 32 # token_urlsafe(32)
def test_round_trip_identity(self):
cred = ws_tickets.internal_ws_credential()
info = ws_tickets.consume_internal_credential(cred)
assert info["user_id"] == ws_tickets.INTERNAL_USER_ID
assert info["provider"] == ws_tickets.INTERNAL_PROVIDER
def test_multi_use(self):
"""Unlike a single-use ticket, the credential survives repeated consume."""
cred = ws_tickets.internal_ws_credential()
for _ in range(5):
assert (
ws_tickets.consume_internal_credential(cred)["provider"]
== ws_tickets.INTERNAL_PROVIDER
)
def test_rejected_before_mint(self):
"""With nothing minted yet, any value is rejected (expected is None)."""
# autouse _reset leaves _internal_credential == None at test start.
with pytest.raises(TicketInvalid):
ws_tickets.consume_internal_credential("anything")
def test_empty_value_rejected(self):
ws_tickets.internal_ws_credential() # mint so expected is non-None
with pytest.raises(TicketInvalid):
ws_tickets.consume_internal_credential("")
def test_wrong_value_rejected(self):
ws_tickets.internal_ws_credential()
with pytest.raises(TicketInvalid):
ws_tickets.consume_internal_credential("not-the-credential")
def test_reset_clears_and_remints(self):
first = ws_tickets.internal_ws_credential()
_reset_for_tests()
# The old value no longer validates after reset.
with pytest.raises(TicketInvalid):
ws_tickets.consume_internal_credential(first)
# A fresh mint produces a different value.
second = ws_tickets.internal_ws_credential()
assert second != first
assert ws_tickets.consume_internal_credential(second)["user_id"] == (
ws_tickets.INTERNAL_USER_ID
)
def test_independent_of_ticket_store(self):
"""The internal credential is not a ticket — minting tickets doesn't
touch it, and consuming the credential doesn't consume tickets."""
cred = ws_tickets.internal_ws_credential()
ticket = mint_ticket(user_id="u1", provider="nous")
# Consuming the internal credential leaves the ticket intact.
ws_tickets.consume_internal_credential(cred)
assert consume_ticket(ticket)["user_id"] == "u1"
@@ -0,0 +1,16 @@
"""Static dashboard tests for browser-safe @nous-research/ui imports."""
from pathlib import Path
WEB_SRC = Path(__file__).resolve().parents[2] / "web" / "src"
def test_dashboard_does_not_import_nous_ui_root_barrel():
offenders = []
for ext in ("*.tsx", "*.ts"):
for path in WEB_SRC.rglob(ext):
content = path.read_text(encoding="utf-8")
if 'from "@nous-research/ui"' in content or "from '@nous-research/ui'" in content:
offenders.append(str(path.relative_to(WEB_SRC)))
assert offenders == []
@@ -0,0 +1,181 @@
"""Tests for ``hermes dashboard --stop`` / ``--status`` flags.
These flags share the detection + kill path with the post-``hermes update``
cleanup, so the heavy coverage of SIGTERM / SIGKILL / Windows taskkill lives
in ``test_update_stale_dashboard.py``. This file just verifies the flag
dispatch: argparse wiring, no-op when nothing is running, and correct
exit codes.
"""
from __future__ import annotations
import argparse
import sys
from unittest.mock import patch, MagicMock
import pytest
from hermes_cli.main import cmd_dashboard
def _ns(**kw):
"""Build an argparse.Namespace with dashboard defaults plus overrides."""
defaults = dict(
port=9119, host="127.0.0.1", no_open=False, insecure=False,
stop=False, status=False,
)
defaults.update(kw)
return argparse.Namespace(**defaults)
class TestDashboardStatus:
def test_status_no_processes(self, capsys):
with patch("hermes_cli.main._find_stale_dashboard_pids",
return_value=[]), \
pytest.raises(SystemExit) as exc:
cmd_dashboard(_ns(status=True))
assert exc.value.code == 0
out = capsys.readouterr().out
assert "No hermes dashboard processes running" in out
def test_status_with_processes(self, capsys):
with patch("hermes_cli.main._find_stale_dashboard_pids",
return_value=[12345, 12346]), \
pytest.raises(SystemExit) as exc:
cmd_dashboard(_ns(status=True))
# Status is informational — always exits 0.
assert exc.value.code == 0
out = capsys.readouterr().out
assert "2 hermes dashboard process(es) running" in out
assert "PID 12345" in out
assert "PID 12346" in out
def test_status_does_not_try_to_import_fastapi(self):
"""`--status` must not require dashboard runtime deps — it's a
process-table scan only. We prove this by making fastapi import
fail and confirming --status still succeeds."""
orig_import = __import__
def fake_import(name, *a, **kw):
if name == "fastapi":
raise ImportError("fastapi missing")
return orig_import(name, *a, **kw)
with patch("hermes_cli.main._find_stale_dashboard_pids",
return_value=[]), \
patch("builtins.__import__", side_effect=fake_import), \
pytest.raises(SystemExit) as exc:
cmd_dashboard(_ns(status=True))
assert exc.value.code == 0
class TestDashboardStop:
def test_stop_when_nothing_running(self, capsys):
with patch("hermes_cli.main._find_stale_dashboard_pids",
return_value=[]), \
pytest.raises(SystemExit) as exc:
cmd_dashboard(_ns(stop=True))
assert exc.value.code == 0
out = capsys.readouterr().out
assert "No hermes dashboard processes running" in out
def test_stop_kills_and_exits_zero_when_all_killed(self, capsys):
"""After the kill, if the second scan returns empty we exit 0."""
# First scan: finds two processes. Second (verification) scan: empty.
scans = iter([[12345, 12346], []])
with patch("hermes_cli.main._find_stale_dashboard_pids",
side_effect=lambda: next(scans)), \
patch("hermes_cli.main._kill_stale_dashboard_processes") as mock_kill, \
pytest.raises(SystemExit) as exc:
cmd_dashboard(_ns(stop=True))
mock_kill.assert_called_once()
# --stop should pass a reason so the output doesn't say "running
# backend no longer matches the updated frontend" (that wording is
# for the post-`hermes update` path).
kwargs = mock_kill.call_args.kwargs
assert "reason" in kwargs
assert "stop" in kwargs["reason"].lower()
assert exc.value.code == 0
def test_stop_exits_nonzero_if_kill_leaves_survivors(self):
"""If the second scan still finds PIDs, we exit 1 so scripts can
detect that the stop didn't succeed (e.g. permission denied)."""
scans = iter([[12345], [12345]]) # both scans find the same PID
with patch("hermes_cli.main._find_stale_dashboard_pids",
side_effect=lambda: next(scans)), \
patch("hermes_cli.main._kill_stale_dashboard_processes"), \
pytest.raises(SystemExit) as exc:
cmd_dashboard(_ns(stop=True))
assert exc.value.code == 1
def test_stop_does_not_try_to_import_fastapi(self):
"""Like --status, --stop must work without dashboard runtime deps."""
orig_import = __import__
def fake_import(name, *a, **kw):
if name == "fastapi":
raise ImportError("fastapi missing")
return orig_import(name, *a, **kw)
with patch("hermes_cli.main._find_stale_dashboard_pids",
return_value=[]), \
patch("builtins.__import__", side_effect=fake_import), \
pytest.raises(SystemExit) as exc:
cmd_dashboard(_ns(stop=True))
assert exc.value.code == 0
class TestLifecycleFlagsTakePrecedence:
"""If both --stop and --status are set, --status wins (it's listed
first in cmd_dashboard). Neither is allowed to fall through to the
server-start path, which is the critical safety property a user
who typed ``hermes dashboard --stop`` must not end up ALSO starting
a new server."""
def test_status_wins_over_stop(self, capsys):
with patch("hermes_cli.main._find_stale_dashboard_pids",
return_value=[]), \
patch("hermes_cli.main._kill_stale_dashboard_processes") as mock_kill, \
pytest.raises(SystemExit):
cmd_dashboard(_ns(status=True, stop=True))
# Kill path must NOT run when --status is also set.
mock_kill.assert_not_called()
def test_stop_does_not_fall_through_to_server_start(self):
"""Covers the worst-case regression: if --stop ever stopped exiting
early, the user would start the dashboard they just asked to stop."""
called = {"start": False}
def fake_start_server(**kw):
called["start"] = True
# Provide a fake web_server module so the import doesn't matter.
fake_ws = MagicMock()
fake_ws.start_server = fake_start_server
with patch("hermes_cli.main._find_stale_dashboard_pids",
return_value=[]), \
patch.dict(sys.modules, {"hermes_cli.web_server": fake_ws}), \
pytest.raises(SystemExit):
cmd_dashboard(_ns(stop=True))
assert called["start"] is False
class TestArgparseWiring:
"""Confirm the flags are exposed via the real argparse tree so
``hermes dashboard --stop`` / ``--status`` actually parse."""
def test_flags_are_registered(self):
from hermes_cli.main import main as _cli_main # noqa: F401
# Rebuild the argparse tree by re-running the section of main()
# that builds it. Cheapest way: introspect via --help on the
# already-built parser would require refactoring; instead we
# parse the flags directly via a minimal replay.
import importlib
mod = importlib.import_module("hermes_cli.main")
# Find the dashboard_parser instance by running build logic would
# be too invasive. Instead parse args as if via the CLI by
# intercepting parse_args. This is overkill for a smoke test —
# we just want to know the flags don't KeyError.
with patch("hermes_cli.main._find_stale_dashboard_pids",
return_value=[]), \
pytest.raises(SystemExit) as exc:
mod.cmd_dashboard(_ns(status=True))
assert exc.value.code == 0
@@ -0,0 +1,12 @@
"""Static dashboard tests for the Profiles navigation copy."""
from pathlib import Path
def test_profiles_nav_label_uses_short_copy():
en_i18n = Path(__file__).resolve().parents[2] / "web" / "src" / "i18n" / "en.ts"
content = en_i18n.read_text(encoding="utf-8")
# Nav label should be the clean short form, not the old verbose string
assert 'profiles: "Profiles"' in content
assert "profiles : multi agents" not in content
+614
View File
@@ -0,0 +1,614 @@
"""Tests for ``hermes dashboard register``.
Covers the CLI half of self-hosted dashboard registration:
- Docker-style auto-name generation
- not-logged-in fast-fail (AuthError with relogin_required)
- managed-install refusal
- the happy path: POST shape, env-var writes, custom redirect URI
- portal-URL write logic (only when non-default and not already set)
- portal HTTP error mapping (401/403)
The portal HTTP call and the Nous token resolution are both mocked this
file proves the CLI wiring + env-write behaviour. The live end-to-end token
round-trip against the Vercel preview build is a separate manual step.
"""
from __future__ import annotations
import argparse
import json
import urllib.error
from io import BytesIO
from unittest.mock import MagicMock, patch
import pytest
import hermes_cli.dashboard_register as dr
def _ns(**kw):
defaults = dict(name=None, redirect_uri=None, portal_url=None)
defaults.update(kw)
return argparse.Namespace(**defaults)
class TestNameGenerator:
def test_shape_is_adjective_underscore_noun(self):
for _ in range(50):
name = dr._generate_dashboard_name()
assert "_" in name
adj, _, noun = name.partition("_")
assert adj in dr._NAME_ADJECTIVES
assert noun in dr._NAME_NOUNS
class TestFastFails:
def test_not_logged_in_exits_1_with_setup_hint(self, capsys):
from hermes_cli.auth import AuthError
err = AuthError("not logged in", provider="nous", relogin_required=True)
with patch.object(dr, "cmd_dashboard_register", dr.cmd_dashboard_register):
with patch(
"hermes_cli.auth.resolve_nous_access_token", side_effect=err
), patch("hermes_cli.config.is_managed", return_value=False):
with pytest.raises(SystemExit) as exc:
dr.cmd_dashboard_register(_ns())
assert exc.value.code == 1
out = capsys.readouterr().out
assert "not logged into Nous Portal" in out
assert "hermes setup" in out
def test_managed_install_refuses(self, capsys):
with patch("hermes_cli.config.is_managed", return_value=True):
with pytest.raises(SystemExit) as exc:
dr.cmd_dashboard_register(_ns())
assert exc.value.code == 1
out = capsys.readouterr().out
assert "not available in a managed" in out
def _fake_http_ok(payload: dict):
"""Return a context-manager urlopen stub yielding `payload` as JSON."""
cm = MagicMock()
cm.__enter__.return_value.read.return_value = json.dumps(payload).encode()
return cm
class TestHappyPath:
def _run(self, *, args, account_token="tok_abc", portal="https://portal.nousresearch.com",
response=None, captured=None, existing_client_id=None):
response = response or {
"client_id": "agent:selfhost-1",
"id": "selfhost-1",
"name": "dreamy_tesla",
"kind": "SELF_HOSTED",
"custom_redirect_uri": None,
"created_at": "2026-06-04T12:00:00.000Z",
}
def fake_urlopen(req, timeout=None):
if captured is not None:
captured["url"] = req.full_url
captured["headers"] = dict(req.header_items())
captured["body"] = json.loads(req.data.decode())
return _fake_http_ok(response)
saved = {}
def fake_save(key, value):
saved[key] = value
# get_env_value is consulted twice: once for the stored client_id
# (idempotency key) and once for HERMES_DASHBOARD_PORTAL_URL. Route by
# key so a test can seed a prior client_id while keeping the portal
# unset (the default-portal-not-persisted path).
def fake_get_env(key):
if key == "HERMES_DASHBOARD_OAUTH_CLIENT_ID":
return existing_client_id
return None
with patch(
"hermes_cli.auth.resolve_nous_access_token", return_value=account_token
), patch("hermes_cli.config.is_managed", return_value=False), patch.object(
dr, "_resolve_portal_base_url", return_value=portal
), patch(
"hermes_cli.config.get_env_value", side_effect=fake_get_env
), patch(
"hermes_cli.config.save_env_value", side_effect=fake_save
), patch.object(
dr.urllib.request, "urlopen", side_effect=fake_urlopen
):
dr.cmd_dashboard_register(args)
return saved
def test_writes_client_id_and_posts_generated_name(self, capsys):
captured: dict = {}
saved = self._run(args=_ns(), captured=captured)
# POST shape
assert captured["url"].endswith("/api/oauth/self-hosted-client")
assert captured["headers"]["Authorization"] == "Bearer tok_abc"
assert "name" in captured["body"] and captured["body"]["name"]
assert "custom_redirect_uri" not in captured["body"]
# env write: client_id present, portal URL NOT written (default portal)
assert saved["HERMES_DASHBOARD_OAUTH_CLIENT_ID"] == "agent:selfhost-1"
assert "HERMES_DASHBOARD_PORTAL_URL" not in saved
out = capsys.readouterr().out
assert "Registered dashboard" in out
assert "non-loopback bind" in out # the gate-engagement hint
def test_explicit_name_is_sent(self, capsys):
captured: dict = {}
self._run(args=_ns(name="my_box"), captured=captured)
assert captured["body"]["name"] == "my_box"
def test_custom_redirect_uri_is_forwarded(self, capsys):
captured: dict = {}
self._run(
args=_ns(redirect_uri="https://hermes.example.com/auth/callback"),
captured=captured,
)
assert (
captured["body"]["custom_redirect_uri"]
== "https://hermes.example.com/auth/callback"
)
def test_non_default_portal_is_persisted(self, capsys):
saved = self._run(
args=_ns(),
portal="https://nous-account-service-git-feat-x.vercel.app",
)
assert (
saved["HERMES_DASHBOARD_PORTAL_URL"]
== "https://nous-account-service-git-feat-x.vercel.app"
)
class TestIdempotentRerun(TestHappyPath):
"""Re-running with a stored client_id updates instead of creating.
Inherits ``_run`` from TestHappyPath; the only new lever is
``existing_client_id`` (the HERMES_DASHBOARD_OAUTH_CLIENT_ID a prior run
persisted), which the CLI re-sends so the portal updates that row.
"""
def test_stored_client_id_is_sent_as_idempotency_key(self, capsys):
captured: dict = {}
# Portal echoes back the SAME id -> it updated in place.
self._run(
args=_ns(),
existing_client_id="agent:selfhost-1",
response={
"client_id": "agent:selfhost-1",
"id": "selfhost-1",
"name": "dreamy_tesla",
"kind": "SELF_HOSTED",
"custom_redirect_uri": None,
"created_at": "2026-06-04T12:00:00.000Z",
},
captured=captured,
)
assert captured["body"]["client_id"] == "agent:selfhost-1"
def test_rerun_without_name_omits_name_to_preserve_stored(self, capsys):
# No --name on a re-run: don't churn the portal-stored name. The CLI
# leaves `name` out of the body so the portal keeps what it has.
captured: dict = {}
self._run(
args=_ns(),
existing_client_id="agent:selfhost-1",
captured=captured,
)
assert "name" not in captured["body"]
assert captured["body"]["client_id"] == "agent:selfhost-1"
def test_rerun_with_explicit_name_still_sends_name(self, capsys):
captured: dict = {}
self._run(
args=_ns(name="renamed_box"),
existing_client_id="agent:selfhost-1",
captured=captured,
)
assert captured["body"]["name"] == "renamed_box"
assert captured["body"]["client_id"] == "agent:selfhost-1"
def test_rerun_prints_updated_when_same_id_returned(self, capsys):
self._run(
args=_ns(),
existing_client_id="agent:selfhost-1",
response={
"client_id": "agent:selfhost-1",
"id": "selfhost-1",
"name": "dreamy_tesla",
"kind": "SELF_HOSTED",
"custom_redirect_uri": None,
"created_at": "2026-06-04T12:00:00.000Z",
},
)
out = capsys.readouterr().out
assert "Updated dashboard" in out
assert "Registered dashboard" not in out
def test_rerun_persists_returned_client_id(self, capsys):
saved = self._run(
args=_ns(),
existing_client_id="agent:selfhost-1",
)
# Same id round-trips into .env -> idempotent, one record.
assert saved["HERMES_DASHBOARD_OAUTH_CLIENT_ID"] == "agent:selfhost-1"
def test_stale_id_falls_through_to_create_prints_registered(self, capsys):
# Stored id no longer resolves server-side -> portal created a fresh
# row and returns a DIFFERENT id. The CLI treats that as a create and
# persists the new id (re-run stays safe, never worse than first run).
captured: dict = {}
saved = self._run(
args=_ns(name="seed_name"),
existing_client_id="agent:selfhost-stale",
response={
"client_id": "agent:selfhost-new",
"id": "selfhost-new",
"name": "seed_name",
"kind": "SELF_HOSTED",
"custom_redirect_uri": None,
"created_at": "2026-06-04T12:00:00.000Z",
},
captured=captured,
)
# The stale id is still SENT (portal decides create-vs-update).
assert captured["body"]["client_id"] == "agent:selfhost-stale"
# Returned id differs from what we sent -> message is "Registered".
out = capsys.readouterr().out
assert "Registered dashboard" in out
assert "Updated dashboard" not in out
assert saved["HERMES_DASHBOARD_OAUTH_CLIENT_ID"] == "agent:selfhost-new"
def test_blank_stored_client_id_treated_as_first_run(self, capsys):
# A blank/whitespace stored value is not a usable key: treat as a
# first registration (auto-generate a name, don't send client_id).
captured: dict = {}
self._run(
args=_ns(),
existing_client_id=" ",
captured=captured,
)
assert "client_id" not in captured["body"]
assert captured["body"].get("name") # auto-generated
class TestCustomPortalPersistence:
"""`--portal-url` / HERMES_DASHBOARD_PORTAL_URL is persisted to .env.
An *explicitly supplied* custom portal URL is an intentional choice the
user wants to survive across sessions, so it's always written (updating an
existing entry in place rather than appending a duplicate). When no custom
URL is supplied, the older conservative behaviour is preserved: an inferred
portal is only written when absent and non-default, and an existing entry
is never altered unexpectedly.
"""
def _run(self, *, args, portal, existing_portal):
"""Drive cmd_dashboard_register, capturing save_env_value calls.
`existing_portal` is what get_env_value returns for
HERMES_DASHBOARD_PORTAL_URL (None = not present in .env).
"""
response = {
"client_id": "agent:selfhost-1",
"id": "selfhost-1",
"name": "dreamy_tesla",
"kind": "SELF_HOSTED",
"custom_redirect_uri": None,
"created_at": "2026-06-04T12:00:00.000Z",
}
saved: dict = {}
def fake_save(key, value):
saved[key] = value
def fake_get_env_value(key, *a, **kw):
if key == "HERMES_DASHBOARD_PORTAL_URL":
return existing_portal
return None
with patch(
"hermes_cli.auth.resolve_nous_access_token", return_value="tok"
), patch("hermes_cli.config.is_managed", return_value=False), patch.dict(
dr.os.environ, {}, clear=False
), patch.object(
dr, "_resolve_portal_base_url", return_value=portal
), patch(
"hermes_cli.config.get_env_value", side_effect=fake_get_env_value
), patch(
"hermes_cli.config.save_env_value", side_effect=fake_save
), patch.object(
dr.urllib.request, "urlopen", return_value=_fake_http_ok(response)
):
# The ambient process env may carry HERMES_DASHBOARD_PORTAL_URL
# (e.g. staging dev shells); drop it so `custom_portal_supplied`
# is driven solely by the args.portal_url under test.
dr.os.environ.pop("HERMES_DASHBOARD_PORTAL_URL", None)
dr.cmd_dashboard_register(args)
return saved
def test_explicit_custom_url_persisted_when_var_absent(self, capsys):
saved = self._run(
args=_ns(portal_url="https://preview.example.com"),
portal="https://preview.example.com",
existing_portal=None,
)
assert saved["HERMES_DASHBOARD_PORTAL_URL"] == "https://preview.example.com"
def test_explicit_custom_url_updates_existing_in_place(self, capsys):
# An entry already exists with a different value; the explicit custom
# URL overwrites it (save_env_value updates the matching key in place).
saved = self._run(
args=_ns(portal_url="https://new-preview.example.com"),
portal="https://new-preview.example.com",
existing_portal="https://old-preview.example.com",
)
assert (
saved["HERMES_DASHBOARD_PORTAL_URL"] == "https://new-preview.example.com"
)
def test_explicit_custom_url_persisted_even_when_equals_default(self, capsys):
# User explicitly asked for the production portal — honour the explicit
# request and persist it (the no-flag path would skip the default).
saved = self._run(
args=_ns(portal_url="https://portal.nousresearch.com"),
portal="https://portal.nousresearch.com",
existing_portal=None,
)
assert (
saved["HERMES_DASHBOARD_PORTAL_URL"] == "https://portal.nousresearch.com"
)
def test_explicit_custom_url_equal_to_existing_is_noop(self, capsys):
# Already persisted with the same value → no redundant write.
saved = self._run(
args=_ns(portal_url="https://preview.example.com"),
portal="https://preview.example.com",
existing_portal="https://preview.example.com",
)
assert "HERMES_DASHBOARD_PORTAL_URL" not in saved
def test_no_flag_default_portal_not_written(self, capsys):
# No custom URL supplied, resolves to default → not written.
saved = self._run(
args=_ns(),
portal="https://portal.nousresearch.com",
existing_portal=None,
)
assert "HERMES_DASHBOARD_PORTAL_URL" not in saved
def test_no_flag_does_not_overwrite_existing_entry(self, capsys):
# No custom URL supplied and the var already exists → left untouched,
# even if the inferred portal differs (acceptance criterion 4).
saved = self._run(
args=_ns(),
portal="https://inferred-from-login.example.com",
existing_portal="https://already-set.example.com",
)
assert "HERMES_DASHBOARD_PORTAL_URL" not in saved
class TestPublicUrlPersistence:
"""`--redirect-uri` derives & persists HERMES_DASHBOARD_PUBLIC_URL in .env.
--redirect-uri is the full public callback (e.g.
https://hermes.example.com/auth/callback). At serve time the dashboard auth
layer reconstructs that callback by appending "/auth/callback" to
HERMES_DASHBOARD_PUBLIC_URL, so the value that's actually consumed is the
ORIGIN (scheme://host). We derive the origin from the supplied redirect URI
and persist THAT as HERMES_DASHBOARD_PUBLIC_URL the var the runtime reads
so the public-URL override is genuinely wired, not just stored.
An explicitly supplied value is always written (updating an existing entry
in place rather than appending a duplicate); a no-op when it already
matches; and never written on a localhost-only install (no --redirect-uri).
"""
def _run(self, *, args, existing_public=None):
"""Drive cmd_dashboard_register, capturing save_env_value calls.
`existing_public` is what get_env_value returns for
HERMES_DASHBOARD_PUBLIC_URL (None = not present in .env).
"""
response = {
"client_id": "agent:selfhost-1",
"id": "selfhost-1",
"name": "dreamy_tesla",
"kind": "SELF_HOSTED",
"custom_redirect_uri": getattr(args, "redirect_uri", None),
"created_at": "2026-06-04T12:00:00.000Z",
}
saved: dict = {}
def fake_save(key, value):
saved[key] = value
def fake_get_env_value(key, *a, **kw):
if key == "HERMES_DASHBOARD_PUBLIC_URL":
return existing_public
return None
with patch(
"hermes_cli.auth.resolve_nous_access_token", return_value="tok"
), patch("hermes_cli.config.is_managed", return_value=False), patch.dict(
dr.os.environ, {}, clear=False
), patch.object(
dr, "_resolve_portal_base_url", return_value="https://portal.nousresearch.com"
), patch(
"hermes_cli.config.get_env_value", side_effect=fake_get_env_value
), patch(
"hermes_cli.config.save_env_value", side_effect=fake_save
), patch.object(
dr.urllib.request, "urlopen", return_value=_fake_http_ok(response)
):
dr.os.environ.pop("HERMES_DASHBOARD_PORTAL_URL", None)
dr.cmd_dashboard_register(args)
return saved
def test_origin_derived_from_full_callback_path(self, capsys):
# The key behaviour: a full callback URL is reduced to its ORIGIN so
# the runtime's "public_url + /auth/callback" reconstruction matches.
saved = self._run(
args=_ns(redirect_uri="https://hermes.example.com/auth/callback"),
existing_public=None,
)
assert saved["HERMES_DASHBOARD_PUBLIC_URL"] == "https://hermes.example.com"
# The full callback path must NOT be persisted verbatim (would double
# the path at serve time).
assert "/auth/callback" not in saved["HERMES_DASHBOARD_PUBLIC_URL"]
def test_origin_preserves_port(self, capsys):
saved = self._run(
args=_ns(redirect_uri="https://hermes.example.com:8443/auth/callback"),
existing_public=None,
)
assert saved["HERMES_DASHBOARD_PUBLIC_URL"] == "https://hermes.example.com:8443"
def test_public_url_updates_existing_in_place(self, capsys):
# A stale public-url entry exists; the new derived origin overwrites it.
saved = self._run(
args=_ns(redirect_uri="https://new.example.com/auth/callback"),
existing_public="https://old.example.com",
)
assert saved["HERMES_DASHBOARD_PUBLIC_URL"] == "https://new.example.com"
def test_public_url_equal_to_existing_is_noop(self, capsys):
# Derived origin already matches what's stored → no redundant write.
saved = self._run(
args=_ns(redirect_uri="https://hermes.example.com/auth/callback"),
existing_public="https://hermes.example.com",
)
assert "HERMES_DASHBOARD_PUBLIC_URL" not in saved
def test_no_redirect_flag_not_written(self, capsys):
# Localhost-only install (no --redirect-uri) → var left untouched.
saved = self._run(
args=_ns(),
existing_public=None,
)
assert "HERMES_DASHBOARD_PUBLIC_URL" not in saved
def test_no_redirect_flag_does_not_overwrite_existing(self, capsys):
# No --redirect-uri supplied but a value already exists → never touch
# it (an existing entry is only changed by an explicit new value).
saved = self._run(
args=_ns(),
existing_public="https://already-set.example.com",
)
assert "HERMES_DASHBOARD_PUBLIC_URL" not in saved
def test_non_http_redirect_not_persisted(self, capsys):
# A malformed / non-http(s) redirect yields no derivable origin → skip.
saved = self._run(
args=_ns(redirect_uri="not-a-url"),
existing_public=None,
)
assert "HERMES_DASHBOARD_PUBLIC_URL" not in saved
def test_public_url_persisted_alongside_portal_url(self, capsys):
# Both --portal-url and --redirect-uri supplied → portal_url AND the
# derived public_url are both persisted (ADD semantics: the public-url
# write does not displace portal-url persistence).
response = {
"client_id": "agent:selfhost-1",
"id": "selfhost-1",
"name": "dreamy_tesla",
"kind": "SELF_HOSTED",
"custom_redirect_uri": "https://hermes.example.com/auth/callback",
"created_at": "2026-06-04T12:00:00.000Z",
}
saved: dict = {}
def fake_save(key, value):
saved[key] = value
with patch(
"hermes_cli.auth.resolve_nous_access_token", return_value="tok"
), patch("hermes_cli.config.is_managed", return_value=False), patch.dict(
dr.os.environ, {}, clear=False
), patch.object(
dr, "_resolve_portal_base_url", return_value="https://preview.example.com"
), patch(
"hermes_cli.config.get_env_value", return_value=None
), patch(
"hermes_cli.config.save_env_value", side_effect=fake_save
), patch.object(
dr.urllib.request, "urlopen", return_value=_fake_http_ok(response)
):
dr.os.environ.pop("HERMES_DASHBOARD_PORTAL_URL", None)
dr.cmd_dashboard_register(
_ns(
portal_url="https://preview.example.com",
redirect_uri="https://hermes.example.com/auth/callback",
)
)
assert saved["HERMES_DASHBOARD_PORTAL_URL"] == "https://preview.example.com"
assert saved["HERMES_DASHBOARD_PUBLIC_URL"] == "https://hermes.example.com"
class TestPortalResolution:
def test_override_arg_wins(self):
assert (
dr._resolve_portal_base_url("https://preview.example.com/")
== "https://preview.example.com"
)
def test_falls_back_to_stored_login_portal(self):
with patch(
"hermes_cli.auth.get_provider_auth_state",
return_value={"portal_base_url": "https://portal.staging-nousresearch.com"},
):
assert (
dr._resolve_portal_base_url(None)
== "https://portal.staging-nousresearch.com"
)
def test_blank_override_ignored(self):
with patch(
"hermes_cli.auth.get_provider_auth_state",
return_value={"portal_base_url": "https://portal.staging-nousresearch.com"},
):
assert (
dr._resolve_portal_base_url(" ")
== "https://portal.staging-nousresearch.com"
)
class TestPortalErrors:
def _run_http_error(self, code, body):
err = urllib.error.HTTPError(
url="https://portal.nousresearch.com/api/oauth/self-hosted-client",
code=code,
msg="err",
hdrs=None,
fp=BytesIO(json.dumps(body).encode()),
)
with patch(
"hermes_cli.auth.resolve_nous_access_token", return_value="tok"
), patch("hermes_cli.config.is_managed", return_value=False), patch.object(
dr, "_resolve_portal_base_url", return_value="https://portal.nousresearch.com"
), patch.object(dr.urllib.request, "urlopen", side_effect=err):
with pytest.raises(SystemExit) as exc:
dr.cmd_dashboard_register(_ns())
return exc.value.code
def test_401_maps_to_reauth_message(self, capsys):
code = self._run_http_error(401, {"error": "invalid_token"})
assert code == 1
assert "re-authenticate" in capsys.readouterr().out
def test_403_surfaces_server_detail(self, capsys):
code = self._run_http_error(
403, {"error": "access_denied", "error_description": "Not permitted here."}
)
assert code == 1
assert "Not permitted here." in capsys.readouterr().out
@@ -0,0 +1,80 @@
"""Regression test: `hermes dashboard --tui` must not hard-crash.
Older Hermes desktop app shells (<= 0.15.x) spawn the backend as::
hermes dashboard --no-open --tui --host 127.0.0.1 --port <PORT>
The ``--tui`` flag was removed from the ``dashboard`` subcommand in cae6b5486
(embedded chat is always on now). When a user's CLI updates past that commit
but their desktop app binary has not, argparse used to reject the unknown flag
with ``error: unrecognized arguments: --tui`` and ``exit(2)`` the backend
died before it became ready and the desktop GUI showed only "Hermes couldn't
start" with no actionable cause.
The fix adds a hidden, deprecated, accepted-and-ignored ``--tui`` flag to the
dashboard subparser so an old app shell + new CLI degrades gracefully instead
of bricking. These tests pin that contract.
"""
import os
import subprocess
import sys
REPO_ROOT = os.path.abspath(
os.path.join(os.path.dirname(__file__), os.pardir, os.pardir)
)
def _run_cli(args, timeout=60):
"""Invoke the real hermes_cli.main parser in a subprocess.
Uses ``--status`` so the dashboard command exits immediately after parsing
(it scans the process table and returns) instead of starting a server.
Returns the CompletedProcess.
"""
env = dict(os.environ)
env["PYTHONPATH"] = REPO_ROOT + os.pathsep + env.get("PYTHONPATH", "")
return subprocess.run(
[sys.executable, "-m", "hermes_cli.main", *args],
cwd=REPO_ROOT,
env=env,
capture_output=True,
text=True,
timeout=timeout,
)
def test_dashboard_tui_flag_is_accepted_not_rejected():
"""The exact argv an old desktop app sends must parse without argparse error."""
result = _run_cli(
["dashboard", "--no-open", "--tui", "--host", "127.0.0.1",
"--port", "39997", "--status"]
)
combined = (result.stdout or "") + (result.stderr or "")
# The pre-fix failure signature.
assert "unrecognized arguments" not in combined, combined
assert "--tui" not in (result.stderr or ""), result.stderr
# argparse usage errors exit 2; the parse itself must not be that error.
assert result.returncode != 2, combined
def test_dashboard_tui_flag_is_hidden_from_help():
"""The deprecated shim must not re-advertise a removed feature in --help."""
result = _run_cli(["dashboard", "--help"])
combined = (result.stdout or "") + (result.stderr or "")
assert result.returncode == 0, combined
assert "--tui" not in combined, (
"dashboard --tui is a deprecated back-compat shim and must stay "
"hidden via argparse.SUPPRESS:\n" + combined
)
def test_dashboard_without_tui_still_parses():
"""Sanity: the modern (no --tui) invocation is unaffected by the shim."""
result = _run_cli(
["dashboard", "--no-open", "--host", "127.0.0.1",
"--port", "39996", "--status"]
)
combined = (result.stdout or "") + (result.stderr or "")
assert "unrecognized arguments" not in combined, combined
assert result.returncode != 2, combined
@@ -0,0 +1,195 @@
"""Tests for the unified profile→machine dashboard launch routing.
`<profile> dashboard` routes to ONE machine-level dashboard instead of
spawning a per-profile server: attach (open browser at ?profile=) when one
is already listening, else re-exec as the machine dashboard with the
launching profile preselected. `--isolated` opts out.
"""
import sys
import types
import pytest
@pytest.fixture
def main_mod():
import hermes_cli.main as main_mod
return main_mod
def _args(**kw):
defaults = dict(
status=False, stop=False, host="127.0.0.1", port=9119,
no_open=True, insecure=False, skip_build=False,
isolated=False, open_profile="",
)
defaults.update(kw)
return types.SimpleNamespace(**defaults)
class TestUnifiedDashboardRouting:
def test_profile_launch_attaches_to_running_dashboard(self, main_mod, monkeypatch):
monkeypatch.setattr(
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
)
monkeypatch.setattr(main_mod, "_dashboard_listening", lambda host, port: True)
execs = []
monkeypatch.setattr(main_mod.os, "execvpe", lambda *a, **k: execs.append(a))
with pytest.raises(SystemExit) as exc:
main_mod.cmd_dashboard(_args())
assert exc.value.code == 0
assert execs == [] # attached, never re-exec'd
def test_profile_launch_attach_opens_scoped_url(self, main_mod, monkeypatch):
"""The attach path must open the browser at ?profile=<name> — that
URL is the entire point of attaching (preselects the switcher)."""
monkeypatch.setattr(
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
)
monkeypatch.setattr(main_mod, "_dashboard_listening", lambda host, port: True)
opened = []
import webbrowser
monkeypatch.setattr(webbrowser, "open", lambda url: opened.append(url))
with pytest.raises(SystemExit) as exc:
main_mod.cmd_dashboard(_args(no_open=False))
assert exc.value.code == 0
assert opened == ["http://127.0.0.1:9119/?profile=worker_x"]
def test_profile_launch_reexecs_machine_dashboard(self, main_mod, monkeypatch):
monkeypatch.setattr(
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
)
monkeypatch.setattr(main_mod, "_dashboard_listening", lambda host, port: False)
execs = []
def fake_exec(exe, argv, env):
execs.append((exe, argv, env))
raise SystemExit(0) # execvpe never returns
monkeypatch.setattr(main_mod.os, "execvpe", fake_exec)
with pytest.raises(SystemExit):
main_mod.cmd_dashboard(_args())
assert len(execs) == 1
exe, argv, env = execs[0]
assert exe == sys.executable
# Pinned to the default profile + launching profile preselected.
assert "-p" in argv and argv[argv.index("-p") + 1] == "default"
assert "--open-profile" in argv
assert argv[argv.index("--open-profile") + 1] == "worker_x"
# Profile HERMES_HOME dropped so the child binds the machine root.
assert "HERMES_HOME" not in env
def test_desktop_profile_backend_skips_machine_dashboard_reroute(self, main_mod, monkeypatch):
"""A desktop-spawned named-profile backend (HERMES_DESKTOP=1) must NOT
reroute into the machine dashboard. The reroute re-execs as the default
profile and exits, so the desktop never sees a ready backend boot
loop. The guard keeps desktop pool backends per-profile."""
monkeypatch.setenv("HERMES_DESKTOP", "1")
monkeypatch.setattr(
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
)
listening_calls = []
monkeypatch.setattr(
main_mod, "_dashboard_listening",
lambda host, port: listening_calls.append(1) or False,
)
execs = []
monkeypatch.setattr(main_mod.os, "execvpe", lambda *a, **k: execs.append(a))
monkeypatch.setitem(sys.modules, "fastapi", None)
with pytest.raises((SystemExit, AttributeError, ImportError, TypeError)):
main_mod.cmd_dashboard(_args())
assert listening_calls == []
assert execs == []
def test_isolated_flag_skips_routing(self, main_mod, monkeypatch):
monkeypatch.setattr(
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
)
listening_calls = []
monkeypatch.setattr(
main_mod, "_dashboard_listening",
lambda host, port: listening_calls.append(1) or True,
)
# With --isolated the routing block is skipped entirely; the command
# proceeds to dependency checks. Make the first post-routing step
# bail so the test doesn't actually start a server.
monkeypatch.setitem(sys.modules, "fastapi", None)
with pytest.raises((SystemExit, AttributeError, ImportError, TypeError)):
main_mod.cmd_dashboard(_args(isolated=True))
assert listening_calls == []
def test_default_profile_launch_skips_routing(self, main_mod, monkeypatch):
monkeypatch.setattr(
"hermes_cli.profiles.get_active_profile_name", lambda: "default"
)
listening_calls = []
monkeypatch.setattr(
main_mod, "_dashboard_listening",
lambda host, port: listening_calls.append(1) or True,
)
monkeypatch.setitem(sys.modules, "fastapi", None)
with pytest.raises((SystemExit, AttributeError, ImportError, TypeError)):
main_mod.cmd_dashboard(_args())
assert listening_calls == []
def test_reexec_child_does_not_reroute(self, main_mod, monkeypatch):
"""The re-exec'd child carries --open-profile; the guard must treat
that as 'already routed' and never re-exec again (no exec loop)."""
monkeypatch.setattr(
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
)
execs = []
monkeypatch.setattr(main_mod.os, "execvpe", lambda *a, **k: execs.append(a))
monkeypatch.setitem(sys.modules, "fastapi", None)
with pytest.raises((SystemExit, AttributeError, ImportError, TypeError)):
main_mod.cmd_dashboard(_args(open_profile="worker_x"))
assert execs == []
def test_dashboard_starts_mcp_discovery_for_ws_backend(self, main_mod, monkeypatch):
"""The dashboard process serves the /api/ws gateway but never runs
tui_gateway/entry.py, so it must kick off MCP discovery itself or
desktop sessions never see a profile's MCP tools."""
monkeypatch.setattr(
"hermes_cli.profiles.get_active_profile_name", lambda: "default"
)
monkeypatch.delenv("HERMES_WEB_DIST", raising=False)
monkeypatch.setattr(main_mod, "_sync_bundled_skills_quietly", lambda: None)
monkeypatch.setattr(main_mod, "_build_web_ui", lambda *_a, **_k: True)
monkeypatch.setitem(sys.modules, "fastapi", types.SimpleNamespace())
monkeypatch.setitem(sys.modules, "uvicorn", types.SimpleNamespace())
monkeypatch.setitem(
sys.modules,
"hermes_logging",
types.SimpleNamespace(setup_logging=lambda **_k: None),
)
monkeypatch.setitem(
sys.modules,
"hermes_cli.plugins",
types.SimpleNamespace(discover_plugins=lambda: None),
)
calls = []
monkeypatch.setattr(
"hermes_cli.mcp_startup.start_background_mcp_discovery",
lambda **kwargs: calls.append(kwargs),
)
monkeypatch.setitem(
sys.modules,
"hermes_cli.web_server",
types.SimpleNamespace(start_server=lambda **_kwargs: None),
)
main_mod.cmd_dashboard(_args())
assert calls == [
{
"logger": main_mod.logger,
"thread_name": "dashboard-mcp-discovery",
}
]
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More