Hermes-agent
This commit is contained in:
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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>"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
@@ -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")
|
||||
@@ -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'"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
@@ -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
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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() == ""
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user