Merge commit '6110aed9b' into feat/whatsapp-cloud-api
This commit is contained in:
@@ -41,6 +41,16 @@ def _suppress_concurrent_hermes_gate(request, monkeypatch):
|
||||
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: []
|
||||
_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()
|
||||
@@ -1,161 +0,0 @@
|
||||
"""AI Gateway model list and pricing translation.
|
||||
|
||||
Vercel AI Gateway exposes ``/v1/models`` with a richer shape than OpenAI's
|
||||
spec (type, tags, pricing). The pricing object uses ``input`` / ``output``
|
||||
where hermes's shared picker expects ``prompt`` / ``completion``; these tests
|
||||
pin the translation and the curated-list filtering.
|
||||
"""
|
||||
import json
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from hermes_cli import models as models_module
|
||||
from hermes_cli.models import (
|
||||
VERCEL_AI_GATEWAY_MODELS,
|
||||
_ai_gateway_model_is_free,
|
||||
fetch_ai_gateway_models,
|
||||
fetch_ai_gateway_pricing,
|
||||
)
|
||||
|
||||
|
||||
def _mock_urlopen(payload):
|
||||
"""Build a urlopen() context manager mock returning the given payload."""
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = json.dumps(payload).encode()
|
||||
ctx = MagicMock()
|
||||
ctx.__enter__.return_value = resp
|
||||
ctx.__exit__.return_value = False
|
||||
return ctx
|
||||
|
||||
|
||||
def _reset_caches():
|
||||
models_module._ai_gateway_catalog_cache = None
|
||||
models_module._pricing_cache.clear()
|
||||
|
||||
|
||||
def test_ai_gateway_pricing_translates_input_output_to_prompt_completion():
|
||||
_reset_caches()
|
||||
payload = {
|
||||
"data": [
|
||||
{
|
||||
"id": "moonshotai/kimi-k2.5",
|
||||
"type": "language",
|
||||
"pricing": {
|
||||
"input": "0.0000006",
|
||||
"output": "0.0000025",
|
||||
"input_cache_read": "0.00000015",
|
||||
"input_cache_write": "0.0000006",
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
with patch("urllib.request.urlopen", return_value=_mock_urlopen(payload)):
|
||||
result = fetch_ai_gateway_pricing(force_refresh=True)
|
||||
|
||||
entry = result["moonshotai/kimi-k2.5"]
|
||||
assert entry["prompt"] == "0.0000006"
|
||||
assert entry["completion"] == "0.0000025"
|
||||
assert entry["input_cache_read"] == "0.00000015"
|
||||
assert entry["input_cache_write"] == "0.0000006"
|
||||
|
||||
|
||||
def test_ai_gateway_pricing_returns_empty_on_fetch_failure():
|
||||
_reset_caches()
|
||||
with patch("urllib.request.urlopen", side_effect=OSError("network down")):
|
||||
result = fetch_ai_gateway_pricing(force_refresh=True)
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_ai_gateway_pricing_skips_entries_without_pricing_dict():
|
||||
_reset_caches()
|
||||
payload = {
|
||||
"data": [
|
||||
{"id": "x/y", "pricing": None},
|
||||
{"id": "a/b", "pricing": {"input": "0", "output": "0"}},
|
||||
]
|
||||
}
|
||||
with patch("urllib.request.urlopen", return_value=_mock_urlopen(payload)):
|
||||
result = fetch_ai_gateway_pricing(force_refresh=True)
|
||||
assert "x/y" not in result
|
||||
assert result["a/b"] == {"prompt": "0", "completion": "0"}
|
||||
|
||||
|
||||
def test_ai_gateway_free_detector():
|
||||
assert _ai_gateway_model_is_free({"input": "0", "output": "0"}) is True
|
||||
assert _ai_gateway_model_is_free({"input": "0", "output": "0.01"}) is False
|
||||
assert _ai_gateway_model_is_free({"input": "0.01", "output": "0"}) is False
|
||||
assert _ai_gateway_model_is_free(None) is False
|
||||
assert _ai_gateway_model_is_free({"input": "not a number"}) is False
|
||||
|
||||
|
||||
def test_fetch_ai_gateway_models_filters_against_live_catalog():
|
||||
_reset_caches()
|
||||
preferred = [mid for mid, _ in VERCEL_AI_GATEWAY_MODELS]
|
||||
live_ids = preferred[:3] # only first three exist live
|
||||
payload = {
|
||||
"data": [
|
||||
{"id": mid, "pricing": {"input": "0.001", "output": "0.002"}}
|
||||
for mid in live_ids
|
||||
]
|
||||
}
|
||||
with patch("urllib.request.urlopen", return_value=_mock_urlopen(payload)):
|
||||
result = fetch_ai_gateway_models(force_refresh=True)
|
||||
|
||||
assert [mid for mid, _ in result] == live_ids
|
||||
assert result[0][1] == "recommended"
|
||||
|
||||
|
||||
def test_fetch_ai_gateway_models_tags_free_models():
|
||||
_reset_caches()
|
||||
first_id = VERCEL_AI_GATEWAY_MODELS[0][0]
|
||||
second_id = VERCEL_AI_GATEWAY_MODELS[1][0]
|
||||
payload = {
|
||||
"data": [
|
||||
{"id": first_id, "pricing": {"input": "0.001", "output": "0.002"}},
|
||||
{"id": second_id, "pricing": {"input": "0", "output": "0"}},
|
||||
]
|
||||
}
|
||||
with patch("urllib.request.urlopen", return_value=_mock_urlopen(payload)):
|
||||
result = fetch_ai_gateway_models(force_refresh=True)
|
||||
|
||||
by_id = dict(result)
|
||||
assert by_id[first_id] == "recommended"
|
||||
assert by_id[second_id] == "free"
|
||||
|
||||
|
||||
def test_free_moonshot_model_auto_promoted_to_top_even_if_not_curated():
|
||||
_reset_caches()
|
||||
first_curated = VERCEL_AI_GATEWAY_MODELS[0][0]
|
||||
unlisted_free_moonshot = "moonshotai/kimi-coder-free-preview"
|
||||
payload = {
|
||||
"data": [
|
||||
{"id": first_curated, "pricing": {"input": "0.001", "output": "0.002"}},
|
||||
{"id": unlisted_free_moonshot, "pricing": {"input": "0", "output": "0"}},
|
||||
]
|
||||
}
|
||||
with patch("urllib.request.urlopen", return_value=_mock_urlopen(payload)):
|
||||
result = fetch_ai_gateway_models(force_refresh=True)
|
||||
|
||||
assert result[0] == (unlisted_free_moonshot, "recommended")
|
||||
assert any(mid == first_curated for mid, _ in result)
|
||||
|
||||
|
||||
def test_paid_moonshot_does_not_get_auto_promoted():
|
||||
_reset_caches()
|
||||
first_curated = VERCEL_AI_GATEWAY_MODELS[0][0]
|
||||
payload = {
|
||||
"data": [
|
||||
{"id": first_curated, "pricing": {"input": "0.001", "output": "0.002"}},
|
||||
{"id": "moonshotai/some-paid-variant", "pricing": {"input": "0.001", "output": "0.002"}},
|
||||
]
|
||||
}
|
||||
with patch("urllib.request.urlopen", return_value=_mock_urlopen(payload)):
|
||||
result = fetch_ai_gateway_models(force_refresh=True)
|
||||
|
||||
assert result[0][0] == first_curated
|
||||
|
||||
|
||||
def test_fetch_ai_gateway_models_falls_back_on_error():
|
||||
_reset_caches()
|
||||
with patch("urllib.request.urlopen", side_effect=OSError("network")):
|
||||
result = fetch_ai_gateway_models(force_refresh=True)
|
||||
assert result == list(VERCEL_AI_GATEWAY_MODELS)
|
||||
@@ -6,11 +6,8 @@ Claude Code credentials are available. The fast-path silently proceeds to
|
||||
model selection with a broken token instead of offering re-auth.
|
||||
"""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from hermes_cli.config import load_env, save_env_value
|
||||
from hermes_cli.config import save_env_value
|
||||
|
||||
|
||||
class TestStaleOAuthTokenDetection:
|
||||
@@ -54,7 +51,7 @@ class TestStaleOAuthTokenDetection:
|
||||
|
||||
# Simulate user types "3" (Cancel) when prompted for re-auth
|
||||
monkeypatch.setattr("builtins.input", lambda _: "3")
|
||||
monkeypatch.setattr("getpass.getpass", lambda _: "")
|
||||
monkeypatch.setattr("hermes_cli.secret_prompt.masked_secret_prompt", lambda _: "")
|
||||
|
||||
from hermes_cli.main import _model_flow_anthropic
|
||||
cfg = {}
|
||||
|
||||
@@ -40,7 +40,10 @@ def test_run_anthropic_oauth_flow_manual_token_still_persists(tmp_path, monkeypa
|
||||
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("getpass.getpass", 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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for API-key provider support (z.ai/GLM, Kimi, MiniMax, AI Gateway)."""
|
||||
"""Tests for API-key provider support (z.ai/GLM, Kimi, MiniMax)."""
|
||||
|
||||
import os
|
||||
|
||||
@@ -6,7 +6,6 @@ import pytest
|
||||
|
||||
from hermes_cli.auth import (
|
||||
PROVIDER_REGISTRY,
|
||||
ProviderConfig,
|
||||
resolve_provider,
|
||||
get_api_key_provider_status,
|
||||
resolve_api_key_provider_credentials,
|
||||
@@ -40,7 +39,6 @@ class TestProviderRegistry:
|
||||
("stepfun", "StepFun Step Plan", "api_key"),
|
||||
("minimax", "MiniMax", "api_key"),
|
||||
("minimax-cn", "MiniMax (China)", "api_key"),
|
||||
("ai-gateway", "Vercel AI Gateway", "api_key"),
|
||||
("kilocode", "Kilo Code", "api_key"),
|
||||
("gmi", "GMI Cloud", "api_key"),
|
||||
])
|
||||
@@ -97,11 +95,6 @@ class TestProviderRegistry:
|
||||
assert pconfig.api_key_env_vars == ("MINIMAX_CN_API_KEY",)
|
||||
assert pconfig.base_url_env_var == "MINIMAX_CN_BASE_URL"
|
||||
|
||||
def test_ai_gateway_env_vars(self):
|
||||
pconfig = PROVIDER_REGISTRY["ai-gateway"]
|
||||
assert pconfig.api_key_env_vars == ("AI_GATEWAY_API_KEY",)
|
||||
assert pconfig.base_url_env_var == "AI_GATEWAY_BASE_URL"
|
||||
|
||||
def test_kilocode_env_vars(self):
|
||||
pconfig = PROVIDER_REGISTRY["kilocode"]
|
||||
assert pconfig.api_key_env_vars == ("KILOCODE_API_KEY",)
|
||||
@@ -125,7 +118,6 @@ class TestProviderRegistry:
|
||||
assert PROVIDER_REGISTRY["stepfun"].inference_base_url == STEPFUN_STEP_PLAN_INTL_BASE_URL
|
||||
assert PROVIDER_REGISTRY["minimax"].inference_base_url == "https://api.minimax.io/anthropic"
|
||||
assert PROVIDER_REGISTRY["minimax-cn"].inference_base_url == "https://api.minimaxi.com/anthropic"
|
||||
assert PROVIDER_REGISTRY["ai-gateway"].inference_base_url == "https://ai-gateway.vercel.sh/v1"
|
||||
assert PROVIDER_REGISTRY["kilocode"].inference_base_url == "https://api.kilo.ai/api/gateway"
|
||||
assert PROVIDER_REGISTRY["gmi"].inference_base_url == "https://api.gmi-serving.com/v1"
|
||||
assert PROVIDER_REGISTRY["huggingface"].inference_base_url == "https://router.huggingface.co/v1"
|
||||
@@ -149,7 +141,6 @@ PROVIDER_ENV_VARS = (
|
||||
"GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY",
|
||||
"KIMI_API_KEY", "KIMI_BASE_URL", "STEPFUN_API_KEY", "STEPFUN_BASE_URL",
|
||||
"MINIMAX_API_KEY", "MINIMAX_CN_API_KEY",
|
||||
"AI_GATEWAY_API_KEY", "AI_GATEWAY_BASE_URL",
|
||||
"KILOCODE_API_KEY", "KILOCODE_BASE_URL",
|
||||
"GMI_API_KEY", "GMI_BASE_URL",
|
||||
"DASHSCOPE_API_KEY", "OPENCODE_ZEN_API_KEY", "OPENCODE_GO_API_KEY",
|
||||
@@ -184,9 +175,6 @@ class TestResolveProvider:
|
||||
def test_explicit_minimax_cn(self):
|
||||
assert resolve_provider("minimax-cn") == "minimax-cn"
|
||||
|
||||
def test_explicit_ai_gateway(self):
|
||||
assert resolve_provider("ai-gateway") == "ai-gateway"
|
||||
|
||||
def test_explicit_gmi(self):
|
||||
assert resolve_provider("gmi") == "gmi"
|
||||
|
||||
@@ -211,12 +199,6 @@ class TestResolveProvider:
|
||||
def test_alias_minimax_underscore(self):
|
||||
assert resolve_provider("minimax_cn") == "minimax-cn"
|
||||
|
||||
def test_alias_aigateway(self):
|
||||
assert resolve_provider("aigateway") == "ai-gateway"
|
||||
|
||||
def test_alias_vercel(self):
|
||||
assert resolve_provider("vercel") == "ai-gateway"
|
||||
|
||||
def test_alias_gmi_cloud(self):
|
||||
assert resolve_provider("gmi-cloud") == "gmi"
|
||||
|
||||
@@ -291,10 +273,6 @@ class TestResolveProvider:
|
||||
monkeypatch.setenv("MINIMAX_CN_API_KEY", "test-mm-cn-key")
|
||||
assert resolve_provider("auto") == "minimax-cn"
|
||||
|
||||
def test_auto_detects_ai_gateway_key(self, monkeypatch):
|
||||
monkeypatch.setenv("AI_GATEWAY_API_KEY", "test-gw-key")
|
||||
assert resolve_provider("auto") == "ai-gateway"
|
||||
|
||||
def test_auto_detects_gmi_key(self, monkeypatch):
|
||||
monkeypatch.setenv("GMI_API_KEY", "test-gmi-key")
|
||||
assert resolve_provider("auto") == "gmi"
|
||||
@@ -535,13 +513,6 @@ class TestResolveApiKeyProviderCredentials:
|
||||
assert creds["api_key"] == "mmcn-secret-key"
|
||||
assert creds["base_url"] == "https://api.minimaxi.com/anthropic"
|
||||
|
||||
def test_resolve_ai_gateway_with_key(self, monkeypatch):
|
||||
monkeypatch.setenv("AI_GATEWAY_API_KEY", "gw-secret-key")
|
||||
creds = resolve_api_key_provider_credentials("ai-gateway")
|
||||
assert creds["provider"] == "ai-gateway"
|
||||
assert creds["api_key"] == "gw-secret-key"
|
||||
assert creds["base_url"] == "https://ai-gateway.vercel.sh/v1"
|
||||
|
||||
def test_resolve_kilocode_with_key(self, monkeypatch):
|
||||
monkeypatch.setenv("KILOCODE_API_KEY", "kilo-secret-key")
|
||||
creds = resolve_api_key_provider_credentials("kilocode")
|
||||
@@ -641,15 +612,6 @@ class TestRuntimeProviderResolution:
|
||||
assert result["provider"] == "minimax"
|
||||
assert result["api_key"] == "mm-key"
|
||||
|
||||
def test_runtime_ai_gateway(self, monkeypatch):
|
||||
monkeypatch.setenv("AI_GATEWAY_API_KEY", "gw-key")
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
result = resolve_runtime_provider(requested="ai-gateway")
|
||||
assert result["provider"] == "ai-gateway"
|
||||
assert result["api_mode"] == "chat_completions"
|
||||
assert result["api_key"] == "gw-key"
|
||||
assert "ai-gateway.vercel.sh" in result["base_url"]
|
||||
|
||||
def test_runtime_kilocode(self, monkeypatch):
|
||||
monkeypatch.setenv("KILOCODE_API_KEY", "kilo-key")
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
@@ -1317,6 +1279,20 @@ class TestMinimaxOAuthProvider:
|
||||
assert len(models) >= 1
|
||||
|
||||
def test_minimax_oauth_aux_model_registered(self):
|
||||
from agent.auxiliary_client import _API_KEY_PROVIDER_AUX_MODELS
|
||||
assert "minimax-oauth" in _API_KEY_PROVIDER_AUX_MODELS
|
||||
assert _API_KEY_PROVIDER_AUX_MODELS["minimax-oauth"] # non-empty
|
||||
# Aux model for the minimax-oauth provider now lives on the
|
||||
# ProviderProfile (plugins/model-providers/minimax/__init__.py),
|
||||
# not the legacy _API_KEY_PROVIDER_AUX_MODELS dict in
|
||||
# agent/auxiliary_client.py. The profile layer is the source
|
||||
# of truth; _get_aux_model_for_provider() reads from it first
|
||||
# and only falls back to the dict when no profile is registered.
|
||||
import model_tools # noqa: F401 -- triggers plugin discovery
|
||||
import providers
|
||||
|
||||
profile = providers.get_provider_profile("minimax-oauth")
|
||||
assert profile is not None, "minimax-oauth provider profile must be registered"
|
||||
assert profile.default_aux_model, (
|
||||
"minimax-oauth profile must advertise a non-empty default_aux_model "
|
||||
"so the auxiliary client (compression / vision / session-search) "
|
||||
"doesn't fire the 'No auxiliary LLM provider configured' warning "
|
||||
"for every minimax-oauth session."
|
||||
)
|
||||
|
||||
@@ -15,7 +15,6 @@ import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _run_apply_profile_override(
|
||||
|
||||
@@ -16,7 +16,7 @@ _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", "AI_GATEWAY_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",
|
||||
)
|
||||
|
||||
@@ -14,7 +14,6 @@ so the subparser only sets the attribute when the user explicitly provides it.
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -57,6 +56,59 @@ def _build_parser():
|
||||
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.
|
||||
|
||||
|
||||
@@ -133,6 +133,38 @@ class TestAtomicJsonWrite:
|
||||
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
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Tests for utils.atomic_yaml_write — crash-safe YAML file writes."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -7,7 +7,6 @@ from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from hermes_cli.auth import (
|
||||
AuthError,
|
||||
@@ -17,8 +16,6 @@ from hermes_cli.auth import (
|
||||
_save_codex_tokens,
|
||||
_import_codex_cli_tokens,
|
||||
_login_openai_codex,
|
||||
get_codex_auth_status,
|
||||
get_provider_auth_state,
|
||||
refresh_codex_oauth_pure,
|
||||
resolve_codex_runtime_credentials,
|
||||
resolve_provider,
|
||||
@@ -125,6 +122,98 @@ def test_resolve_codex_runtime_credentials_force_refresh(tmp_path, monkeypatch):
|
||||
assert resolved["api_key"] == "access-forced"
|
||||
|
||||
|
||||
def test_resolve_codex_runtime_credentials_falls_back_to_pool_when_singleton_empty(tmp_path, monkeypatch):
|
||||
"""Regression for #32992 — chat path returns 401 when singleton is empty but pool has creds.
|
||||
|
||||
The chat path historically went through ``resolve_codex_runtime_credentials`` which
|
||||
only consulted ``providers.openai-codex.tokens`` and raised ``AuthError`` when that
|
||||
was empty. The auxiliary path went through ``_read_codex_access_token`` which
|
||||
checks the pool first. Users with creds only in the pool (manual seed, partial
|
||||
re-auth, restore from backup) hit a bare HTTP 401 on chat but worked fine on
|
||||
auxiliary calls. The fallback closes that divergence.
|
||||
"""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
# Singleton: empty tokens (would normally raise AuthError).
|
||||
# Pool: valid access_token.
|
||||
auth_store = {
|
||||
"version": 1,
|
||||
"providers": {}, # no openai-codex singleton at all
|
||||
"credential_pool": {
|
||||
"openai-codex": [
|
||||
{
|
||||
"source": "device_code",
|
||||
"access_token": "pool-fallback-token",
|
||||
"refresh_token": "pool-refresh",
|
||||
"last_status": "ok",
|
||||
"auth_type": "oauth",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
(hermes_home / "auth.json").write_text(json.dumps(auth_store))
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
resolved = resolve_codex_runtime_credentials()
|
||||
assert resolved["api_key"] == "pool-fallback-token"
|
||||
assert resolved["source"] == "credential_pool"
|
||||
assert resolved["base_url"] # default codex backend URL
|
||||
|
||||
|
||||
def test_resolve_codex_runtime_credentials_pool_fallback_skips_exhausted(tmp_path, monkeypatch):
|
||||
"""The pool fallback skips entries currently in an exhaustion cooldown window."""
|
||||
import time as _time
|
||||
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
future_reset = _time.time() + 3600 # 1h cooldown remaining
|
||||
auth_store = {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"credential_pool": {
|
||||
"openai-codex": [
|
||||
{
|
||||
"source": "device_code",
|
||||
"access_token": "wedged-token",
|
||||
"last_error_reset_at": future_reset, # in cooldown
|
||||
},
|
||||
{
|
||||
"source": "device_code",
|
||||
"access_token": "usable-token",
|
||||
"last_status": "ok",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
(hermes_home / "auth.json").write_text(json.dumps(auth_store))
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
resolved = resolve_codex_runtime_credentials()
|
||||
assert resolved["api_key"] == "usable-token"
|
||||
assert resolved["source"] == "credential_pool"
|
||||
|
||||
|
||||
def test_resolve_codex_runtime_credentials_pool_fallback_no_usable_entry(tmp_path, monkeypatch):
|
||||
"""When both singleton and pool are empty/unusable, the original AuthError propagates."""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
auth_store = {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"credential_pool": {
|
||||
"openai-codex": [
|
||||
{"source": "device_code", "access_token": ""}, # empty
|
||||
],
|
||||
},
|
||||
}
|
||||
(hermes_home / "auth.json").write_text(json.dumps(auth_store))
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
with pytest.raises(AuthError) as exc:
|
||||
resolve_codex_runtime_credentials()
|
||||
assert exc.value.code == "codex_auth_missing"
|
||||
|
||||
|
||||
def test_resolve_provider_explicit_codex_does_not_fallback(monkeypatch):
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||
@@ -144,6 +233,505 @@ def test_save_codex_tokens_roundtrip(tmp_path, monkeypatch):
|
||||
assert data["tokens"]["refresh_token"] == "rt456"
|
||||
|
||||
|
||||
def test_save_codex_tokens_syncs_credential_pool(tmp_path, monkeypatch):
|
||||
"""Re-auth must update the credential_pool device_code entry, not just providers.
|
||||
|
||||
Regression for #33000: the runtime selects from credential_pool, so a
|
||||
re-auth that only refreshed providers.openai-codex.tokens left the pool
|
||||
holding a consumed refresh token and stale error markers, causing an
|
||||
immediate 401 token_invalidated on the next request.
|
||||
"""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
(hermes_home / "auth.json").write_text(json.dumps({
|
||||
"version": 1,
|
||||
"providers": {
|
||||
"openai-codex": {
|
||||
"tokens": {"access_token": "old-at", "refresh_token": "old-rt"},
|
||||
"last_refresh": "2026-01-01T00:00:00Z",
|
||||
"auth_mode": "chatgpt",
|
||||
},
|
||||
},
|
||||
"credential_pool": {
|
||||
"openai-codex": [
|
||||
{
|
||||
"id": "abc123",
|
||||
"source": "device_code",
|
||||
"auth_type": "oauth",
|
||||
"access_token": "old-at",
|
||||
"refresh_token": "old-rt",
|
||||
"last_status": "exhausted",
|
||||
"last_error_code": 401,
|
||||
"last_error_reason": "token_invalidated",
|
||||
"last_error_reset_at": 9999999999,
|
||||
},
|
||||
{
|
||||
"id": "manual1",
|
||||
"source": "manual:codex",
|
||||
"auth_type": "oauth",
|
||||
"access_token": "manual-at",
|
||||
"refresh_token": "manual-rt",
|
||||
},
|
||||
],
|
||||
},
|
||||
}))
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
_save_codex_tokens({"access_token": "new-at", "refresh_token": "new-rt"},
|
||||
last_refresh="2026-05-27T00:00:00Z")
|
||||
|
||||
auth = json.loads((hermes_home / "auth.json").read_text())
|
||||
pool = auth["credential_pool"]["openai-codex"]
|
||||
seeded = next(e for e in pool if e["source"] == "device_code")
|
||||
assert seeded["access_token"] == "new-at"
|
||||
assert seeded["refresh_token"] == "new-rt"
|
||||
assert seeded["last_refresh"] == "2026-05-27T00:00:00Z"
|
||||
assert seeded["last_status"] is None
|
||||
assert seeded["last_error_code"] is None
|
||||
assert seeded["last_error_reason"] is None
|
||||
assert seeded["last_error_reset_at"] is None
|
||||
|
||||
# Manual entries are independent credentials and must not be overwritten.
|
||||
manual = next(e for e in pool if e["source"] == "manual:codex")
|
||||
assert manual["access_token"] == "manual-at"
|
||||
assert manual["refresh_token"] == "manual-rt"
|
||||
|
||||
# Provider singleton is updated too.
|
||||
assert auth["providers"]["openai-codex"]["tokens"]["access_token"] == "new-at"
|
||||
|
||||
|
||||
def test_save_codex_tokens_syncs_manual_device_code_entries(tmp_path, monkeypatch):
|
||||
"""Re-auth must refresh ``manual:device_code`` entries that are true
|
||||
aliases of the singleton, while leaving INDEPENDENT entries alone.
|
||||
|
||||
Original regression for #33538: a user who hit #33000 before the #33164
|
||||
fix landed would have run ``hermes auth add openai-codex`` as a
|
||||
workaround, leaving a pool entry with ``source="manual:device_code"``.
|
||||
On every subsequent re-auth via setup/model picker, the singleton-seeded
|
||||
``device_code`` entry got refreshed but the ``manual:device_code`` entry
|
||||
stayed stale, recreating the same 401 token_invalidated symptom that
|
||||
#33164 was supposed to fix.
|
||||
|
||||
Narrowed for #39236: the original fix treated every ``manual:device_code``
|
||||
entry as a singleton-alias and refreshed them all, which silently
|
||||
clobbered independent accounts added via ``hermes auth add openai-codex``.
|
||||
The current behavior refreshes only entries whose access_token matches
|
||||
the *previous* singleton access_token (true legacy aliases), and leaves
|
||||
distinct-token entries alone (independent accounts).
|
||||
"""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
(hermes_home / "auth.json").write_text(json.dumps({
|
||||
"version": 1,
|
||||
"providers": {
|
||||
"openai-codex": {
|
||||
"tokens": {"access_token": "old-at", "refresh_token": "old-rt"},
|
||||
"last_refresh": "2026-01-01T00:00:00Z",
|
||||
"auth_mode": "chatgpt",
|
||||
},
|
||||
},
|
||||
"credential_pool": {
|
||||
"openai-codex": [
|
||||
{
|
||||
"id": "seeded",
|
||||
"source": "device_code",
|
||||
"auth_type": "oauth",
|
||||
"access_token": "old-at",
|
||||
"refresh_token": "old-rt",
|
||||
},
|
||||
# Legacy alias from the #33000 workaround era — its tokens
|
||||
# match the singleton, so it is a true alias and SHOULD be
|
||||
# refreshed (preserves #33538 behavior).
|
||||
{
|
||||
"id": "legacy-alias",
|
||||
"source": "manual:device_code",
|
||||
"auth_type": "oauth",
|
||||
"access_token": "old-at",
|
||||
"refresh_token": "old-rt",
|
||||
"last_status": "exhausted",
|
||||
"last_error_code": 401,
|
||||
"last_error_reason": "token_invalidated",
|
||||
},
|
||||
# Independent account from `hermes auth add openai-codex` —
|
||||
# its tokens are distinct from the singleton. Must NOT be
|
||||
# overwritten by a re-auth that targeted a different account
|
||||
# (#39236).
|
||||
{
|
||||
"id": "independent",
|
||||
"source": "manual:device_code",
|
||||
"auth_type": "oauth",
|
||||
"access_token": "independent-at",
|
||||
"refresh_token": "independent-rt",
|
||||
},
|
||||
{
|
||||
"id": "api-key",
|
||||
"source": "manual:api_key",
|
||||
"auth_type": "api_key",
|
||||
"access_token": "user-api-key",
|
||||
},
|
||||
],
|
||||
},
|
||||
}))
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
_save_codex_tokens({"access_token": "fresh-at", "refresh_token": "fresh-rt"},
|
||||
last_refresh="2026-05-28T00:00:00Z")
|
||||
|
||||
auth = json.loads((hermes_home / "auth.json").read_text())
|
||||
pool = auth["credential_pool"]["openai-codex"]
|
||||
|
||||
# Singleton-seeded device_code entry: refreshed and error markers cleared.
|
||||
seeded = next(e for e in pool if e["id"] == "seeded")
|
||||
assert seeded["access_token"] == "fresh-at"
|
||||
assert seeded["refresh_token"] == "fresh-rt"
|
||||
|
||||
# Legacy alias (tokens matched previous singleton): ALSO refreshed.
|
||||
legacy = next(e for e in pool if e["id"] == "legacy-alias")
|
||||
assert legacy["access_token"] == "fresh-at"
|
||||
assert legacy["refresh_token"] == "fresh-rt"
|
||||
assert legacy["last_refresh"] == "2026-05-28T00:00:00Z"
|
||||
assert legacy["last_status"] is None
|
||||
assert legacy["last_error_code"] is None
|
||||
assert legacy["last_error_reason"] is None
|
||||
|
||||
# Independent manual:device_code entry: NOT overwritten (#39236).
|
||||
independent = next(e for e in pool if e["id"] == "independent")
|
||||
assert independent["access_token"] == "independent-at"
|
||||
assert independent["refresh_token"] == "independent-rt"
|
||||
|
||||
# manual:api_key entry: untouched — independent credential.
|
||||
api_key = next(e for e in pool if e["source"] == "manual:api_key")
|
||||
assert api_key["access_token"] == "user-api-key"
|
||||
assert "refresh_token" not in api_key or api_key.get("refresh_token") is None
|
||||
|
||||
|
||||
def test_save_codex_tokens_does_not_overwrite_independent_manual_entries(tmp_path, monkeypatch):
|
||||
"""Re-auth must NOT overwrite ``manual:device_code`` entries that hold
|
||||
independent token material (different OpenAI/ChatGPT accounts).
|
||||
|
||||
Regression for #39236: ``hermes auth add openai-codex`` for accounts B and C
|
||||
routes through ``_save_codex_tokens`` because the singleton path is the
|
||||
only Codex OAuth save flow. The #33538 fix refreshed every
|
||||
``manual:device_code`` entry on every re-auth, which works fine for the
|
||||
one-account/legacy-workaround case but silently overwrote distinct
|
||||
independent accounts with the latest-authenticated tokens (labels
|
||||
preserved, token material clobbered, status/quota readings then lie).
|
||||
|
||||
The safe invariant: an entry is a singleton-alias only when its current
|
||||
access_token matches the *previous* singleton access_token. Manual
|
||||
entries whose tokens never matched the singleton are independent accounts
|
||||
and must be left alone.
|
||||
"""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
(hermes_home / "auth.json").write_text(json.dumps({
|
||||
"version": 1,
|
||||
"providers": {
|
||||
"openai-codex": {
|
||||
# Old singleton tokens — represent "account A" which the user
|
||||
# logged in with via setup originally.
|
||||
"tokens": {"access_token": "acctA-at", "refresh_token": "acctA-rt"},
|
||||
"last_refresh": "2026-01-01T00:00:00Z",
|
||||
"auth_mode": "chatgpt",
|
||||
"label": "account-A",
|
||||
},
|
||||
},
|
||||
"credential_pool": {
|
||||
"openai-codex": [
|
||||
# The seeded singleton mirror of account A.
|
||||
{
|
||||
"id": "seeded",
|
||||
"label": "account-A",
|
||||
"source": "device_code",
|
||||
"auth_type": "oauth",
|
||||
"access_token": "acctA-at",
|
||||
"refresh_token": "acctA-rt",
|
||||
},
|
||||
# Two INDEPENDENT manual entries added later via
|
||||
# ``hermes auth add openai-codex`` (account B and account C).
|
||||
# Each has its OWN distinct token material, unrelated to the
|
||||
# singleton.
|
||||
{
|
||||
"id": "acctB",
|
||||
"label": "account-B",
|
||||
"source": "manual:device_code",
|
||||
"auth_type": "oauth",
|
||||
"access_token": "acctB-at",
|
||||
"refresh_token": "acctB-rt",
|
||||
},
|
||||
{
|
||||
"id": "acctC",
|
||||
"label": "account-C",
|
||||
"source": "manual:device_code",
|
||||
"auth_type": "oauth",
|
||||
"access_token": "acctC-at",
|
||||
"refresh_token": "acctC-rt",
|
||||
},
|
||||
],
|
||||
},
|
||||
}))
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
# User re-authenticates account A — fresh device-code login produces new
|
||||
# tokens. The legitimate update is the seeded singleton mirror; the
|
||||
# independent acctB/acctC entries must be untouched.
|
||||
_save_codex_tokens(
|
||||
{"access_token": "acctA-new-at", "refresh_token": "acctA-new-rt"},
|
||||
last_refresh="2026-06-05T00:00:00Z",
|
||||
)
|
||||
|
||||
auth = json.loads((hermes_home / "auth.json").read_text())
|
||||
pool = auth["credential_pool"]["openai-codex"]
|
||||
|
||||
# Singleton-seeded entry: refreshed (legitimate sync).
|
||||
seeded = next(e for e in pool if e["source"] == "device_code")
|
||||
assert seeded["access_token"] == "acctA-new-at"
|
||||
assert seeded["refresh_token"] == "acctA-new-rt"
|
||||
assert seeded["last_refresh"] == "2026-06-05T00:00:00Z"
|
||||
|
||||
# acctB: INDEPENDENT entry — must NOT be overwritten.
|
||||
acctB = next(e for e in pool if e["id"] == "acctB")
|
||||
assert acctB["access_token"] == "acctB-at", (
|
||||
"acctB was clobbered by acctA re-auth (#39236 regression)"
|
||||
)
|
||||
assert acctB["refresh_token"] == "acctB-rt"
|
||||
|
||||
# acctC: INDEPENDENT entry — must NOT be overwritten.
|
||||
acctC = next(e for e in pool if e["id"] == "acctC")
|
||||
assert acctC["access_token"] == "acctC-at", (
|
||||
"acctC was clobbered by acctA re-auth (#39236 regression)"
|
||||
)
|
||||
assert acctC["refresh_token"] == "acctC-rt"
|
||||
|
||||
|
||||
def test_save_codex_tokens_still_refreshes_legacy_manual_alias(tmp_path, monkeypatch):
|
||||
"""The #33538 legacy use case must keep working.
|
||||
|
||||
A user who hit #33000 before the #33164 fix landed might have run
|
||||
``hermes auth add openai-codex`` as a workaround when there was no
|
||||
singleton entry — that created a ``manual:device_code`` pool entry that
|
||||
holds the SAME token material as the (later) singleton. This entry is a
|
||||
true alias of the singleton and SHOULD still be refreshed on subsequent
|
||||
re-auths, otherwise it goes stale and recreates the #33538 symptom.
|
||||
|
||||
The distinguishing signal: a legacy alias has access_token == previous
|
||||
singleton access_token; an independent account does not.
|
||||
"""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
(hermes_home / "auth.json").write_text(json.dumps({
|
||||
"version": 1,
|
||||
"providers": {
|
||||
"openai-codex": {
|
||||
"tokens": {"access_token": "shared-at", "refresh_token": "shared-rt"},
|
||||
"last_refresh": "2026-01-01T00:00:00Z",
|
||||
"auth_mode": "chatgpt",
|
||||
},
|
||||
},
|
||||
"credential_pool": {
|
||||
"openai-codex": [
|
||||
{
|
||||
"id": "seeded",
|
||||
"source": "device_code",
|
||||
"auth_type": "oauth",
|
||||
"access_token": "shared-at",
|
||||
"refresh_token": "shared-rt",
|
||||
},
|
||||
{
|
||||
"id": "legacy",
|
||||
"label": "legacy-alias",
|
||||
"source": "manual:device_code",
|
||||
"auth_type": "oauth",
|
||||
# Token material matches the singleton — this is a true
|
||||
# alias from the #33000 workaround era.
|
||||
"access_token": "shared-at",
|
||||
"refresh_token": "shared-rt",
|
||||
"last_status": "exhausted",
|
||||
"last_error_code": 401,
|
||||
"last_error_reason": "token_invalidated",
|
||||
},
|
||||
],
|
||||
},
|
||||
}))
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
_save_codex_tokens(
|
||||
{"access_token": "fresh-at", "refresh_token": "fresh-rt"},
|
||||
last_refresh="2026-06-05T00:00:00Z",
|
||||
)
|
||||
|
||||
auth = json.loads((hermes_home / "auth.json").read_text())
|
||||
pool = auth["credential_pool"]["openai-codex"]
|
||||
|
||||
# Singleton: refreshed.
|
||||
seeded = next(e for e in pool if e["source"] == "device_code")
|
||||
assert seeded["access_token"] == "fresh-at"
|
||||
|
||||
# Legacy alias: still refreshed (preserves #33538 fix).
|
||||
legacy = next(e for e in pool if e["id"] == "legacy")
|
||||
assert legacy["access_token"] == "fresh-at"
|
||||
assert legacy["refresh_token"] == "fresh-rt"
|
||||
assert legacy["last_refresh"] == "2026-06-05T00:00:00Z"
|
||||
# Error markers cleared on the refreshed entry.
|
||||
assert legacy["last_status"] is None
|
||||
assert legacy["last_error_code"] is None
|
||||
assert legacy["last_error_reason"] is None
|
||||
|
||||
|
||||
def test_save_codex_tokens_handles_missing_previous_singleton_tokens(tmp_path, monkeypatch):
|
||||
"""First-ever Codex save (no prior singleton tokens) must not crash.
|
||||
|
||||
Edge case: a user has only pool entries (e.g. via direct auth.json edit
|
||||
or a partial state from a corrupted upgrade), no `providers.openai-codex.tokens`
|
||||
block at all. The previous-singleton-tokens guard must handle missing
|
||||
state gracefully — fall back to "no previous tokens", which means no
|
||||
pool entry can be a true alias and only the singleton-seeded entry gets
|
||||
written.
|
||||
"""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
(hermes_home / "auth.json").write_text(json.dumps({
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"credential_pool": {
|
||||
"openai-codex": [
|
||||
{
|
||||
"id": "preexisting",
|
||||
"label": "pre-existing-manual",
|
||||
"source": "manual:device_code",
|
||||
"auth_type": "oauth",
|
||||
"access_token": "preexisting-at",
|
||||
"refresh_token": "preexisting-rt",
|
||||
},
|
||||
],
|
||||
},
|
||||
}))
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
_save_codex_tokens(
|
||||
{"access_token": "first-at", "refresh_token": "first-rt"},
|
||||
last_refresh="2026-06-05T00:00:00Z",
|
||||
)
|
||||
|
||||
auth = json.loads((hermes_home / "auth.json").read_text())
|
||||
pool = auth["credential_pool"]["openai-codex"]
|
||||
# Pre-existing independent entry with no relationship to a (now-new)
|
||||
# singleton MUST be preserved.
|
||||
pre = next(e for e in pool if e["id"] == "preexisting")
|
||||
assert pre["access_token"] == "preexisting-at"
|
||||
assert pre["refresh_token"] == "preexisting-rt"
|
||||
|
||||
|
||||
def test_save_codex_tokens_alias_match_uses_access_token_only(tmp_path, monkeypatch):
|
||||
"""A manual entry counts as an alias if its access_token matches the
|
||||
previous singleton access_token, regardless of refresh_token presence.
|
||||
|
||||
Some legacy entries (older auth.json schemas, pre-refresh-token versions)
|
||||
have access_token but no refresh_token. These should still be treated as
|
||||
aliases when the access_token matches.
|
||||
"""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
(hermes_home / "auth.json").write_text(json.dumps({
|
||||
"version": 1,
|
||||
"providers": {
|
||||
"openai-codex": {
|
||||
"tokens": {"access_token": "shared-at", "refresh_token": "shared-rt"},
|
||||
"auth_mode": "chatgpt",
|
||||
},
|
||||
},
|
||||
"credential_pool": {
|
||||
"openai-codex": [
|
||||
{
|
||||
"id": "alias-no-refresh",
|
||||
"source": "manual:device_code",
|
||||
"auth_type": "oauth",
|
||||
"access_token": "shared-at",
|
||||
# No refresh_token at all — legacy schema.
|
||||
},
|
||||
],
|
||||
},
|
||||
}))
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
_save_codex_tokens(
|
||||
{"access_token": "new-at", "refresh_token": "new-rt"},
|
||||
last_refresh="2026-06-05T00:00:00Z",
|
||||
)
|
||||
|
||||
auth = json.loads((hermes_home / "auth.json").read_text())
|
||||
pool = auth["credential_pool"]["openai-codex"]
|
||||
alias = next(e for e in pool if e["id"] == "alias-no-refresh")
|
||||
# Treated as alias → refreshed with new tokens.
|
||||
assert alias["access_token"] == "new-at"
|
||||
assert alias["refresh_token"] == "new-rt"
|
||||
|
||||
|
||||
def test_save_codex_tokens_clears_error_markers_only_on_refreshed_entries(tmp_path, monkeypatch):
|
||||
"""Error markers must be cleared only on entries that were actually
|
||||
refreshed by this re-auth. Independent ``manual:device_code`` entries
|
||||
with their own stale-error markers must be left alone (their stale state
|
||||
is not the current re-auth's business).
|
||||
"""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
(hermes_home / "auth.json").write_text(json.dumps({
|
||||
"version": 1,
|
||||
"providers": {
|
||||
"openai-codex": {
|
||||
"tokens": {"access_token": "acctA-at", "refresh_token": "acctA-rt"},
|
||||
"auth_mode": "chatgpt",
|
||||
},
|
||||
},
|
||||
"credential_pool": {
|
||||
"openai-codex": [
|
||||
{
|
||||
"id": "seeded",
|
||||
"source": "device_code",
|
||||
"auth_type": "oauth",
|
||||
"access_token": "acctA-at",
|
||||
"refresh_token": "acctA-rt",
|
||||
"last_status": "exhausted",
|
||||
"last_error_code": 401,
|
||||
},
|
||||
{
|
||||
"id": "acctB",
|
||||
"source": "manual:device_code",
|
||||
"auth_type": "oauth",
|
||||
"access_token": "acctB-at",
|
||||
"refresh_token": "acctB-rt",
|
||||
"last_status": "exhausted",
|
||||
"last_error_code": 429,
|
||||
"last_error_reason": "quota_exhausted",
|
||||
},
|
||||
],
|
||||
},
|
||||
}))
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
_save_codex_tokens(
|
||||
{"access_token": "fresh-at", "refresh_token": "fresh-rt"},
|
||||
last_refresh="2026-06-05T00:00:00Z",
|
||||
)
|
||||
|
||||
auth = json.loads((hermes_home / "auth.json").read_text())
|
||||
pool = auth["credential_pool"]["openai-codex"]
|
||||
|
||||
# Singleton: refreshed AND error markers cleared.
|
||||
seeded = next(e for e in pool if e["id"] == "seeded")
|
||||
assert seeded["access_token"] == "fresh-at"
|
||||
assert seeded["last_status"] is None
|
||||
assert seeded["last_error_code"] is None
|
||||
|
||||
# Independent acctB: NOT refreshed AND error markers NOT cleared.
|
||||
# (Its 429 quota state belongs to acctB's own account, not acctA's re-auth.)
|
||||
acctB = next(e for e in pool if e["id"] == "acctB")
|
||||
assert acctB["access_token"] == "acctB-at" # not overwritten
|
||||
assert acctB["last_status"] == "exhausted" # not cleared
|
||||
assert acctB["last_error_code"] == 429
|
||||
assert acctB["last_error_reason"] == "quota_exhausted"
|
||||
|
||||
|
||||
def test_import_codex_cli_tokens(tmp_path, monkeypatch):
|
||||
codex_home = tmp_path / "codex-cli"
|
||||
codex_home.mkdir(parents=True, exist_ok=True)
|
||||
@@ -196,9 +784,10 @@ def test_resolve_returns_hermes_auth_store_source(tmp_path, monkeypatch):
|
||||
|
||||
|
||||
class _StubHTTPResponse:
|
||||
def __init__(self, status_code: int, payload):
|
||||
def __init__(self, status_code: int, payload, headers=None):
|
||||
self.status_code = status_code
|
||||
self._payload = payload
|
||||
self.headers = headers or {}
|
||||
self.text = json.dumps(payload) if isinstance(payload, (dict, list)) else str(payload)
|
||||
|
||||
def json(self):
|
||||
@@ -315,6 +904,74 @@ def test_refresh_falls_back_to_generic_message_on_unparseable_body(monkeypatch):
|
||||
assert "status 401" in str(err)
|
||||
|
||||
|
||||
def test_refresh_429_classified_as_quota_not_auth_failure(monkeypatch):
|
||||
"""429 from the token endpoint is a usage-quota cap, not an auth failure.
|
||||
|
||||
Regression test for #32790: must NOT force relogin and must carry the
|
||||
dedicated rate-limit code so callers surface a "retry later" notice rather
|
||||
than a misleading "run hermes auth".
|
||||
"""
|
||||
from hermes_cli.auth import (
|
||||
CODEX_RATE_LIMITED_CODE,
|
||||
format_auth_error,
|
||||
is_rate_limited_auth_error,
|
||||
)
|
||||
|
||||
response = _StubHTTPResponse(
|
||||
429,
|
||||
{"error": {"message": "You hit your usage limit.", "code": "usage_limit_reached"}},
|
||||
headers={"retry-after": "120"},
|
||||
)
|
||||
_patch_httpx(monkeypatch, response)
|
||||
|
||||
with pytest.raises(AuthError) as exc_info:
|
||||
refresh_codex_oauth_pure("a-tok", "r-tok")
|
||||
|
||||
err = exc_info.value
|
||||
assert err.code == CODEX_RATE_LIMITED_CODE
|
||||
assert err.relogin_required is False
|
||||
assert is_rate_limited_auth_error(err) is True
|
||||
assert "retry after 120s" in str(err)
|
||||
# User-facing copy must not tell the operator to re-authenticate.
|
||||
rendered = format_auth_error(err)
|
||||
assert "re-authenticate" not in rendered
|
||||
assert "hermes auth" not in rendered
|
||||
|
||||
|
||||
def test_refresh_429_without_retry_after_header(monkeypatch):
|
||||
"""429 without a Retry-After header still classifies as quota, no relogin."""
|
||||
from hermes_cli.auth import CODEX_RATE_LIMITED_CODE
|
||||
|
||||
response = _StubHTTPResponse(429, {"error": "rate_limited"})
|
||||
_patch_httpx(monkeypatch, response)
|
||||
|
||||
with pytest.raises(AuthError) as exc_info:
|
||||
refresh_codex_oauth_pure("a-tok", "r-tok")
|
||||
|
||||
err = exc_info.value
|
||||
assert err.code == CODEX_RATE_LIMITED_CODE
|
||||
assert err.relogin_required is False
|
||||
assert "quota exhausted" in str(err).lower()
|
||||
|
||||
|
||||
def test_is_rate_limited_auth_error_distinguishes_credential_errors():
|
||||
"""Missing/expired credentials must NOT be treated as rate-limit errors."""
|
||||
from hermes_cli.auth import CODEX_RATE_LIMITED_CODE, is_rate_limited_auth_error
|
||||
|
||||
rate_limited = AuthError(
|
||||
"quota", provider="openai-codex", code=CODEX_RATE_LIMITED_CODE, relogin_required=False
|
||||
)
|
||||
missing_creds = AuthError(
|
||||
"No Codex credentials stored.",
|
||||
provider="openai-codex",
|
||||
code="codex_auth_missing",
|
||||
relogin_required=True,
|
||||
)
|
||||
assert is_rate_limited_auth_error(rate_limited) is True
|
||||
assert is_rate_limited_auth_error(missing_creds) is False
|
||||
assert is_rate_limited_auth_error(ValueError("nope")) is False
|
||||
|
||||
|
||||
def test_login_openai_codex_force_new_login_skips_existing_reuse_prompt(monkeypatch):
|
||||
called = {"device_login": 0}
|
||||
|
||||
|
||||
@@ -97,6 +97,100 @@ def test_auth_add_anthropic_oauth_persists_pool_entry(tmp_path, monkeypatch):
|
||||
assert entry["expires_at_ms"] == 1711234567000
|
||||
|
||||
|
||||
def test_auth_add_google_gemini_cli_sets_active_provider(tmp_path, monkeypatch):
|
||||
"""hermes auth add google-gemini-cli must set active_provider in auth.json.
|
||||
|
||||
Tokens are managed by agent.google_oauth (written to the Google credential
|
||||
file by start_oauth_flow). The auth.json entry must record active_provider
|
||||
so get_active_provider() and _model_section_has_credentials() detect the
|
||||
provider — without storing tokens that would become stale.
|
||||
"""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
_write_auth_store(tmp_path, {"version": 1, "providers": {}})
|
||||
monkeypatch.setattr(
|
||||
"agent.google_oauth.run_gemini_oauth_login_pure",
|
||||
lambda: {
|
||||
"access_token": "ya29.test-token",
|
||||
"refresh_token": "google-refresh",
|
||||
"email": "user@example.com",
|
||||
"expires_at_ms": 9999999999000,
|
||||
"project_id": "my-project",
|
||||
},
|
||||
)
|
||||
|
||||
from hermes_cli.auth_commands import auth_add_command
|
||||
|
||||
class _Args:
|
||||
provider = "google-gemini-cli"
|
||||
auth_type = "oauth"
|
||||
api_key = None
|
||||
label = None
|
||||
|
||||
auth_add_command(_Args())
|
||||
|
||||
payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
|
||||
assert payload["active_provider"] == "google-gemini-cli"
|
||||
state = payload["providers"]["google-gemini-cli"]
|
||||
# Only email stored — no access_token/refresh_token (those live in
|
||||
# the Google OAuth credential file managed by agent.google_oauth).
|
||||
assert state.get("email") == "user@example.com"
|
||||
assert "access_token" not in state
|
||||
assert "refresh_token" not in state
|
||||
# pool entry from pool.add_entry() still present for hermes auth list
|
||||
entries = payload["credential_pool"]["google-gemini-cli"]
|
||||
entry = next(item for item in entries if item["source"] == "manual:google_pkce")
|
||||
assert entry["access_token"] == "ya29.test-token"
|
||||
|
||||
|
||||
def test_auth_add_qwen_oauth_sets_active_provider(tmp_path, monkeypatch):
|
||||
"""hermes auth add qwen-oauth must set active_provider in auth.json.
|
||||
|
||||
Tokens are managed by the Qwen CLI credential file via
|
||||
resolve_qwen_runtime_credentials(). The auth.json entry must record
|
||||
active_provider — without storing tokens that would become stale.
|
||||
"""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
_write_auth_store(tmp_path, {"version": 1, "providers": {}})
|
||||
_fake_creds = {
|
||||
"provider": "qwen-oauth",
|
||||
"base_url": "https://portal.qwen.ai/v1",
|
||||
"api_key": "qwen-test-token",
|
||||
"source": "qwen-cli",
|
||||
"expires_at_ms": None,
|
||||
"auth_file": "/home/user/.qwen/oauth_creds.json",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth.resolve_qwen_runtime_credentials",
|
||||
lambda **kw: _fake_creds,
|
||||
)
|
||||
# Prevent _seed_from_singletons from calling the real Qwen CLI file path
|
||||
monkeypatch.setattr(
|
||||
"agent.credential_pool._seed_from_singletons",
|
||||
lambda provider, entries: (False, set()),
|
||||
)
|
||||
|
||||
from hermes_cli.auth_commands import auth_add_command
|
||||
|
||||
class _Args:
|
||||
provider = "qwen-oauth"
|
||||
auth_type = "oauth"
|
||||
api_key = None
|
||||
label = None
|
||||
|
||||
auth_add_command(_Args())
|
||||
|
||||
payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
|
||||
assert payload["active_provider"] == "qwen-oauth"
|
||||
state = payload["providers"]["qwen-oauth"]
|
||||
# Only base_url stored — no api_key (that lives in the Qwen CLI file).
|
||||
assert state.get("base_url") == "https://portal.qwen.ai/v1"
|
||||
assert "api_key" not in state
|
||||
# pool entry from pool.add_entry() still present for hermes auth list
|
||||
entries = payload["credential_pool"]["qwen-oauth"]
|
||||
entry = next(item for item in entries if item["source"] == "manual:qwen_cli")
|
||||
assert entry["access_token"] == "qwen-test-token"
|
||||
|
||||
|
||||
def test_auth_add_nous_oauth_persists_pool_entry(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
_write_auth_store(tmp_path, {"version": 1, "providers": {}})
|
||||
@@ -107,15 +201,15 @@ def test_auth_add_nous_oauth_persists_pool_entry(tmp_path, monkeypatch):
|
||||
"portal_base_url": "https://portal.example.com",
|
||||
"inference_base_url": "https://inference.example.com/v1",
|
||||
"client_id": "hermes-cli",
|
||||
"scope": "inference:invoke inference:mint_agent_key",
|
||||
"scope": "inference:invoke",
|
||||
"token_type": "Bearer",
|
||||
"access_token": token,
|
||||
"refresh_token": "refresh-token",
|
||||
"obtained_at": "2026-03-23T10:00:00+00:00",
|
||||
"expires_at": "2026-03-23T11:00:00+00:00",
|
||||
"expires_in": 3600,
|
||||
"agent_key": "ak-test",
|
||||
"agent_key_id": "ak-id",
|
||||
"agent_key": token,
|
||||
"agent_key_id": None,
|
||||
"agent_key_expires_at": "2026-03-23T10:30:00+00:00",
|
||||
"agent_key_expires_in": 1800,
|
||||
"agent_key_reused": False,
|
||||
@@ -155,17 +249,17 @@ def test_auth_add_nous_oauth_persists_pool_entry(tmp_path, monkeypatch):
|
||||
assert not any(item["source"] == "manual:device_code" for item in entries)
|
||||
entry = device_code_entries[0]
|
||||
assert entry["source"] == "device_code"
|
||||
assert entry["agent_key"] == "ak-test"
|
||||
assert entry["agent_key"] == token
|
||||
assert entry["portal_base_url"] == "https://portal.example.com"
|
||||
|
||||
# `hermes auth add nous` must also populate providers.nous so the
|
||||
# 401-recovery path (resolve_nous_runtime_credentials) can mint a fresh
|
||||
# agent_key when the 24h TTL expires. If this mirror is missing, recovery
|
||||
# 401-recovery path (resolve_nous_runtime_credentials) can refresh an
|
||||
# invoke JWT when the token expires. If this mirror is missing, recovery
|
||||
# raises "Hermes is not logged into Nous Portal" and the agent dies.
|
||||
singleton = payload["providers"]["nous"]
|
||||
assert singleton["access_token"] == token
|
||||
assert singleton["refresh_token"] == "refresh-token"
|
||||
assert singleton["agent_key"] == "ak-test"
|
||||
assert singleton["agent_key"] == token
|
||||
assert singleton["portal_base_url"] == "https://portal.example.com"
|
||||
assert singleton["inference_base_url"] == "https://inference.example.com/v1"
|
||||
|
||||
@@ -228,15 +322,15 @@ def test_auth_add_nous_oauth_honors_custom_label(tmp_path, monkeypatch):
|
||||
"portal_base_url": "https://portal.example.com",
|
||||
"inference_base_url": "https://inference.example.com/v1",
|
||||
"client_id": "hermes-cli",
|
||||
"scope": "inference:invoke inference:mint_agent_key",
|
||||
"scope": "inference:invoke",
|
||||
"token_type": "Bearer",
|
||||
"access_token": token,
|
||||
"refresh_token": "refresh-token",
|
||||
"obtained_at": "2026-03-23T10:00:00+00:00",
|
||||
"expires_at": "2026-03-23T11:00:00+00:00",
|
||||
"expires_in": 3600,
|
||||
"agent_key": "ak-test",
|
||||
"agent_key_id": "ak-id",
|
||||
"agent_key": token,
|
||||
"agent_key_id": None,
|
||||
"agent_key_expires_at": "2026-03-23T10:30:00+00:00",
|
||||
"agent_key_expires_in": 1800,
|
||||
"agent_key_reused": False,
|
||||
@@ -303,13 +397,144 @@ def test_auth_add_codex_oauth_persists_pool_entry(tmp_path, monkeypatch):
|
||||
|
||||
payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
|
||||
entries = payload["credential_pool"]["openai-codex"]
|
||||
# The add path now creates a distinct, self-contained ``manual:device_code``
|
||||
# pool entry per account instead of routing through the singleton save path
|
||||
# (which collapsed multiple accounts into the latest login — #39236).
|
||||
entry = next(item for item in entries if item["source"] == "manual:device_code")
|
||||
assert payload["active_provider"] == "openai-codex"
|
||||
# No singleton ``providers.openai-codex`` block is written by the add path.
|
||||
assert "openai-codex" not in payload.get("providers", {})
|
||||
assert entry["label"] == "codex@example.com"
|
||||
assert entry["source"] == "manual:device_code"
|
||||
assert entry["access_token"] == token
|
||||
assert entry["refresh_token"] == "refresh-token"
|
||||
assert entry["base_url"] == "https://chatgpt.com/backend-api/codex"
|
||||
|
||||
|
||||
def test_auth_add_codex_oauth_keeps_distinct_pool_accounts(tmp_path, monkeypatch):
|
||||
"""Two ``hermes auth add openai-codex`` runs for different ChatGPT
|
||||
accounts must produce two independent pool entries with distinct tokens.
|
||||
|
||||
Regression for #39236: the add path used to route through the singleton
|
||||
``_save_codex_tokens`` save, so the second login overwrote the first
|
||||
account's singleton-mirrored ``device_code`` entry instead of adding a
|
||||
second independent one. ``hermes auth list`` showed two labels sharing
|
||||
one token pair, and rotation silently always used the latest account.
|
||||
"""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
_write_auth_store(tmp_path, {"version": 1, "providers": {}})
|
||||
first_token = _jwt_with_email("first-codex@example.com")
|
||||
second_token = _jwt_with_email("second-codex@example.com")
|
||||
logins = iter(
|
||||
[
|
||||
{
|
||||
"tokens": {
|
||||
"access_token": first_token,
|
||||
"refresh_token": "first-refresh-token",
|
||||
},
|
||||
"base_url": "https://chatgpt.com/backend-api/codex",
|
||||
"last_refresh": "2026-03-23T10:00:00Z",
|
||||
},
|
||||
{
|
||||
"tokens": {
|
||||
"access_token": second_token,
|
||||
"refresh_token": "second-refresh-token",
|
||||
},
|
||||
"base_url": "https://chatgpt.com/backend-api/codex",
|
||||
"last_refresh": "2026-03-23T10:05:00Z",
|
||||
},
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr("hermes_cli.auth._codex_device_code_login", lambda: next(logins))
|
||||
|
||||
from hermes_cli.auth_commands import auth_add_command
|
||||
from agent.credential_pool import load_pool
|
||||
|
||||
class _Args:
|
||||
provider = "openai-codex"
|
||||
auth_type = "oauth"
|
||||
api_key = None
|
||||
label = None
|
||||
|
||||
auth_add_command(_Args())
|
||||
auth_add_command(_Args())
|
||||
|
||||
pool = load_pool("openai-codex")
|
||||
entries = pool.entries()
|
||||
|
||||
assert [entry.source for entry in entries] == [
|
||||
"manual:device_code",
|
||||
"manual:device_code",
|
||||
]
|
||||
assert [entry.label for entry in entries] == [
|
||||
"first-codex@example.com",
|
||||
"second-codex@example.com",
|
||||
]
|
||||
assert [entry.access_token for entry in entries] == [first_token, second_token]
|
||||
assert [entry.refresh_token for entry in entries] == [
|
||||
"first-refresh-token",
|
||||
"second-refresh-token",
|
||||
]
|
||||
|
||||
payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
|
||||
# No singleton block — the add path is now pool-only.
|
||||
assert "openai-codex" not in payload.get("providers", {})
|
||||
# First add activated the provider; second add left it as-is.
|
||||
assert payload["active_provider"] == "openai-codex"
|
||||
|
||||
|
||||
def test_auth_add_xai_oauth_sets_active_provider(tmp_path, monkeypatch):
|
||||
"""hermes auth add xai-oauth must write providers singleton and set active_provider.
|
||||
|
||||
Previously pool.add_entry() was called directly, which wrote only the
|
||||
credential-pool entry without setting active_provider. _model_section_has_credentials()
|
||||
checks get_active_provider() first; with it unset, the setup wizard would
|
||||
report "No inference provider configured" after a successful OAuth login.
|
||||
"""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
_write_auth_store(tmp_path, {"version": 1, "providers": {}})
|
||||
access_token = "xai-test-access-token"
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth._xai_oauth_loopback_login",
|
||||
lambda **kwargs: {
|
||||
"tokens": {
|
||||
"access_token": access_token,
|
||||
"refresh_token": "xai-refresh-token",
|
||||
"id_token": "",
|
||||
"token_type": "Bearer",
|
||||
},
|
||||
"discovery": {"token_endpoint": "https://auth.x.ai/token"},
|
||||
"redirect_uri": "http://127.0.0.1:7777/callback",
|
||||
"base_url": "https://api.x.ai/v1",
|
||||
"last_refresh": "2026-06-02T10:00:00Z",
|
||||
"source": "oauth-loopback",
|
||||
},
|
||||
)
|
||||
|
||||
from hermes_cli.auth_commands import auth_add_command
|
||||
|
||||
class _Args:
|
||||
provider = "xai-oauth"
|
||||
auth_type = "oauth"
|
||||
api_key = None
|
||||
label = None
|
||||
timeout = None
|
||||
no_browser = False
|
||||
manual_paste = False
|
||||
|
||||
auth_add_command(_Args())
|
||||
|
||||
payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
|
||||
# active_provider must be set — the core of this regression
|
||||
assert payload["active_provider"] == "xai-oauth"
|
||||
# providers singleton written by _save_xai_oauth_tokens
|
||||
assert payload["providers"]["xai-oauth"]["tokens"]["access_token"] == access_token
|
||||
# pool seeded from singleton by _seed_from_singletons("xai-oauth")
|
||||
entries = payload["credential_pool"]["xai-oauth"]
|
||||
entry = next(item for item in entries if item["source"] == "loopback_pkce")
|
||||
assert entry["refresh_token"] == "xai-refresh-token"
|
||||
|
||||
|
||||
def test_auth_remove_reindexes_priorities(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
# Prevent pool auto-seeding from host env vars and file-backed sources
|
||||
@@ -1129,10 +1354,6 @@ def test_auth_remove_codex_manual_source_suppresses_reseed(tmp_path, monkeypatch
|
||||
def test_auth_add_codex_clears_suppression_marker(tmp_path, monkeypatch):
|
||||
"""Re-linking codex via `hermes auth add openai-codex` must clear any suppression marker."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
monkeypatch.setattr(
|
||||
"agent.credential_pool._seed_from_singletons",
|
||||
lambda provider, entries: (False, set()),
|
||||
)
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -1169,9 +1390,10 @@ def test_auth_add_codex_clears_suppression_marker(tmp_path, monkeypatch):
|
||||
payload = json.loads((hermes_home / "auth.json").read_text())
|
||||
# Suppression marker must be cleared
|
||||
assert "openai-codex" not in payload.get("suppressed_sources", {})
|
||||
# New pool entry must be present
|
||||
# New pool entry must be present (distinct manual:device_code entry — #39236)
|
||||
entries = payload["credential_pool"]["openai-codex"]
|
||||
assert any(e["source"] == "manual:device_code" for e in entries)
|
||||
assert payload["active_provider"] == "openai-codex"
|
||||
|
||||
|
||||
def test_seed_from_singletons_respects_codex_suppression(tmp_path, monkeypatch):
|
||||
@@ -1590,20 +1812,16 @@ def test_auth_remove_copilot_suppresses_all_variants(tmp_path, monkeypatch):
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
# The copilot pool entry is no longer persisted directly in auth.json —
|
||||
# `(copilot, gh_cli)` is borrowed and stripped by
|
||||
# sanitize_borrowed_credential_payload (PR #31416, May 2026). Tokens are
|
||||
# hydrated at runtime via resolve_copilot_token(). Mock that path so the
|
||||
# pool has an entry to remove.
|
||||
_write_auth_store(
|
||||
tmp_path,
|
||||
{
|
||||
"version": 1,
|
||||
"credential_pool": {
|
||||
"copilot": [{
|
||||
"id": "c1",
|
||||
"label": "gh auth token",
|
||||
"auth_type": "api_key",
|
||||
"priority": 0,
|
||||
"source": "gh_cli",
|
||||
"access_token": "ghp_fake",
|
||||
}]
|
||||
},
|
||||
"credential_pool": {"copilot": []},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1611,7 +1829,14 @@ def test_auth_remove_copilot_suppresses_all_variants(tmp_path, monkeypatch):
|
||||
from hermes_cli.auth import is_source_suppressed
|
||||
from hermes_cli.auth_commands import auth_remove_command
|
||||
|
||||
auth_remove_command(SimpleNamespace(provider="copilot", target="1"))
|
||||
with patch(
|
||||
"hermes_cli.copilot_auth.resolve_copilot_token",
|
||||
return_value=("ghp_fake", "gh"),
|
||||
), patch(
|
||||
"hermes_cli.copilot_auth.get_copilot_api_token",
|
||||
return_value="ghu_fake_api",
|
||||
):
|
||||
auth_remove_command(SimpleNamespace(provider="copilot", target="1"))
|
||||
|
||||
assert is_source_suppressed("copilot", "gh_cli")
|
||||
assert is_source_suppressed("copilot", "env:COPILOT_GITHUB_TOKEN")
|
||||
|
||||
@@ -11,7 +11,6 @@ import io
|
||||
import contextlib
|
||||
import socket
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import auth as auth_mod
|
||||
|
||||
|
||||
@@ -330,6 +330,107 @@ def test_xai_loopback_login_manual_paste_state_mismatch_raises(monkeypatch):
|
||||
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(
|
||||
@@ -363,6 +464,205 @@ def test_xai_loopback_login_manual_paste_missing_code_raises(monkeypatch):
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -275,6 +275,98 @@ def test_provider_auth_state_returns_none_when_neither_has_it(profile_env):
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Tests for is_provider_explicitly_configured()."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import pytest
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ resolve_qwen_runtime_credentials, get_qwen_auth_status.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -392,8 +391,84 @@ def test_get_qwen_auth_status_logged_in(qwen_env):
|
||||
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,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)
|
||||
@@ -24,7 +24,6 @@ from __future__ import annotations
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -68,6 +68,13 @@ def _make_hermes_tree(root: Path) -> None:
|
||||
(root / "logs" / "agent.log").write_text("log line\n")
|
||||
|
||||
|
||||
def _symlink_file_or_skip(link: Path, target: Path) -> None:
|
||||
try:
|
||||
link.symlink_to(target)
|
||||
except OSError as exc:
|
||||
pytest.skip(f"symlinks unavailable in test environment: {exc}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _should_exclude tests
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -257,6 +264,29 @@ class TestBackup:
|
||||
zips = list(tmp_path.glob("hermes-backup-*.zip"))
|
||||
assert len(zips) == 1
|
||||
|
||||
def test_skips_symlinked_files(self, tmp_path, monkeypatch):
|
||||
"""Backup must not dereference symlinks and leak files outside HERMES_HOME."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
_make_hermes_tree(hermes_home)
|
||||
outside = tmp_path / "outside-secret.txt"
|
||||
outside.write_text("outside secret\n")
|
||||
_symlink_file_or_skip(hermes_home / "skills" / "outside-link.txt", outside)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
out_zip = tmp_path / "backup.zip"
|
||||
args = Namespace(output=str(out_zip))
|
||||
|
||||
from hermes_cli.backup import run_backup
|
||||
run_backup(args)
|
||||
|
||||
with zipfile.ZipFile(out_zip, "r") as zf:
|
||||
names = zf.namelist()
|
||||
assert "skills/outside-link.txt" not in names
|
||||
assert all(zf.read(name) != b"outside secret\n" for name in names)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _validate_backup_zip tests
|
||||
@@ -999,7 +1029,6 @@ class TestProfileRestoration:
|
||||
args = Namespace(zipfile=str(zip_path), force=True)
|
||||
|
||||
# Simulate profiles module not being available
|
||||
import hermes_cli.backup as backup_mod
|
||||
original_import = __builtins__.__import__ if hasattr(__builtins__, '__import__') else __import__
|
||||
|
||||
def fake_import(name, *a, **kw):
|
||||
@@ -1421,6 +1450,21 @@ class TestPreUpdateBackup:
|
||||
f"remaining={remaining}"
|
||||
)
|
||||
|
||||
def test_skips_symlinked_files(self, hermes_home, tmp_path):
|
||||
"""Pre-update backups must not dereference symlinks outside HERMES_HOME."""
|
||||
from hermes_cli.backup import create_pre_update_backup
|
||||
|
||||
outside = tmp_path / "outside-secret.txt"
|
||||
outside.write_text("outside secret\n")
|
||||
_symlink_file_or_skip(hermes_home / "skills" / "outside-link.txt", outside)
|
||||
|
||||
out = create_pre_update_backup(hermes_home=hermes_home)
|
||||
assert out is not None
|
||||
with zipfile.ZipFile(out) as zf:
|
||||
names = zf.namelist()
|
||||
assert "skills/outside-link.txt" not in names
|
||||
assert all(zf.read(name) != b"outside secret\n" for name in names)
|
||||
|
||||
|
||||
class TestRunPreUpdateBackup:
|
||||
"""Tests for the ``_run_pre_update_backup`` wrapper in main.py —
|
||||
@@ -1635,3 +1679,105 @@ class TestPreMigrationBackup:
|
||||
_t.sleep(1.05)
|
||||
# Update backup must still be there
|
||||
assert update_backup.exists(), "pre-migration rotation wrongly pruned the pre-update backup"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cron jobs auto-restore after silent migration loss (issue #34600)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRestoreCronJobsIfEmptied:
|
||||
"""`hermes update` config migration can leave cron/jobs.json valid-but-empty,
|
||||
silently dropping every scheduled job. `restore_cron_jobs_if_emptied` is the
|
||||
post-migration safety net that restores from the pre-update snapshot."""
|
||||
|
||||
@staticmethod
|
||||
def _seed_jobs(path: Path, jobs):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps({"jobs": jobs}))
|
||||
|
||||
def _make_snapshot(self, hermes_home: Path, label="pre-update"):
|
||||
from hermes_cli.backup import create_quick_snapshot
|
||||
return create_quick_snapshot(label=label, hermes_home=hermes_home, keep=5)
|
||||
|
||||
def test_restores_when_emptied_after_migration(self, tmp_path):
|
||||
from hermes_cli.backup import restore_cron_jobs_if_emptied
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
jobs_path = hermes_home / "cron" / "jobs.json"
|
||||
# Pre-update: 3 real jobs.
|
||||
self._seed_jobs(jobs_path, [{"id": "a"}, {"id": "b"}, {"id": "c"}])
|
||||
snap_id = self._make_snapshot(hermes_home)
|
||||
assert snap_id
|
||||
|
||||
# Migration silently empties the file (valid JSON, zero jobs).
|
||||
jobs_path.write_text(json.dumps({"jobs": []}))
|
||||
|
||||
result = restore_cron_jobs_if_emptied(snap_id, hermes_home=hermes_home)
|
||||
assert result is not None
|
||||
assert result["restored"] is True
|
||||
assert result["job_count"] == 3
|
||||
assert result["snapshot_id"] == snap_id
|
||||
|
||||
# The live file now has the jobs back.
|
||||
restored = json.loads(jobs_path.read_text())
|
||||
assert len(restored["jobs"]) == 3
|
||||
|
||||
def test_noop_when_live_file_still_has_jobs(self, tmp_path):
|
||||
from hermes_cli.backup import restore_cron_jobs_if_emptied
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
jobs_path = hermes_home / "cron" / "jobs.json"
|
||||
self._seed_jobs(jobs_path, [{"id": "a"}, {"id": "b"}])
|
||||
snap_id = self._make_snapshot(hermes_home)
|
||||
|
||||
# Healthy path: file unchanged after update.
|
||||
result = restore_cron_jobs_if_emptied(snap_id, hermes_home=hermes_home)
|
||||
assert result is None
|
||||
|
||||
def test_noop_when_snapshot_had_no_jobs(self, tmp_path):
|
||||
from hermes_cli.backup import restore_cron_jobs_if_emptied
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
jobs_path = hermes_home / "cron" / "jobs.json"
|
||||
# Pre-update genuinely had zero jobs; current is also empty.
|
||||
self._seed_jobs(jobs_path, [])
|
||||
snap_id = self._make_snapshot(hermes_home)
|
||||
jobs_path.write_text(json.dumps({"jobs": []}))
|
||||
|
||||
result = restore_cron_jobs_if_emptied(snap_id, hermes_home=hermes_home)
|
||||
assert result is None
|
||||
|
||||
def test_noop_when_live_file_unreadable(self, tmp_path):
|
||||
"""An unparseable live file is left alone — that's a different failure
|
||||
mode the user should see, not silently overwrite."""
|
||||
from hermes_cli.backup import restore_cron_jobs_if_emptied
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
jobs_path = hermes_home / "cron" / "jobs.json"
|
||||
self._seed_jobs(jobs_path, [{"id": "a"}])
|
||||
snap_id = self._make_snapshot(hermes_home)
|
||||
jobs_path.write_text("{ this is not valid json")
|
||||
|
||||
result = restore_cron_jobs_if_emptied(snap_id, hermes_home=hermes_home)
|
||||
assert result is None
|
||||
# File left untouched.
|
||||
assert jobs_path.read_text() == "{ this is not valid json"
|
||||
|
||||
def test_noop_when_snapshot_id_missing(self, tmp_path):
|
||||
from hermes_cli.backup import restore_cron_jobs_if_emptied
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
jobs_path = hermes_home / "cron" / "jobs.json"
|
||||
self._seed_jobs(jobs_path, [])
|
||||
assert restore_cron_jobs_if_emptied(None, hermes_home=hermes_home) is None
|
||||
assert restore_cron_jobs_if_emptied("", hermes_home=hermes_home) is None
|
||||
|
||||
def test_restores_legacy_bare_list_snapshot_shape(self, tmp_path):
|
||||
"""A legacy snapshot storing a bare JSON list (not {"jobs": [...]}) is
|
||||
still counted and restored."""
|
||||
from hermes_cli.backup import restore_cron_jobs_if_emptied
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
jobs_path = hermes_home / "cron" / "jobs.json"
|
||||
jobs_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
jobs_path.write_text(json.dumps([{"id": "a"}, {"id": "b"}]))
|
||||
snap_id = self._make_snapshot(hermes_home)
|
||||
|
||||
jobs_path.write_text(json.dumps({"jobs": []}))
|
||||
result = restore_cron_jobs_if_emptied(snap_id, hermes_home=hermes_home)
|
||||
assert result is not None
|
||||
assert result["job_count"] == 2
|
||||
|
||||
@@ -133,3 +133,37 @@ def test_build_welcome_banner_title_falls_back_when_no_tag():
|
||||
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
|
||||
|
||||
|
||||
@@ -61,3 +61,56 @@ def test_get_git_banner_state_reads_origin_and_head(tmp_path):
|
||||
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}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
_MOCK_SKILLS = [
|
||||
|
||||
@@ -16,12 +16,10 @@ Covers the three paths changed by fix/bedrock-provider-model-ids-live-discovery:
|
||||
All Bedrock API calls are mocked — no real AWS credentials needed.
|
||||
"""
|
||||
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
from types import ModuleType
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -95,7 +93,7 @@ class TestProviderModelIdsBedrock:
|
||||
|
||||
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_MODELS, provider_model_ids
|
||||
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"):
|
||||
|
||||
@@ -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
|
||||
@@ -1,8 +1,6 @@
|
||||
"""Tests for hermes_cli/bundles.py — the `hermes bundles` CLI subcommand."""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from hermes_cli.config import load_config, save_config, save_env_value, get_env_value
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -39,6 +39,76 @@ 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."""
|
||||
|
||||
@@ -106,6 +176,33 @@ class TestCmdUpdateBranchFallback:
|
||||
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(
|
||||
@@ -117,7 +214,13 @@ class TestCmdUpdateBranchFallback:
|
||||
mock_run.side_effect = _make_run_side_effect(
|
||||
branch="main", verify_ok=True, commit_count="1"
|
||||
)
|
||||
with patch.object(hm, "_is_termux_env", return_value=False):
|
||||
# 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 = [
|
||||
@@ -126,48 +229,75 @@ class TestCmdUpdateBranchFallback:
|
||||
if call.args and call.args[0][0] == "/usr/bin/npm"
|
||||
]
|
||||
|
||||
# cmd_update runs npm commands in three locations:
|
||||
# 1. repo root — slash-command / TUI bridge deps
|
||||
# 2. ui-tui/ — Ink TUI deps
|
||||
# 3. web/ — install + "npm run build" for the web frontend
|
||||
# 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)
|
||||
#
|
||||
# Repo-root and ui-tui installs intentionally omit `--silent` and run
|
||||
# without `capture_output` so optional postinstall scripts (e.g.
|
||||
# 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). The web/ install
|
||||
# keeps `--silent` because its build step is short and noisy.
|
||||
update_flags = [
|
||||
# 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] == [
|
||||
(update_flags, PROJECT_ROOT),
|
||||
(update_flags, PROJECT_ROOT / "ui-tui"),
|
||||
(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", "--silent"], PROJECT_ROOT / "web"),
|
||||
(["/usr/bin/npm", "run", "build"], PROJECT_ROOT / "web"),
|
||||
(["/usr/bin/npm", "ci", "--workspace", "web", "--silent"], PROJECT_ROOT),
|
||||
]
|
||||
|
||||
# Regression for #18840: repo root + ui-tui installs must stream
|
||||
# output (capture_output=False) so postinstall progress is visible
|
||||
# to the user.
|
||||
repo_and_tui_calls = [
|
||||
# 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") in {PROJECT_ROOT, PROJECT_ROOT / "ui-tui"}
|
||||
and call.kwargs.get("cwd") == PROJECT_ROOT
|
||||
and "--silent" not in call.args[0]
|
||||
]
|
||||
assert len(repo_and_tui_calls) == 2
|
||||
for call in repo_and_tui_calls:
|
||||
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 / ui-tui npm install must stream output "
|
||||
"repo-root npm install must stream output "
|
||||
"(no capture_output) so postinstall progress is visible"
|
||||
)
|
||||
|
||||
@@ -201,6 +331,83 @@ class TestCmdUpdateBranchFallback:
|
||||
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.
|
||||
|
||||
@@ -276,6 +483,315 @@ class TestCmdUpdateProfileSkillSync:
|
||||
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
|
||||
|
||||
|
||||
@@ -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 the 30s
|
||||
pytest-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
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Tests for _coalesce_session_name_args — multi-word session name merging."""
|
||||
|
||||
import pytest
|
||||
from hermes_cli.main import _coalesce_session_name_args
|
||||
|
||||
|
||||
|
||||
@@ -13,11 +13,8 @@ existing Codex CLI tokens via `hermes auth openai-codex`. The old
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -60,16 +60,19 @@ def test_get_codex_model_ids_falls_back_to_curated_defaults(tmp_path, monkeypatc
|
||||
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.2-codex"],
|
||||
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.2-codex",
|
||||
"gpt-5.3-codex",
|
||||
"gpt-5.5",
|
||||
"gpt-5.4-mini",
|
||||
"gpt-5.4",
|
||||
"gpt-5.3-codex",
|
||||
"gpt-5.3-codex-spark",
|
||||
]
|
||||
|
||||
@@ -130,7 +133,7 @@ def test_model_command_uses_runtime_access_token_for_codex_list(monkeypatch):
|
||||
captured["access_token"] = access_token
|
||||
return ["gpt-5.2-codex", "gpt-5.2"]
|
||||
|
||||
def _fake_prompt_model_selection(model_ids, current_model=""):
|
||||
def _fake_prompt_model_selection(model_ids, current_model="", **_kwargs):
|
||||
captured["model_ids"] = list(model_ids)
|
||||
captured["current_model"] = current_model
|
||||
return None
|
||||
@@ -178,7 +181,7 @@ def test_model_command_prompts_to_reuse_or_reauthenticate_codex_session(monkeypa
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth._prompt_model_selection",
|
||||
lambda model_ids, current_model="": None,
|
||||
lambda model_ids, current_model="", **_kwargs: None,
|
||||
)
|
||||
|
||||
_model_flow_openai_codex({}, current_model="gpt-5.4")
|
||||
@@ -216,7 +219,7 @@ def test_model_command_uses_existing_codex_session_without_relogin(monkeypatch):
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth._prompt_model_selection",
|
||||
lambda model_ids, current_model="": None,
|
||||
lambda model_ids, current_model="", **_kwargs: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth._login_openai_codex",
|
||||
|
||||
@@ -2,14 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.codex_runtime_plugin_migration import (
|
||||
MIGRATION_MARKER,
|
||||
MIGRATION_END_MARKER,
|
||||
MigrationReport,
|
||||
_build_hermes_tools_mcp_entry,
|
||||
_format_toml_value,
|
||||
_looks_like_test_tempdir,
|
||||
|
||||
@@ -336,13 +336,23 @@ class TestSlackNativeSlashes:
|
||||
)
|
||||
|
||||
def test_includes_aliases_as_first_class_slashes(self):
|
||||
"""Aliases (/btw, /bg, /reset, /q) must be registered as standalone
|
||||
slashes — this is the whole point of native-slashes parity."""
|
||||
names = {n for n, _d, _h in slack_native_slashes()}
|
||||
"""Aliases (/btw, /bg, /reset, …) must be registered as standalone
|
||||
slashes — this is the whole point of native-slashes parity.
|
||||
|
||||
Asserts the contract (aliases are surfaced as first-class slashes),
|
||||
not a specific alias's survival of Slack's 50-slash clamp — which alias
|
||||
lands last shifts whenever a canonical command is added, so pinning one
|
||||
name (previously ``q``) made this a change-detector.
|
||||
"""
|
||||
slashes = slack_native_slashes()
|
||||
names = {n for n, _d, _h in slashes}
|
||||
# Aliases that sort early in the registry always fit under the cap.
|
||||
assert "btw" in names
|
||||
assert "bg" in names
|
||||
assert "reset" in names
|
||||
assert "q" in names
|
||||
# And at least one alias is surfaced as an alias entry (description
|
||||
# carries the "Alias for /…" marker), proving the alias pass ran.
|
||||
assert any(d.startswith("Alias for /") for _n, d, _h in slashes)
|
||||
|
||||
def test_telegram_parity(self):
|
||||
"""Every Telegram bot command must be registerable on Slack too.
|
||||
@@ -1003,7 +1013,7 @@ class TestTelegramMenuCommands:
|
||||
|
||||
def test_excludes_telegram_disabled_skills(self, tmp_path, monkeypatch):
|
||||
"""Skills disabled for telegram should not appear in the menu."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
# Set up a config with a telegram-specific disabled list
|
||||
config_file = tmp_path / "config.yaml"
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from hermes_cli.config import (
|
||||
DEFAULT_CONFIG,
|
||||
check_config_version,
|
||||
get_hermes_home,
|
||||
ensure_hermes_home,
|
||||
get_compatible_custom_providers,
|
||||
@@ -155,6 +157,70 @@ class TestLoadConfigParseFailure:
|
||||
after_edit = capsys.readouterr().err
|
||||
assert "hermes config:" in after_edit, "edited file should re-warn"
|
||||
|
||||
def test_corrupt_config_is_backed_up(self, tmp_path, capsys):
|
||||
"""A broken config.yaml is snapshotted to a timestamped .bak so the
|
||||
user's recoverable overrides survive a later wizard/config-set rewrite.
|
||||
|
||||
Ported from google-gemini/gemini-cli#21541 (policy-file TOML recovery),
|
||||
adapted: we back up but deliberately do NOT reset config.yaml.
|
||||
"""
|
||||
from hermes_cli import config as cfg_mod
|
||||
cfg_mod._CONFIG_PARSE_WARNED.clear()
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
broken = "\tmodel: test/custom\nbroken indent:\n"
|
||||
(tmp_path / "config.yaml").write_text(broken)
|
||||
|
||||
load_config()
|
||||
err = capsys.readouterr().err
|
||||
|
||||
baks = list(tmp_path.glob("config.yaml.corrupt.*.bak"))
|
||||
assert len(baks) == 1, f"expected one backup, got {baks}"
|
||||
# Backup preserves the original broken content verbatim
|
||||
assert baks[0].read_text() == broken
|
||||
# Original config.yaml is left untouched (not reset to clean state)
|
||||
assert (tmp_path / "config.yaml").read_text() == broken
|
||||
# User is told where the backup landed
|
||||
assert str(baks[0]) in err
|
||||
|
||||
def test_backup_skips_when_same_size_bak_exists(self, tmp_path, capsys):
|
||||
"""Don't churn backups: if a corrupt backup of the same size already
|
||||
exists (same corruption already preserved), skip making another."""
|
||||
from hermes_cli import config as cfg_mod
|
||||
cfg_mod._CONFIG_PARSE_WARNED.clear()
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
broken = "\tbroken:\n"
|
||||
cfg = tmp_path / "config.yaml"
|
||||
cfg.write_text(broken)
|
||||
|
||||
# Pre-existing backup of identical size simulates an earlier snapshot.
|
||||
(tmp_path / "config.yaml.corrupt.20260101-000000.bak").write_text(broken)
|
||||
|
||||
load_config()
|
||||
|
||||
baks = list(tmp_path.glob("config.yaml.corrupt.*.bak"))
|
||||
assert len(baks) == 1, f"should not add a second same-size backup, got {baks}"
|
||||
|
||||
def test_corrupt_symlink_config_not_backed_up(self, tmp_path):
|
||||
"""Symlinked config.yaml is not copied (mirrors Gemini #21541 lstat
|
||||
guard) — avoids clobbering whatever the symlink points at."""
|
||||
import sys as _sys
|
||||
if _sys.platform == "win32":
|
||||
pytest.skip("symlink creation requires privileges on Windows")
|
||||
from hermes_cli import config as cfg_mod
|
||||
cfg_mod._CONFIG_PARSE_WARNED.clear()
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
real = tmp_path / "real_config.yaml"
|
||||
real.write_text("\tbroken:\n")
|
||||
link = tmp_path / "config.yaml"
|
||||
link.symlink_to(real)
|
||||
|
||||
load_config()
|
||||
|
||||
assert not list(tmp_path.glob("config.yaml.corrupt.*.bak"))
|
||||
|
||||
|
||||
class TestSaveAndLoadRoundtrip:
|
||||
def test_roundtrip(self, tmp_path):
|
||||
@@ -226,6 +292,25 @@ class TestSaveEnvValueSecure:
|
||||
env_mode = (tmp_path / ".env").stat().st_mode & 0o777
|
||||
assert env_mode == 0o600
|
||||
|
||||
def test_save_env_value_preserves_existing_file_mode_on_posix(self, tmp_path):
|
||||
"""Regression for #31518: pre-existing .env mode (e.g. 0640 for a
|
||||
Docker bind-mount that the operator chose) survives subsequent
|
||||
writes. Previously _secure_file ran unconditionally after the
|
||||
mode-restore branch and re-tightened to 0600.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
return
|
||||
|
||||
env_path = tmp_path / ".env"
|
||||
env_path.write_text("EXISTING=value\n")
|
||||
os.chmod(env_path, 0o640)
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
save_env_value("TENOR_API_KEY", "sk-test-secret")
|
||||
|
||||
env_mode = env_path.stat().st_mode & 0o777
|
||||
assert env_mode == 0o640, f"expected 0o640, got {oct(env_mode)}"
|
||||
|
||||
|
||||
class TestRemoveEnvValue:
|
||||
def test_removes_key_from_env_file(self, tmp_path):
|
||||
@@ -269,6 +354,28 @@ class TestRemoveEnvValue:
|
||||
remove_env_value("ORPHAN_KEY")
|
||||
assert "ORPHAN_KEY" not in os.environ
|
||||
|
||||
def test_remove_env_value_preserves_existing_file_mode_on_posix(self, tmp_path):
|
||||
"""Regression: pre-existing .env mode (e.g. 0640 for a Docker
|
||||
bind-mount the operator chose) survives a remove just as it does a
|
||||
save. Previously _secure_file ran unconditionally after the
|
||||
mode-restore branch and re-tightened to 0600 — the same bug fixed
|
||||
in save_env_value (#33699), in the sibling remove path.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
return
|
||||
|
||||
env_path = tmp_path / ".env"
|
||||
env_path.write_text("KEEP=value\nDROP=gone\n")
|
||||
os.chmod(env_path, 0o640)
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path), "DROP": "gone"}):
|
||||
removed = remove_env_value("DROP")
|
||||
|
||||
assert removed is True
|
||||
assert "DROP" not in env_path.read_text()
|
||||
env_mode = env_path.stat().st_mode & 0o777
|
||||
assert env_mode == 0o640, f"expected 0o640, got {oct(env_mode)}"
|
||||
|
||||
|
||||
class TestSaveConfigAtomicity:
|
||||
"""Verify save_config uses atomic writes (tempfile + os.replace)."""
|
||||
@@ -485,6 +592,83 @@ class TestOptionalEnvVarsRegistry:
|
||||
all_vars.extend(vars_list)
|
||||
assert "TAVILY_API_KEY" in all_vars
|
||||
|
||||
def test_max_iterations_not_offered_as_env_var(self):
|
||||
"""HERMES_MAX_ITERATIONS must NOT be in OPTIONAL_ENV_VARS (issue #17534).
|
||||
|
||||
Offering it as an editable env var (dashboard, `hermes setup`) lets a
|
||||
user write it to .env, recreating the stale ghost that shadows
|
||||
config.yaml's agent.max_turns. The iteration budget is configured ONLY
|
||||
via config.yaml; HERMES_MAX_ITERATIONS remains a read-only backward-compat
|
||||
fallback in the gateway/CLI, never a promoted write target.
|
||||
"""
|
||||
from hermes_cli.config import OPTIONAL_ENV_VARS
|
||||
assert "HERMES_MAX_ITERATIONS" not in OPTIONAL_ENV_VARS
|
||||
|
||||
|
||||
class TestConfigMigrationSecretPrompts:
|
||||
def test_required_secret_env_prompt_uses_masked_prompt(self, tmp_path, monkeypatch):
|
||||
from hermes_cli import config as cfg_mod
|
||||
|
||||
saved = {}
|
||||
|
||||
monkeypatch.setattr(cfg_mod, "sanitize_env_file", lambda: 0)
|
||||
monkeypatch.setattr(cfg_mod, "check_config_version", lambda: (999, 999))
|
||||
monkeypatch.setattr(cfg_mod, "get_missing_config_fields", lambda: [])
|
||||
monkeypatch.setattr(cfg_mod, "get_missing_skill_config_vars", lambda: [])
|
||||
monkeypatch.setattr(
|
||||
cfg_mod,
|
||||
"get_missing_env_vars",
|
||||
lambda required_only=True: [
|
||||
{
|
||||
"name": "TEST_API_KEY",
|
||||
"description": "Test key",
|
||||
"prompt": "Test API key",
|
||||
"password": True,
|
||||
}
|
||||
]
|
||||
if required_only
|
||||
else [],
|
||||
)
|
||||
def fake_masked_secret_prompt(prompt):
|
||||
saved["prompt"] = prompt
|
||||
return "secret"
|
||||
|
||||
monkeypatch.setattr(cfg_mod, "masked_secret_prompt", fake_masked_secret_prompt)
|
||||
monkeypatch.setattr(
|
||||
cfg_mod,
|
||||
"save_env_value",
|
||||
lambda name, value: saved.update({name: value}),
|
||||
)
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
results = cfg_mod.migrate_config(interactive=True, quiet=True)
|
||||
|
||||
assert saved["prompt"] == " Test API key: "
|
||||
assert saved["TEST_API_KEY"] == "secret"
|
||||
assert results["env_added"] == ["TEST_API_KEY"]
|
||||
|
||||
|
||||
class TestConfigVersionDetection:
|
||||
def test_check_config_version_uses_raw_on_disk_version(self, tmp_path):
|
||||
config_path = tmp_path / "config.yaml"
|
||||
config_path.write_text("model: {}\n", encoding="utf-8")
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
assert load_config()["_config_version"] == DEFAULT_CONFIG["_config_version"]
|
||||
assert check_config_version() == (0, DEFAULT_CONFIG["_config_version"])
|
||||
|
||||
def test_check_config_version_treats_missing_file_as_current(self, tmp_path):
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
latest = DEFAULT_CONFIG["_config_version"]
|
||||
assert check_config_version() == (latest, latest)
|
||||
|
||||
def test_check_config_version_does_not_migrate_invalid_yaml(self, tmp_path):
|
||||
(tmp_path / "config.yaml").write_text("model: [unterminated\n", encoding="utf-8")
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
latest = DEFAULT_CONFIG["_config_version"]
|
||||
assert check_config_version() == (latest, latest)
|
||||
|
||||
|
||||
class TestAnthropicTokenMigration:
|
||||
"""Test that config version 8→9 clears ANTHROPIC_TOKEN."""
|
||||
@@ -563,6 +747,71 @@ class TestCustomProviderCompatibility:
|
||||
# custom_providers removed by migration — runtime reads via compat layer
|
||||
assert "custom_providers" not in raw
|
||||
|
||||
def test_v11_upgrade_preserves_custom_provider_model_metadata(self, tmp_path):
|
||||
config_path = tmp_path / "config.yaml"
|
||||
model_map = {
|
||||
"kimi-k2.6": {"context_length": 262144},
|
||||
"moonshotai/Kimi-K2.6-ACED": {"context_length": 131072},
|
||||
}
|
||||
config_path.write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"_config_version": 11,
|
||||
"custom_providers": [
|
||||
{
|
||||
"name": "Kimi Coding Plan",
|
||||
"base_url": "https://api.kimi.example.com/coding",
|
||||
"api_key_env": "KIMI_CODING_API_KEY",
|
||||
"api_mode": "anthropic_messages",
|
||||
"model": "kimi-k2.6",
|
||||
"models": model_map,
|
||||
"context_length": 262144,
|
||||
"rate_limit_delay": 0.25,
|
||||
"discover_models": False,
|
||||
"extra_body": {
|
||||
"chat_template_kwargs": {"enable_thinking": False}
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "List Models",
|
||||
"base_url": "https://list.example.com/v1",
|
||||
"models": ["alpha", "beta"],
|
||||
},
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
migrate_config(interactive=False, quiet=True)
|
||||
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
compatible = get_compatible_custom_providers(raw)
|
||||
|
||||
assert "custom_providers" not in raw
|
||||
provider = raw["providers"]["kimi-coding-plan"]
|
||||
assert provider["api"] == "https://api.kimi.example.com/coding"
|
||||
assert provider["key_env"] == "KIMI_CODING_API_KEY"
|
||||
assert provider["transport"] == "anthropic_messages"
|
||||
assert provider["default_model"] == "kimi-k2.6"
|
||||
assert provider["models"] == model_map
|
||||
assert provider["context_length"] == 262144
|
||||
assert provider["rate_limit_delay"] == 0.25
|
||||
assert provider["discover_models"] is False
|
||||
assert provider["extra_body"] == {
|
||||
"chat_template_kwargs": {"enable_thinking": False}
|
||||
}
|
||||
assert raw["providers"]["list-models"]["models"] == {
|
||||
"alpha": {},
|
||||
"beta": {},
|
||||
}
|
||||
|
||||
compatible_provider = next(
|
||||
entry for entry in compatible if entry["provider_key"] == "kimi-coding-plan"
|
||||
)
|
||||
assert compatible_provider["models"] == model_map
|
||||
assert compatible_provider["key_env"] == "KIMI_CODING_API_KEY"
|
||||
|
||||
def test_providers_dict_resolves_at_runtime(self, tmp_path):
|
||||
"""After migration deleted custom_providers, get_compatible_custom_providers
|
||||
still finds entries from the providers dict."""
|
||||
@@ -732,3 +981,166 @@ class TestUserMessagePreviewConfig:
|
||||
preview = DEFAULT_CONFIG["display"]["user_message_preview"]
|
||||
assert preview["first_lines"] == 2
|
||||
assert preview["last_lines"] == 2
|
||||
|
||||
|
||||
class TestEnvWriteDenylist:
|
||||
"""``save_env_value`` refuses to persist env-var names that
|
||||
influence how subprocesses execute — ``LD_PRELOAD``, ``PYTHONPATH``,
|
||||
``PATH``, ``EDITOR``, etc. — or any ``HERMES_*`` runtime flag.
|
||||
|
||||
The dashboard exposes ``PUT /api/env`` to any authed caller (and
|
||||
the session token lives in the SPA's HTML where any future plugin
|
||||
XSS or local process could exfiltrate it). Without this gate, an
|
||||
attacker who steals the token could plant
|
||||
``LD_PRELOAD=/tmp/evil.so`` in ``.env`` and own the next Hermes
|
||||
process on next startup via the dotenv → ``os.environ`` chain in
|
||||
``hermes_cli/env_loader.py``.
|
||||
|
||||
Regression test for the dashboard pentest finding filed alongside
|
||||
the ``web-pentest`` skill (PR #32265 / issue #32267).
|
||||
"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _hermes_home(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
ensure_hermes_home()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"denied_key",
|
||||
[
|
||||
"LD_PRELOAD",
|
||||
"LD_LIBRARY_PATH",
|
||||
"LD_AUDIT",
|
||||
"DYLD_INSERT_LIBRARIES",
|
||||
"DYLD_LIBRARY_PATH",
|
||||
"PYTHONPATH",
|
||||
"PYTHONHOME",
|
||||
"PYTHONSTARTUP",
|
||||
"NODE_OPTIONS",
|
||||
"NODE_PATH",
|
||||
"PATH",
|
||||
"SHELL",
|
||||
"EDITOR",
|
||||
"VISUAL",
|
||||
"PAGER",
|
||||
"BROWSER",
|
||||
"GIT_SSH_COMMAND",
|
||||
"GIT_EXEC_PATH",
|
||||
"HERMES_HOME",
|
||||
"HERMES_PROFILE",
|
||||
"HERMES_CONFIG",
|
||||
"HERMES_ENV",
|
||||
],
|
||||
)
|
||||
def test_denylisted_keys_rejected(self, denied_key):
|
||||
"""Each denylisted name raises ``ValueError`` and never reaches
|
||||
the on-disk ``.env`` file."""
|
||||
with pytest.raises(ValueError, match="denylist"):
|
||||
save_env_value(denied_key, "anything")
|
||||
|
||||
# And nothing landed on disk either.
|
||||
env = load_env()
|
||||
assert denied_key not in env
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"allowed_key",
|
||||
[
|
||||
"HERMES_GEMINI_CLIENT_ID",
|
||||
"HERMES_LANGFUSE_PUBLIC_KEY",
|
||||
"HERMES_SPOTIFY_CLIENT_ID",
|
||||
"HERMES_QWEN_BASE_URL",
|
||||
"HERMES_MAX_ITERATIONS",
|
||||
],
|
||||
)
|
||||
def test_hermes_integration_keys_still_writable(self, allowed_key):
|
||||
"""``HERMES_*`` overall is NOT blocked — only the four runtime
|
||||
location names (HOME/PROFILE/CONFIG/ENV) are. Integration
|
||||
credentials following the ``HERMES_*`` convention must keep
|
||||
working or we'd regress every provider setup wizard that
|
||||
currently writes one of these (auth.py, Spotify, Langfuse, …)."""
|
||||
save_env_value(allowed_key, "test-value-123")
|
||||
env = load_env()
|
||||
assert env[allowed_key] == "test-value-123"
|
||||
|
||||
def test_legitimate_provider_key_still_works(self):
|
||||
"""The denylist must not regress on real provider key writes."""
|
||||
save_env_value("OPENROUTER_API_KEY", "sk-or-test-1234")
|
||||
env = load_env()
|
||||
assert env["OPENROUTER_API_KEY"] == "sk-or-test-1234"
|
||||
|
||||
def test_arbitrary_user_key_still_works(self):
|
||||
"""Plugin / user-defined env vars (anything outside the
|
||||
denylist and outside ``HERMES_*``) keep working. The denylist
|
||||
is narrow on purpose."""
|
||||
save_env_value("MY_PLUGIN_TOKEN", "plugin-secret-123")
|
||||
env = load_env()
|
||||
assert env["MY_PLUGIN_TOKEN"] == "plugin-secret-123"
|
||||
|
||||
def test_save_env_value_secure_inherits_denylist(self):
|
||||
"""The ``_secure`` variant goes through ``save_env_value`` so
|
||||
it inherits the gate — verify, don't assume."""
|
||||
with pytest.raises(ValueError, match="denylist"):
|
||||
save_env_value_secure("LD_PRELOAD", "/tmp/evil.so")
|
||||
|
||||
def test_pre_existing_value_in_env_file_is_left_alone(self, tmp_path):
|
||||
"""The gate is on *write*. If ``.env`` already contains
|
||||
``LD_PRELOAD`` (set out-of-band by the operator before this
|
||||
change shipped, or hand-edited), we don't blow up — we just
|
||||
refuse to add or update it via the API."""
|
||||
env_path = tmp_path / ".env"
|
||||
env_path.write_text("LD_PRELOAD=/something/legit.so\n")
|
||||
|
||||
# load_env returns it (the read path is intentionally permissive)
|
||||
env = load_env()
|
||||
assert env["LD_PRELOAD"] == "/something/legit.so"
|
||||
|
||||
# But the write path still refuses to update it
|
||||
with pytest.raises(ValueError, match="denylist"):
|
||||
save_env_value("LD_PRELOAD", "/tmp/evil.so")
|
||||
|
||||
|
||||
class TestWriteApprovalMigration:
|
||||
"""Version 28→29 renames memory/skills write_mode → write_approval (bool).
|
||||
|
||||
Only an explicit ``approve`` carried gating intent and maps to ``True``;
|
||||
``on``/``off``/unset map to ``False`` (gate off). The old ``write_mode`` key
|
||||
is removed. Only a persisted key is rewritten — never invented.
|
||||
"""
|
||||
|
||||
def _write(self, tmp_path, body: str):
|
||||
(tmp_path / "config.yaml").write_text(body)
|
||||
|
||||
def test_approve_maps_to_true(self, tmp_path):
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
self._write(tmp_path,
|
||||
"_config_version: 28\nmemory:\n write_mode: approve\n"
|
||||
"skills:\n write_mode: approve\n")
|
||||
migrate_config(interactive=False, quiet=True)
|
||||
raw = yaml.safe_load((tmp_path / "config.yaml").read_text())
|
||||
assert raw["memory"]["write_approval"] is True
|
||||
assert raw["skills"]["write_approval"] is True
|
||||
assert "write_mode" not in raw["memory"]
|
||||
assert "write_mode" not in raw["skills"]
|
||||
|
||||
def test_on_and_off_map_to_false(self, tmp_path):
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
# YAML 1.1 parses bare on/off as bools — write_mode could be either
|
||||
# the string or the bool; both legacy "not gating" values → False.
|
||||
self._write(tmp_path,
|
||||
"_config_version: 28\nmemory:\n write_mode: 'on'\n"
|
||||
"skills:\n write_mode: 'off'\n")
|
||||
migrate_config(interactive=False, quiet=True)
|
||||
raw = yaml.safe_load((tmp_path / "config.yaml").read_text())
|
||||
assert raw["memory"]["write_approval"] is False
|
||||
assert raw["skills"]["write_approval"] is False
|
||||
|
||||
def test_unset_key_defaults_to_false(self, tmp_path):
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
self._write(tmp_path, "_config_version: 28\nmemory:\n memory_enabled: true\n")
|
||||
migrate_config(interactive=False, quiet=True)
|
||||
raw = yaml.safe_load((tmp_path / "config.yaml").read_text())
|
||||
# No write_mode was persisted, so the rename is a no-op; the missing-
|
||||
# field pass then seeds the default (False = gate off). Either way the
|
||||
# gate ends up off and there's no leftover write_mode key.
|
||||
assert raw["memory"].get("write_approval", False) is False
|
||||
assert "write_mode" not in raw.get("memory", {})
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"""Tests for ${ENV_VAR} substitution in config.yaml values."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from hermes_cli.config import _expand_env_vars, load_config
|
||||
from unittest.mock import patch as mock_patch
|
||||
|
||||
|
||||
class TestExpandEnvVars:
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Tests for config.yaml structure validation (validate_config_structure)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.config import validate_config_structure, ConfigIssue
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -1,8 +1,7 @@
|
||||
"""Tests for hermes_cli.copilot_auth — Copilot token validation and resolution."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class TestTokenValidation:
|
||||
|
||||
@@ -6,25 +6,6 @@ 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_keeps_curated_copilot_models_when_live_catalog_unavailable():
|
||||
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=None):
|
||||
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 "gpt-5.4" in copilot["models"]
|
||||
assert "claude-sonnet-4.6" in copilot["models"]
|
||||
assert "claude-sonnet-4" in copilot["models"]
|
||||
assert "claude-sonnet-4.5" in copilot["models"]
|
||||
assert "claude-haiku-4.5" in copilot["models"]
|
||||
assert "gemini-3.1-pro-preview" in copilot["models"]
|
||||
assert "claude-opus-4.6" not in copilot["models"]
|
||||
|
||||
|
||||
@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"]
|
||||
|
||||
@@ -111,3 +111,19 @@ class TestCronCommandLifecycle:
|
||||
assert jobs[0]["skills"] == ["blogwatcher", "maps"]
|
||||
assert jobs[0]["name"] == "Skill combo"
|
||||
assert jobs[0]["profile"] == "default"
|
||||
|
||||
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
|
||||
|
||||
@@ -12,12 +12,8 @@ Covers:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from contextlib import redirect_stdout, redirect_stderr
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _ns(**kwargs):
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -6,8 +6,7 @@ immediately when provider_info had a saved ``model`` field, making it
|
||||
impossible to switch models on multi-model endpoints.
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch, MagicMock, call
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -46,7 +45,7 @@ class TestCustomProviderModelSwitch:
|
||||
}
|
||||
|
||||
with patch("hermes_cli.models.fetch_api_models", return_value=["model-A", "model-B"]) as mock_fetch, \
|
||||
patch.dict("sys.modules", {"simple_term_menu": None}), \
|
||||
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)
|
||||
@@ -71,7 +70,7 @@ class TestCustomProviderModelSwitch:
|
||||
}
|
||||
|
||||
with patch("hermes_cli.models.fetch_api_models", return_value=["model-A", "model-B"]), \
|
||||
patch.dict("sys.modules", {"simple_term_menu": None}), \
|
||||
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)
|
||||
@@ -117,7 +116,7 @@ class TestCustomProviderModelSwitch:
|
||||
}
|
||||
|
||||
with patch("hermes_cli.models.fetch_api_models", return_value=["model-X"]), \
|
||||
patch.dict("sys.modules", {"simple_term_menu": None}), \
|
||||
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)
|
||||
@@ -141,7 +140,7 @@ class TestCustomProviderModelSwitch:
|
||||
}
|
||||
|
||||
with patch("hermes_cli.models.fetch_api_models", return_value=["claude-3"]) as mock_fetch, \
|
||||
patch.dict("sys.modules", {"simple_term_menu": None}), \
|
||||
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)
|
||||
@@ -174,7 +173,7 @@ class TestCustomProviderModelSwitch:
|
||||
}
|
||||
|
||||
with patch("hermes_cli.models.fetch_api_models", return_value=["llama-3"]), \
|
||||
patch.dict("sys.modules", {"simple_term_menu": None}), \
|
||||
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)
|
||||
@@ -211,7 +210,7 @@ class TestCustomProviderModelSwitch:
|
||||
}
|
||||
|
||||
with patch("hermes_cli.models.fetch_api_models", return_value=["qwen3.6-35b-fast"]) as mock_fetch, \
|
||||
patch.dict("sys.modules", {"simple_term_menu": None}), \
|
||||
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)
|
||||
@@ -252,7 +251,7 @@ class TestCustomProviderModelSwitch:
|
||||
}
|
||||
|
||||
with patch("hermes_cli.models.fetch_api_models", return_value=["qwen3.6-35b-fast"]), \
|
||||
patch.dict("sys.modules", {"simple_term_menu": None}), \
|
||||
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)
|
||||
@@ -310,7 +309,7 @@ class TestCustomProviderModelSwitch:
|
||||
side_effect=_pick_neuralwatt), \
|
||||
patch("hermes_cli.models.fetch_api_models",
|
||||
return_value=["qwen3.6-35b-fast"]) as mock_fetch, \
|
||||
patch.dict("sys.modules", {"simple_term_menu": None}), \
|
||||
patch("hermes_cli.curses_ui.curses_radiolist", side_effect=ImportError), \
|
||||
patch("builtins.input", return_value="1"), \
|
||||
patch("builtins.print"):
|
||||
select_provider_and_model()
|
||||
@@ -423,7 +422,7 @@ class TestCustomProviderModelSwitch:
|
||||
side_effect=_pick_neuralwatt), \
|
||||
patch("hermes_cli.models.fetch_api_models",
|
||||
return_value=["qwen3.6-35b-fast"]) as mock_fetch, \
|
||||
patch.dict("sys.modules", {"simple_term_menu": None}), \
|
||||
patch("hermes_cli.curses_ui.curses_radiolist", side_effect=ImportError), \
|
||||
patch("builtins.input", return_value="1"), \
|
||||
patch("builtins.print"):
|
||||
select_provider_and_model()
|
||||
@@ -487,7 +486,7 @@ class TestCustomProviderModelSwitch:
|
||||
"hermes_cli.models.fetch_api_models",
|
||||
return_value=["claude-opus-4-7"],
|
||||
) as mock_fetch, \
|
||||
patch.dict("sys.modules", {"simple_term_menu": None}), \
|
||||
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)
|
||||
@@ -552,7 +551,7 @@ class TestCustomProviderModelSwitch:
|
||||
"hermes_cli.models.fetch_api_models",
|
||||
return_value=["claude-opus-4-7"],
|
||||
), \
|
||||
patch.dict("sys.modules", {"simple_term_menu": None}), \
|
||||
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)
|
||||
@@ -564,3 +563,133 @@ class TestCustomProviderModelSwitch:
|
||||
# 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()
|
||||
|
||||
@@ -0,0 +1,956 @@
|
||||
"""Tests for the dashboard admin API endpoints (MCP, pairing, webhooks,
|
||||
credential pool, memory, gateway lifecycle, ops, skills hub).
|
||||
|
||||
These endpoints turn the web dashboard into an administration panel for
|
||||
operators without CLI access to the host. The tests assert the request
|
||||
contract and the CLI-config parity (servers/keys written via the API are
|
||||
visible to the CLI data layer), not specific catalog values.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _client():
|
||||
try:
|
||||
from starlette.testclient import TestClient
|
||||
except ImportError:
|
||||
pytest.skip("fastapi/starlette not installed")
|
||||
import hermes_state
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
|
||||
client = TestClient(app)
|
||||
client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
# Keep the state DB under the isolated HERMES_HOME for any handler that
|
||||
# touches it.
|
||||
hermes_state.DEFAULT_DB_PATH = get_hermes_home() / "state.db"
|
||||
return client, _SESSION_HEADER_NAME
|
||||
|
||||
|
||||
class TestMcpEndpoints:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, self.header = _client()
|
||||
|
||||
def test_list_add_remove_roundtrip(self):
|
||||
assert self.client.get("/api/mcp/servers").json()["servers"] == []
|
||||
|
||||
r = self.client.post(
|
||||
"/api/mcp/servers", json={"name": "srv1", "url": "https://x/mcp"}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["transport"] == "http"
|
||||
|
||||
servers = self.client.get("/api/mcp/servers").json()["servers"]
|
||||
assert [s["name"] for s in servers] == ["srv1"]
|
||||
|
||||
# CLI parity: the server is in config.yaml under mcp_servers.
|
||||
from hermes_cli.mcp_config import _get_mcp_servers
|
||||
|
||||
assert "srv1" in _get_mcp_servers()
|
||||
|
||||
assert self.client.delete("/api/mcp/servers/srv1").status_code == 200
|
||||
assert self.client.get("/api/mcp/servers").json()["servers"] == []
|
||||
|
||||
def test_stdio_env_is_redacted_on_read(self):
|
||||
self.client.post(
|
||||
"/api/mcp/servers",
|
||||
json={
|
||||
"name": "srv2",
|
||||
"command": "npx",
|
||||
"args": ["-y", "pkg"],
|
||||
"env": {"API_KEY": "sk-secret-1234567890"},
|
||||
},
|
||||
)
|
||||
srv = self.client.get("/api/mcp/servers").json()["servers"][0]
|
||||
assert srv["env"]["API_KEY"] != "sk-secret-1234567890"
|
||||
|
||||
def test_duplicate_rejected(self):
|
||||
self.client.post("/api/mcp/servers", json={"name": "dup", "url": "u"})
|
||||
r = self.client.post("/api/mcp/servers", json={"name": "dup", "url": "u"})
|
||||
assert r.status_code == 409
|
||||
|
||||
def test_missing_transport_rejected(self):
|
||||
r = self.client.post("/api/mcp/servers", json={"name": "bad"})
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_enable_disable_toggle(self):
|
||||
self.client.post("/api/mcp/servers", json={"name": "tog", "url": "u"})
|
||||
r = self.client.put("/api/mcp/servers/tog/enabled", json={"enabled": False})
|
||||
assert r.status_code == 200 and r.json()["enabled"] is False
|
||||
srv = [
|
||||
s for s in self.client.get("/api/mcp/servers").json()["servers"]
|
||||
if s["name"] == "tog"
|
||||
][0]
|
||||
assert srv["enabled"] is False
|
||||
# Toggling a missing server is a 404.
|
||||
assert self.client.put(
|
||||
"/api/mcp/servers/nope/enabled", json={"enabled": True}
|
||||
).status_code == 404
|
||||
|
||||
def test_catalog_lists_entries(self):
|
||||
r = self.client.get("/api/mcp/catalog")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert "entries" in body and "diagnostics" in body
|
||||
# The shipped optional-mcps/ catalog has at least one entry; each must
|
||||
# carry the install/enabled status fields the UI relies on.
|
||||
for e in body["entries"]:
|
||||
assert {"name", "transport", "installed", "enabled", "needs_install"} <= set(e)
|
||||
|
||||
def test_catalog_install_unknown_404(self):
|
||||
r = self.client.post("/api/mcp/catalog/install", json={"name": "no-such-mcp-xyz"})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
|
||||
class TestCredentialPoolEndpoints:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, _ = _client()
|
||||
|
||||
def test_add_list_remove_and_cli_parity(self):
|
||||
assert self.client.get("/api/credentials/pool").json()["providers"] == []
|
||||
|
||||
r = self.client.post(
|
||||
"/api/credentials/pool",
|
||||
json={"provider": "openrouter", "api_key": "sk-or-abcdef1234", "label": "p"},
|
||||
)
|
||||
assert r.status_code == 200 and r.json()["count"] == 1
|
||||
|
||||
providers = self.client.get("/api/credentials/pool").json()["providers"]
|
||||
entry = providers[0]["entries"][0]
|
||||
# API redacts the key but exposes a preview + 1-based index.
|
||||
assert entry["index"] == 1
|
||||
assert entry["token_preview"] != "sk-or-abcdef1234"
|
||||
|
||||
# CLI parity: the raw, usable key is retrievable via the pool API.
|
||||
from agent.credential_pool import load_pool
|
||||
|
||||
raw = load_pool("openrouter").entries()
|
||||
assert raw[0].access_token == "sk-or-abcdef1234"
|
||||
|
||||
assert self.client.delete("/api/credentials/pool/openrouter/1").status_code == 200
|
||||
assert self.client.delete("/api/credentials/pool/openrouter/99").status_code == 404
|
||||
|
||||
def test_empty_body_rejected(self):
|
||||
r = self.client.post(
|
||||
"/api/credentials/pool", json={"provider": "", "api_key": ""}
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
class TestMemoryEndpoints:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, _ = _client()
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
(get_hermes_home() / "memories").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def test_status_and_select(self):
|
||||
data = self.client.get("/api/memory").json()
|
||||
assert "active" in data and "providers" in data and "builtin_files" in data
|
||||
|
||||
r = self.client.put("/api/memory/provider", json={"provider": "built-in"})
|
||||
assert r.status_code == 200 and r.json()["active"] == ""
|
||||
|
||||
r = self.client.put(
|
||||
"/api/memory/provider", json={"provider": "no-such-provider-xyz"}
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_reset_targets(self):
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
mem = get_hermes_home() / "memories"
|
||||
(mem / "MEMORY.md").write_text("notes")
|
||||
(mem / "USER.md").write_text("user")
|
||||
|
||||
r = self.client.post("/api/memory/reset", json={"target": "user"})
|
||||
assert r.status_code == 200 and "USER.md" in r.json()["deleted"]
|
||||
assert (mem / "MEMORY.md").exists()
|
||||
|
||||
assert self.client.post(
|
||||
"/api/memory/reset", json={"target": "bogus"}
|
||||
).status_code == 400
|
||||
|
||||
|
||||
class TestPairingEndpoints:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, _ = _client()
|
||||
|
||||
def test_list_and_bad_approve(self):
|
||||
data = self.client.get("/api/pairing").json()
|
||||
assert data == {"pending": [], "approved": []}
|
||||
r = self.client.post(
|
||||
"/api/pairing/approve", json={"platform": "telegram", "code": "NOPE99"}
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
class TestWebhookEndpoints:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, _ = _client()
|
||||
|
||||
def test_list_disabled_and_create_blocked(self):
|
||||
data = self.client.get("/api/webhooks").json()
|
||||
assert data["enabled"] is False
|
||||
r = self.client.post("/api/webhooks", json={"name": "gh", "deliver": "log"})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
class TestOpsEndpoints:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, _ = _client()
|
||||
|
||||
def test_hooks_list_reads_config(self):
|
||||
from hermes_cli.config import load_config, save_config
|
||||
|
||||
cfg = load_config()
|
||||
cfg["hooks"] = {
|
||||
"pre_tool_call": [
|
||||
{"matcher": "terminal", "command": "/bin/echo hi", "timeout": 5}
|
||||
]
|
||||
}
|
||||
save_config(cfg)
|
||||
data = self.client.get("/api/ops/hooks").json()
|
||||
assert data["hooks"][0]["command"] == "/bin/echo hi"
|
||||
assert "valid_events" in data and len(data["valid_events"]) >= 1
|
||||
|
||||
def test_hook_create_and_delete(self):
|
||||
# Create with consent approval.
|
||||
r = self.client.post(
|
||||
"/api/ops/hooks",
|
||||
json={
|
||||
"event": "pre_tool_call",
|
||||
"command": "/bin/echo created",
|
||||
"matcher": "terminal",
|
||||
"timeout": 7,
|
||||
"approve": True,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200 and r.json()["approved"] is True
|
||||
|
||||
hooks = self.client.get("/api/ops/hooks").json()["hooks"]
|
||||
created = [h for h in hooks if h["command"] == "/bin/echo created"]
|
||||
assert created and created[0]["allowed"] is True
|
||||
|
||||
# Unknown event rejected.
|
||||
assert self.client.post(
|
||||
"/api/ops/hooks", json={"event": "no_such_event", "command": "/x"}
|
||||
).status_code == 400
|
||||
|
||||
# Delete it.
|
||||
r = self.client.request(
|
||||
"DELETE",
|
||||
"/api/ops/hooks",
|
||||
json={"event": "pre_tool_call", "command": "/bin/echo created"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
hooks2 = self.client.get("/api/ops/hooks").json()["hooks"]
|
||||
assert not [h for h in hooks2 if h["command"] == "/bin/echo created"]
|
||||
|
||||
def test_checkpoints_list_empty(self):
|
||||
data = self.client.get("/api/ops/checkpoints").json()
|
||||
assert data == {"sessions": [], "total_bytes": 0}
|
||||
|
||||
def test_import_missing_archive_404(self):
|
||||
r = self.client.post("/api/ops/import", json={"archive": "/no/such.zip"})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
class TestSystemStatsEndpoint:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, _ = _client()
|
||||
|
||||
def test_stats_shape(self):
|
||||
r = self.client.get("/api/system/stats")
|
||||
assert r.status_code == 200
|
||||
s = r.json()
|
||||
# Identity fields always present (stdlib-sourced).
|
||||
for key in ("os", "arch", "hostname", "python_version", "hermes_version"):
|
||||
assert key in s and s[key]
|
||||
# psutil flag tells the UI whether the richer metrics are populated.
|
||||
assert "psutil" in s
|
||||
|
||||
|
||||
class TestCuratorEndpoints:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, _ = _client()
|
||||
|
||||
def test_status_and_pause_toggle(self):
|
||||
r = self.client.get("/api/curator")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert {"enabled", "paused", "interval_hours"} <= set(body)
|
||||
# Pause then resume; the read reflects the write.
|
||||
r = self.client.put("/api/curator/paused", json={"paused": True})
|
||||
assert r.status_code == 200 and r.json()["paused"] is True
|
||||
assert self.client.get("/api/curator").json()["paused"] is True
|
||||
r = self.client.put("/api/curator/paused", json={"paused": False})
|
||||
assert r.status_code == 200 and r.json()["paused"] is False
|
||||
|
||||
|
||||
class TestPortalEndpoint:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, _ = _client()
|
||||
|
||||
def test_status_shape(self):
|
||||
r = self.client.get("/api/portal")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert {"logged_in", "features", "subscription_url", "provider"} <= set(body)
|
||||
assert isinstance(body["features"], list)
|
||||
|
||||
|
||||
class TestSessionManagementEndpoints:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, _ = _client()
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
db.create_session(session_id="sess-x", source="cli")
|
||||
db.close()
|
||||
|
||||
def test_stats_not_shadowed_by_session_id_route(self):
|
||||
# /api/sessions/stats must resolve to the stats handler, not be captured
|
||||
# as {session_id}="stats" by the parameterized route registered after it.
|
||||
r = self.client.get("/api/sessions/stats")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert {"total", "active_store", "archived", "messages", "by_source"} <= set(body)
|
||||
assert body["total"] >= 1
|
||||
|
||||
def test_rename(self):
|
||||
r = self.client.patch("/api/sessions/sess-x", json={"title": "Renamed"})
|
||||
assert r.status_code == 200 and r.json()["title"] == "Renamed"
|
||||
|
||||
def test_export(self):
|
||||
r = self.client.get("/api/sessions/sess-x/export")
|
||||
assert r.status_code == 200 and "messages" in r.json()
|
||||
assert self.client.get("/api/sessions/nope/export").status_code == 404
|
||||
|
||||
def test_prune_validation(self):
|
||||
r = self.client.post("/api/sessions/prune", json={"older_than_days": 9999})
|
||||
assert r.status_code == 200 and "removed" in r.json()
|
||||
assert self.client.post(
|
||||
"/api/sessions/prune", json={"older_than_days": 0}
|
||||
).status_code == 400
|
||||
|
||||
|
||||
class TestSkillsHubSearchEndpoint:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, _ = _client()
|
||||
|
||||
def test_empty_query_returns_empty(self):
|
||||
# Empty query short-circuits (no network) and returns the enriched
|
||||
# empty shape (results + per-source counts + timeouts + installed map).
|
||||
r = self.client.get("/api/skills/hub/search?q=")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["results"] == []
|
||||
assert body["source_counts"] == {}
|
||||
assert body["timed_out"] == []
|
||||
assert body["installed"] == {}
|
||||
|
||||
|
||||
class _FakeMeta:
|
||||
"""Minimal SkillMeta stand-in for monkeypatched source search."""
|
||||
|
||||
def __init__(self, identifier, trust_level="community", source="github"):
|
||||
self.name = identifier.rsplit("/", 1)[-1]
|
||||
self.description = "desc"
|
||||
self.source = source
|
||||
self.identifier = identifier
|
||||
self.trust_level = trust_level
|
||||
self.repo = "owner/repo"
|
||||
self.tags = ["a", "b"]
|
||||
# Used by the preview endpoint's getattr() fallbacks.
|
||||
self.files = {}
|
||||
|
||||
|
||||
class _FakeBundle:
|
||||
def __init__(self, identifier, source="github", trust_level="community"):
|
||||
self.name = identifier.rsplit("/", 1)[-1]
|
||||
self.identifier = identifier
|
||||
self.source = source
|
||||
self.trust_level = trust_level
|
||||
self.description = "desc"
|
||||
self.repo = "owner/repo"
|
||||
self.tags = ["a", "b"]
|
||||
# Mix str + bytes to exercise the decode-or-placeholder branch.
|
||||
self.files = {
|
||||
"SKILL.md": b"---\nname: x\n---\nbody text",
|
||||
"icon.png": b"\xff\xd8\xff\xe0binary",
|
||||
"notes.txt": "plain string content",
|
||||
}
|
||||
self.metadata = {}
|
||||
|
||||
|
||||
class TestSkillsHubSourcesEndpoint:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, _ = _client()
|
||||
|
||||
def test_sources_lists_configured_hubs(self, monkeypatch):
|
||||
# The endpoint should enumerate the configured hub sources without
|
||||
# requiring any live network — monkeypatch the router.
|
||||
class _Src:
|
||||
is_available = False
|
||||
|
||||
def __init__(self, sid):
|
||||
self._sid = sid
|
||||
|
||||
def source_id(self):
|
||||
return self._sid
|
||||
|
||||
def search(self, q, limit=10):
|
||||
return [_FakeMeta("hermes-index/featured-skill", "trusted")]
|
||||
|
||||
def _fake_router():
|
||||
srcs = [_Src("official"), _Src("github")]
|
||||
# hermes-index source advertises availability + featured search.
|
||||
idx = _Src("hermes-index")
|
||||
idx.is_available = True
|
||||
srcs.insert(1, idx)
|
||||
return srcs
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tools.skills_hub.create_source_router", _fake_router
|
||||
)
|
||||
r = self.client.get("/api/skills/hub/sources")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
ids = {s["id"] for s in body["sources"]}
|
||||
assert {"official", "github", "hermes-index"} <= ids
|
||||
# Every source carries a human label.
|
||||
assert all(s.get("label") for s in body["sources"])
|
||||
assert body["index_available"] is True
|
||||
# Featured pulled from the index (zero extra API calls).
|
||||
assert len(body["featured"]) == 1
|
||||
assert body["featured"][0]["trust_level"] == "trusted"
|
||||
assert isinstance(body["installed"], dict)
|
||||
|
||||
|
||||
class TestSkillsHubPreviewEndpoint:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, _ = _client()
|
||||
|
||||
def test_preview_requires_identifier(self):
|
||||
r = self.client.get("/api/skills/hub/preview?identifier=")
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_preview_returns_skill_md_text(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"tools.skills_hub.create_source_router", lambda: []
|
||||
)
|
||||
bundle = _FakeBundle("github/owner/repo/x")
|
||||
meta = _FakeMeta("github/owner/repo/x")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.skills_hub._resolve_source_meta_and_bundle",
|
||||
lambda ident, sources: (meta, bundle, None),
|
||||
)
|
||||
r = self.client.get(
|
||||
"/api/skills/hub/preview?identifier=github/owner/repo/x"
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
# Bytes-stored SKILL.md decodes to text.
|
||||
assert "body text" in body["skill_md"]
|
||||
# Binary file is masked, text files decode.
|
||||
assert "icon.png" in body["files"]
|
||||
assert sorted(body["files"]) == ["SKILL.md", "icon.png", "notes.txt"]
|
||||
|
||||
def test_preview_404_when_unresolved(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"tools.skills_hub.create_source_router", lambda: []
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.skills_hub._resolve_source_meta_and_bundle",
|
||||
lambda ident, sources: (None, None, None),
|
||||
)
|
||||
r = self.client.get("/api/skills/hub/preview?identifier=nope/x")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
class TestSkillsHubScanEndpoint:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, _ = _client()
|
||||
|
||||
def test_scan_requires_identifier(self):
|
||||
r = self.client.get("/api/skills/hub/scan?identifier=")
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_scan_returns_verdict_and_policy(self, monkeypatch):
|
||||
from tools.skills_guard import ScanResult, Finding
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tools.skills_hub.create_source_router", lambda: []
|
||||
)
|
||||
bundle = _FakeBundle("github/owner/repo/x", trust_level="community")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.skills_hub._resolve_source_meta_and_bundle",
|
||||
lambda ident, sources: (None, bundle, None),
|
||||
)
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tools.skills_hub.quarantine_bundle", lambda b: Path("/tmp/_fake_q")
|
||||
)
|
||||
|
||||
fake_result = ScanResult(
|
||||
skill_name="x",
|
||||
source="github/owner/repo/x",
|
||||
trust_level="community",
|
||||
verdict="caution",
|
||||
findings=[
|
||||
Finding(
|
||||
pattern_id="p",
|
||||
severity="high",
|
||||
category="exfiltration",
|
||||
file="SKILL.md",
|
||||
line=10,
|
||||
match="m",
|
||||
description="leaks data",
|
||||
)
|
||||
],
|
||||
summary="s",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"tools.skills_guard.scan_skill",
|
||||
lambda path, source="community": fake_result,
|
||||
)
|
||||
# Avoid touching the filesystem during cleanup.
|
||||
monkeypatch.setattr("shutil.rmtree", lambda *a, **k: None)
|
||||
|
||||
r = self.client.get(
|
||||
"/api/skills/hub/scan?identifier=github/owner/repo/x"
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["verdict"] == "caution"
|
||||
assert body["trust_level"] == "community"
|
||||
# community + caution => blocked by install policy.
|
||||
assert body["policy"] == "block"
|
||||
assert body["severity_counts"]["high"] == 1
|
||||
assert body["findings"][0]["category"] == "exfiltration"
|
||||
assert body["findings"][0]["file"] == "SKILL.md"
|
||||
|
||||
def test_scan_404_when_no_bundle(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"tools.skills_hub.create_source_router", lambda: []
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.skills_hub._resolve_source_meta_and_bundle",
|
||||
lambda ident, sources: (None, None, None),
|
||||
)
|
||||
r = self.client.get("/api/skills/hub/scan?identifier=nope/x")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
|
||||
|
||||
class TestWebhookToggleEndpoint:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, _ = _client()
|
||||
# Enable the webhook platform so a subscription can be created.
|
||||
from hermes_cli.config import load_config, save_config
|
||||
|
||||
cfg = load_config()
|
||||
cfg.setdefault("platforms", {})["webhook"] = {
|
||||
"enabled": True,
|
||||
"extra": {"host": "0.0.0.0", "port": 8644},
|
||||
}
|
||||
save_config(cfg)
|
||||
|
||||
def test_create_toggle_disable(self):
|
||||
r = self.client.post(
|
||||
"/api/webhooks", json={"name": "hook1", "deliver": "log", "events": ["push"]}
|
||||
)
|
||||
assert r.status_code == 200 and r.json()["enabled"] is True
|
||||
r = self.client.put("/api/webhooks/hook1/enabled", json={"enabled": False})
|
||||
assert r.status_code == 200 and r.json()["enabled"] is False
|
||||
subs = self.client.get("/api/webhooks").json()["subscriptions"]
|
||||
assert subs[0]["enabled"] is False
|
||||
assert self.client.put(
|
||||
"/api/webhooks/nope/enabled", json={"enabled": True}
|
||||
).status_code == 404
|
||||
|
||||
|
||||
|
||||
class TestAdminEndpointsAuthGate:
|
||||
"""Every admin endpoint must sit behind the dashboard session-token gate."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
from starlette.testclient import TestClient
|
||||
from hermes_cli.web_server import app
|
||||
|
||||
# No session header → must be rejected.
|
||||
self.client = TestClient(app)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
[
|
||||
"/api/mcp/servers",
|
||||
"/api/pairing",
|
||||
"/api/webhooks",
|
||||
"/api/credentials/pool",
|
||||
"/api/memory",
|
||||
"/api/ops/hooks",
|
||||
"/api/ops/checkpoints",
|
||||
"/api/curator",
|
||||
"/api/portal",
|
||||
"/api/system/stats",
|
||||
"/api/hermes/update/check",
|
||||
],
|
||||
)
|
||||
def test_gated(self, path):
|
||||
resp = self.client.get(path)
|
||||
assert resp.status_code in (401, 403)
|
||||
|
||||
|
||||
class TestUpdateCheckEndpoint:
|
||||
"""``GET /api/hermes/update/check`` reports availability without applying.
|
||||
|
||||
Powers the dashboard's check-before-you-update flow: the System page
|
||||
shows the commit-behind count and asks the user to confirm before
|
||||
``POST /api/hermes/update`` runs ``hermes update``.
|
||||
"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, _ = _client()
|
||||
|
||||
def test_git_install_reports_behind_count(self, monkeypatch):
|
||||
import hermes_cli.web_server as ws
|
||||
|
||||
monkeypatch.setattr(ws, "detect_install_method", lambda *a, **k: "git")
|
||||
# Stub the shared checker so the contract is deterministic (no network).
|
||||
import hermes_cli.banner as banner
|
||||
|
||||
monkeypatch.setattr(banner, "check_for_updates", lambda: 5)
|
||||
|
||||
r = self.client.get("/api/hermes/update/check")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert {
|
||||
"install_method",
|
||||
"current_version",
|
||||
"behind",
|
||||
"update_available",
|
||||
"can_apply",
|
||||
"update_command",
|
||||
"message",
|
||||
} <= set(body)
|
||||
assert body["install_method"] == "git"
|
||||
assert body["behind"] == 5
|
||||
assert body["update_available"] is True
|
||||
# git/pip installs can apply the update in place from the dashboard.
|
||||
assert body["can_apply"] is True
|
||||
|
||||
def test_up_to_date(self, monkeypatch):
|
||||
import hermes_cli.web_server as ws
|
||||
import hermes_cli.banner as banner
|
||||
|
||||
monkeypatch.setattr(ws, "detect_install_method", lambda *a, **k: "git")
|
||||
monkeypatch.setattr(banner, "check_for_updates", lambda: 0)
|
||||
|
||||
body = self.client.get("/api/hermes/update/check").json()
|
||||
assert body["behind"] == 0
|
||||
assert body["update_available"] is False
|
||||
|
||||
def test_docker_is_not_applyable(self, monkeypatch):
|
||||
import hermes_cli.web_server as ws
|
||||
|
||||
monkeypatch.setattr(ws, "detect_install_method", lambda *a, **k: "docker")
|
||||
body = self.client.get("/api/hermes/update/check").json()
|
||||
# Docker images are immutable — the dashboard can't apply an update.
|
||||
assert body["can_apply"] is False
|
||||
assert body["message"]
|
||||
assert body["behind"] is None
|
||||
|
||||
def test_check_failure_is_soft(self, monkeypatch):
|
||||
import hermes_cli.web_server as ws
|
||||
import hermes_cli.banner as banner
|
||||
|
||||
monkeypatch.setattr(ws, "detect_install_method", lambda *a, **k: "git")
|
||||
|
||||
def _boom():
|
||||
raise RuntimeError("offline")
|
||||
|
||||
monkeypatch.setattr(banner, "check_for_updates", _boom)
|
||||
# A failed check must not 500 — it returns behind=null with guidance.
|
||||
r = self.client.get("/api/hermes/update/check")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["behind"] is None
|
||||
assert body["update_available"] is False
|
||||
assert body["message"]
|
||||
|
||||
def test_git_behind_includes_commits(self, monkeypatch):
|
||||
import hermes_cli.web_server as ws
|
||||
import hermes_cli.banner as banner
|
||||
|
||||
monkeypatch.setattr(ws, "detect_install_method", lambda *a, **k: "git")
|
||||
monkeypatch.setattr(banner, "check_for_updates", lambda: 3)
|
||||
monkeypatch.setattr(
|
||||
ws,
|
||||
"_recent_upstream_commits",
|
||||
lambda n=20: [
|
||||
{"sha": "abc1234", "summary": "feat: x", "author": "a", "at": 1},
|
||||
],
|
||||
)
|
||||
|
||||
body = self.client.get("/api/hermes/update/check").json()
|
||||
# The desktop overlay renders this as the "what's changed" list.
|
||||
assert isinstance(body["commits"], list)
|
||||
assert body["commits"][0]["sha"] == "abc1234"
|
||||
assert body["commits"][0]["summary"] == "feat: x"
|
||||
|
||||
def test_up_to_date_omits_commits(self, monkeypatch):
|
||||
import hermes_cli.web_server as ws
|
||||
import hermes_cli.banner as banner
|
||||
|
||||
monkeypatch.setattr(ws, "detect_install_method", lambda *a, **k: "git")
|
||||
monkeypatch.setattr(banner, "check_for_updates", lambda: 0)
|
||||
|
||||
body = self.client.get("/api/hermes/update/check").json()
|
||||
# No commits list when there's nothing to show (additive, non-breaking).
|
||||
assert body.get("commits", []) == []
|
||||
|
||||
|
||||
class TestDebugShareEndpoint:
|
||||
"""POST /api/ops/debug-share returns the paste URLs synchronously so the
|
||||
dashboard can render them as copyable links (not a backgrounded log tail)."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, self.header = _client()
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
logs = get_hermes_home() / "logs"
|
||||
logs.mkdir(parents=True, exist_ok=True)
|
||||
(logs / "agent.log").write_text("agent line\n")
|
||||
(logs / "errors.log").write_text("err line\n")
|
||||
(logs / "gateway.log").write_text("gw line\n")
|
||||
|
||||
def test_returns_structured_urls(self, monkeypatch):
|
||||
import hermes_cli.debug as dbg
|
||||
|
||||
count = [0]
|
||||
|
||||
def _upload(content, expiry_days=7):
|
||||
count[0] += 1
|
||||
return f"https://paste.rs/p{count[0]}"
|
||||
|
||||
monkeypatch.setattr(dbg, "upload_to_pastebin", _upload)
|
||||
monkeypatch.setattr(dbg, "_schedule_auto_delete", lambda *a, **k: None)
|
||||
monkeypatch.setattr(dbg, "_best_effort_sweep_expired_pastes", lambda: None)
|
||||
monkeypatch.setattr("hermes_cli.dump.run_dump", lambda a: None)
|
||||
|
||||
r = self.client.post("/api/ops/debug-share", json={"redact": True})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["ok"] is True
|
||||
assert "Report" in body["urls"]
|
||||
assert body["redacted"] is True
|
||||
assert body["auto_delete_seconds"] == 21600
|
||||
assert isinstance(body["failures"], list)
|
||||
|
||||
def test_redact_false_is_honored(self, monkeypatch):
|
||||
import hermes_cli.debug as dbg
|
||||
|
||||
monkeypatch.setattr(
|
||||
dbg, "upload_to_pastebin", lambda c, expiry_days=7: "https://paste.rs/x"
|
||||
)
|
||||
monkeypatch.setattr(dbg, "_schedule_auto_delete", lambda *a, **k: None)
|
||||
monkeypatch.setattr(dbg, "_best_effort_sweep_expired_pastes", lambda: None)
|
||||
monkeypatch.setattr("hermes_cli.dump.run_dump", lambda a: None)
|
||||
|
||||
r = self.client.post("/api/ops/debug-share", json={"redact": False})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["redacted"] is False
|
||||
|
||||
def test_default_body_redacts(self, monkeypatch):
|
||||
import hermes_cli.debug as dbg
|
||||
|
||||
monkeypatch.setattr(
|
||||
dbg, "upload_to_pastebin", lambda c, expiry_days=7: "https://paste.rs/x"
|
||||
)
|
||||
monkeypatch.setattr(dbg, "_schedule_auto_delete", lambda *a, **k: None)
|
||||
monkeypatch.setattr(dbg, "_best_effort_sweep_expired_pastes", lambda: None)
|
||||
monkeypatch.setattr("hermes_cli.dump.run_dump", lambda a: None)
|
||||
|
||||
# No JSON body at all — should default redact=True.
|
||||
r = self.client.post("/api/ops/debug-share")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["redacted"] is True
|
||||
|
||||
def test_upload_failure_returns_502(self, monkeypatch):
|
||||
import hermes_cli.debug as dbg
|
||||
|
||||
monkeypatch.setattr(
|
||||
dbg,
|
||||
"upload_to_pastebin",
|
||||
lambda c, expiry_days=7: (_ for _ in ()).throw(RuntimeError("down")),
|
||||
)
|
||||
monkeypatch.setattr(dbg, "_schedule_auto_delete", lambda *a, **k: None)
|
||||
monkeypatch.setattr(dbg, "_best_effort_sweep_expired_pastes", lambda: None)
|
||||
monkeypatch.setattr("hermes_cli.dump.run_dump", lambda a: None)
|
||||
|
||||
r = self.client.post("/api/ops/debug-share", json={"redact": True})
|
||||
assert r.status_code == 502
|
||||
|
||||
def test_requires_session_token(self):
|
||||
# Drop the token header and confirm the global auth gate rejects it.
|
||||
bare = self.client
|
||||
r = bare.post(
|
||||
"/api/ops/debug-share",
|
||||
json={"redact": True},
|
||||
headers={self.header: "wrong-token"},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
class TestToolsConfigEndpoints:
|
||||
"""Provider selection, API-key save, and post-setup spawn for toolsets —
|
||||
the dashboard surface that replicates the `hermes tools` configurator."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, self.header = _client()
|
||||
|
||||
def test_list_toolsets_shape(self):
|
||||
r = self.client.get("/api/tools/toolsets")
|
||||
assert r.status_code == 200
|
||||
rows = r.json()
|
||||
assert isinstance(rows, list) and rows
|
||||
row = rows[0]
|
||||
for k in ("name", "label", "enabled", "configured", "tools"):
|
||||
assert k in row
|
||||
|
||||
def test_toolset_config_provider_matrix(self):
|
||||
# `web` has a TOOL_CATEGORIES entry → providers list populated.
|
||||
r = self.client.get("/api/tools/toolsets/web/config")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["has_category"] is True
|
||||
assert isinstance(body["providers"], list)
|
||||
|
||||
def test_unknown_toolset_config_400(self):
|
||||
r = self.client.get("/api/tools/toolsets/not_a_toolset/config")
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_save_env_writes_key_and_validates_allowlist(self):
|
||||
from hermes_cli.config import get_env_value
|
||||
|
||||
cfg = self.client.get("/api/tools/toolsets/web/config").json()
|
||||
# Find a real env-var key from the visible provider matrix.
|
||||
key = None
|
||||
for prov in cfg["providers"]:
|
||||
for e in prov.get("env_vars", []):
|
||||
key = e["key"]
|
||||
break
|
||||
if key:
|
||||
break
|
||||
if not key:
|
||||
pytest.skip("no env-var-bearing web provider in this build")
|
||||
|
||||
r = self.client.put(
|
||||
"/api/tools/toolsets/web/env", json={"env": {key: "test-secret-123"}}
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert key in body["saved"]
|
||||
assert body["is_set"][key] is True
|
||||
# CLI-config parity: the key landed in the .env store the CLI reads.
|
||||
assert get_env_value(key) == "test-secret-123"
|
||||
|
||||
def test_save_env_rejects_unknown_key(self):
|
||||
r = self.client.put(
|
||||
"/api/tools/toolsets/web/env",
|
||||
json={"env": {"TOTALLY_BOGUS_KEY": "x"}},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_save_env_blank_value_skipped(self):
|
||||
cfg = self.client.get("/api/tools/toolsets/web/config").json()
|
||||
key = None
|
||||
for prov in cfg["providers"]:
|
||||
for e in prov.get("env_vars", []):
|
||||
key = e["key"]
|
||||
break
|
||||
if key:
|
||||
break
|
||||
if not key:
|
||||
pytest.skip("no env-var-bearing web provider in this build")
|
||||
r = self.client.put(
|
||||
"/api/tools/toolsets/web/env", json={"env": {key: " "}}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert key in r.json()["skipped"]
|
||||
|
||||
def test_post_setup_unknown_key_400(self):
|
||||
r = self.client.post(
|
||||
"/api/tools/toolsets/browser/post-setup", json={"key": "bogus"}
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_post_setup_unknown_toolset_400(self):
|
||||
r = self.client.post(
|
||||
"/api/tools/toolsets/not_a_toolset/post-setup",
|
||||
json={"key": "agent_browser"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_post_setup_spawns_action(self, monkeypatch):
|
||||
import hermes_cli.web_server as ws
|
||||
|
||||
spawned = {}
|
||||
|
||||
class _FakeProc:
|
||||
pid = 4321
|
||||
|
||||
def _fake_spawn(subcommand, name):
|
||||
spawned["subcommand"] = subcommand
|
||||
spawned["name"] = name
|
||||
return _FakeProc()
|
||||
|
||||
monkeypatch.setattr(ws, "_spawn_hermes_action", _fake_spawn)
|
||||
r = self.client.post(
|
||||
"/api/tools/toolsets/browser/post-setup",
|
||||
json={"key": "agent_browser"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["name"] == "tools-post-setup"
|
||||
assert body["pid"] == 4321
|
||||
assert spawned["subcommand"] == ["tools", "post-setup", "agent_browser"]
|
||||
|
||||
def test_endpoints_require_session_token(self):
|
||||
for method, path, payload in [
|
||||
("get", "/api/tools/toolsets/web/config", None),
|
||||
("put", "/api/tools/toolsets/web/env", {"env": {}}),
|
||||
("post", "/api/tools/toolsets/web/post-setup", {"key": "ddgs"}),
|
||||
]:
|
||||
fn = getattr(self.client, method)
|
||||
kwargs = {"headers": {self.header: "wrong-token"}}
|
||||
if payload is not None:
|
||||
kwargs["json"] = payload
|
||||
r = fn(path, **kwargs)
|
||||
assert r.status_code == 401, f"{method} {path} not gated"
|
||||
|
||||
@@ -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,259 @@
|
||||
"""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.run with a no-op recorder so start_server returns
|
||||
immediately (rather than blocking on the event loop). Returns the dict
|
||||
that will capture the keyword args."""
|
||||
import uvicorn
|
||||
captured: dict = {}
|
||||
|
||||
def _fake_run(*args, **kwargs):
|
||||
captured["args"] = args
|
||||
captured["kwargs"] = kwargs
|
||||
|
||||
monkeypatch.setattr(uvicorn, "run", _fake_run)
|
||||
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,98 @@
|
||||
"""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}"
|
||||
@@ -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"
|
||||
@@ -15,14 +15,14 @@ from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.main import cmd_dashboard, _report_dashboard_status
|
||||
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,
|
||||
tui=False, stop=False, status=False,
|
||||
stop=False, status=False,
|
||||
)
|
||||
defaults.update(kw)
|
||||
return argparse.Namespace(**defaults)
|
||||
|
||||
@@ -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
|
||||
@@ -1,10 +1,8 @@
|
||||
"""Tests for ``hermes debug`` CLI command and debug utilities."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch, call
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -33,6 +31,9 @@ def hermes_home(tmp_path, monkeypatch):
|
||||
(logs_dir / "gateway.log").write_text(
|
||||
"2026-04-12 17:00:10 INFO gateway.run: started\n"
|
||||
)
|
||||
(logs_dir / "desktop.log").write_text(
|
||||
"2026-04-12 17:00:15 INFO desktop: backend spawned\n"
|
||||
)
|
||||
|
||||
return home
|
||||
|
||||
@@ -337,7 +338,6 @@ class TestCaptureLogSnapshotRedaction:
|
||||
redaction feature ships silently broken for users who opted out of
|
||||
runtime redaction (e.g. developers working on the redactor itself).
|
||||
"""
|
||||
import os
|
||||
|
||||
# Force the runtime flag off so we're exercising the force=True path,
|
||||
# not the default-on path.
|
||||
@@ -353,6 +353,40 @@ class TestCaptureLogSnapshotRedaction:
|
||||
assert snap.full_text is not None
|
||||
assert _REDACT_FIXTURE_TOKEN not in snap.full_text
|
||||
|
||||
def test_default_redacts_email_addresses_for_public_share(
|
||||
self, hermes_home_with_secret
|
||||
):
|
||||
from hermes_cli.debug import _capture_log_snapshot
|
||||
|
||||
log_path = hermes_home_with_secret / "logs" / "agent.log"
|
||||
log_path.write_text(
|
||||
"2026-04-12 17:00:00 INFO gateway.run: "
|
||||
"inbound message: platform=bluebubbles "
|
||||
"user=person@example.com chat=iMessage;-;person@example.com msg='hello'\n"
|
||||
)
|
||||
|
||||
snap = _capture_log_snapshot("agent", tail_lines=10)
|
||||
|
||||
assert "person@example.com" not in snap.tail_text
|
||||
assert "[REDACTED_EMAIL]" in snap.tail_text
|
||||
assert snap.full_text is not None
|
||||
assert "person@example.com" not in snap.full_text
|
||||
|
||||
def test_no_redact_preserves_email_addresses(self, hermes_home_with_secret):
|
||||
from hermes_cli.debug import _capture_log_snapshot
|
||||
|
||||
log_path = hermes_home_with_secret / "logs" / "agent.log"
|
||||
log_path.write_text(
|
||||
"2026-04-12 17:00:00 INFO gateway.run: "
|
||||
"inbound message: platform=bluebubbles "
|
||||
"user=person@example.com chat=iMessage;-;person@example.com msg='hello'\n"
|
||||
)
|
||||
|
||||
snap = _capture_log_snapshot("agent", tail_lines=10, redact=False)
|
||||
|
||||
assert "person@example.com" in snap.tail_text
|
||||
assert "person@example.com" in (snap.full_text or "")
|
||||
|
||||
def test_capture_default_log_snapshots_threads_redact(
|
||||
self, hermes_home_with_secret
|
||||
):
|
||||
@@ -420,6 +454,15 @@ class TestCollectDebugReport:
|
||||
|
||||
assert "--- gateway.log" in report
|
||||
|
||||
def test_report_includes_desktop_log(self, hermes_home):
|
||||
from hermes_cli.debug import collect_debug_report
|
||||
|
||||
with patch("hermes_cli.dump.run_dump"):
|
||||
report = collect_debug_report(log_lines=50)
|
||||
|
||||
assert "--- desktop.log" in report
|
||||
assert "backend spawned" in report
|
||||
|
||||
def test_missing_logs_handled(self, tmp_path, monkeypatch):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
@@ -495,8 +538,8 @@ class TestRunDebugShare:
|
||||
assert "FULL agent.log" in out
|
||||
assert "FULL gateway.log" in out
|
||||
|
||||
def test_share_uploads_three_pastes(self, hermes_home, capsys):
|
||||
"""Successful share uploads report + agent.log + gateway.log."""
|
||||
def test_share_uploads_four_pastes(self, hermes_home, capsys):
|
||||
"""Successful share uploads report + agent.log + gateway.log + desktop.log."""
|
||||
from hermes_cli.debug import run_debug_share
|
||||
|
||||
args = MagicMock()
|
||||
@@ -518,14 +561,16 @@ class TestRunDebugShare:
|
||||
run_debug_share(args)
|
||||
|
||||
out = capsys.readouterr().out
|
||||
# Should have 3 uploads: report, agent.log, gateway.log
|
||||
assert call_count[0] == 3
|
||||
# Should have 4 uploads: report, agent.log, gateway.log, desktop.log
|
||||
assert call_count[0] == 4
|
||||
assert "paste.rs/paste1" in out # Report
|
||||
assert "paste.rs/paste2" in out # agent.log
|
||||
assert "paste.rs/paste3" in out # gateway.log
|
||||
assert "paste.rs/paste4" in out # desktop.log
|
||||
assert "Report" in out
|
||||
assert "agent.log" in out
|
||||
assert "gateway.log" in out
|
||||
assert "desktop.log" in out
|
||||
|
||||
# Each log paste should start with the dump header
|
||||
agent_paste = uploaded_content[1]
|
||||
@@ -534,6 +579,9 @@ class TestRunDebugShare:
|
||||
gateway_paste = uploaded_content[2]
|
||||
assert "--- hermes dump ---" in gateway_paste
|
||||
assert "--- full gateway.log ---" in gateway_paste
|
||||
desktop_paste = uploaded_content[3]
|
||||
assert "--- hermes dump ---" in desktop_paste
|
||||
assert "--- full desktop.log ---" in desktop_paste
|
||||
|
||||
def test_share_keeps_report_and_full_log_on_same_snapshot(self, hermes_home, capsys):
|
||||
"""A mid-run rotation must not make full agent.log older than the report."""
|
||||
@@ -1225,3 +1273,110 @@ class TestShareIncludesAutoDelete:
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "public paste service" not in out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_debug_share — structured core used by the dashboard endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildDebugShare:
|
||||
"""The shared core that returns structured paste URLs (not printed text).
|
||||
|
||||
Backs both ``hermes debug share`` (CLI) and ``POST /api/ops/debug-share``
|
||||
(dashboard). The dashboard renders ``urls`` as real, copyable links, so the
|
||||
contract here is the return value, not stdout.
|
||||
"""
|
||||
|
||||
def test_returns_structured_urls(self, hermes_home):
|
||||
from hermes_cli.debug import build_debug_share, DebugShareResult
|
||||
|
||||
count = [0]
|
||||
|
||||
def _upload(content, expiry_days=7):
|
||||
count[0] += 1
|
||||
return f"https://paste.rs/p{count[0]}"
|
||||
|
||||
with patch("hermes_cli.dump.run_dump"), patch(
|
||||
"hermes_cli.debug.upload_to_pastebin", side_effect=_upload
|
||||
), patch("hermes_cli.debug._schedule_auto_delete"):
|
||||
result = build_debug_share(log_lines=50, redact=True)
|
||||
|
||||
assert isinstance(result, DebugShareResult)
|
||||
# All four seeded logs (agent/gateway/desktop) + the summary report.
|
||||
assert "Report" in result.urls
|
||||
assert "agent.log" in result.urls
|
||||
assert "gateway.log" in result.urls
|
||||
assert "desktop.log" in result.urls
|
||||
assert result.failures == []
|
||||
assert result.redacted is True
|
||||
assert result.auto_delete_seconds == 21600
|
||||
|
||||
def test_skips_missing_logs_without_failure(self, hermes_home):
|
||||
from hermes_cli.debug import build_debug_share
|
||||
|
||||
# Remove desktop.log so it should be neither uploaded nor reported failed.
|
||||
(hermes_home / "logs" / "desktop.log").unlink()
|
||||
|
||||
with patch("hermes_cli.dump.run_dump"), patch(
|
||||
"hermes_cli.debug.upload_to_pastebin",
|
||||
side_effect=lambda c, expiry_days=7: "https://paste.rs/x",
|
||||
), patch("hermes_cli.debug._schedule_auto_delete"):
|
||||
result = build_debug_share(log_lines=50, redact=True)
|
||||
|
||||
assert "desktop.log" not in result.urls
|
||||
assert result.failures == []
|
||||
|
||||
def test_redaction_keeps_secrets_out_of_payload(self, hermes_home):
|
||||
from hermes_cli.debug import build_debug_share
|
||||
|
||||
secret = "sk-proj-SUPERSECRETtoken1234567890"
|
||||
(hermes_home / "logs" / "agent.log").write_text(
|
||||
f"line one\nauthorization token={secret}\nline three\n"
|
||||
)
|
||||
|
||||
uploaded = []
|
||||
|
||||
def _upload(content, expiry_days=7):
|
||||
uploaded.append(content)
|
||||
return "https://paste.rs/x"
|
||||
|
||||
with patch("hermes_cli.dump.run_dump"), patch(
|
||||
"hermes_cli.debug.upload_to_pastebin", side_effect=_upload
|
||||
), patch("hermes_cli.debug._schedule_auto_delete"):
|
||||
result = build_debug_share(log_lines=50, redact=True)
|
||||
|
||||
assert result.redacted is True
|
||||
joined = "\n".join(uploaded)
|
||||
assert secret not in joined, "secret leaked into upload payload"
|
||||
|
||||
def test_optional_log_failure_is_collected_not_raised(self, hermes_home):
|
||||
from hermes_cli.debug import build_debug_share
|
||||
|
||||
count = [0]
|
||||
|
||||
def _upload(content, expiry_days=7):
|
||||
count[0] += 1
|
||||
# First call (the required Report) succeeds; a later one fails.
|
||||
if count[0] == 2:
|
||||
raise RuntimeError("paste service hiccup")
|
||||
return f"https://paste.rs/p{count[0]}"
|
||||
|
||||
with patch("hermes_cli.dump.run_dump"), patch(
|
||||
"hermes_cli.debug.upload_to_pastebin", side_effect=_upload
|
||||
), patch("hermes_cli.debug._schedule_auto_delete"):
|
||||
result = build_debug_share(log_lines=50, redact=True)
|
||||
|
||||
assert "Report" in result.urls
|
||||
assert len(result.failures) == 1
|
||||
assert "paste service hiccup" in result.failures[0]
|
||||
|
||||
def test_required_report_failure_raises(self, hermes_home):
|
||||
from hermes_cli.debug import build_debug_share
|
||||
|
||||
with patch("hermes_cli.dump.run_dump"), patch(
|
||||
"hermes_cli.debug.upload_to_pastebin",
|
||||
side_effect=RuntimeError("all paste services down"),
|
||||
), patch("hermes_cli.debug._schedule_auto_delete"):
|
||||
with pytest.raises(RuntimeError, match="all paste services down"):
|
||||
build_debug_share(log_lines=50, redact=True)
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Tests for the configurable default interface (cli vs tui).
|
||||
|
||||
`hermes` launches the classic prompt_toolkit REPL by default, but users can
|
||||
flip ``display.interface: tui`` in config.yaml to make the modern Ink TUI the
|
||||
default for bare ``hermes`` / ``hermes chat``. Explicit flags always win:
|
||||
|
||||
--cli forces the classic REPL (highest precedence)
|
||||
--tui / HERMES_TUI=1 forces the TUI
|
||||
display.interface the configured default
|
||||
(unset) classic REPL
|
||||
|
||||
These tests pin that precedence at every layer that makes the decision:
|
||||
|
||||
* ``_resolve_use_tui(args)`` — the canonical args-aware resolver used by
|
||||
``cmd_chat`` and the Termux fast-TUI path.
|
||||
* ``_wants_tui_early(argv)`` — the dependency-free early resolver used by
|
||||
mouse-residue suppression and the Termux fast paths, before argparse and
|
||||
``hermes_cli.config`` are importable.
|
||||
* the argument parser — both ``--cli`` and ``--tui`` parse at the top
|
||||
level and under the ``chat`` subcommand and are relaunch-inherited.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import main as m
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_early_cache(monkeypatch):
|
||||
# The early resolver memoizes the config read; clear it so each test sees
|
||||
# a fresh value, and make sure no stray HERMES_TUI leaks in.
|
||||
monkeypatch.setattr(m, "_EARLY_INTERFACE_CACHE", None)
|
||||
monkeypatch.delenv("HERMES_TUI", raising=False)
|
||||
yield
|
||||
monkeypatch.setattr(m, "_EARLY_INTERFACE_CACHE", None)
|
||||
|
||||
|
||||
def _args(**kw):
|
||||
kw.setdefault("cli", False)
|
||||
kw.setdefault("tui", False)
|
||||
return SimpleNamespace(**kw)
|
||||
|
||||
|
||||
def _patch_config(monkeypatch, interface):
|
||||
import hermes_cli.config as cfg
|
||||
|
||||
monkeypatch.setattr(
|
||||
cfg, "load_config", lambda: {"display": {"interface": interface}}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _resolve_use_tui — args-aware resolver
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestResolveUseTui:
|
||||
def test_cli_flag_beats_config_tui(self, monkeypatch):
|
||||
_patch_config(monkeypatch, "tui")
|
||||
assert m._resolve_use_tui(_args(cli=True)) is False
|
||||
|
||||
def test_cli_flag_beats_tui_flag_and_env(self, monkeypatch):
|
||||
_patch_config(monkeypatch, "tui")
|
||||
monkeypatch.setenv("HERMES_TUI", "1")
|
||||
assert m._resolve_use_tui(_args(cli=True, tui=True)) is False
|
||||
|
||||
def test_tui_flag_beats_config_cli(self, monkeypatch):
|
||||
_patch_config(monkeypatch, "cli")
|
||||
assert m._resolve_use_tui(_args(tui=True)) is True
|
||||
|
||||
def test_env_beats_config_cli(self, monkeypatch):
|
||||
_patch_config(monkeypatch, "cli")
|
||||
monkeypatch.setenv("HERMES_TUI", "1")
|
||||
assert m._resolve_use_tui(_args()) is True
|
||||
|
||||
def test_config_tui_with_no_flags(self, monkeypatch):
|
||||
_patch_config(monkeypatch, "tui")
|
||||
assert m._resolve_use_tui(_args()) is True
|
||||
|
||||
def test_config_cli_is_default(self, monkeypatch):
|
||||
_patch_config(monkeypatch, "cli")
|
||||
assert m._resolve_use_tui(_args()) is False
|
||||
|
||||
def test_interface_value_is_case_insensitive(self, monkeypatch):
|
||||
_patch_config(monkeypatch, "TUI")
|
||||
assert m._resolve_use_tui(_args()) is True
|
||||
|
||||
def test_load_config_failure_falls_back_to_cli(self, monkeypatch):
|
||||
import hermes_cli.config as cfg
|
||||
|
||||
def boom():
|
||||
raise RuntimeError("config unreadable")
|
||||
|
||||
monkeypatch.setattr(cfg, "load_config", boom)
|
||||
assert m._resolve_use_tui(_args()) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _wants_tui_early — dependency-free early resolver
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestWantsTuiEarly:
|
||||
@pytest.fixture
|
||||
def home_with_interface(self, tmp_path, monkeypatch):
|
||||
def _make(interface):
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
f"display:\n interface: {interface}\n"
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setattr(m, "_EARLY_INTERFACE_CACHE", None)
|
||||
|
||||
return _make
|
||||
|
||||
def test_config_tui_bare_argv(self, home_with_interface):
|
||||
home_with_interface("tui")
|
||||
assert m._wants_tui_early([]) is True
|
||||
|
||||
def test_cli_flag_overrides_config_tui(self, home_with_interface):
|
||||
home_with_interface("tui")
|
||||
assert m._wants_tui_early(["--cli"]) is False
|
||||
|
||||
def test_tui_flag_with_config_cli(self, home_with_interface):
|
||||
home_with_interface("cli")
|
||||
assert m._wants_tui_early(["--tui"]) is True
|
||||
|
||||
def test_env_with_config_cli(self, home_with_interface, monkeypatch):
|
||||
home_with_interface("cli")
|
||||
monkeypatch.setenv("HERMES_TUI", "1")
|
||||
assert m._wants_tui_early([]) is True
|
||||
|
||||
def test_config_cli_bare_argv(self, home_with_interface):
|
||||
home_with_interface("cli")
|
||||
assert m._wants_tui_early([]) is False
|
||||
|
||||
def test_missing_config_defaults_to_cli(self, tmp_path, monkeypatch):
|
||||
# HERMES_HOME points at an empty dir — no config.yaml.
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setattr(m, "_EARLY_INTERFACE_CACHE", None)
|
||||
assert m._wants_tui_early([]) is False
|
||||
|
||||
def test_unreadable_config_defaults_to_cli(self, tmp_path, monkeypatch):
|
||||
# Garbage YAML must not crash the hot path; falls back to cli.
|
||||
(tmp_path / "config.yaml").write_text("this: : : not valid yaml\n")
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setattr(m, "_EARLY_INTERFACE_CACHE", None)
|
||||
assert m._wants_tui_early([]) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# argument parser — flags exist at both levels and are relaunch-inherited
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestParserFlags:
|
||||
def _parser(self):
|
||||
from hermes_cli._parser import build_top_level_parser
|
||||
|
||||
parser, _subparsers, _chat = build_top_level_parser()
|
||||
return parser
|
||||
|
||||
def test_top_level_cli_flag(self):
|
||||
args = self._parser().parse_args(["--cli"])
|
||||
assert args.cli is True and args.tui is False
|
||||
|
||||
def test_top_level_tui_flag(self):
|
||||
args = self._parser().parse_args(["--tui"])
|
||||
assert args.tui is True and args.cli is False
|
||||
|
||||
def test_chat_subcommand_cli_flag(self):
|
||||
args = self._parser().parse_args(["chat", "--cli"])
|
||||
assert args.cli is True
|
||||
|
||||
def test_chat_subcommand_tui_flag(self):
|
||||
args = self._parser().parse_args(["chat", "--tui"])
|
||||
assert args.tui is True
|
||||
|
||||
def test_cli_and_tui_are_relaunch_inherited(self):
|
||||
from hermes_cli.relaunch import _INHERITED_FLAGS_TABLE
|
||||
|
||||
inherited = {flag for flag, _takes_value in _INHERITED_FLAGS_TABLE}
|
||||
assert "--cli" in inherited
|
||||
assert "--tui" in inherited
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# config default — shipped default preserves classic behavior
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_default_config_interface_is_cli():
|
||||
from hermes_cli.config import DEFAULT_CONFIG
|
||||
|
||||
assert DEFAULT_CONFIG["display"]["interface"] == "cli"
|
||||
@@ -1,4 +1,3 @@
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Tests for warn_deprecated_cwd_env_vars() migration warning."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
|
||||
|
||||
class TestDeprecatedCwdWarning:
|
||||
|
||||
+134
-35
@@ -253,38 +253,6 @@ def test_check_gateway_service_linger_skips_when_service_not_installed(monkeypat
|
||||
assert issues == []
|
||||
|
||||
|
||||
def test_doctor_reports_vercel_backend_diagnostics(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
|
||||
monkeypatch.setenv("TERMINAL_VERCEL_RUNTIME", "python3.13")
|
||||
monkeypatch.setenv("TERMINAL_CONTAINER_DISK", "2048")
|
||||
monkeypatch.setenv("VERCEL_TOKEN", "super-secret-value")
|
||||
monkeypatch.delenv("VERCEL_PROJECT_ID", raising=False)
|
||||
monkeypatch.setenv("VERCEL_TEAM_ID", "team")
|
||||
monkeypatch.setattr(doctor_mod.importlib.util, "find_spec", lambda name: object() if name == "vercel" else None)
|
||||
|
||||
fake_model_tools = types.SimpleNamespace(
|
||||
check_tool_availability=lambda *a, **kw: ([], []),
|
||||
TOOLSET_REQUIREMENTS={},
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
|
||||
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
doctor_mod.run_doctor(Namespace(fix=False))
|
||||
|
||||
out = buf.getvalue()
|
||||
assert "Vercel runtime" in out
|
||||
assert "python3.13" in out
|
||||
assert "Vercel custom disk unsupported" in out
|
||||
assert "Vercel auth incomplete" in out
|
||||
assert "VERCEL_PROJECT_ID" in out
|
||||
assert "Vercel auth mode: incomplete access token" in out
|
||||
assert "Vercel auth present env: VERCEL_TOKEN, VERCEL_TEAM_ID" in out
|
||||
assert "Vercel auth missing env: VERCEL_PROJECT_ID" in out
|
||||
assert "super-secret-value" not in out
|
||||
assert "snapshot filesystem only" in out
|
||||
|
||||
|
||||
# ── Memory provider section (doctor should only check the *active* provider) ──
|
||||
|
||||
|
||||
@@ -522,7 +490,6 @@ def test_run_doctor_flags_missing_credentials_for_active_openrouter_provider(mon
|
||||
@pytest.mark.parametrize(
|
||||
("provider", "default_model"),
|
||||
[
|
||||
("ai-gateway", "anthropic/claude-sonnet-4.6"),
|
||||
("opencode-zen", "anthropic/claude-sonnet-4.6"),
|
||||
("kilocode", "anthropic/claude-sonnet-4.6"),
|
||||
("kimi-coding", "kimi-k2"),
|
||||
@@ -566,13 +533,61 @@ def test_run_doctor_accepts_hermes_provider_ids_that_catalog_aliases(
|
||||
out = buf.getvalue()
|
||||
assert f"model.provider '{provider}' is not a recognised provider" not in out
|
||||
assert f"model.provider '{provider}' is unknown" not in out
|
||||
if provider in {"ai-gateway", "opencode-zen", "kilocode"}:
|
||||
if provider in {"opencode-zen", "kilocode"}:
|
||||
assert (
|
||||
f"model.default '{default_model}' uses a vendor/model slug but provider is '{provider}'"
|
||||
not in out
|
||||
)
|
||||
|
||||
|
||||
def test_run_doctor_accepts_vendor_slugs_for_named_custom_provider(monkeypatch, tmp_path):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
(home / "config.yaml").write_text(
|
||||
"model:\n"
|
||||
" provider: custom:hpc-ai\n"
|
||||
" default: deepseek/deepseek-v4-flash\n"
|
||||
"custom_providers:\n"
|
||||
" - name: hpc-ai\n"
|
||||
" base_url: https://hpc-ai.example/v1\n"
|
||||
" api_key: test-key\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
|
||||
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
|
||||
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
|
||||
(tmp_path / "project").mkdir(exist_ok=True)
|
||||
|
||||
fake_model_tools = types.SimpleNamespace(
|
||||
check_tool_availability=lambda *a, **kw: ([], []),
|
||||
TOOLSET_REQUIREMENTS={},
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
|
||||
|
||||
try:
|
||||
from hermes_cli import auth as _auth_mod
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
doctor_mod.run_doctor(Namespace(fix=False))
|
||||
|
||||
out = buf.getvalue()
|
||||
assert "model.provider 'custom:hpc-ai' is not a recognised provider" not in out
|
||||
assert "model.provider 'custom:hpc-ai' is unknown" not in out
|
||||
assert (
|
||||
"model.default 'deepseek/deepseek-v4-flash' uses a vendor/model slug but provider is "
|
||||
"'custom:hpc-ai'"
|
||||
not in out
|
||||
)
|
||||
assert "Either set model.provider to 'openrouter', or drop the vendor prefix." not in out
|
||||
|
||||
|
||||
|
||||
|
||||
def test_run_doctor_accepts_kimi_coding_cn_provider(monkeypatch, tmp_path):
|
||||
@@ -825,7 +840,7 @@ class TestGitHubTokenCheck:
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setenv("PATH", "/nonexistent") # gh not found
|
||||
|
||||
from hermes_cli.doctor import run_doctor, _DHH
|
||||
from hermes_cli.doctor import run_doctor
|
||||
import io, contextlib
|
||||
|
||||
buf = io.StringIO()
|
||||
@@ -1307,3 +1322,87 @@ class TestDoctorCodexCliHintPlacement:
|
||||
minimax_idx = next(i for i, l in enumerate(lines) if "MiniMax OAuth" in l)
|
||||
assert self._hint_line() not in lines[minimax_idx - 1]
|
||||
assert minimax_idx + 1 >= len(lines) or self._hint_line() not in lines[minimax_idx + 1]
|
||||
|
||||
|
||||
class TestDoctorStaleMaxIterationsDrift:
|
||||
"""Regression for #17534: a stale HERMES_MAX_ITERATIONS in .env shadows
|
||||
agent.max_turns in config.yaml. The repro symptom is config.yaml saying
|
||||
400 while the gateway activity line reads N/90. Doctor must detect the
|
||||
drift, and `--fix` must remove the .env ghost (config.yaml wins).
|
||||
|
||||
The detector reads the .env FILE directly, NOT os.environ — the gateway
|
||||
startup bridge can already have overridden os.environ to the config value,
|
||||
so the ghost is only visible in the file.
|
||||
"""
|
||||
|
||||
def _run_config_section(self, monkeypatch, tmp_path, *, fix, ghost, cfg_turns,
|
||||
os_environ_value=None):
|
||||
import pathlib
|
||||
import contextlib
|
||||
import io
|
||||
from argparse import Namespace
|
||||
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir(parents=True)
|
||||
(hermes_home / "config.yaml").write_text(
|
||||
f"agent:\n max_turns: {cfg_turns}\n", encoding="utf-8"
|
||||
)
|
||||
env_lines = ["OPENAI_API_KEY=sk-test\n"]
|
||||
if ghost is not None:
|
||||
env_lines.append(f"HERMES_MAX_ITERATIONS={ghost}\n")
|
||||
(hermes_home / ".env").write_text("".join(env_lines), encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(doctor_mod, "HERMES_HOME", hermes_home)
|
||||
monkeypatch.setattr(doctor_mod, "get_hermes_home", lambda: hermes_home)
|
||||
# Point the config helpers at the temp home.
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
if os_environ_value is not None:
|
||||
# Simulate the gateway bridge having already overridden os.environ.
|
||||
monkeypatch.setenv("HERMES_MAX_ITERATIONS", str(os_environ_value))
|
||||
else:
|
||||
monkeypatch.delenv("HERMES_MAX_ITERATIONS", raising=False)
|
||||
|
||||
# Short-circuit at the Tool Availability stage — the drift check runs
|
||||
# well before it in the Configuration Files section.
|
||||
fake_model_tools = types.SimpleNamespace(
|
||||
check_tool_availability=lambda *a, **kw: (_ for _ in ()).throw(SystemExit(0)),
|
||||
TOOLSET_REQUIREMENTS={},
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
|
||||
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf), pytest.raises(SystemExit):
|
||||
doctor_mod.run_doctor(Namespace(fix=fix))
|
||||
return buf.getvalue(), hermes_home
|
||||
|
||||
def test_detects_drift_warn_only(self, monkeypatch, tmp_path):
|
||||
out, hermes_home = self._run_config_section(
|
||||
monkeypatch, tmp_path, fix=False, ghost=90, cfg_turns=400,
|
||||
os_environ_value=400, # bridge contaminated os.environ
|
||||
)
|
||||
assert "HERMES_MAX_ITERATIONS=90" in out
|
||||
assert "shadows" in out
|
||||
# Warn-only must NOT mutate .env.
|
||||
assert "HERMES_MAX_ITERATIONS=90" in (hermes_home / ".env").read_text(encoding="utf-8")
|
||||
|
||||
def test_fix_removes_ghost(self, monkeypatch, tmp_path):
|
||||
out, hermes_home = self._run_config_section(
|
||||
monkeypatch, tmp_path, fix=True, ghost=90, cfg_turns=400,
|
||||
os_environ_value=400,
|
||||
)
|
||||
assert "Removed stale HERMES_MAX_ITERATIONS" in out
|
||||
env_after = (hermes_home / ".env").read_text(encoding="utf-8")
|
||||
assert "HERMES_MAX_ITERATIONS" not in env_after
|
||||
assert "OPENAI_API_KEY=sk-test" in env_after # other keys preserved
|
||||
|
||||
def test_no_drift_when_values_match(self, monkeypatch, tmp_path):
|
||||
out, _ = self._run_config_section(
|
||||
monkeypatch, tmp_path, fix=False, ghost=400, cfg_turns=400,
|
||||
)
|
||||
assert "shadows" not in out
|
||||
|
||||
def test_no_drift_when_ghost_absent(self, monkeypatch, tmp_path):
|
||||
out, _ = self._run_config_section(
|
||||
monkeypatch, tmp_path, fix=False, ghost=None, cfg_turns=400,
|
||||
)
|
||||
assert "shadows" not in out
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Tests for the Command Installation check in hermes doctor."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from argparse import Namespace
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Tests for hermes_cli.dump._get_git_commit — git SHA resolution for ``hermes dump``.
|
||||
|
||||
``hermes dump`` prints the running commit so support bug reports identify the
|
||||
exact version. Source installs resolve it live via ``git rev-parse``; the
|
||||
published Docker image excludes ``.git`` and falls back to the baked SHA
|
||||
written by the Dockerfile's ``HERMES_GIT_SHA`` build-arg.
|
||||
|
||||
These tests cover both paths plus the failure modes (no git, no baked file).
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
def test_get_git_commit_uses_live_git_when_available(tmp_path):
|
||||
"""Source install: ``git rev-parse --short=8 HEAD`` wins; no fallback."""
|
||||
from hermes_cli import dump
|
||||
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
|
||||
git_result = MagicMock(returncode=0, stdout="deadbeef\n")
|
||||
# build_info should NOT be consulted when live git succeeds.
|
||||
with patch("hermes_cli.dump.subprocess.run", return_value=git_result) as mock_run, \
|
||||
patch("hermes_cli.build_info.get_build_sha") as mock_build:
|
||||
commit = dump._get_git_commit(repo_dir)
|
||||
|
||||
assert commit == "deadbeef"
|
||||
mock_run.assert_called_once()
|
||||
mock_build.assert_not_called()
|
||||
|
||||
|
||||
def test_get_git_commit_falls_back_to_build_sha_when_live_git_fails(tmp_path):
|
||||
"""Docker image case: live git returns non-zero → use baked SHA."""
|
||||
from hermes_cli import dump
|
||||
|
||||
repo_dir = tmp_path / "no-git-here"
|
||||
repo_dir.mkdir()
|
||||
|
||||
failed = MagicMock(returncode=128, stdout="")
|
||||
with patch("hermes_cli.dump.subprocess.run", return_value=failed), \
|
||||
patch("hermes_cli.build_info.get_build_sha", return_value="cafef00d"):
|
||||
commit = dump._get_git_commit(repo_dir)
|
||||
|
||||
assert commit == "cafef00d"
|
||||
|
||||
|
||||
def test_get_git_commit_falls_back_when_git_returns_empty_stdout(tmp_path):
|
||||
"""Edge case: git exits 0 but prints nothing — still try the baked SHA."""
|
||||
from hermes_cli import dump
|
||||
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
|
||||
empty = MagicMock(returncode=0, stdout="\n")
|
||||
with patch("hermes_cli.dump.subprocess.run", return_value=empty), \
|
||||
patch("hermes_cli.build_info.get_build_sha", return_value="abcdef12"):
|
||||
commit = dump._get_git_commit(repo_dir)
|
||||
|
||||
assert commit == "abcdef12"
|
||||
|
||||
|
||||
def test_get_git_commit_falls_back_when_git_raises(tmp_path):
|
||||
"""git binary missing (e.g. minimal container w/o git) → baked SHA path."""
|
||||
from hermes_cli import dump
|
||||
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
|
||||
with patch("hermes_cli.dump.subprocess.run", side_effect=FileNotFoundError("git")), \
|
||||
patch("hermes_cli.build_info.get_build_sha", return_value="feedface"):
|
||||
commit = dump._get_git_commit(repo_dir)
|
||||
|
||||
assert commit == "feedface"
|
||||
|
||||
|
||||
def test_get_git_commit_returns_unknown_when_neither_source_available(tmp_path):
|
||||
"""Pip-installed wheel: no git, no baked SHA → '(unknown)' (legacy contract)."""
|
||||
from hermes_cli import dump
|
||||
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
|
||||
failed = MagicMock(returncode=128, stdout="")
|
||||
with patch("hermes_cli.dump.subprocess.run", return_value=failed), \
|
||||
patch("hermes_cli.build_info.get_build_sha", return_value=None):
|
||||
commit = dump._get_git_commit(repo_dir)
|
||||
|
||||
assert commit == "(unknown)"
|
||||
|
||||
|
||||
def test_get_git_commit_output_format_identical_between_sources(tmp_path):
|
||||
"""Regression guard: live-git and baked-SHA outputs share the same shape.
|
||||
|
||||
Ben explicitly asked for identical output between Docker and source installs
|
||||
so support tooling that parses ``hermes dump`` doesn't have to special-case
|
||||
container builds. Both paths must return a bare 8-char SHA — no prefix,
|
||||
no suffix, no annotation.
|
||||
"""
|
||||
from hermes_cli import dump
|
||||
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
|
||||
# Live-git path.
|
||||
git_result = MagicMock(returncode=0, stdout="b2f477a3\n")
|
||||
with patch("hermes_cli.dump.subprocess.run", return_value=git_result):
|
||||
live = dump._get_git_commit(repo_dir)
|
||||
|
||||
# Baked-SHA path.
|
||||
failed = MagicMock(returncode=128, stdout="")
|
||||
with patch("hermes_cli.dump.subprocess.run", return_value=failed), \
|
||||
patch("hermes_cli.build_info.get_build_sha", return_value="b2f477a3"):
|
||||
baked = dump._get_git_commit(repo_dir)
|
||||
|
||||
assert live == baked == "b2f477a3"
|
||||
# Same length, same charset — no decoration in either branch.
|
||||
assert len(live) == 8
|
||||
assert all(c in "0123456789abcdef" for c in live)
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Regression tests for #34107 — Docker UID/GID handling in ensure_hermes_home.
|
||||
|
||||
When Hermes runs in Docker with ``HERMES_UID=1000`` / ``HERMES_GID=911``,
|
||||
the entrypoint chowns the top-level ``HERMES_HOME`` once at startup. But
|
||||
subdirectories created at runtime by ``ensure_hermes_home()`` — especially
|
||||
for profile namespaces under ``profiles/<name>/`` spawned by kanban
|
||||
workers — were landing as ``root:root`` and blocking subsequent
|
||||
uid-mapped worker invocations with ``PermissionError [Errno 13]``.
|
||||
|
||||
The fix is a ``_chown_to_hermes_uid`` helper that reads the env vars and
|
||||
applies chown after ``mkdir``, invoked from ``_secure_dir`` (which already
|
||||
runs after every directory creation in the home-init path).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _resolve_hermes_uid_gid
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolveHermesUidGid:
|
||||
def test_returns_parsed_values_when_both_set(self, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_UID", "1000")
|
||||
monkeypatch.setenv("HERMES_GID", "911")
|
||||
from hermes_cli.config import _resolve_hermes_uid_gid
|
||||
uid, gid = _resolve_hermes_uid_gid()
|
||||
assert uid == 1000
|
||||
assert gid == 911
|
||||
|
||||
def test_returns_none_when_unset(self, monkeypatch):
|
||||
monkeypatch.delenv("HERMES_UID", raising=False)
|
||||
monkeypatch.delenv("HERMES_GID", raising=False)
|
||||
from hermes_cli.config import _resolve_hermes_uid_gid
|
||||
uid, gid = _resolve_hermes_uid_gid()
|
||||
assert uid is None
|
||||
assert gid is None
|
||||
|
||||
def test_uid_only_returns_gid_none(self, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_UID", "1000")
|
||||
monkeypatch.delenv("HERMES_GID", raising=False)
|
||||
from hermes_cli.config import _resolve_hermes_uid_gid
|
||||
uid, gid = _resolve_hermes_uid_gid()
|
||||
assert uid == 1000
|
||||
assert gid is None
|
||||
|
||||
def test_invalid_uid_returns_none_for_that_field(self, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_UID", "not-a-number")
|
||||
monkeypatch.setenv("HERMES_GID", "911")
|
||||
from hermes_cli.config import _resolve_hermes_uid_gid
|
||||
uid, gid = _resolve_hermes_uid_gid()
|
||||
assert uid is None
|
||||
assert gid == 911
|
||||
|
||||
def test_empty_string_treated_as_unset(self, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_UID", "")
|
||||
monkeypatch.setenv("HERMES_GID", "")
|
||||
from hermes_cli.config import _resolve_hermes_uid_gid
|
||||
uid, gid = _resolve_hermes_uid_gid()
|
||||
assert uid is None
|
||||
assert gid is None
|
||||
|
||||
def test_whitespace_padded_values(self, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_UID", " 1000 ")
|
||||
monkeypatch.setenv("HERMES_GID", " 911")
|
||||
from hermes_cli.config import _resolve_hermes_uid_gid
|
||||
uid, gid = _resolve_hermes_uid_gid()
|
||||
assert uid == 1000
|
||||
assert gid == 911
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific")
|
||||
def test_windows_returns_none_none(self, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_UID", "1000")
|
||||
monkeypatch.setenv("HERMES_GID", "911")
|
||||
from hermes_cli.config import _resolve_hermes_uid_gid
|
||||
uid, gid = _resolve_hermes_uid_gid()
|
||||
assert uid is None
|
||||
assert gid is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _chown_to_hermes_uid
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestChownToHermesUid:
|
||||
def test_calls_os_chown_when_both_set(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_UID", "1000")
|
||||
monkeypatch.setenv("HERMES_GID", "911")
|
||||
from hermes_cli import config as cfg
|
||||
|
||||
d = tmp_path / "subdir"
|
||||
d.mkdir()
|
||||
|
||||
with patch.object(cfg.os, "chown") as mock_chown:
|
||||
cfg._chown_to_hermes_uid(d)
|
||||
mock_chown.assert_called_once_with(d, 1000, 911)
|
||||
|
||||
def test_uses_minus_one_for_missing_field(self, tmp_path, monkeypatch):
|
||||
"""When only one env var is set, the other field passes -1 to
|
||||
os.chown which means 'do not change' on POSIX."""
|
||||
monkeypatch.setenv("HERMES_UID", "1000")
|
||||
monkeypatch.delenv("HERMES_GID", raising=False)
|
||||
from hermes_cli import config as cfg
|
||||
|
||||
d = tmp_path / "subdir"
|
||||
d.mkdir()
|
||||
|
||||
with patch.object(cfg.os, "chown") as mock_chown:
|
||||
cfg._chown_to_hermes_uid(d)
|
||||
mock_chown.assert_called_once_with(d, 1000, -1)
|
||||
|
||||
def test_no_op_when_neither_set(self, tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("HERMES_UID", raising=False)
|
||||
monkeypatch.delenv("HERMES_GID", raising=False)
|
||||
from hermes_cli import config as cfg
|
||||
|
||||
d = tmp_path / "subdir"
|
||||
d.mkdir()
|
||||
|
||||
with patch.object(cfg.os, "chown") as mock_chown:
|
||||
cfg._chown_to_hermes_uid(d)
|
||||
mock_chown.assert_not_called()
|
||||
|
||||
def test_eperm_is_silently_swallowed(self, tmp_path, monkeypatch):
|
||||
"""When running as non-root, os.chown raises EPERM. That's fine —
|
||||
the entrypoint's startup chown -R will pick it up on restart, and
|
||||
in most cases the dir was already correctly-owned by the calling
|
||||
user anyway."""
|
||||
monkeypatch.setenv("HERMES_UID", "1000")
|
||||
monkeypatch.setenv("HERMES_GID", "911")
|
||||
from hermes_cli import config as cfg
|
||||
|
||||
d = tmp_path / "subdir"
|
||||
d.mkdir()
|
||||
|
||||
def _raises_eperm(*args, **kwargs):
|
||||
raise PermissionError("operation not permitted")
|
||||
|
||||
with patch.object(cfg.os, "chown", side_effect=_raises_eperm):
|
||||
# Must not raise — the catch is non-fatal.
|
||||
cfg._chown_to_hermes_uid(d)
|
||||
|
||||
def test_attributeerror_swallowed_for_windows_compat(self, tmp_path, monkeypatch):
|
||||
"""os.chown doesn't exist on Windows. Catching AttributeError keeps
|
||||
the helper portable."""
|
||||
monkeypatch.setenv("HERMES_UID", "1000")
|
||||
monkeypatch.setenv("HERMES_GID", "911")
|
||||
from hermes_cli import config as cfg
|
||||
|
||||
d = tmp_path / "subdir"
|
||||
d.mkdir()
|
||||
|
||||
with patch.object(cfg.os, "chown", side_effect=AttributeError("no chown on this platform")):
|
||||
cfg._chown_to_hermes_uid(d) # must not raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end: _secure_dir now also chowns
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSecureDirChown:
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="chown is no-op on Windows")
|
||||
def test_secure_dir_invokes_chown_when_env_set(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_UID", "1000")
|
||||
monkeypatch.setenv("HERMES_GID", "911")
|
||||
from hermes_cli import config as cfg
|
||||
|
||||
d = tmp_path / "subdir"
|
||||
d.mkdir()
|
||||
|
||||
with patch.object(cfg.os, "chown") as mock_chown:
|
||||
cfg._secure_dir(d)
|
||||
mock_chown.assert_called_once_with(d, 1000, 911)
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="chown is no-op on Windows")
|
||||
def test_secure_dir_no_chown_when_env_unset(self, tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("HERMES_UID", raising=False)
|
||||
monkeypatch.delenv("HERMES_GID", raising=False)
|
||||
from hermes_cli import config as cfg
|
||||
|
||||
d = tmp_path / "subdir"
|
||||
d.mkdir()
|
||||
|
||||
with patch.object(cfg.os, "chown") as mock_chown:
|
||||
cfg._secure_dir(d)
|
||||
mock_chown.assert_not_called()
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Regression tests for hermes_cli._ensure_utf8().
|
||||
|
||||
Covers the crash class where the setup wizard (and other banner-printing
|
||||
commands) emit box-drawing characters and the ⚕ glyph, which raise
|
||||
UnicodeEncodeError when stdout/stderr are bound to a non-UTF-8 codec.
|
||||
|
||||
Historically the repair was gated on ``sys.platform == "win32"`` and only
|
||||
caught the Windows cp1252 case. Linux hosts with a latin-1 / C / POSIX locale
|
||||
(common on minimal Debian installs and Raspberry Pi) hit the identical crash
|
||||
in ``hermes setup`` because the repair returned early. See the Raspberry Pi
|
||||
report: latin-1 locale → UnicodeEncodeError before the wizard could start.
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
|
||||
import hermes_cli
|
||||
|
||||
|
||||
# The exact glyphs the setup wizard / banners print (setup.py ~line 2962+).
|
||||
_BANNER = "┌─────┐\n│ ⚕ Hermes │\n└─────┘"
|
||||
|
||||
|
||||
class _FakeStream:
|
||||
"""Minimal text stream backed by an in-memory byte buffer with a codec.
|
||||
|
||||
Mirrors how CPython binds sys.stdout to the locale encoding: writes that
|
||||
can't be encoded raise UnicodeEncodeError, just like a real latin-1 TTY.
|
||||
"""
|
||||
|
||||
def __init__(self, encoding, *, supports_reconfigure=True):
|
||||
self.encoding = encoding
|
||||
self._supports_reconfigure = supports_reconfigure
|
||||
self.errors = "strict"
|
||||
self._buf = io.BytesIO()
|
||||
|
||||
def write(self, s):
|
||||
self._buf.write(s.encode(self.encoding, self.errors))
|
||||
return len(s)
|
||||
|
||||
def flush(self):
|
||||
pass
|
||||
|
||||
def reconfigure(self, *, encoding=None, errors=None):
|
||||
if not self._supports_reconfigure:
|
||||
raise AttributeError("reconfigure")
|
||||
if encoding is not None:
|
||||
self.encoding = encoding
|
||||
if errors is not None:
|
||||
self.errors = errors
|
||||
|
||||
def getvalue(self):
|
||||
return self._buf.getvalue()
|
||||
|
||||
|
||||
def _run_with_streams(monkeypatch, out, err):
|
||||
monkeypatch.setattr(sys, "stdout", out, raising=False)
|
||||
monkeypatch.setattr(sys, "stderr", err, raising=False)
|
||||
hermes_cli._ensure_utf8()
|
||||
|
||||
|
||||
def test_latin1_stdout_is_repaired_to_utf8(monkeypatch):
|
||||
"""A latin-1 stdout (the Raspberry Pi case) becomes UTF-8 capable."""
|
||||
out = _FakeStream("latin-1")
|
||||
err = _FakeStream("latin-1")
|
||||
|
||||
# Sanity: before the fix, the banner cannot be encoded.
|
||||
try:
|
||||
out.write(_BANNER)
|
||||
pre_fix_crashes = False
|
||||
except UnicodeEncodeError:
|
||||
pre_fix_crashes = True
|
||||
assert pre_fix_crashes, "fixture should reproduce the original crash"
|
||||
|
||||
out = _FakeStream("latin-1")
|
||||
err = _FakeStream("latin-1")
|
||||
_run_with_streams(monkeypatch, out, err)
|
||||
|
||||
assert sys.stdout.encoding.lower().replace("-", "") == "utf8"
|
||||
assert sys.stderr.encoding.lower().replace("-", "") == "utf8"
|
||||
# The banner now encodes without raising.
|
||||
sys.stdout.write(_BANNER)
|
||||
assert "⚕".encode("utf-8") in sys.stdout.getvalue()
|
||||
|
||||
|
||||
def test_ascii_posix_locale_is_repaired(monkeypatch):
|
||||
"""C/POSIX locale resolves to ascii stdout — also must be repaired."""
|
||||
out = _FakeStream("ascii")
|
||||
err = _FakeStream("ascii")
|
||||
_run_with_streams(monkeypatch, out, err)
|
||||
assert sys.stdout.encoding.lower().replace("-", "") == "utf8"
|
||||
sys.stdout.write(_BANNER) # no raise
|
||||
|
||||
|
||||
def test_utf8_stream_left_untouched(monkeypatch):
|
||||
"""Already-UTF-8 streams are a no-op: object identity preserved AND the
|
||||
process environment is left untouched (no PYTHONUTF8/PYTHONIOENCODING
|
||||
burned in on a healthy UTF-8 host)."""
|
||||
out = _FakeStream("utf-8")
|
||||
err = _FakeStream("utf-8")
|
||||
sentinel_out, sentinel_err = out, err
|
||||
monkeypatch.delenv("PYTHONUTF8", raising=False)
|
||||
monkeypatch.delenv("PYTHONIOENCODING", raising=False)
|
||||
_run_with_streams(monkeypatch, out, err)
|
||||
assert sys.stdout is sentinel_out
|
||||
assert sys.stderr is sentinel_err
|
||||
# Healthy UTF-8 host: no environment mutation (minimal footprint).
|
||||
assert "PYTHONUTF8" not in os.environ
|
||||
assert "PYTHONIOENCODING" not in os.environ
|
||||
|
||||
|
||||
def test_repair_sets_child_process_env(monkeypatch):
|
||||
"""When a real repair happens, child-process UTF-8 hints are set."""
|
||||
monkeypatch.delenv("PYTHONUTF8", raising=False)
|
||||
monkeypatch.delenv("PYTHONIOENCODING", raising=False)
|
||||
_run_with_streams(monkeypatch, _FakeStream("latin-1"), _FakeStream("latin-1"))
|
||||
assert os.environ.get("PYTHONUTF8") == "1"
|
||||
assert os.environ.get("PYTHONIOENCODING") == "utf-8"
|
||||
|
||||
|
||||
def test_repair_does_not_override_explicit_env(monkeypatch):
|
||||
"""A user's explicit PYTHONIOENCODING is respected (setdefault, not set)."""
|
||||
monkeypatch.setenv("PYTHONIOENCODING", "utf-16")
|
||||
monkeypatch.delenv("PYTHONUTF8", raising=False)
|
||||
_run_with_streams(monkeypatch, _FakeStream("latin-1"), _FakeStream("latin-1"))
|
||||
assert os.environ["PYTHONIOENCODING"] == "utf-16"
|
||||
|
||||
|
||||
def test_fallback_when_reconfigure_unavailable(monkeypatch, tmp_path):
|
||||
"""Streams without reconfigure() fall back to reopening the fd as UTF-8."""
|
||||
real_path = tmp_path / "out.txt"
|
||||
fh = open(real_path, "w", encoding="latin-1")
|
||||
|
||||
class _NoReconfigure:
|
||||
"""latin-1 stream exposing a real fileno() but no reconfigure()."""
|
||||
|
||||
encoding = "latin-1"
|
||||
|
||||
def fileno(self):
|
||||
return fh.fileno()
|
||||
|
||||
stream = _NoReconfigure()
|
||||
monkeypatch.setattr(sys, "stdout", stream, raising=False)
|
||||
monkeypatch.setattr(sys, "stderr", stream, raising=False)
|
||||
hermes_cli._ensure_utf8()
|
||||
|
||||
# Replaced with a new UTF-8 stream object (not reconfigured in place).
|
||||
assert sys.stdout is not stream
|
||||
assert sys.stdout.encoding.lower().replace("-", "") == "utf8"
|
||||
sys.stdout.write(_BANNER)
|
||||
sys.stdout.flush()
|
||||
fh.close()
|
||||
assert "⚕".encode("utf-8") in real_path.read_bytes()
|
||||
|
||||
|
||||
def test_broken_stream_does_not_raise(monkeypatch):
|
||||
"""A stream whose repair raises must be swallowed, never crash import."""
|
||||
|
||||
class _Hostile:
|
||||
encoding = "latin-1"
|
||||
|
||||
def reconfigure(self, *a, **k):
|
||||
raise OSError("nope")
|
||||
|
||||
def fileno(self):
|
||||
raise OSError("no fd")
|
||||
|
||||
monkeypatch.setattr(sys, "stdout", _Hostile(), raising=False)
|
||||
monkeypatch.setattr(sys, "stderr", _Hostile(), raising=False)
|
||||
# Must not propagate.
|
||||
hermes_cli._ensure_utf8()
|
||||
|
||||
|
||||
def test_none_streams_do_not_raise(monkeypatch):
|
||||
"""pythonw / detached streams (sys.stdout is None) must be tolerated."""
|
||||
monkeypatch.setattr(sys, "stdout", None, raising=False)
|
||||
monkeypatch.setattr(sys, "stderr", None, raising=False)
|
||||
hermes_cli._ensure_utf8()
|
||||
@@ -1,7 +1,6 @@
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_cli.env_loader import load_hermes_dotenv
|
||||
|
||||
@@ -70,6 +69,23 @@ def test_user_env_takes_precedence_over_project_env(tmp_path, monkeypatch):
|
||||
assert os.getenv("OPENAI_API_KEY") == "project-key"
|
||||
|
||||
|
||||
def test_null_bytes_in_user_env_are_stripped(tmp_path, monkeypatch):
|
||||
home = tmp_path / "hermes"
|
||||
home.mkdir()
|
||||
env_file = home / ".env"
|
||||
# Null bytes can be introduced when copy-pasting API keys.
|
||||
env_file.write_text("GLM_API_KEY=abc\x00\x00\nOPENAI_API_KEY=sk-123\n", encoding="utf-8")
|
||||
|
||||
monkeypatch.delenv("GLM_API_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
|
||||
loaded = load_hermes_dotenv(hermes_home=home)
|
||||
|
||||
assert loaded == [env_file]
|
||||
assert os.getenv("GLM_API_KEY") == "abc"
|
||||
assert os.getenv("OPENAI_API_KEY") == "sk-123"
|
||||
|
||||
|
||||
def test_main_import_applies_user_env_over_shell_values(tmp_path, monkeypatch):
|
||||
home = tmp_path / "hermes"
|
||||
home.mkdir()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Tests for `hermes fallback` — chain reading, add/remove/clear, legacy migration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import types
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
@@ -55,6 +54,31 @@ class TestReadChain:
|
||||
{"provider": "nous", "model": "Hermes-4-Llama-3.1-405B"},
|
||||
]
|
||||
|
||||
def test_merges_new_and_legacy_formats(self):
|
||||
from hermes_cli.fallback_cmd import _read_chain
|
||||
cfg = {
|
||||
"fallback_providers": [
|
||||
{"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"},
|
||||
],
|
||||
"fallback_model": {"provider": "nous", "model": "Hermes-4"},
|
||||
}
|
||||
assert _read_chain(cfg) == [
|
||||
{"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"},
|
||||
{"provider": "nous", "model": "Hermes-4"},
|
||||
]
|
||||
|
||||
def test_legacy_duplicate_is_deduplicated_after_merge(self):
|
||||
from hermes_cli.fallback_cmd import _read_chain
|
||||
cfg = {
|
||||
"fallback_providers": [
|
||||
{"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"},
|
||||
],
|
||||
"fallback_model": {"provider": "OpenRouter", "model": "anthropic/claude-sonnet-4.6"},
|
||||
}
|
||||
assert _read_chain(cfg) == [
|
||||
{"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"},
|
||||
]
|
||||
|
||||
def test_migrates_legacy_single_dict(self):
|
||||
from hermes_cli.fallback_cmd import _read_chain
|
||||
cfg = {"fallback_model": {"provider": "openrouter", "model": "gpt-5.4"}}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import sys
|
||||
from types import ModuleType, SimpleNamespace
|
||||
from unittest.mock import patch, call
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -580,6 +579,28 @@ def test_find_gateway_pids_falls_back_to_pid_file_when_process_scan_fails(monkey
|
||||
assert gateway.find_gateway_pids() == [321]
|
||||
|
||||
|
||||
def test_scan_gateway_pids_detects_windows_hermes_exe_case_variants(monkeypatch):
|
||||
monkeypatch.setattr(gateway, "is_windows", lambda: True)
|
||||
monkeypatch.setattr(gateway, "_get_ancestor_pids", lambda: set())
|
||||
monkeypatch.setattr(gateway.shutil, "which", lambda name: "wmic.exe" if name == "wmic" else None)
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
if cmd[:4] == ["wmic.exe", "process", "get", "ProcessId,CommandLine"]:
|
||||
return SimpleNamespace(
|
||||
returncode=0,
|
||||
stdout=(
|
||||
"CommandLine=C:\\Program Files\\Hermes\\Hermes.EXE gateway run --replace\n"
|
||||
"ProcessId=2468\n\n"
|
||||
),
|
||||
stderr="",
|
||||
)
|
||||
raise AssertionError(f"Unexpected command: {cmd}")
|
||||
|
||||
monkeypatch.setattr(gateway.subprocess, "run", fake_run)
|
||||
|
||||
assert gateway._scan_gateway_pids(set(), all_profiles=True) == [2468]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _wait_for_gateway_exit
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -12,7 +12,6 @@ Currently:
|
||||
Windows path that works.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
class TestMatrixHiddenOnWindows:
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
"""Tests for gateway restart-loop defenses (#30719).
|
||||
|
||||
Covers:
|
||||
- Defense 1: gateway stop/restart refuse when _HERMES_GATEWAY=1
|
||||
- Defense 2: cron create rejects prompts containing gateway lifecycle commands
|
||||
- _contains_gateway_lifecycle_command pattern matching
|
||||
"""
|
||||
|
||||
import os
|
||||
from argparse import Namespace
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.cron import (
|
||||
_contains_gateway_lifecycle_command,
|
||||
cron_command,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Defense 2: _contains_gateway_lifecycle_command pattern tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGatewayLifecyclePattern:
|
||||
"""Verify the regex catches gateway lifecycle commands."""
|
||||
|
||||
@pytest.mark.parametrize("text", [
|
||||
"hermes gateway restart",
|
||||
"hermes gateway stop",
|
||||
"hermes gateway start",
|
||||
"hermes gateway restart", # double spaces
|
||||
"Hermez Gateway Restart".lower().replace("z", "s"), # case handled
|
||||
"HERMES GATEWAY RESTART", # uppercase
|
||||
])
|
||||
def test_hermes_gateway_commands(self, text):
|
||||
assert _contains_gateway_lifecycle_command(text), f"Should match: {text!r}"
|
||||
|
||||
@pytest.mark.parametrize("text", [
|
||||
"launchctl kickstart gui/501/ai.hermes.gateway",
|
||||
"launchctl unload ~/Library/LaunchAgents/ai.hermes.gateway.plist",
|
||||
"launchctl stop ai.hermes.gateway",
|
||||
"systemctl restart hermes-gateway",
|
||||
"systemctl stop hermes-gateway.service",
|
||||
"systemctl start hermes-gateway",
|
||||
])
|
||||
def test_service_manager_commands(self, text):
|
||||
assert _contains_gateway_lifecycle_command(text), f"Should match: {text!r}"
|
||||
|
||||
@pytest.mark.parametrize("text", [
|
||||
"kill hermes gateway process",
|
||||
"pkill -f hermes.*gateway",
|
||||
])
|
||||
def test_kill_commands(self, text):
|
||||
assert _contains_gateway_lifecycle_command(text), f"Should match: {text!r}"
|
||||
|
||||
@pytest.mark.parametrize("text", [
|
||||
"restart the server application",
|
||||
"hermes cron list",
|
||||
"hermes update",
|
||||
"hermes config set model claude",
|
||||
"echo 'just a normal cron job'",
|
||||
"run the backup script",
|
||||
"gateway is running fine",
|
||||
# Regression (#30728 follow-up): legit prompts that merely mention an
|
||||
# unrelated gateway + a restart must NOT be blocked.
|
||||
"Summarize the API gateway logs and report any restart events from last night",
|
||||
"Check if the payment gateway needs a restart after the deploy",
|
||||
"Monitor the gateway and tell me if a restart is recommended",
|
||||
])
|
||||
def test_safe_commands(self, text):
|
||||
assert not _contains_gateway_lifecycle_command(text), f"Should NOT match: {text!r}"
|
||||
|
||||
|
||||
class TestCronCreateLifecycleBlock:
|
||||
"""Verify cron create rejects gateway lifecycle prompts."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup_cron_dir(self, 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")
|
||||
|
||||
def test_block_hermes_gateway_restart(self, capsys):
|
||||
args = Namespace(
|
||||
cron_command="create",
|
||||
schedule="30m",
|
||||
prompt="Upgrade hermes then run hermes gateway restart",
|
||||
name=None,
|
||||
deliver=None,
|
||||
repeat=None,
|
||||
skill=None,
|
||||
skills=None,
|
||||
script=None,
|
||||
workdir=None,
|
||||
profile=None,
|
||||
no_agent=False,
|
||||
)
|
||||
rc = cron_command(args)
|
||||
assert rc == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "Blocked" in out
|
||||
assert "#30719" in out
|
||||
|
||||
def test_block_launchctl_kickstart(self, capsys):
|
||||
args = Namespace(
|
||||
cron_command="create",
|
||||
schedule="0 9 * * *",
|
||||
prompt="Run launchctl kickstart -k gui/501/ai.hermes.gateway",
|
||||
name=None,
|
||||
deliver=None,
|
||||
repeat=None,
|
||||
skill=None,
|
||||
skills=None,
|
||||
script=None,
|
||||
workdir=None,
|
||||
profile=None,
|
||||
no_agent=False,
|
||||
)
|
||||
rc = cron_command(args)
|
||||
assert rc == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "Blocked" in out
|
||||
|
||||
def test_block_script_with_lifecycle_command(self, tmp_path, capsys):
|
||||
script = tmp_path / "restart.sh"
|
||||
script.write_text("#!/bin/bash\nhermes gateway restart\n")
|
||||
args = Namespace(
|
||||
cron_command="create",
|
||||
schedule="1h",
|
||||
prompt=None,
|
||||
name=None,
|
||||
deliver=None,
|
||||
repeat=None,
|
||||
skill=None,
|
||||
skills=None,
|
||||
script=str(script),
|
||||
workdir=None,
|
||||
profile=None,
|
||||
no_agent=False,
|
||||
)
|
||||
rc = cron_command(args)
|
||||
assert rc == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "Blocked" in out
|
||||
|
||||
def test_allow_safe_prompt(self, capsys):
|
||||
args = Namespace(
|
||||
cron_command="create",
|
||||
schedule="30m",
|
||||
prompt="Check server health and report status",
|
||||
name=None,
|
||||
deliver=None,
|
||||
repeat=None,
|
||||
skill=None,
|
||||
skills=None,
|
||||
script=None,
|
||||
workdir=None,
|
||||
profile=None,
|
||||
no_agent=False,
|
||||
)
|
||||
rc = cron_command(args)
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "Created job" in out
|
||||
|
||||
def test_allow_empty_prompt(self, capsys):
|
||||
"""Empty prompt (no lifecycle content) should pass the filter — the
|
||||
API will still reject it for lacking prompt+skill, but that's a
|
||||
separate validation, not the lifecycle guard."""
|
||||
args = Namespace(
|
||||
cron_command="create",
|
||||
schedule="30m",
|
||||
prompt=None,
|
||||
name=None,
|
||||
deliver=None,
|
||||
repeat=None,
|
||||
skill=None,
|
||||
skills=None,
|
||||
script=None,
|
||||
workdir=None,
|
||||
profile=None,
|
||||
no_agent=False,
|
||||
)
|
||||
rc = cron_command(args)
|
||||
# The lifecycle guard passes (no gateway command in prompt).
|
||||
# The API rejects it for "requires prompt or skill" → rc 1, but
|
||||
# the error message is about prompt/skill, NOT about "Blocked".
|
||||
out = capsys.readouterr().out
|
||||
assert "Blocked" not in out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Defense 1: gateway stop/restart refuse inside gateway
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGatewaySelfTargetingGuard:
|
||||
"""Verify hermes gateway stop/restart refuse when _HERMES_GATEWAY=1."""
|
||||
|
||||
def test_stop_refuses_inside_gateway(self, monkeypatch):
|
||||
monkeypatch.setenv("_HERMES_GATEWAY", "1")
|
||||
from hermes_cli.gateway import gateway_command
|
||||
args = Namespace(gateway_command="stop", all=False, system=False)
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
gateway_command(args)
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
def test_restart_refuses_inside_gateway(self, monkeypatch):
|
||||
monkeypatch.setenv("_HERMES_GATEWAY", "1")
|
||||
from hermes_cli.gateway import gateway_command
|
||||
args = Namespace(gateway_command="restart", all=False, system=False)
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
gateway_command(args)
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
def test_stop_allows_outside_gateway(self, monkeypatch):
|
||||
# With the gateway marker unset, the self-targeting guard must NOT
|
||||
# fire. Prove control reaches the real stop path (rather than driving
|
||||
# real signal delivery, which would trip the live-system guard) by
|
||||
# short-circuiting the first downstream call with a sentinel.
|
||||
monkeypatch.delenv("_HERMES_GATEWAY", raising=False)
|
||||
import hermes_cli.gateway as gw
|
||||
|
||||
class _Reached(Exception):
|
||||
pass
|
||||
|
||||
def _sentinel(*a, **k):
|
||||
raise _Reached()
|
||||
|
||||
monkeypatch.setattr(gw, "_dispatch_via_service_manager_if_s6", _sentinel)
|
||||
monkeypatch.setattr(gw, "_dispatch_all_via_service_manager_if_s6", _sentinel)
|
||||
args = Namespace(gateway_command="stop", all=False, system=False)
|
||||
with pytest.raises(_Reached):
|
||||
gw.gateway_command(args)
|
||||
|
||||
def test_restart_allows_outside_gateway(self, monkeypatch):
|
||||
# Same as above for restart: guard must not fire when the marker is
|
||||
# unset. The first thing restart does after the guard is the s6
|
||||
# dispatch check — sentinel it so we never reach real signal delivery.
|
||||
monkeypatch.delenv("_HERMES_GATEWAY", raising=False)
|
||||
import hermes_cli.gateway as gw
|
||||
|
||||
class _Reached(Exception):
|
||||
pass
|
||||
|
||||
def _sentinel(*a, **k):
|
||||
raise _Reached()
|
||||
|
||||
monkeypatch.setattr(gw, "_dispatch_via_service_manager_if_s6", _sentinel)
|
||||
monkeypatch.setattr(gw, "_dispatch_all_via_service_manager_if_s6", _sentinel)
|
||||
args = Namespace(gateway_command="restart", all=False, system=False)
|
||||
with pytest.raises(_Reached):
|
||||
gw.gateway_command(args)
|
||||
@@ -0,0 +1,614 @@
|
||||
"""Tests for the Phase 4 s6 dispatch helper in hermes_cli.gateway.
|
||||
|
||||
`_dispatch_via_service_manager_if_s6` decides whether a
|
||||
`hermes gateway start/stop/restart` invocation should be routed to
|
||||
the in-container S6ServiceManager instead of falling through to the
|
||||
host systemd/launchd/windows code path.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class _CallRecorder:
|
||||
"""Minimal stand-in for S6ServiceManager."""
|
||||
kind = "s6"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, str]] = []
|
||||
|
||||
def start(self, name: str) -> None:
|
||||
self.calls.append(("start", name))
|
||||
|
||||
def stop(self, name: str) -> None:
|
||||
self.calls.append(("stop", name))
|
||||
|
||||
def restart(self, name: str) -> None:
|
||||
self.calls.append(("restart", name))
|
||||
|
||||
|
||||
def test_dispatch_returns_false_on_host(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""When the environment isn't s6 (host run), the helper must
|
||||
return False and not invoke a manager — callers continue with
|
||||
their existing systemd/launchd/windows path."""
|
||||
from hermes_cli import gateway as gw
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.detect_service_manager", lambda: "systemd",
|
||||
)
|
||||
# Should not even attempt to construct a manager.
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.get_service_manager",
|
||||
lambda: pytest.fail("manager should not be constructed on host"),
|
||||
)
|
||||
assert gw._dispatch_via_service_manager_if_s6("start", profile="x") is False
|
||||
|
||||
|
||||
def test_dispatch_returns_true_and_calls_start_on_s6(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from hermes_cli import gateway as gw
|
||||
rec = _CallRecorder()
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.detect_service_manager", lambda: "s6",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.get_service_manager", lambda: rec,
|
||||
)
|
||||
assert gw._dispatch_via_service_manager_if_s6("start", profile="coder") is True
|
||||
assert rec.calls == [("start", "gateway-coder")]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("action,expected", [
|
||||
("start", "start"),
|
||||
("stop", "stop"),
|
||||
("restart", "restart"),
|
||||
])
|
||||
def test_dispatch_translates_action_to_manager_method(
|
||||
monkeypatch: pytest.MonkeyPatch, action: str, expected: str,
|
||||
) -> None:
|
||||
from hermes_cli import gateway as gw
|
||||
rec = _CallRecorder()
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.detect_service_manager", lambda: "s6",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.get_service_manager", lambda: rec,
|
||||
)
|
||||
assert gw._dispatch_via_service_manager_if_s6(action, profile="x") is True
|
||||
assert rec.calls == [(expected, "gateway-x")]
|
||||
|
||||
|
||||
def test_dispatch_unknown_action_returns_false(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""An unrecognized action (e.g. 'install') must not silently
|
||||
succeed — return False so the host code path handles it."""
|
||||
from hermes_cli import gateway as gw
|
||||
rec = _CallRecorder()
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.detect_service_manager", lambda: "s6",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.get_service_manager", lambda: rec,
|
||||
)
|
||||
assert gw._dispatch_via_service_manager_if_s6("install", profile="x") is False
|
||||
assert rec.calls == []
|
||||
|
||||
|
||||
def test_dispatch_defaults_profile_to_default(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""When profile is None, the helper resolves it via _profile_arg().
|
||||
With no profile context set anywhere, that resolves to "default"."""
|
||||
from hermes_cli import gateway as gw
|
||||
rec = _CallRecorder()
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.detect_service_manager", lambda: "s6",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.get_service_manager", lambda: rec,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.gateway._profile_suffix", lambda: "",
|
||||
)
|
||||
assert gw._dispatch_via_service_manager_if_s6("start") is True
|
||||
assert rec.calls == [("start", "gateway-default")]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _dispatch_all_via_service_manager_if_s6 — --all under s6
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _ListingRecorder(_CallRecorder):
|
||||
"""_CallRecorder that also exposes a profile list."""
|
||||
|
||||
def __init__(self, profiles: list[str]) -> None:
|
||||
super().__init__()
|
||||
self._profiles = profiles
|
||||
|
||||
def list_profile_gateways(self) -> list[str]:
|
||||
return list(self._profiles)
|
||||
|
||||
|
||||
def test_dispatch_all_returns_false_on_host(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from hermes_cli import gateway as gw
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.detect_service_manager", lambda: "systemd",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.get_service_manager",
|
||||
lambda: pytest.fail("manager should not be constructed on host"),
|
||||
)
|
||||
assert gw._dispatch_all_via_service_manager_if_s6("stop") is False
|
||||
|
||||
|
||||
def test_dispatch_all_iterates_every_profile_on_stop(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture,
|
||||
) -> None:
|
||||
from hermes_cli import gateway as gw
|
||||
rec = _ListingRecorder(["coder", "writer", "assistant"])
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.detect_service_manager", lambda: "s6",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.get_service_manager", lambda: rec,
|
||||
)
|
||||
assert gw._dispatch_all_via_service_manager_if_s6("stop") is True
|
||||
assert rec.calls == [
|
||||
("stop", "gateway-coder"),
|
||||
("stop", "gateway-writer"),
|
||||
("stop", "gateway-assistant"),
|
||||
]
|
||||
out = capsys.readouterr().out
|
||||
assert "Stopped 3 profile gateway(s)" in out
|
||||
|
||||
|
||||
def test_dispatch_all_iterates_every_profile_on_restart(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture,
|
||||
) -> None:
|
||||
from hermes_cli import gateway as gw
|
||||
rec = _ListingRecorder(["coder", "writer"])
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.detect_service_manager", lambda: "s6",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.get_service_manager", lambda: rec,
|
||||
)
|
||||
assert gw._dispatch_all_via_service_manager_if_s6("restart") is True
|
||||
assert rec.calls == [
|
||||
("restart", "gateway-coder"),
|
||||
("restart", "gateway-writer"),
|
||||
]
|
||||
out = capsys.readouterr().out
|
||||
assert "Restarted 2 profile gateway(s)" in out
|
||||
|
||||
|
||||
def test_dispatch_all_handles_partial_failure(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture,
|
||||
) -> None:
|
||||
"""A failure on one profile must not skip the others; the helper
|
||||
reports each failure and the success count."""
|
||||
from hermes_cli import gateway as gw
|
||||
|
||||
class _FailOnWriter(_ListingRecorder):
|
||||
def stop(self, name: str) -> None:
|
||||
if name == "gateway-writer":
|
||||
raise RuntimeError("supervise FIFO permission denied")
|
||||
super().stop(name)
|
||||
|
||||
rec = _FailOnWriter(["coder", "writer", "assistant"])
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.detect_service_manager", lambda: "s6",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.get_service_manager", lambda: rec,
|
||||
)
|
||||
assert gw._dispatch_all_via_service_manager_if_s6("stop") is True
|
||||
# The two successful ones were called; writer raised before recording.
|
||||
assert ("stop", "gateway-coder") in rec.calls
|
||||
assert ("stop", "gateway-assistant") in rec.calls
|
||||
assert ("stop", "gateway-writer") not in rec.calls
|
||||
out = capsys.readouterr().out
|
||||
assert "Stopped 2 profile gateway(s)" in out
|
||||
assert "Could not stop gateway-writer" in out
|
||||
assert "supervise FIFO permission denied" in out
|
||||
|
||||
|
||||
def test_dispatch_all_empty_list_reports_and_returns_true(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture,
|
||||
) -> None:
|
||||
"""With no profile gateways registered the helper still claims the
|
||||
dispatch (returns True) and prints a friendly message — the host
|
||||
fallback would just pkill nothing, which isn't useful inside a
|
||||
container."""
|
||||
from hermes_cli import gateway as gw
|
||||
rec = _ListingRecorder([])
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.detect_service_manager", lambda: "s6",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.get_service_manager", lambda: rec,
|
||||
)
|
||||
assert gw._dispatch_all_via_service_manager_if_s6("stop") is True
|
||||
assert rec.calls == []
|
||||
assert "No profile gateways" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_dispatch_all_unknown_action_returns_false(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""`start --all` is not a supported CLI surface; the helper must
|
||||
fall through to the host code path rather than no-op."""
|
||||
from hermes_cli import gateway as gw
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.detect_service_manager", lambda: "s6",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.get_service_manager",
|
||||
lambda: pytest.fail(
|
||||
"manager should not be constructed for unsupported --all action",
|
||||
),
|
||||
)
|
||||
assert gw._dispatch_all_via_service_manager_if_s6("start") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Friendly error rendering — GatewayNotRegisteredError / S6CommandError
|
||||
# (PR #30136 review item I2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_dispatch_renders_gateway_not_registered_friendly(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture,
|
||||
) -> None:
|
||||
"""`hermes -p typo gateway start` should print a clear message and
|
||||
exit 1 — not dump a traceback at the user."""
|
||||
from hermes_cli import gateway as gw
|
||||
from hermes_cli.service_manager import GatewayNotRegisteredError
|
||||
|
||||
class _RaisesMissing:
|
||||
kind = "s6"
|
||||
|
||||
def start(self, name: str) -> None:
|
||||
raise GatewayNotRegisteredError("typo")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.detect_service_manager", lambda: "s6",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.get_service_manager", lambda: _RaisesMissing(),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
gw._dispatch_via_service_manager_if_s6("start", profile="typo")
|
||||
assert excinfo.value.code == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "no such gateway 'typo'" in out
|
||||
assert "hermes profile create typo" in out
|
||||
# And critically: no traceback prefix.
|
||||
assert "Traceback" not in out
|
||||
|
||||
|
||||
def test_dispatch_renders_s6_command_error_friendly(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture,
|
||||
) -> None:
|
||||
"""An s6-svc failure (e.g. EACCES on the supervise FIFO) should
|
||||
surface the stderr inline, not as an opaque traceback."""
|
||||
from hermes_cli import gateway as gw
|
||||
from hermes_cli.service_manager import S6CommandError
|
||||
|
||||
class _RaisesS6Error:
|
||||
kind = "s6"
|
||||
|
||||
def start(self, name: str) -> None:
|
||||
raise S6CommandError(
|
||||
service=name,
|
||||
action="start",
|
||||
returncode=111,
|
||||
stderr="s6-svc: fatal: Permission denied",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.detect_service_manager", lambda: "s6",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.get_service_manager", lambda: _RaisesS6Error(),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
gw._dispatch_via_service_manager_if_s6("start", profile="coder")
|
||||
assert excinfo.value.code == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "rc=111" in out
|
||||
assert "Permission denied" in out
|
||||
assert "Traceback" not in out
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# `_maybe_redirect_run_to_s6_supervision`: the "upgrade old `gateway run`
|
||||
# invocation to supervised semantics inside an s6 container" helper.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class _Args:
|
||||
"""Lightweight argparse-like namespace for the helper."""
|
||||
|
||||
def __init__(self, no_supervise: bool = False) -> None:
|
||||
self.no_supervise = no_supervise
|
||||
|
||||
|
||||
def _stub_s6(monkeypatch: pytest.MonkeyPatch, *, on_s6: bool) -> _CallRecorder:
|
||||
"""Wire up service-manager stubs so the underlying dispatcher will
|
||||
fire (on_s6=True) or return False (on_s6=False)."""
|
||||
rec = _CallRecorder()
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.detect_service_manager",
|
||||
lambda: "s6" if on_s6 else "systemd",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.get_service_manager", lambda: rec,
|
||||
)
|
||||
return rec
|
||||
|
||||
|
||||
def test_redirect_noop_on_host(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Host runs (non-s6) must not redirect. Returns False; caller
|
||||
continues to the foreground gateway code path unchanged."""
|
||||
from hermes_cli import gateway as gw
|
||||
|
||||
_stub_s6(monkeypatch, on_s6=False)
|
||||
# If execvp got called we'd raise — keep it bound so test fails loudly.
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.gateway.os.execvp",
|
||||
lambda *a, **kw: pytest.fail("execvp should not be called on host"),
|
||||
)
|
||||
monkeypatch.delenv("HERMES_S6_SUPERVISED_CHILD", raising=False)
|
||||
monkeypatch.delenv("HERMES_GATEWAY_NO_SUPERVISE", raising=False)
|
||||
|
||||
assert gw._maybe_redirect_run_to_s6_supervision(_Args()) is False
|
||||
|
||||
|
||||
def test_redirect_fires_inside_s6_container(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""Inside an s6 container, `gateway run` should:
|
||||
|
||||
1. Dispatch `start` to the service manager.
|
||||
2. Print the loud breadcrumb to stderr.
|
||||
3. exec `sleep infinity` to keep the CMD alive (the cheap heartbeat;
|
||||
no resident Python interpreter) without binding container
|
||||
lifetime to gateway PID lifetime.
|
||||
"""
|
||||
from hermes_cli import gateway as gw
|
||||
|
||||
rec = _stub_s6(monkeypatch, on_s6=True)
|
||||
monkeypatch.setattr("hermes_cli.gateway._profile_suffix", lambda: "")
|
||||
|
||||
class _ExecvpCalled(BaseException):
|
||||
def __init__(self, argv: list[str]) -> None:
|
||||
self.argv = argv
|
||||
|
||||
execvp_calls: list[list[str]] = []
|
||||
|
||||
def fake_execvp(file: str, args: list[str]) -> None:
|
||||
execvp_calls.append([file, *args])
|
||||
raise _ExecvpCalled([file, *args])
|
||||
|
||||
monkeypatch.setattr("hermes_cli.gateway.os.execvp", fake_execvp)
|
||||
# If the fallback ran, the normal sleep path was wrongly skipped.
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.gateway._block_until_terminated",
|
||||
lambda: pytest.fail("fallback should not run when sleep is available"),
|
||||
)
|
||||
monkeypatch.delenv("HERMES_S6_SUPERVISED_CHILD", raising=False)
|
||||
monkeypatch.delenv("HERMES_GATEWAY_NO_SUPERVISE", raising=False)
|
||||
|
||||
with pytest.raises(_ExecvpCalled) as excinfo:
|
||||
gw._maybe_redirect_run_to_s6_supervision(_Args())
|
||||
|
||||
# 1. Dispatcher fired.
|
||||
assert rec.calls == [("start", "gateway-default")]
|
||||
# 2. Breadcrumb went to stderr and mentions the opt-out path.
|
||||
err = capsys.readouterr().err
|
||||
assert "s6 supervision" in err
|
||||
assert "--no-supervise" in err
|
||||
assert "HERMES_GATEWAY_NO_SUPERVISE" in err
|
||||
# 3. exec'd `sleep infinity` (the preferred cheap heartbeat).
|
||||
assert execvp_calls == [["sleep", "sleep", "infinity"]]
|
||||
assert excinfo.value.argv == ["sleep", "sleep", "infinity"]
|
||||
|
||||
|
||||
def test_redirect_falls_back_when_sleep_missing(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""Regression guard for issue #36208: when ``os.execvp("sleep", ...)``
|
||||
raises (no `sleep` on a clobbered/empty PATH, or a minimal image
|
||||
without it), the redirect must NOT crash the container — it falls
|
||||
back to the in-process ``_block_until_terminated`` heartbeat so the
|
||||
container keeps running.
|
||||
"""
|
||||
from hermes_cli import gateway as gw
|
||||
|
||||
rec = _stub_s6(monkeypatch, on_s6=True)
|
||||
monkeypatch.setattr("hermes_cli.gateway._profile_suffix", lambda: "")
|
||||
|
||||
def missing_sleep(file: str, args: list[str]) -> None:
|
||||
raise FileNotFoundError(2, "No such file or directory", file)
|
||||
|
||||
monkeypatch.setattr("hermes_cli.gateway.os.execvp", missing_sleep)
|
||||
block_calls: list[bool] = []
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.gateway._block_until_terminated",
|
||||
lambda: block_calls.append(True),
|
||||
)
|
||||
monkeypatch.delenv("HERMES_S6_SUPERVISED_CHILD", raising=False)
|
||||
monkeypatch.delenv("HERMES_GATEWAY_NO_SUPERVISE", raising=False)
|
||||
|
||||
# Must not raise FileNotFoundError — that was the #36208 crash.
|
||||
result = gw._maybe_redirect_run_to_s6_supervision(_Args())
|
||||
|
||||
assert result is True
|
||||
assert rec.calls == [("start", "gateway-default")]
|
||||
# Fell back to the in-process heartbeat instead of crashing.
|
||||
assert block_calls == [True]
|
||||
err = capsys.readouterr().err
|
||||
assert "`sleep` is unavailable" in err
|
||||
|
||||
|
||||
def test_block_until_terminated_installs_sigterm_handler_and_blocks(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""``_block_until_terminated`` must register a SIGTERM handler (so
|
||||
`docker stop` exits cleanly) and then block on signal.pause() — never
|
||||
touching an external binary. Regression guard for issue #36208, where
|
||||
os.execvp("sleep", ...) crashed the container with FileNotFoundError
|
||||
when PATH lacked a directory containing `sleep`.
|
||||
"""
|
||||
import signal as _signal
|
||||
from hermes_cli import gateway as gw
|
||||
|
||||
registered: dict[int, object] = {}
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.gateway.signal.signal",
|
||||
lambda signum, handler: registered.__setitem__(signum, handler),
|
||||
)
|
||||
|
||||
# Make signal.pause() raise after the first call so the infinite loop
|
||||
# terminates deterministically instead of hanging the test.
|
||||
pause_calls = {"n": 0}
|
||||
|
||||
def fake_pause() -> None:
|
||||
pause_calls["n"] += 1
|
||||
raise KeyboardInterrupt # break out of the `while True: pause()` loop
|
||||
|
||||
monkeypatch.setattr("hermes_cli.gateway.signal.pause", fake_pause)
|
||||
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
gw._block_until_terminated()
|
||||
|
||||
# A SIGTERM handler was installed...
|
||||
assert _signal.SIGTERM in registered
|
||||
# ...and it exits with the conventional 128+signum code.
|
||||
handler = registered[_signal.SIGTERM]
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
handler(_signal.SIGTERM, None) # type: ignore[operator]
|
||||
assert exc.value.code == 128 + _signal.SIGTERM
|
||||
# ...and we actually blocked on pause().
|
||||
assert pause_calls["n"] == 1
|
||||
|
||||
|
||||
def test_redirect_short_circuits_supervised_child(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The recursion guard: when the supervised gateway s6-supervise is
|
||||
running execs `hermes gateway run --replace`, the
|
||||
HERMES_S6_SUPERVISED_CHILD sentinel must short-circuit the redirect
|
||||
so the gateway actually starts foreground. Without this guard the
|
||||
supervised process would re-dispatch `start` → re-exec `run` → ...
|
||||
in an infinite loop.
|
||||
"""
|
||||
from hermes_cli import gateway as gw
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.detect_service_manager",
|
||||
lambda: pytest.fail("dispatcher should not run when sentinel is set"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.gateway.os.execvp",
|
||||
lambda *a, **kw: pytest.fail("execvp should not run when sentinel is set"),
|
||||
)
|
||||
monkeypatch.setenv("HERMES_S6_SUPERVISED_CHILD", "1")
|
||||
monkeypatch.delenv("HERMES_GATEWAY_NO_SUPERVISE", raising=False)
|
||||
|
||||
assert gw._maybe_redirect_run_to_s6_supervision(_Args()) is False
|
||||
|
||||
|
||||
def test_redirect_respects_no_supervise_flag(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""`--no-supervise` (CLI flag) must skip the redirect even inside
|
||||
an s6 container, restoring pre-s6 foreground semantics."""
|
||||
from hermes_cli import gateway as gw
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.detect_service_manager",
|
||||
lambda: pytest.fail("dispatcher should not run when --no-supervise is set"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.gateway.os.execvp",
|
||||
lambda *a, **kw: pytest.fail("execvp should not run when --no-supervise is set"),
|
||||
)
|
||||
monkeypatch.delenv("HERMES_S6_SUPERVISED_CHILD", raising=False)
|
||||
monkeypatch.delenv("HERMES_GATEWAY_NO_SUPERVISE", raising=False)
|
||||
|
||||
assert gw._maybe_redirect_run_to_s6_supervision(_Args(no_supervise=True)) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["1", "true", "TRUE", "yes", "Yes"])
|
||||
def test_redirect_respects_no_supervise_env(
|
||||
monkeypatch: pytest.MonkeyPatch, value: str,
|
||||
) -> None:
|
||||
"""`HERMES_GATEWAY_NO_SUPERVISE=1` (env var) must skip the redirect.
|
||||
|
||||
Truthiness mirrors the dashboard service's own env var parsing —
|
||||
1/true/yes are all accepted, case-insensitively.
|
||||
"""
|
||||
from hermes_cli import gateway as gw
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.detect_service_manager",
|
||||
lambda: pytest.fail("dispatcher should not run when env opt-out is set"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.gateway.os.execvp",
|
||||
lambda *a, **kw: pytest.fail("execvp should not run when env opt-out is set"),
|
||||
)
|
||||
monkeypatch.delenv("HERMES_S6_SUPERVISED_CHILD", raising=False)
|
||||
monkeypatch.setenv("HERMES_GATEWAY_NO_SUPERVISE", value)
|
||||
|
||||
assert gw._maybe_redirect_run_to_s6_supervision(_Args()) is False
|
||||
|
||||
|
||||
def test_redirect_no_supervise_env_falsy_values_dont_opt_out(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Falsy / unrecognized values of HERMES_GATEWAY_NO_SUPERVISE must
|
||||
NOT opt out. We're strict about what counts as "yes" so a typo
|
||||
like `HERMES_GATEWAY_NO_SUPERVISE=0` doesn't silently enable the
|
||||
historical foreground behavior."""
|
||||
from hermes_cli import gateway as gw
|
||||
|
||||
_stub_s6(monkeypatch, on_s6=True)
|
||||
monkeypatch.setattr("hermes_cli.gateway._profile_suffix", lambda: "")
|
||||
|
||||
# The redirect reaching its `sleep` heartbeat means it did NOT opt
|
||||
# out. Stub execvp to record + raise (so it doesn't replace the test
|
||||
# process) rather than actually exec.
|
||||
class _ExecvpCalled(BaseException):
|
||||
pass
|
||||
|
||||
execvp_calls: list[str] = []
|
||||
|
||||
def fake_execvp(file: str, args: list[str]) -> None:
|
||||
execvp_calls.append(file)
|
||||
raise _ExecvpCalled
|
||||
|
||||
monkeypatch.setattr("hermes_cli.gateway.os.execvp", fake_execvp)
|
||||
monkeypatch.delenv("HERMES_S6_SUPERVISED_CHILD", raising=False)
|
||||
|
||||
for falsy in ("", "0", "false", "no", "off", "garbage"):
|
||||
execvp_calls.clear()
|
||||
monkeypatch.setenv("HERMES_GATEWAY_NO_SUPERVISE", falsy)
|
||||
with pytest.raises(_ExecvpCalled):
|
||||
gw._maybe_redirect_run_to_s6_supervision(_Args())
|
||||
assert execvp_calls == ["sleep"], f"redirect should fire for {falsy!r}"
|
||||
@@ -8,6 +8,7 @@ from types import SimpleNamespace
|
||||
import pytest
|
||||
|
||||
pwd = pytest.importorskip("pwd")
|
||||
grp = pytest.importorskip("grp")
|
||||
|
||||
import hermes_cli.gateway as gateway_cli
|
||||
from gateway import status
|
||||
@@ -678,6 +679,162 @@ class TestLaunchdServiceRecovery:
|
||||
assert "stale" in output.lower()
|
||||
assert "not loaded" in output.lower()
|
||||
|
||||
def test_launchd_domain_uses_user_domain(self):
|
||||
# The user/<uid> domain (not gui/<uid>) is the one reachable from
|
||||
# non-Aqua/background sessions on macOS 26+ (issue #23387).
|
||||
assert gateway_cli._launchd_domain() == f"user/{os.getuid()}"
|
||||
|
||||
def test_launchctl_domain_unsupported_recognizes_macos26_codes(self):
|
||||
# Codes that persist after a fresh bootstrap → launchd truly unavailable.
|
||||
assert gateway_cli._launchctl_domain_unsupported(5) is True
|
||||
assert gateway_cli._launchctl_domain_unsupported(125) is True
|
||||
assert gateway_cli._launchctl_domain_unsupported(3) is False
|
||||
assert gateway_cli._launchctl_domain_unsupported(113) is False
|
||||
assert gateway_cli._launchctl_domain_unsupported(0) is False
|
||||
|
||||
def test_launchd_start_reloads_on_kickstart_exit_code_125(self, tmp_path, monkeypatch):
|
||||
"""Exit code 125 means the job is absent from the domain → bootstrap recovery."""
|
||||
plist_path = tmp_path / "ai.hermes.gateway.plist"
|
||||
plist_path.write_text(gateway_cli.generate_launchd_plist(), encoding="utf-8")
|
||||
label = gateway_cli.get_launchd_label()
|
||||
|
||||
calls = []
|
||||
domain = gateway_cli._launchd_domain()
|
||||
target = f"{domain}/{label}"
|
||||
|
||||
def fake_run(cmd, check=False, **kwargs):
|
||||
if cmd and cmd[0] == "launchctl":
|
||||
calls.append(cmd)
|
||||
if cmd == ["launchctl", "kickstart", target] and calls.count(cmd) == 1:
|
||||
raise gateway_cli.subprocess.CalledProcessError(
|
||||
125, cmd, stderr="Domain does not support specified action"
|
||||
)
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path)
|
||||
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
|
||||
|
||||
gateway_cli.launchd_start()
|
||||
|
||||
assert calls == [
|
||||
["launchctl", "kickstart", target],
|
||||
["launchctl", "bootstrap", domain, str(plist_path)],
|
||||
["launchctl", "kickstart", target],
|
||||
]
|
||||
|
||||
def test_launchd_start_falls_back_to_detached_when_rebootstrap_fails(self, tmp_path, monkeypatch, capsys):
|
||||
"""If even a fresh bootstrap can't manage the domain, spawn detached."""
|
||||
plist_path = tmp_path / "ai.hermes.gateway.plist"
|
||||
plist_path.write_text(gateway_cli.generate_launchd_plist(), encoding="utf-8")
|
||||
label = gateway_cli.get_launchd_label()
|
||||
target = f"{gateway_cli._launchd_domain()}/{label}"
|
||||
|
||||
monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path)
|
||||
monkeypatch.setattr(gateway_cli, "refresh_launchd_plist_if_needed", lambda: False)
|
||||
|
||||
def fake_run(cmd, check=False, **kwargs):
|
||||
if cmd == ["launchctl", "kickstart", target]:
|
||||
# First kickstart: job not loaded (125). After bootstrap also
|
||||
# fails, this won't be reached again.
|
||||
raise gateway_cli.subprocess.CalledProcessError(
|
||||
125, cmd, stderr="Domain does not support specified action"
|
||||
)
|
||||
if cmd[:2] == ["launchctl", "bootstrap"]:
|
||||
raise gateway_cli.subprocess.CalledProcessError(
|
||||
5, cmd, stderr="Input/output error"
|
||||
)
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
|
||||
|
||||
spawned = []
|
||||
monkeypatch.setattr(
|
||||
gateway_cli, "_spawn_detached_gateway", lambda: spawned.append(True) or True
|
||||
)
|
||||
|
||||
gateway_cli.launchd_start()
|
||||
|
||||
assert spawned == [True]
|
||||
assert "background process" in capsys.readouterr().out.lower()
|
||||
|
||||
def test_launchd_install_falls_back_to_detached_on_bootstrap_5(self, tmp_path, monkeypatch, capsys):
|
||||
"""macOS bootstrap error 5 should spawn a detached gateway, not crash."""
|
||||
plist_path = tmp_path / "ai.hermes.gateway.plist"
|
||||
monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path)
|
||||
|
||||
def fake_run(cmd, check=False, **kwargs):
|
||||
if cmd[:2] == ["launchctl", "bootstrap"]:
|
||||
raise gateway_cli.subprocess.CalledProcessError(
|
||||
5, cmd, stderr="Input/output error"
|
||||
)
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
|
||||
|
||||
spawned = []
|
||||
monkeypatch.setattr(
|
||||
gateway_cli, "_spawn_detached_gateway", lambda: spawned.append(True) or True
|
||||
)
|
||||
|
||||
gateway_cli.launchd_install(force=True)
|
||||
|
||||
assert spawned == [True]
|
||||
assert "Service installed and loaded" not in capsys.readouterr().out
|
||||
|
||||
def test_launchd_restart_falls_back_to_detached_on_error_5(self, monkeypatch, capsys):
|
||||
"""kickstart -k error 5 (domain unmanageable) should relaunch detached."""
|
||||
target = f"{gateway_cli._launchd_domain()}/{gateway_cli.get_launchd_label()}"
|
||||
|
||||
monkeypatch.setattr(gateway_cli, "_get_restart_drain_timeout", lambda: 5.0)
|
||||
monkeypatch.setattr(gateway_cli, "_request_gateway_self_restart", lambda pid: False)
|
||||
monkeypatch.setattr(gateway_cli, "_wait_for_gateway_exit", lambda timeout, force_after=None: True)
|
||||
monkeypatch.setattr(gateway_cli, "terminate_pid", lambda pid, force=False: None)
|
||||
monkeypatch.setattr("gateway.status.get_running_pid", lambda: 321)
|
||||
|
||||
def fake_run(cmd, check=False, **kwargs):
|
||||
if cmd == ["launchctl", "kickstart", "-k", target]:
|
||||
raise gateway_cli.subprocess.CalledProcessError(
|
||||
5, cmd, stderr="Input/output error"
|
||||
)
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
|
||||
|
||||
spawned = []
|
||||
monkeypatch.setattr(
|
||||
gateway_cli, "_spawn_detached_gateway", lambda: spawned.append(True) or True
|
||||
)
|
||||
|
||||
gateway_cli.launchd_restart()
|
||||
|
||||
assert spawned == [True]
|
||||
|
||||
def test_launchd_stop_tolerates_domain_unsupported_bootout(self, monkeypatch, capsys):
|
||||
"""bootout exit 125 (macOS 26) must fall through to PID-based kill, not raise."""
|
||||
def fake_run(cmd, check=False, **kwargs):
|
||||
if "bootout" in cmd:
|
||||
raise gateway_cli.subprocess.CalledProcessError(
|
||||
125, cmd, stderr="Domain does not support specified action"
|
||||
)
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
|
||||
monkeypatch.setattr(gateway_cli, "_wait_for_gateway_exit", lambda **kw: None)
|
||||
|
||||
gateway_cli.launchd_stop()
|
||||
|
||||
assert "stopped" in capsys.readouterr().out.lower()
|
||||
|
||||
def test_launchd_fallback_exits_when_spawn_fails(self, monkeypatch, capsys):
|
||||
"""If the detached spawn fails, surface the manual workaround and exit 1."""
|
||||
monkeypatch.setattr(gateway_cli, "_spawn_detached_gateway", lambda: False)
|
||||
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
gateway_cli._launchd_fallback_to_detached("test reason")
|
||||
assert exc.value.code == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "nohup hermes gateway run" in out
|
||||
|
||||
|
||||
class TestGatewayServiceDetection:
|
||||
def test_supports_systemd_services_requires_systemctl_binary(self, monkeypatch):
|
||||
@@ -1321,7 +1478,6 @@ class TestSystemServiceIdentityRootHandling:
|
||||
|
||||
def test_auto_detected_root_is_rejected(self, monkeypatch):
|
||||
"""When root is auto-detected (not explicitly requested), raise."""
|
||||
import grp
|
||||
|
||||
monkeypatch.delenv("SUDO_USER", raising=False)
|
||||
monkeypatch.setenv("USER", "root")
|
||||
@@ -1332,7 +1488,6 @@ class TestSystemServiceIdentityRootHandling:
|
||||
|
||||
def test_explicit_root_is_allowed(self, monkeypatch):
|
||||
"""When root is explicitly passed via --run-as-user root, allow it."""
|
||||
import grp
|
||||
|
||||
root_info = pwd.getpwnam("root")
|
||||
root_group = grp.getgrgid(root_info.pw_gid).gr_name
|
||||
@@ -1343,7 +1498,6 @@ class TestSystemServiceIdentityRootHandling:
|
||||
|
||||
def test_non_root_user_passes_through(self, monkeypatch):
|
||||
"""Normal non-root user works as before."""
|
||||
import grp
|
||||
|
||||
monkeypatch.delenv("SUDO_USER", raising=False)
|
||||
monkeypatch.setenv("USER", "nobody")
|
||||
@@ -1618,7 +1772,12 @@ class TestProfileArg:
|
||||
monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: profile_dir)
|
||||
unit = gateway_cli.generate_systemd_unit(system=False)
|
||||
assert "--profile mybot" in unit
|
||||
assert "gateway run --replace" in unit
|
||||
assert "gateway run" in unit
|
||||
# Under a process supervisor (Restart=always), --replace makes each
|
||||
# restart kill its predecessor → self-kill loop. The systemd unit must
|
||||
# NOT use --replace; the supervisor owns the lifecycle. (--replace stays
|
||||
# on the manual launchd fallback path — see test_launchd_plist_includes_profile.)
|
||||
assert "--replace" not in unit
|
||||
|
||||
def test_launchd_plist_includes_profile(self, tmp_path, monkeypatch):
|
||||
"""generate_launchd_plist should include --profile in ProgramArguments for named profiles."""
|
||||
@@ -1631,6 +1790,14 @@ class TestProfileArg:
|
||||
assert "<string>--profile</string>" in plist
|
||||
assert "<string>mybot</string>" in plist
|
||||
|
||||
def test_launchd_plist_supports_aqua_and_background_sessions(self):
|
||||
# macOS 26+ only loads the agent in non-Aqua sessions when the plist
|
||||
# opts into Background as well (issue #23387).
|
||||
plist = gateway_cli.generate_launchd_plist()
|
||||
assert "<key>LimitLoadToSessionType</key>" in plist
|
||||
assert "<string>Aqua</string>" in plist
|
||||
assert "<string>Background</string>" in plist
|
||||
|
||||
def test_launchd_plist_path_uses_real_user_home_not_profile_home(self, tmp_path, monkeypatch):
|
||||
profile_dir = tmp_path / ".hermes" / "profiles" / "orcha"
|
||||
profile_dir.mkdir(parents=True)
|
||||
@@ -1706,7 +1873,12 @@ class TestSystemUnitPathRemapping:
|
||||
assert str(root_home) not in unit
|
||||
# Target user paths should be present
|
||||
assert "/home/alice" in unit
|
||||
assert "WorkingDirectory=/home/alice/.hermes/hermes-agent" in unit
|
||||
# WorkingDirectory is anchored at the target user's HERMES_HOME (stable,
|
||||
# always exists) — NOT the source checkout under it. Pinning cwd to the
|
||||
# checkout is the rot bug fixed alongside this: a relocated/removed
|
||||
# checkout would crash-loop the unit on CHDIR (status=200).
|
||||
assert "WorkingDirectory=/home/alice/.hermes" in unit
|
||||
assert "WorkingDirectory=/home/alice/.hermes/hermes-agent" not in unit
|
||||
|
||||
|
||||
class TestDockerAwareGateway:
|
||||
@@ -2533,3 +2705,67 @@ class TestGatewayCommandCatchesSystemScopeError:
|
||||
# Renders the message, NOT the ``('msg', 'action')`` tuple repr
|
||||
assert "System gateway start requires root. Re-run with sudo." in out
|
||||
assert "('" not in out # no tuple repr leaking through
|
||||
|
||||
|
||||
class TestServiceWorkingDirIsStable:
|
||||
"""The gateway service must anchor WorkingDirectory at a stable path
|
||||
(HERMES_HOME), never the source checkout / worktree, so a relocated or
|
||||
deleted checkout can't crash-loop the unit on CHDIR (status=200).
|
||||
"""
|
||||
|
||||
def test_stable_working_dir_uses_hermes_home(self, tmp_path, monkeypatch):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: home)
|
||||
assert Path(gateway_cli._stable_service_working_dir()) == home.resolve()
|
||||
|
||||
def test_stable_working_dir_falls_back_to_project_root(self, tmp_path, monkeypatch):
|
||||
# HERMES_HOME points somewhere that does not exist -> fall back.
|
||||
missing = tmp_path / "does-not-exist" / ".hermes"
|
||||
monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: missing)
|
||||
assert gateway_cli._stable_service_working_dir() == str(gateway_cli.PROJECT_ROOT)
|
||||
|
||||
def test_user_unit_workingdirectory_is_hermes_home_not_checkout(self, tmp_path, monkeypatch):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: home)
|
||||
unit = gateway_cli.generate_systemd_unit(system=False)
|
||||
wd = [l for l in unit.splitlines() if l.startswith("WorkingDirectory=")]
|
||||
assert wd, "unit has no WorkingDirectory line"
|
||||
value = wd[0].split("=", 1)[1]
|
||||
assert Path(value).resolve() == home.resolve()
|
||||
# The bug class: never pin cwd inside a transient worktree checkout.
|
||||
assert "/.worktrees/" not in value
|
||||
|
||||
def test_launchd_workingdirectory_is_hermes_home(self, tmp_path, monkeypatch):
|
||||
import re
|
||||
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: home)
|
||||
plist = gateway_cli.generate_launchd_plist()
|
||||
m = re.search(r"<key>WorkingDirectory</key>\s*<string>(.*?)</string>", plist)
|
||||
assert m, "plist has no WorkingDirectory entry"
|
||||
assert Path(m.group(1)).resolve() == home.resolve()
|
||||
assert "/.worktrees/" not in m.group(1)
|
||||
|
||||
def test_launchd_plist_keepalive_unconditional(self, tmp_path, monkeypatch):
|
||||
"""KeepAlive must be unconditional <true/> so the gateway restarts on clean exits.
|
||||
|
||||
Bug #37388: the old ``KeepAlive.SuccessfulExit = false`` dict form meant
|
||||
launchd would NOT restart after a zero-exit (e.g. ``gateway run --replace``
|
||||
causes the old instance to exit cleanly). Switching to the scalar
|
||||
``<key>KeepAlive</key><true/>`` makes launchd restart regardless of exit code.
|
||||
"""
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: home)
|
||||
plist = gateway_cli.generate_launchd_plist()
|
||||
|
||||
# Scalar <true/> must be present immediately after the KeepAlive key
|
||||
assert "<key>KeepAlive</key>" in plist
|
||||
# The unconditional form
|
||||
assert "<key>KeepAlive</key>\n <true/>" in plist
|
||||
# The old conditional dict form must NOT appear
|
||||
assert "SuccessfulExit" not in plist
|
||||
assert "<key>KeepAlive</key>\n <dict>" not in plist
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
|
||||
@@ -29,15 +29,60 @@ def test_schtasks_fallback_does_not_hide_unknown_errors():
|
||||
assert gateway_windows._should_fall_back(1, "ERROR: The system cannot find the file specified.") is False
|
||||
|
||||
|
||||
def test_schtasks_encoding_falls_back_to_utf8(monkeypatch):
|
||||
"""A broken/empty locale must not leave us without a decoder (issue #38172)."""
|
||||
|
||||
monkeypatch.setattr(gateway_windows.locale, "getpreferredencoding", lambda *a, **k: "")
|
||||
assert gateway_windows._schtasks_encoding() == "utf-8"
|
||||
|
||||
def _boom(*args, **kwargs):
|
||||
raise RuntimeError("locale exploded")
|
||||
|
||||
monkeypatch.setattr(gateway_windows.locale, "getpreferredencoding", _boom)
|
||||
assert gateway_windows._schtasks_encoding() == "utf-8"
|
||||
|
||||
|
||||
def test_exec_schtasks_decodes_with_replace_errors(monkeypatch):
|
||||
"""schtasks output must be decoded with errors='replace' so localized
|
||||
(non-UTF-8) bytes never surface a UnicodeDecodeError traceback (#38172)."""
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class _FakeCompleted:
|
||||
returncode = 0
|
||||
stdout = "ok"
|
||||
stderr = ""
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
captured["cmd"] = cmd
|
||||
captured.update(kwargs)
|
||||
return _FakeCompleted()
|
||||
|
||||
monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None)
|
||||
monkeypatch.setattr(gateway_windows.shutil, "which", lambda name: r"C:\\Windows\\System32\\schtasks.exe")
|
||||
monkeypatch.setattr(gateway_windows.subprocess, "run", fake_run)
|
||||
|
||||
code, out, err = gateway_windows._exec_schtasks(["/Query", "/TN", "Hermes_Gateway"])
|
||||
|
||||
assert (code, out, err) == (0, "ok", "")
|
||||
assert captured["errors"] == "replace", "schtasks output must decode with errors='replace'"
|
||||
assert isinstance(captured["encoding"], str) and captured["encoding"], (
|
||||
"an explicit non-empty encoding must be passed to subprocess.run"
|
||||
)
|
||||
assert captured["text"] is True
|
||||
|
||||
|
||||
def test_build_gateway_argv_uses_base_pythonw_for_uv_venv_launcher(monkeypatch, tmp_path):
|
||||
"""Avoid uv's venv pythonw launcher because it respawns console python.exe."""
|
||||
|
||||
project = tmp_path / "project"
|
||||
scripts = project / "venv" / "Scripts"
|
||||
site_packages = project / "venv" / "Lib" / "site-packages"
|
||||
hermes_home = tmp_path / "hermes-home"
|
||||
base = tmp_path / "uv" / "python" / "cpython-3.11-windows-x86_64-none"
|
||||
scripts.mkdir(parents=True)
|
||||
site_packages.mkdir(parents=True)
|
||||
hermes_home.mkdir()
|
||||
base.mkdir(parents=True)
|
||||
|
||||
venv_python = scripts / "python.exe"
|
||||
@@ -56,17 +101,55 @@ def test_build_gateway_argv_uses_base_pythonw_for_uv_venv_launcher(monkeypatch,
|
||||
monkeypatch.setattr(gateway, "PROJECT_ROOT", project)
|
||||
monkeypatch.setattr(gateway, "get_python_path", lambda: str(venv_python))
|
||||
monkeypatch.setattr(gateway, "_profile_arg", lambda hermes_home: "")
|
||||
monkeypatch.setattr("hermes_cli.config.get_hermes_home", lambda: str(tmp_path / "hermes-home"))
|
||||
monkeypatch.setattr("hermes_cli.config.get_hermes_home", lambda: str(hermes_home))
|
||||
|
||||
argv, cwd, env_overlay = gateway_windows._build_gateway_argv()
|
||||
|
||||
assert argv[:3] == [str(base_pythonw), "-m", "hermes_cli.main"]
|
||||
assert cwd == str(project)
|
||||
assert cwd == str(hermes_home.resolve())
|
||||
assert env_overlay["VIRTUAL_ENV"] == str(project / "venv")
|
||||
assert str(project) in env_overlay["PYTHONPATH"].split(gateway_windows.os.pathsep)
|
||||
assert str(site_packages) in env_overlay["PYTHONPATH"].split(gateway_windows.os.pathsep)
|
||||
|
||||
|
||||
class TestStableWindowsGatewayWorkingDir:
|
||||
def test_stable_gateway_working_dir_uses_hermes_home(self, tmp_path, monkeypatch):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setattr("hermes_cli.config.get_hermes_home", lambda: home)
|
||||
assert gateway_windows._stable_gateway_working_dir(tmp_path / "checkout") == str(home.resolve())
|
||||
|
||||
def test_stable_gateway_working_dir_falls_back_to_project_root(self, tmp_path, monkeypatch):
|
||||
missing = tmp_path / "missing" / ".hermes"
|
||||
project = tmp_path / "checkout"
|
||||
monkeypatch.setattr("hermes_cli.config.get_hermes_home", lambda: missing)
|
||||
assert gateway_windows._stable_gateway_working_dir(project) == str(project)
|
||||
|
||||
|
||||
def test_write_task_script_anchors_cmd_cd_at_hermes_home(monkeypatch, tmp_path):
|
||||
project = tmp_path / "project"
|
||||
hermes_home = tmp_path / "hermes-home"
|
||||
hermes_home.mkdir()
|
||||
python_exe = project / "venv" / "Scripts" / "python.exe"
|
||||
python_exe.parent.mkdir(parents=True)
|
||||
python_exe.write_text("", encoding="utf-8")
|
||||
script_path = tmp_path / "gateway.cmd"
|
||||
|
||||
monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None)
|
||||
monkeypatch.setattr(gateway, "PROJECT_ROOT", project)
|
||||
monkeypatch.setattr(gateway, "get_python_path", lambda: str(python_exe))
|
||||
monkeypatch.setattr(gateway, "_profile_arg", lambda hermes_home: "")
|
||||
monkeypatch.setattr("hermes_cli.config.get_hermes_home", lambda: str(hermes_home))
|
||||
monkeypatch.setattr(gateway_windows, "get_task_script_path", lambda: script_path)
|
||||
|
||||
written = gateway_windows._write_task_script()
|
||||
content = script_path.read_text(encoding="utf-8")
|
||||
|
||||
assert written == script_path
|
||||
assert f"cd /d {gateway_windows._quote_cmd_script_arg(str(hermes_home.resolve()))}" in content
|
||||
assert f"cd /d {gateway_windows._quote_cmd_script_arg(str(project))}" not in content
|
||||
|
||||
|
||||
def _arrange_startup_fallback(monkeypatch, tmp_path, running_pids):
|
||||
script_path = tmp_path / "Hermes_Gateway_alice.cmd"
|
||||
startup_entry = tmp_path / "Startup" / "Hermes_Gateway_alice.cmd"
|
||||
@@ -481,4 +564,221 @@ def test_uninstall_access_denied_declined_keeps_task_and_cleans_files(monkeypatc
|
||||
out = capsys.readouterr().out
|
||||
assert "Skipped elevation" in out
|
||||
assert "UAC is Windows' admin approval prompt" in out
|
||||
assert "Scheduled Task still registered" in out
|
||||
assert "Scheduled Task still registered" in out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# stop() drain semantics — issue #33778
|
||||
#
|
||||
# Background: on Windows, asyncio.add_signal_handler raises NotImplementedError,
|
||||
# so the gateway's SIGTERM handler (which drains in-flight agents and writes
|
||||
# resume_pending=True) never fires when `hermes gateway stop` kills the
|
||||
# process. The fix: stop() writes the planned_stop_marker first, waits for
|
||||
# the gateway's marker-watcher thread to drain + exit cleanly, then escalates
|
||||
# to taskkill if drain times out.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_stop_writes_planned_stop_marker_before_killing(monkeypatch):
|
||||
"""stop() must write the planned-stop marker BEFORE any kill signal.
|
||||
|
||||
Without this, the gateway's drain loop never runs on Windows and
|
||||
sessions silently lose context across restarts.
|
||||
"""
|
||||
pid = 99999
|
||||
events = []
|
||||
|
||||
monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None)
|
||||
monkeypatch.setattr(gateway_windows, "is_task_registered", lambda: False)
|
||||
|
||||
# Stub the marker write so we can record the order of operations.
|
||||
from gateway import status as status_mod
|
||||
|
||||
def fake_write_marker(target_pid):
|
||||
events.append(("write_marker", target_pid))
|
||||
return True
|
||||
|
||||
def fake_pid_exists(check_pid):
|
||||
# Drain succeeds: pid "exits" right after the marker write.
|
||||
return ("write_marker", pid) not in events
|
||||
|
||||
monkeypatch.setattr(status_mod, "write_planned_stop_marker", fake_write_marker)
|
||||
monkeypatch.setattr(status_mod, "_pid_exists", fake_pid_exists)
|
||||
monkeypatch.setattr(status_mod, "get_running_pid", lambda: pid)
|
||||
|
||||
def fake_kill(**kwargs):
|
||||
events.append(("kill", kwargs.get("force", False)))
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr("hermes_cli.gateway.kill_gateway_processes", fake_kill)
|
||||
monkeypatch.setattr("hermes_cli.gateway._get_restart_drain_timeout", lambda: 5.0)
|
||||
|
||||
gateway_windows.stop()
|
||||
|
||||
# Marker MUST be written before any kill.
|
||||
kinds = [e[0] for e in events]
|
||||
assert "write_marker" in kinds, "stop() never wrote the planned-stop marker"
|
||||
marker_idx = kinds.index("write_marker")
|
||||
kill_idx = kinds.index("kill") if "kill" in kinds else len(kinds)
|
||||
assert marker_idx < kill_idx, (
|
||||
f"stop() killed before writing the marker (events={events})"
|
||||
)
|
||||
|
||||
|
||||
def test_stop_waits_for_graceful_drain_before_force_kill(monkeypatch):
|
||||
"""When drain succeeds, stop() should NOT force-kill the gateway.
|
||||
|
||||
drained=True means the gateway exited cleanly after seeing the
|
||||
marker — escalating to taskkill /F afterwards would be wasted
|
||||
work and may emit confusing "killed N processes" output.
|
||||
"""
|
||||
pid = 88888
|
||||
events = []
|
||||
|
||||
monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None)
|
||||
monkeypatch.setattr(gateway_windows, "is_task_registered", lambda: False)
|
||||
|
||||
from gateway import status as status_mod
|
||||
monkeypatch.setattr(status_mod, "write_planned_stop_marker", lambda p: True)
|
||||
|
||||
# Simulate the gateway exiting cleanly after one poll tick.
|
||||
poll_count = [0]
|
||||
def fake_pid_exists(check_pid):
|
||||
poll_count[0] += 1
|
||||
return poll_count[0] < 2 # alive on first poll, gone on second
|
||||
monkeypatch.setattr(status_mod, "_pid_exists", fake_pid_exists)
|
||||
monkeypatch.setattr(status_mod, "get_running_pid", lambda: pid)
|
||||
|
||||
def fake_kill(**kwargs):
|
||||
events.append(("kill", kwargs.get("force", False)))
|
||||
return 0
|
||||
monkeypatch.setattr("hermes_cli.gateway.kill_gateway_processes", fake_kill)
|
||||
monkeypatch.setattr("hermes_cli.gateway._get_restart_drain_timeout", lambda: 5.0)
|
||||
|
||||
gateway_windows.stop()
|
||||
|
||||
# kill_gateway_processes is still called as the no-op sweep, but
|
||||
# NOT with force=True — drain succeeded, gateway is already gone.
|
||||
assert events == [("kill", False)], (
|
||||
f"After clean drain, force kill should be disabled (events={events})"
|
||||
)
|
||||
|
||||
|
||||
def test_stop_escalates_to_force_kill_when_drain_times_out(monkeypatch):
|
||||
"""When drain times out, stop() MUST escalate to force=True.
|
||||
|
||||
Drain timeout = gateway is stuck or unresponsive. Without the
|
||||
taskkill /T /F escalation, the gateway stays alive and the next
|
||||
`hermes gateway start` fails with "another instance is running".
|
||||
"""
|
||||
pid = 77777
|
||||
events = []
|
||||
|
||||
monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None)
|
||||
monkeypatch.setattr(gateway_windows, "is_task_registered", lambda: False)
|
||||
|
||||
from gateway import status as status_mod
|
||||
monkeypatch.setattr(status_mod, "write_planned_stop_marker", lambda p: True)
|
||||
# PID never exits — drain times out.
|
||||
monkeypatch.setattr(status_mod, "_pid_exists", lambda check_pid: True)
|
||||
monkeypatch.setattr(status_mod, "get_running_pid", lambda: pid)
|
||||
|
||||
def fake_kill(**kwargs):
|
||||
events.append(("kill", kwargs.get("force", False)))
|
||||
return 1
|
||||
monkeypatch.setattr("hermes_cli.gateway.kill_gateway_processes", fake_kill)
|
||||
# Tiny drain timeout to keep the test fast.
|
||||
monkeypatch.setattr("hermes_cli.gateway._get_restart_drain_timeout", lambda: 1.0)
|
||||
|
||||
gateway_windows.stop()
|
||||
|
||||
# When drain times out, kill is invoked with force=True so taskkill /T /F
|
||||
# walks the process tree.
|
||||
assert events == [("kill", True)], (
|
||||
f"After drain timeout, kill must use force=True (events={events})"
|
||||
)
|
||||
|
||||
|
||||
def test_stop_no_running_gateway_skips_drain(monkeypatch):
|
||||
"""When no gateway is running, skip the drain wait entirely."""
|
||||
events = []
|
||||
|
||||
monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None)
|
||||
monkeypatch.setattr(gateway_windows, "is_task_registered", lambda: False)
|
||||
|
||||
from gateway import status as status_mod
|
||||
monkeypatch.setattr(status_mod, "get_running_pid", lambda: None)
|
||||
|
||||
def fake_write_marker(target_pid):
|
||||
events.append(("write_marker", target_pid))
|
||||
return True
|
||||
monkeypatch.setattr(status_mod, "write_planned_stop_marker", fake_write_marker)
|
||||
monkeypatch.setattr(status_mod, "_pid_exists", lambda check_pid: False)
|
||||
|
||||
def fake_kill(**kwargs):
|
||||
events.append(("kill", kwargs.get("force", False)))
|
||||
return 0
|
||||
monkeypatch.setattr("hermes_cli.gateway.kill_gateway_processes", fake_kill)
|
||||
monkeypatch.setattr("hermes_cli.gateway._get_restart_drain_timeout", lambda: 5.0)
|
||||
|
||||
gateway_windows.stop()
|
||||
|
||||
# With no PID to drain, no marker is written. Kill sweep still runs
|
||||
# (defensive — covers the case where a stray gateway is alive without
|
||||
# a PID file). force=True because drained=False.
|
||||
assert ("write_marker", None) not in events
|
||||
assert all(e[0] != "write_marker" for e in events), (
|
||||
f"Should not write marker when no PID is running (events={events})"
|
||||
)
|
||||
assert events == [("kill", True)]
|
||||
|
||||
|
||||
def test_drain_helper_handles_invalid_pid(monkeypatch):
|
||||
"""_drain_gateway_pid returns False for invalid PIDs without crashing."""
|
||||
assert gateway_windows._drain_gateway_pid(0, 5.0) is False
|
||||
assert gateway_windows._drain_gateway_pid(-1, 5.0) is False
|
||||
|
||||
|
||||
def test_drain_helper_returns_true_when_pid_exits_quickly(monkeypatch):
|
||||
"""_drain_gateway_pid polls _pid_exists until it returns False."""
|
||||
pid = 66666
|
||||
poll_count = [0]
|
||||
|
||||
def fake_pid_exists(check_pid):
|
||||
poll_count[0] += 1
|
||||
return poll_count[0] < 3 # alive twice, then gone
|
||||
|
||||
from gateway import status as status_mod
|
||||
monkeypatch.setattr(status_mod, "write_planned_stop_marker", lambda p: True)
|
||||
monkeypatch.setattr(status_mod, "_pid_exists", fake_pid_exists)
|
||||
|
||||
assert gateway_windows._drain_gateway_pid(pid, drain_timeout=5.0) is True
|
||||
|
||||
|
||||
def test_drain_helper_returns_false_on_timeout(monkeypatch):
|
||||
"""_drain_gateway_pid returns False when the PID never exits."""
|
||||
from gateway import status as status_mod
|
||||
monkeypatch.setattr(status_mod, "write_planned_stop_marker", lambda p: True)
|
||||
monkeypatch.setattr(status_mod, "_pid_exists", lambda check_pid: True)
|
||||
|
||||
assert gateway_windows._drain_gateway_pid(55555, drain_timeout=1.0) is False
|
||||
|
||||
|
||||
def test_drain_helper_still_waits_if_marker_write_fails(monkeypatch):
|
||||
"""Marker-write failures are swallowed; drain still polls for PID exit.
|
||||
|
||||
If the marker can't be written (disk full, permission error), the
|
||||
gateway can't drain — but the wait still happens so a slow-shutdown
|
||||
gateway from a different code path (e.g. SIGTERM working on this
|
||||
platform after all) still gets observed cleanly.
|
||||
"""
|
||||
pid = 44444
|
||||
def fake_write(target_pid):
|
||||
raise OSError("disk full")
|
||||
|
||||
from gateway import status as status_mod
|
||||
monkeypatch.setattr(status_mod, "write_planned_stop_marker", fake_write)
|
||||
monkeypatch.setattr(status_mod, "_pid_exists", lambda check_pid: False)
|
||||
|
||||
# Returns True because _pid_exists immediately says "gone".
|
||||
assert gateway_windows._drain_gateway_pid(pid, drain_timeout=5.0) is True
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"""Tests for WSL detection and WSL-aware gateway behavior."""
|
||||
|
||||
import io
|
||||
import subprocess
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch, MagicMock, mock_open
|
||||
|
||||
@@ -169,6 +167,14 @@ class TestGatewayCommandWSLMessages:
|
||||
monkeypatch.setattr(gateway, "supports_systemd_services", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_macos", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_managed", lambda: False)
|
||||
# CRITICAL: also stub is_windows. Without this, running this test on a
|
||||
# real Windows host falls through to the is_windows() branch *before*
|
||||
# the WSL guidance branch, invoking gateway_windows.install() which
|
||||
# writes a Startup-folder .cmd into the real user's Startup folder
|
||||
# (NOT tmp_path) pointing at a now-vanished pytest fixture path.
|
||||
# The user then sees a broken Hermes_Gateway.cmd flash a cmd.exe
|
||||
# window on every login. See fix/windows-gateway-reliability.
|
||||
monkeypatch.setattr(gateway, "is_windows", lambda: False)
|
||||
|
||||
args = SimpleNamespace(
|
||||
gateway_command="install", force=False, system=False,
|
||||
@@ -191,6 +197,10 @@ class TestGatewayCommandWSLMessages:
|
||||
monkeypatch.setattr(gateway, "is_wsl", lambda: True)
|
||||
monkeypatch.setattr(gateway, "supports_systemd_services", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_macos", lambda: False)
|
||||
# See test_install_wsl_no_systemd: stub is_windows so a Windows host
|
||||
# running this test does NOT actually spawn a detached gateway via
|
||||
# gateway_windows.start().
|
||||
monkeypatch.setattr(gateway, "is_windows", lambda: False)
|
||||
|
||||
args = SimpleNamespace(gateway_command="start", system=False)
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
@@ -208,6 +218,9 @@ class TestGatewayCommandWSLMessages:
|
||||
monkeypatch.setattr(gateway, "is_macos", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_wsl", lambda: True)
|
||||
# Stub is_windows so a Windows host running this test does NOT take
|
||||
# the Windows status branch (which reads gateway_windows.is_installed()).
|
||||
monkeypatch.setattr(gateway, "is_windows", lambda: False)
|
||||
monkeypatch.setattr(gateway, "find_gateway_pids", lambda: [12345])
|
||||
monkeypatch.setattr(gateway, "_runtime_health_lines", lambda: [])
|
||||
# Stub out the systemd unit path check
|
||||
@@ -233,6 +246,8 @@ class TestGatewayCommandWSLMessages:
|
||||
monkeypatch.setattr(gateway, "is_macos", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_wsl", lambda: True)
|
||||
# See test_status_wsl_running_manual.
|
||||
monkeypatch.setattr(gateway, "is_windows", lambda: False)
|
||||
monkeypatch.setattr(gateway, "find_gateway_pids", lambda: [])
|
||||
monkeypatch.setattr(gateway, "_runtime_health_lines", lambda: [])
|
||||
monkeypatch.setattr(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Tests for Google AI Studio (Gemini) provider integration."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
@@ -80,14 +80,6 @@ class TestGmiConfigRegistry:
|
||||
|
||||
|
||||
class TestGmiModelCatalog:
|
||||
def test_static_model_fallback_exists(self):
|
||||
assert "gmi" in _PROVIDER_MODELS
|
||||
models = _PROVIDER_MODELS["gmi"]
|
||||
assert "zai-org/GLM-5.1-FP8" in models
|
||||
assert "deepseek-ai/DeepSeek-V3.2" in models
|
||||
assert "moonshotai/Kimi-K2.5" in models
|
||||
assert "anthropic/claude-sonnet-4.6" in models
|
||||
|
||||
def test_canonical_provider_entry(self):
|
||||
slugs = [p.slug for p in CANONICAL_PROVIDERS]
|
||||
assert "gmi" in slugs
|
||||
@@ -183,7 +175,6 @@ class TestGmiDoctor:
|
||||
"DASHSCOPE_API_KEY",
|
||||
"MINIMAX_API_KEY",
|
||||
"MINIMAX_CN_API_KEY",
|
||||
"AI_GATEWAY_API_KEY",
|
||||
"KILOCODE_API_KEY",
|
||||
"OPENCODE_ZEN_API_KEY",
|
||||
"OPENCODE_GO_API_KEY",
|
||||
@@ -268,11 +259,6 @@ class TestGmiModelMetadata:
|
||||
|
||||
|
||||
class TestGmiAuxiliary:
|
||||
def test_aux_default_model(self):
|
||||
from agent.auxiliary_client import _get_aux_model_for_provider
|
||||
|
||||
assert _get_aux_model_for_provider("gmi") == "google/gemini-3.1-flash-lite-preview"
|
||||
|
||||
def test_resolve_provider_client_uses_gmi_aux_default(self, monkeypatch):
|
||||
monkeypatch.setenv("GMI_API_KEY", "gmi-test-key")
|
||||
|
||||
|
||||
@@ -525,7 +525,6 @@ class TestGoalStateSubgoalsBackcompat:
|
||||
def test_old_state_meta_row_loads_without_subgoals(self):
|
||||
"""A goal serialized BEFORE the subgoals field existed must
|
||||
round-trip with an empty list, not crash."""
|
||||
import json
|
||||
from hermes_cli.goals import GoalState
|
||||
|
||||
legacy = json.dumps({
|
||||
@@ -647,7 +646,7 @@ class TestJudgeGoalWithSubgoals:
|
||||
We don't actually call the model — we patch the aux client to
|
||||
capture the prompt that would be sent.
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
from unittest.mock import patch
|
||||
from hermes_cli import goals
|
||||
|
||||
captured = {}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Tests for `_can_open_graphical_browser()` in hermes_cli.auth.
|
||||
|
||||
Guards the fix for the May 2026 report where `hermes auth add xai-oauth`
|
||||
launched a text-mode browser (w3m) INSIDE the terminal on a headless Linux
|
||||
box — `_is_remote_session()` only checked SSH/cloud-shell env vars, so a plain
|
||||
local box with no GUI browser still called `webbrowser.open()`, which resolved
|
||||
to a console browser and hijacked the TTY.
|
||||
|
||||
The helper distinguishes "a real windowed browser will pop up" from "a console
|
||||
browser will hijack the terminal" so OAuth callsites can fall back to printing
|
||||
the URL / manual paste instead of auto-opening.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import webbrowser
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.auth import _can_open_graphical_browser
|
||||
|
||||
|
||||
class _FakeController:
|
||||
def __init__(self, name: str) -> None:
|
||||
self.name = name
|
||||
|
||||
def open(self, *_a, **_kw): # pragma: no cover - never invoked
|
||||
return True
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_browser_env(monkeypatch):
|
||||
"""Each test controls DISPLAY / WAYLAND_DISPLAY / BROWSER explicitly."""
|
||||
for var in ("DISPLAY", "WAYLAND_DISPLAY", "BROWSER"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
yield
|
||||
|
||||
|
||||
def _force_platform_linux(monkeypatch):
|
||||
monkeypatch.setattr("hermes_cli.auth.sys.platform", "linux")
|
||||
|
||||
|
||||
def _force_resolved_browser(monkeypatch, name: str):
|
||||
monkeypatch.setattr(webbrowser, "get", lambda *_a, **_kw: _FakeController(name))
|
||||
|
||||
|
||||
def test_headless_linux_no_display_refuses(monkeypatch):
|
||||
"""The reported bug: headless Linux, no display server → don't auto-open."""
|
||||
_force_platform_linux(monkeypatch)
|
||||
# Even if a GUI browser somehow resolved, no display means no GUI.
|
||||
_force_resolved_browser(monkeypatch, "google-chrome")
|
||||
assert _can_open_graphical_browser() is False
|
||||
|
||||
|
||||
def test_browser_env_pointing_at_console_browser_refuses(monkeypatch):
|
||||
"""$BROWSER=w3m must refuse even with a display server present."""
|
||||
_force_platform_linux(monkeypatch)
|
||||
monkeypatch.setenv("DISPLAY", ":0")
|
||||
monkeypatch.setenv("BROWSER", "/usr/bin/w3m")
|
||||
assert _can_open_graphical_browser() is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("console", ["w3m", "lynx", "links", "elinks", "browsh"])
|
||||
def test_resolved_console_browser_refuses(monkeypatch, console):
|
||||
"""When webbrowser resolves to a console browser, refuse to auto-open."""
|
||||
_force_platform_linux(monkeypatch)
|
||||
monkeypatch.setenv("DISPLAY", ":0")
|
||||
_force_resolved_browser(monkeypatch, console)
|
||||
assert _can_open_graphical_browser() is False
|
||||
|
||||
|
||||
def test_graphical_browser_with_display_allows(monkeypatch):
|
||||
"""Real GUI browser + display server → auto-open is fine."""
|
||||
_force_platform_linux(monkeypatch)
|
||||
monkeypatch.setenv("DISPLAY", ":0")
|
||||
_force_resolved_browser(monkeypatch, "firefox")
|
||||
assert _can_open_graphical_browser() is True
|
||||
|
||||
|
||||
def test_webbrowser_get_raises_refuses(monkeypatch):
|
||||
"""No resolvable browser at all → don't auto-open."""
|
||||
_force_platform_linux(monkeypatch)
|
||||
monkeypatch.setenv("DISPLAY", ":0")
|
||||
|
||||
def _boom(*_a, **_kw):
|
||||
raise webbrowser.Error("no browser")
|
||||
|
||||
monkeypatch.setattr(webbrowser, "get", _boom)
|
||||
assert _can_open_graphical_browser() is False
|
||||
|
||||
|
||||
def test_non_linux_with_gui_allows(monkeypatch):
|
||||
"""macOS / Windows always have a usable default GUI browser."""
|
||||
monkeypatch.setattr("hermes_cli.auth.sys.platform", "darwin")
|
||||
_force_resolved_browser(monkeypatch, "MacOSX")
|
||||
assert _can_open_graphical_browser() is True
|
||||
@@ -0,0 +1,628 @@
|
||||
"""Tests for ``hermes gui`` desktop launcher wiring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import main as cli_main
|
||||
|
||||
|
||||
def _ns(**kw):
|
||||
defaults = dict(
|
||||
skip_build=False,
|
||||
build_only=False,
|
||||
force_build=False,
|
||||
source=False,
|
||||
fake_boot=False,
|
||||
ignore_existing=False,
|
||||
hermes_root=None,
|
||||
cwd=None,
|
||||
)
|
||||
defaults.update(kw)
|
||||
return argparse.Namespace(**defaults)
|
||||
|
||||
|
||||
def _make_desktop_tree(tmp_path: Path) -> Path:
|
||||
root = tmp_path / "hermes-agent"
|
||||
desktop_dir = root / "apps" / "desktop"
|
||||
desktop_dir.mkdir(parents=True)
|
||||
(desktop_dir / "package.json").write_text("{}", encoding="utf-8")
|
||||
return root
|
||||
|
||||
|
||||
def _make_packaged_executable(root: Path, monkeypatch, platform: str = "darwin") -> Path:
|
||||
monkeypatch.setattr(cli_main.sys, "platform", platform)
|
||||
desktop_dir = root / "apps" / "desktop"
|
||||
if platform == "darwin":
|
||||
exe = desktop_dir / "release" / "mac-arm64" / "Hermes.app" / "Contents" / "MacOS" / "Hermes"
|
||||
elif platform == "win32":
|
||||
exe = desktop_dir / "release" / "win-unpacked" / "Hermes.exe"
|
||||
else:
|
||||
exe = desktop_dir / "release" / "linux-unpacked" / "hermes"
|
||||
exe.parent.mkdir(parents=True)
|
||||
exe.write_text("", encoding="utf-8")
|
||||
return exe
|
||||
|
||||
|
||||
def test_gui_installs_packages_and_launches_desktop_app(tmp_path, monkeypatch):
|
||||
root = _make_desktop_tree(tmp_path)
|
||||
desktop_dir = root / "apps" / "desktop"
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
packaged_exe = _make_packaged_executable(root, monkeypatch)
|
||||
|
||||
install_ok = subprocess.CompletedProcess(["npm", "ci"], 0)
|
||||
pack_ok = subprocess.CompletedProcess(["npm", "run", "pack"], 0)
|
||||
launch_ok = subprocess.CompletedProcess([str(packaged_exe)], 0)
|
||||
|
||||
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
|
||||
patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok) as mock_install, \
|
||||
patch("hermes_cli.main._desktop_build_needed", return_value=True), \
|
||||
patch("hermes_cli.main._write_desktop_build_stamp"), \
|
||||
patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \
|
||||
patch("hermes_cli.main.subprocess.run", side_effect=[pack_ok, launch_ok]) as mock_run, \
|
||||
pytest.raises(SystemExit) as exc:
|
||||
cli_main.cmd_gui(_ns())
|
||||
|
||||
assert exc.value.code == 0
|
||||
mock_install.assert_called_once_with("/usr/bin/npm", root, capture_output=False, env=None)
|
||||
assert mock_run.call_args_list[0].args[0] == ["/usr/bin/npm", "run", "pack"]
|
||||
assert mock_run.call_args_list[0].kwargs["cwd"] == desktop_dir
|
||||
assert mock_run.call_args_list[1].args[0] == [str(packaged_exe)]
|
||||
assert mock_run.call_args_list[1].kwargs["cwd"] == desktop_dir
|
||||
|
||||
|
||||
def test_gui_forwards_desktop_environment_overrides(tmp_path, monkeypatch):
|
||||
root = _make_desktop_tree(tmp_path)
|
||||
hermes_root = tmp_path / "custom-hermes"
|
||||
cwd = tmp_path / "project"
|
||||
hermes_root.mkdir()
|
||||
cwd.mkdir()
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
_make_packaged_executable(root, monkeypatch)
|
||||
|
||||
ok = subprocess.CompletedProcess([], 0)
|
||||
|
||||
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
|
||||
patch("hermes_cli.main._run_npm_install_deterministic", return_value=ok), \
|
||||
patch("hermes_cli.main._desktop_build_needed", return_value=True), \
|
||||
patch("hermes_cli.main._write_desktop_build_stamp"), \
|
||||
patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \
|
||||
patch("hermes_cli.main.subprocess.run", side_effect=[ok, ok]) as mock_run, \
|
||||
pytest.raises(SystemExit):
|
||||
cli_main.cmd_gui(_ns(
|
||||
fake_boot=True,
|
||||
ignore_existing=True,
|
||||
hermes_root=str(hermes_root),
|
||||
cwd=str(cwd),
|
||||
))
|
||||
|
||||
launch_env = mock_run.call_args_list[1].kwargs["env"]
|
||||
assert launch_env["HERMES_DESKTOP_BOOT_FAKE"] == "1"
|
||||
assert launch_env["HERMES_DESKTOP_IGNORE_EXISTING"] == "1"
|
||||
assert launch_env["HERMES_DESKTOP_HERMES_ROOT"] == str(hermes_root)
|
||||
assert launch_env["HERMES_DESKTOP_CWD"] == str(cwd)
|
||||
|
||||
|
||||
def test_gui_exits_when_npm_missing(tmp_path, monkeypatch, capsys):
|
||||
root = _make_desktop_tree(tmp_path)
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
|
||||
with patch("hermes_cli.main.shutil.which", return_value=None), \
|
||||
pytest.raises(SystemExit) as exc:
|
||||
cli_main.cmd_gui(_ns())
|
||||
|
||||
assert exc.value.code == 1
|
||||
assert "npm was not found" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_gui_skip_build_requires_existing_packaged_app(tmp_path, monkeypatch, capsys):
|
||||
root = _make_desktop_tree(tmp_path)
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
monkeypatch.setattr(cli_main.sys, "platform", "darwin")
|
||||
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
cli_main.cmd_gui(_ns(skip_build=True))
|
||||
|
||||
assert exc.value.code == 1
|
||||
assert "no packaged desktop app" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_gui_skip_build_launches_existing_packaged_app_without_npm(tmp_path, monkeypatch):
|
||||
root = _make_desktop_tree(tmp_path)
|
||||
desktop_dir = root / "apps" / "desktop"
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
packaged_exe = _make_packaged_executable(root, monkeypatch)
|
||||
|
||||
launch_ok = subprocess.CompletedProcess([str(packaged_exe)], 0)
|
||||
|
||||
with patch("hermes_cli.main.shutil.which", return_value=None), \
|
||||
patch("hermes_cli.main._run_npm_install_deterministic") as mock_install, \
|
||||
patch("hermes_cli.main.subprocess.run", return_value=launch_ok) as mock_run, \
|
||||
pytest.raises(SystemExit) as exc:
|
||||
cli_main.cmd_gui(_ns(skip_build=True))
|
||||
|
||||
assert exc.value.code == 0
|
||||
mock_install.assert_not_called()
|
||||
mock_run.assert_called_once()
|
||||
assert mock_run.call_args.args[0] == [str(packaged_exe)]
|
||||
|
||||
|
||||
def test_gui_linux_configures_sandbox_before_launch(tmp_path, monkeypatch):
|
||||
root = _make_desktop_tree(tmp_path)
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
packaged_exe = _make_packaged_executable(root, monkeypatch, platform="linux")
|
||||
sandbox = packaged_exe.parent / "chrome-sandbox"
|
||||
sandbox.write_text("", encoding="utf-8")
|
||||
sandbox.chmod(0o755)
|
||||
ok = subprocess.CompletedProcess([], 0)
|
||||
|
||||
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/sudo"), \
|
||||
patch("hermes_cli.main.subprocess.run", return_value=ok) as mock_run, \
|
||||
pytest.raises(SystemExit) as exc:
|
||||
cli_main.cmd_gui(_ns(skip_build=True))
|
||||
|
||||
assert exc.value.code == 0
|
||||
assert mock_run.call_args_list[0].args[0] == ["/usr/bin/sudo", "chown", "root:root", str(sandbox)]
|
||||
assert mock_run.call_args_list[1].args[0] == ["/usr/bin/sudo", "chmod", "4755", str(sandbox)]
|
||||
assert mock_run.call_args_list[2].args[0] == [str(packaged_exe)]
|
||||
|
||||
|
||||
def test_gui_linux_rejects_symlink_sandbox(tmp_path, monkeypatch):
|
||||
root = _make_desktop_tree(tmp_path)
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
packaged_exe = _make_packaged_executable(root, monkeypatch, platform="linux")
|
||||
# Point chrome-sandbox at an unrelated file via symlink
|
||||
target = tmp_path / "dangerous"
|
||||
target.write_text("pwned", encoding="utf-8")
|
||||
sandbox = packaged_exe.parent / "chrome-sandbox"
|
||||
sandbox.symlink_to(target)
|
||||
|
||||
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/sudo"), \
|
||||
patch("hermes_cli.main.subprocess.run") as mock_run, \
|
||||
pytest.raises(SystemExit) as exc:
|
||||
cli_main.cmd_gui(_ns(skip_build=True))
|
||||
|
||||
assert exc.value.code == 1
|
||||
# Must NOT have called sudo chown/chmod on the symlink target
|
||||
for call in mock_run.call_args_list:
|
||||
assert "chown" not in call.args[0]
|
||||
assert "chmod" not in call.args[0]
|
||||
|
||||
|
||||
def test_gui_linux_skips_fixup_when_already_configured(tmp_path, monkeypatch):
|
||||
root = _make_desktop_tree(tmp_path)
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
packaged_exe = _make_packaged_executable(root, monkeypatch, platform="linux")
|
||||
sandbox = packaged_exe.parent / "chrome-sandbox"
|
||||
sandbox.write_text("", encoding="utf-8")
|
||||
# Simulate root-owned 4755 — lstat().st_uid==0 and mode==0o4755
|
||||
# We can't actually chown to root in tests, so mock lstat to return
|
||||
# the expected values directly.
|
||||
import stat as stat_mod
|
||||
fake_stat = type("s", (), {"st_uid": 0, "st_mode": 0o4755 | stat_mod.S_IFREG})()
|
||||
sandbox_lstat_orig = type(sandbox).lstat
|
||||
monkeypatch.setattr(type(sandbox), "lstat", lambda self: fake_stat)
|
||||
|
||||
launch_ok = subprocess.CompletedProcess([str(packaged_exe)], 0)
|
||||
|
||||
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/sudo"), \
|
||||
patch("hermes_cli.main.subprocess.run", return_value=launch_ok) as mock_run, \
|
||||
pytest.raises(SystemExit) as exc:
|
||||
cli_main.cmd_gui(_ns(skip_build=True))
|
||||
|
||||
assert exc.value.code == 0
|
||||
# Only the launch call — no sudo chown/chmod
|
||||
mock_run.assert_called_once()
|
||||
assert mock_run.call_args.args[0] == [str(packaged_exe)]
|
||||
|
||||
|
||||
def test_gui_source_mode_uses_renderer_build_and_electron(tmp_path, monkeypatch):
|
||||
root = _make_desktop_tree(tmp_path)
|
||||
desktop_dir = root / "apps" / "desktop"
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
|
||||
install_ok = subprocess.CompletedProcess(["npm", "ci"], 0)
|
||||
build_ok = subprocess.CompletedProcess(["npm", "run", "build"], 0)
|
||||
launch_ok = subprocess.CompletedProcess(["npm", "exec", "--", "electron", "."], 0)
|
||||
|
||||
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
|
||||
patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok), \
|
||||
patch("hermes_cli.main._desktop_build_needed", return_value=True), \
|
||||
patch("hermes_cli.main._write_desktop_build_stamp"), \
|
||||
patch("hermes_cli.main.subprocess.run", side_effect=[build_ok, launch_ok]) as mock_run, \
|
||||
pytest.raises(SystemExit) as exc:
|
||||
cli_main.cmd_gui(_ns(source=True))
|
||||
|
||||
assert exc.value.code == 0
|
||||
assert mock_run.call_args_list[0].args[0] == ["/usr/bin/npm", "run", "build"]
|
||||
assert mock_run.call_args_list[0].kwargs["cwd"] == desktop_dir
|
||||
assert mock_run.call_args_list[1].args[0] == ["/usr/bin/npm", "exec", "--", "electron", "."]
|
||||
assert mock_run.call_args_list[1].kwargs["cwd"] == desktop_dir
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"argv",
|
||||
[
|
||||
["hermes", "gui"],
|
||||
["hermes", "-m", "gpt5", "gui"],
|
||||
],
|
||||
)
|
||||
def test_gui_is_known_builtin_for_plugin_gating(argv):
|
||||
with patch.object(sys, "argv", argv):
|
||||
assert cli_main._plugin_cli_discovery_needed() is False
|
||||
|
||||
|
||||
# ── Content-hash stamp tests ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_desktop_build_stamp_skips_build_when_up_to_date(tmp_path, monkeypatch):
|
||||
"""When the stamp matches and the artifact exists, build is skipped entirely."""
|
||||
root = _make_desktop_tree(tmp_path)
|
||||
desktop_dir = root / "apps" / "desktop"
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
_make_packaged_executable(root, monkeypatch)
|
||||
|
||||
launch_ok = subprocess.CompletedProcess([], 0)
|
||||
|
||||
with patch("hermes_cli.main._desktop_build_needed", return_value=False), \
|
||||
patch("hermes_cli.main._run_npm_install_deterministic") as mock_install, \
|
||||
patch("hermes_cli.main.subprocess.run", return_value=launch_ok) as mock_run, \
|
||||
patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \
|
||||
pytest.raises(SystemExit) as exc:
|
||||
cli_main.cmd_gui(_ns())
|
||||
|
||||
assert exc.value.code == 0
|
||||
mock_install.assert_not_called()
|
||||
mock_run.assert_called_once() # only the launch call, no build
|
||||
|
||||
|
||||
def test_desktop_force_build_overrides_stamp(tmp_path, monkeypatch):
|
||||
"""--force-build forces a rebuild even when the stamp says up-to-date."""
|
||||
root = _make_desktop_tree(tmp_path)
|
||||
desktop_dir = root / "apps" / "desktop"
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
_make_packaged_executable(root, monkeypatch)
|
||||
|
||||
install_ok = subprocess.CompletedProcess(["npm", "ci"], 0)
|
||||
pack_ok = subprocess.CompletedProcess(["npm", "run", "pack"], 0)
|
||||
launch_ok = subprocess.CompletedProcess([], 0)
|
||||
|
||||
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
|
||||
patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok) as mock_install, \
|
||||
patch("hermes_cli.main._desktop_build_needed", return_value=False), \
|
||||
patch("hermes_cli.main._write_desktop_build_stamp") as mock_stamp, \
|
||||
patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \
|
||||
patch("hermes_cli.main.subprocess.run", side_effect=[pack_ok, launch_ok]) as mock_run, \
|
||||
pytest.raises(SystemExit) as exc:
|
||||
cli_main.cmd_gui(_ns(force_build=True))
|
||||
|
||||
assert exc.value.code == 0
|
||||
mock_install.assert_called_once()
|
||||
mock_stamp.assert_called_once()
|
||||
# pack + launch = 2 calls
|
||||
assert mock_run.call_count == 2
|
||||
|
||||
|
||||
def test_compute_desktop_content_hash_stable(tmp_path, monkeypatch):
|
||||
"""_compute_desktop_content_hash returns the same digest for identical trees."""
|
||||
root = _make_desktop_tree(tmp_path)
|
||||
(root / "apps" / "desktop" / "main.js").write_text("console.log('hi')", encoding="utf-8")
|
||||
(root / "package.json").write_text('{"name":"hermes"}', encoding="utf-8")
|
||||
(root / "package-lock.json").write_text('{}', encoding="utf-8")
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
|
||||
h1 = cli_main._compute_desktop_content_hash(root)
|
||||
h2 = cli_main._compute_desktop_content_hash(root)
|
||||
assert h1 == h2
|
||||
assert len(h1) == 64 # sha256 hex
|
||||
|
||||
|
||||
def test_compute_desktop_content_hash_changes_on_edit(tmp_path, monkeypatch):
|
||||
"""Editing a file under apps/desktop/ changes the hash."""
|
||||
root = _make_desktop_tree(tmp_path)
|
||||
(root / "apps" / "desktop" / "main.js").write_text("v1", encoding="utf-8")
|
||||
(root / "package.json").write_text("{}", encoding="utf-8")
|
||||
(root / "package-lock.json").write_text("{}", encoding="utf-8")
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
|
||||
h1 = cli_main._compute_desktop_content_hash(root)
|
||||
(root / "apps" / "desktop" / "main.js").write_text("v2", encoding="utf-8")
|
||||
h2 = cli_main._compute_desktop_content_hash(root)
|
||||
assert h1 != h2
|
||||
|
||||
|
||||
def test_desktop_build_needed_detects_missing_artifact(tmp_path, monkeypatch):
|
||||
"""Even with a valid stamp, missing artifact means build is needed."""
|
||||
root = _make_desktop_tree(tmp_path)
|
||||
(root / "package.json").write_text("{}", encoding="utf-8")
|
||||
(root / "package-lock.json").write_text("{}", encoding="utf-8")
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
# Write a stamp that matches current content
|
||||
cli_main._write_desktop_build_stamp(root, source_mode=False)
|
||||
# No packaged executable exists → build needed
|
||||
assert cli_main._desktop_build_needed(
|
||||
root / "apps" / "desktop", root, source_mode=False
|
||||
) is True
|
||||
|
||||
|
||||
def test_desktop_build_stamp_round_trip(tmp_path, monkeypatch):
|
||||
"""Write stamp, then _desktop_build_needed returns False when artifact exists."""
|
||||
root = _make_desktop_tree(tmp_path)
|
||||
(root / "package.json").write_text("{}", encoding="utf-8")
|
||||
(root / "package-lock.json").write_text("{}", encoding="utf-8")
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
# Create the artifact so the "artifact exists" check passes
|
||||
_make_packaged_executable(root, monkeypatch)
|
||||
# Write stamp
|
||||
cli_main._write_desktop_build_stamp(root, source_mode=False)
|
||||
# Build should NOT be needed
|
||||
assert cli_main._desktop_build_needed(
|
||||
root / "apps" / "desktop", root, source_mode=False
|
||||
) is False
|
||||
|
||||
|
||||
def test_compute_desktop_content_hash_works_without_gitignore(tmp_path, monkeypatch):
|
||||
"""When no .gitignore exists, _compute_desktop_content_hash still works (matches everything)."""
|
||||
root = _make_desktop_tree(tmp_path)
|
||||
(root / "apps" / "desktop" / "main.js").write_text("v1", encoding="utf-8")
|
||||
(root / "package.json").write_text("{}", encoding="utf-8")
|
||||
(root / "package-lock.json").write_text("{}", encoding="utf-8")
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
|
||||
# No .gitignore → pathspec matches nothing → all files hashed
|
||||
h = cli_main._compute_desktop_content_hash(root)
|
||||
assert len(h) == 64 # valid sha256 hex
|
||||
|
||||
# Edit a file → hash changes
|
||||
(root / "apps" / "desktop" / "main.js").write_text("v2", encoding="utf-8")
|
||||
h2 = cli_main._compute_desktop_content_hash(root)
|
||||
assert h != h2
|
||||
|
||||
|
||||
def test_compute_desktop_content_hash_respects_gitignore(tmp_path, monkeypatch):
|
||||
"""Files matched by .gitignore are excluded from the hash."""
|
||||
root = _make_desktop_tree(tmp_path)
|
||||
(root / "apps" / "desktop" / "main.js").write_text("hello", encoding="utf-8")
|
||||
(root / "apps" / "desktop" / "secrets.env").write_text("API_KEY=xxx", encoding="utf-8")
|
||||
(root / "package.json").write_text("{}", encoding="utf-8")
|
||||
(root / "package-lock.json").write_text("{}", encoding="utf-8")
|
||||
(root / ".gitignore").write_text("*.env\n", encoding="utf-8")
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
|
||||
# Reset cached spec
|
||||
cli_main._DESKTOP_STAMP_SPEC = None
|
||||
|
||||
h1 = cli_main._compute_desktop_content_hash(root)
|
||||
|
||||
# Change the .env file (ignored) — hash should NOT change
|
||||
(root / "apps" / "desktop" / "secrets.env").write_text("API_KEY=yyy", encoding="utf-8")
|
||||
cli_main._DESKTOP_STAMP_SPEC = None # reset since gitignore hasn't changed
|
||||
h2 = cli_main._compute_desktop_content_hash(root)
|
||||
assert h1 == h2, "changing an ignored file should not change the hash"
|
||||
|
||||
# Change the .js file (not ignored) — hash SHOULD change
|
||||
(root / "apps" / "desktop" / "main.js").write_text("world", encoding="utf-8")
|
||||
cli_main._DESKTOP_STAMP_SPEC = None
|
||||
h3 = cli_main._compute_desktop_content_hash(root)
|
||||
assert h1 != h3, "changing a tracked file should change the hash"
|
||||
|
||||
|
||||
# ── Electron build-cache recovery tests ───────────────────────────────
|
||||
|
||||
|
||||
def _write_zip(path: Path) -> None:
|
||||
import zipfile
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zipfile.ZipFile(path, "w") as zf:
|
||||
zf.writestr("electron", "fake binary payload")
|
||||
|
||||
|
||||
def test_purge_electron_build_cache_clears_all_zips_and_unpacked_dir(tmp_path, monkeypatch):
|
||||
"""Purge is unconditional: it removes every electron-*.zip (regardless of
|
||||
whether stdlib zipfile thinks it's corrupt) plus the half-written unpacked
|
||||
dir, because @electron/get's own SHASUM check on re-download is the real
|
||||
validator — not a self-rolled one."""
|
||||
cache = tmp_path / "electron-cache"
|
||||
# A "clean" zip and a prepended-junk zip — the latter is the real-world
|
||||
# corruption that zipfile.testzip() silently passes (it reads from the
|
||||
# end-of-central-directory backward), which is why we don't gate on it.
|
||||
clean = cache / "electron-v40.9.3-linux-x64.zip"
|
||||
prepended = cache / "hashdir" / "electron-v40.9.3-linux-x64.zip"
|
||||
_write_zip(clean)
|
||||
_write_zip(prepended)
|
||||
prepended.write_bytes(b"\x00" * 4096 + prepended.read_bytes())
|
||||
|
||||
desktop_dir = tmp_path / "apps" / "desktop"
|
||||
unpacked = desktop_dir / "release" / "linux-unpacked"
|
||||
unpacked.mkdir(parents=True)
|
||||
(unpacked / "LICENSE.electron.txt").write_text("x", encoding="utf-8")
|
||||
(unpacked / "resources.pak").write_text("x", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(cli_main, "_electron_download_cache_dirs", lambda: [cache])
|
||||
|
||||
removed = cli_main._purge_electron_build_cache(desktop_dir)
|
||||
|
||||
assert clean in removed
|
||||
assert prepended in removed
|
||||
assert unpacked in removed
|
||||
assert not clean.exists()
|
||||
assert not prepended.exists()
|
||||
assert not unpacked.exists()
|
||||
|
||||
|
||||
def test_purge_electron_build_cache_empty_when_nothing_present(tmp_path, monkeypatch):
|
||||
"""No cached zips and no unpacked dir → nothing removed, so the caller
|
||||
knows a retry is pointless."""
|
||||
cache = tmp_path / "electron-cache"
|
||||
cache.mkdir()
|
||||
desktop_dir = tmp_path / "apps" / "desktop"
|
||||
monkeypatch.setattr(cli_main, "_electron_download_cache_dirs", lambda: [cache])
|
||||
|
||||
assert cli_main._purge_electron_build_cache(desktop_dir) == []
|
||||
|
||||
|
||||
def test_gui_retries_pack_once_after_purging_build_cache(tmp_path, monkeypatch):
|
||||
"""First pack fails, purge clears the cache, second pack succeeds, launch."""
|
||||
root = _make_desktop_tree(tmp_path)
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
packaged_exe = _make_packaged_executable(root, monkeypatch, platform="linux")
|
||||
|
||||
install_ok = subprocess.CompletedProcess(["npm", "ci"], 0)
|
||||
pack_fail = subprocess.CompletedProcess(["npm", "run", "pack"], 1)
|
||||
pack_ok = subprocess.CompletedProcess(["npm", "run", "pack"], 0)
|
||||
launch_ok = subprocess.CompletedProcess([str(packaged_exe)], 0)
|
||||
|
||||
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
|
||||
patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok), \
|
||||
patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \
|
||||
patch("hermes_cli.main._desktop_linux_sandbox_fixup", return_value=True), \
|
||||
patch("hermes_cli.main._write_desktop_build_stamp"), \
|
||||
patch("hermes_cli.main._purge_electron_build_cache", return_value=[Path("/c/electron.zip")]) as mock_purge, \
|
||||
patch("hermes_cli.main.subprocess.run", side_effect=[pack_fail, pack_ok, launch_ok]) as mock_run, \
|
||||
pytest.raises(SystemExit) as exc:
|
||||
cli_main.cmd_gui(_ns())
|
||||
|
||||
assert exc.value.code == 0
|
||||
mock_purge.assert_called_once()
|
||||
# pack(fail) → purge → pack(ok) → launch = 3 subprocess.run calls
|
||||
assert mock_run.call_count == 3
|
||||
assert mock_run.call_args_list[0].args[0] == ["/usr/bin/npm", "run", "pack"]
|
||||
assert mock_run.call_args_list[1].args[0] == ["/usr/bin/npm", "run", "pack"]
|
||||
assert mock_run.call_args_list[2].args[0] == [str(packaged_exe)]
|
||||
|
||||
|
||||
def test_gui_falls_back_to_mirror_when_purge_finds_nothing(tmp_path, monkeypatch, capsys):
|
||||
"""Purge clears nothing (not a cache problem) → fall back to an Electron
|
||||
mirror once before failing, so a GitHub-blocked download self-heals."""
|
||||
root = _make_desktop_tree(tmp_path)
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
_make_packaged_executable(root, monkeypatch, platform="linux")
|
||||
monkeypatch.delenv("ELECTRON_MIRROR", raising=False)
|
||||
|
||||
install_ok = subprocess.CompletedProcess(["npm", "ci"], 0)
|
||||
pack_fail = subprocess.CompletedProcess(["npm", "run", "pack"], 1)
|
||||
|
||||
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
|
||||
patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok), \
|
||||
patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \
|
||||
patch("hermes_cli.main._purge_electron_build_cache", return_value=[]) as mock_purge, \
|
||||
patch("hermes_cli.main.subprocess.run", side_effect=[pack_fail, pack_fail]) as mock_run, \
|
||||
pytest.raises(SystemExit) as exc:
|
||||
cli_main.cmd_gui(_ns())
|
||||
|
||||
assert exc.value.code == 1
|
||||
mock_purge.assert_called_once()
|
||||
# pack(fail) → purge(nothing) → pack via mirror(fail) = 2 subprocess.run calls
|
||||
assert mock_run.call_count == 2
|
||||
# The retry runs the same build but with ELECTRON_MIRROR injected.
|
||||
assert "ELECTRON_MIRROR" not in (mock_run.call_args_list[0].kwargs.get("env") or {})
|
||||
assert mock_run.call_args_list[1].kwargs["env"]["ELECTRON_MIRROR"]
|
||||
assert "Desktop GUI build failed" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_gui_does_not_override_user_electron_mirror(tmp_path, monkeypatch, capsys):
|
||||
"""A user-pinned ELECTRON_MIRROR is respected: no extra mirror fallback
|
||||
attempt (and we never swap in our default mirror)."""
|
||||
root = _make_desktop_tree(tmp_path)
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
_make_packaged_executable(root, monkeypatch, platform="linux")
|
||||
monkeypatch.setenv("ELECTRON_MIRROR", "https://mirror.example/electron/")
|
||||
|
||||
install_ok = subprocess.CompletedProcess(["npm", "ci"], 0)
|
||||
pack_fail = subprocess.CompletedProcess(["npm", "run", "pack"], 1)
|
||||
|
||||
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
|
||||
patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok), \
|
||||
patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \
|
||||
patch("hermes_cli.main._purge_electron_build_cache", return_value=[]) as mock_purge, \
|
||||
patch("hermes_cli.main.subprocess.run", side_effect=[pack_fail]) as mock_run, \
|
||||
pytest.raises(SystemExit) as exc:
|
||||
cli_main.cmd_gui(_ns())
|
||||
|
||||
assert exc.value.code == 1
|
||||
mock_purge.assert_called_once()
|
||||
assert mock_run.call_count == 1
|
||||
assert mock_run.call_args_list[0].kwargs["env"]["ELECTRON_MIRROR"] == "https://mirror.example/electron/"
|
||||
assert "Desktop GUI build failed" in capsys.readouterr().out
|
||||
|
||||
|
||||
class _FakeProc:
|
||||
"""Minimal psutil.Process stand-in for the lock-breaker tests."""
|
||||
|
||||
def __init__(self, pid: int, exe: str | None):
|
||||
self.pid = pid
|
||||
self.info = {"pid": pid, "exe": exe}
|
||||
self.terminated = False
|
||||
self.killed = False
|
||||
|
||||
def terminate(self):
|
||||
self.terminated = True
|
||||
|
||||
def kill(self):
|
||||
self.killed = True
|
||||
|
||||
|
||||
def test_stop_desktop_build_lock_noop_off_windows(tmp_path, monkeypatch):
|
||||
"""POSIX can unlink a running binary, so the helper is a no-op there."""
|
||||
desktop_dir = tmp_path / "apps" / "desktop"
|
||||
exe = desktop_dir / "release" / "linux-unpacked" / "hermes"
|
||||
exe.parent.mkdir(parents=True)
|
||||
exe.write_text("", encoding="utf-8")
|
||||
monkeypatch.setattr(cli_main.sys, "platform", "linux")
|
||||
|
||||
proc = _FakeProc(4321, str(exe))
|
||||
with patch("psutil.process_iter", return_value=[proc]) as it:
|
||||
assert cli_main._stop_desktop_processes_locking_build(desktop_dir) == []
|
||||
it.assert_not_called()
|
||||
assert proc.terminated is False
|
||||
|
||||
|
||||
def test_stop_desktop_build_lock_terminates_only_release_procs(tmp_path, monkeypatch):
|
||||
desktop_dir = tmp_path / "apps" / "desktop"
|
||||
release = desktop_dir / "release" / "win-unpacked"
|
||||
release.mkdir(parents=True)
|
||||
locker_exe = release / "Hermes.exe"
|
||||
locker_exe.write_text("", encoding="utf-8")
|
||||
other_exe = tmp_path / "elsewhere" / "Hermes.exe"
|
||||
other_exe.parent.mkdir(parents=True)
|
||||
other_exe.write_text("", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(cli_main.sys, "platform", "win32")
|
||||
monkeypatch.setattr(cli_main.os, "getpid", lambda: 999)
|
||||
|
||||
locker = _FakeProc(101, str(locker_exe))
|
||||
unrelated = _FakeProc(102, str(other_exe))
|
||||
selfish = _FakeProc(999, str(locker_exe)) # our own PID — never killed
|
||||
no_exe = _FakeProc(103, None)
|
||||
|
||||
captured = {}
|
||||
|
||||
def _wait(procs, timeout=None):
|
||||
captured["waited"] = list(procs)
|
||||
return procs, []
|
||||
|
||||
with patch("psutil.process_iter", return_value=[locker, unrelated, selfish, no_exe]), \
|
||||
patch("psutil.wait_procs", side_effect=_wait):
|
||||
stopped = cli_main._stop_desktop_processes_locking_build(desktop_dir)
|
||||
|
||||
assert stopped == [101]
|
||||
assert locker.terminated is True
|
||||
assert unrelated.terminated is False
|
||||
assert selfish.terminated is False
|
||||
assert captured["waited"] == [locker]
|
||||
|
||||
|
||||
def test_stop_desktop_build_lock_no_release_dir(tmp_path, monkeypatch):
|
||||
desktop_dir = tmp_path / "apps" / "desktop"
|
||||
desktop_dir.mkdir(parents=True)
|
||||
monkeypatch.setattr(cli_main.sys, "platform", "win32")
|
||||
with patch("psutil.process_iter") as it:
|
||||
assert cli_main._stop_desktop_processes_locking_build(desktop_dir) == []
|
||||
it.assert_not_called()
|
||||
@@ -0,0 +1,348 @@
|
||||
"""Tests for hermes_cli.gui_uninstall — GUI-only uninstall + install discovery.
|
||||
|
||||
Covers the cross-platform artifact discovery, the agent/GUI detection the
|
||||
desktop UI gates options on, and that ``uninstall_gui`` removes only GUI
|
||||
artifacts (built renderer/release/node_modules, packaged bundle, Electron
|
||||
userData) while leaving the Python agent + config/sessions/.env intact.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_cli.gui_uninstall as gu
|
||||
|
||||
|
||||
def _make_agent(hermes_home: Path) -> Path:
|
||||
"""Create a fake agent install: source package + venv."""
|
||||
agent_root = hermes_home / "hermes-agent"
|
||||
(agent_root / "hermes_cli").mkdir(parents=True)
|
||||
(agent_root / "hermes_cli" / "__init__.py").write_text("")
|
||||
(agent_root / "venv" / "bin").mkdir(parents=True)
|
||||
return agent_root
|
||||
|
||||
|
||||
def _make_gui_build(hermes_home: Path) -> None:
|
||||
"""Create the source-built GUI artifacts a `hermes desktop` run produces."""
|
||||
desktop = hermes_home / "hermes-agent" / "apps" / "desktop"
|
||||
(desktop / "dist").mkdir(parents=True)
|
||||
(desktop / "dist" / "index.html").write_text("<html>")
|
||||
(desktop / "release" / "linux-unpacked").mkdir(parents=True)
|
||||
(desktop / "node_modules").mkdir(parents=True)
|
||||
(hermes_home / "hermes-agent" / "node_modules").mkdir(parents=True)
|
||||
(hermes_home / "desktop-build-stamp.json").write_text("{}")
|
||||
|
||||
|
||||
def _make_user_data(hermes_home: Path) -> None:
|
||||
(hermes_home / "config.yaml").write_text("x: 1\n")
|
||||
(hermes_home / ".env").write_text("KEY=secret\n")
|
||||
(hermes_home / "sessions").mkdir()
|
||||
|
||||
|
||||
def test_agent_is_installed_detects_source_and_venv(tmp_path):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
assert gu.agent_is_installed(hermes_home) is False
|
||||
_make_agent(hermes_home)
|
||||
assert gu.agent_is_installed(hermes_home) is True
|
||||
|
||||
|
||||
def test_agent_is_installed_venv_only(tmp_path):
|
||||
"""A checkout with only a venv (no package dir yet) still counts."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
(hermes_home / "hermes-agent" / "venv").mkdir(parents=True)
|
||||
assert gu.agent_is_installed(hermes_home) is True
|
||||
|
||||
|
||||
def test_source_built_artifacts_lists_known_paths(tmp_path):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
_make_gui_build(hermes_home)
|
||||
artifacts = gu.source_built_gui_artifacts(hermes_home)
|
||||
names = {p.name for p in artifacts}
|
||||
assert "dist" in names
|
||||
assert "release" in names
|
||||
assert "node_modules" in names
|
||||
assert "desktop-build-stamp.json" in names
|
||||
|
||||
|
||||
def test_gui_is_installed_true_when_built(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
_make_gui_build(hermes_home)
|
||||
# Make sure packaged-app + userdata probes don't false-positive on the box
|
||||
# running the test.
|
||||
monkeypatch.setattr(gu, "packaged_gui_app_paths", lambda: [])
|
||||
monkeypatch.setattr(gu, "desktop_userdata_dir", lambda: tmp_path / "nope")
|
||||
assert gu.gui_is_installed(hermes_home) is True
|
||||
|
||||
|
||||
def test_gui_is_installed_false_when_nothing(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setattr(gu, "packaged_gui_app_paths", lambda: [])
|
||||
monkeypatch.setattr(gu, "desktop_userdata_dir", lambda: tmp_path / "nope")
|
||||
assert gu.gui_is_installed(hermes_home) is False
|
||||
|
||||
|
||||
def test_uninstall_gui_removes_only_gui_artifacts(tmp_path, monkeypatch):
|
||||
"""The core invariant: GUI gone, agent + user data untouched."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
agent_root = _make_agent(hermes_home)
|
||||
_make_gui_build(hermes_home)
|
||||
_make_user_data(hermes_home)
|
||||
|
||||
# Isolate the packaged-app + userdata probes from the test machine.
|
||||
monkeypatch.setattr(gu, "packaged_gui_app_paths", lambda: [])
|
||||
monkeypatch.setattr(gu, "desktop_userdata_dir", lambda: tmp_path / "userdata-none")
|
||||
|
||||
removed = gu.uninstall_gui(hermes_home)
|
||||
removed_names = {p.name for p in removed}
|
||||
|
||||
# GUI artifacts removed.
|
||||
desktop = agent_root / "apps" / "desktop"
|
||||
assert not (desktop / "dist").exists()
|
||||
assert not (desktop / "release").exists()
|
||||
assert not (desktop / "node_modules").exists()
|
||||
assert not (agent_root / "node_modules").exists()
|
||||
assert not (hermes_home / "desktop-build-stamp.json").exists()
|
||||
assert "dist" in removed_names
|
||||
|
||||
# Agent + user data preserved.
|
||||
assert (agent_root / "hermes_cli" / "__init__.py").exists()
|
||||
assert (agent_root / "venv").exists()
|
||||
assert (hermes_home / "config.yaml").exists()
|
||||
assert (hermes_home / ".env").exists()
|
||||
assert (hermes_home / "sessions").exists()
|
||||
# The desktop source dir itself survives (only its build output is gone).
|
||||
assert desktop.exists()
|
||||
|
||||
|
||||
def test_uninstall_gui_removes_userdata(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
_make_agent(hermes_home)
|
||||
userdata = tmp_path / "Hermes-userdata"
|
||||
userdata.mkdir()
|
||||
(userdata / "connection.json").write_text("{}")
|
||||
|
||||
monkeypatch.setattr(gu, "packaged_gui_app_paths", lambda: [])
|
||||
monkeypatch.setattr(gu, "desktop_userdata_dir", lambda: userdata)
|
||||
|
||||
gu.uninstall_gui(hermes_home)
|
||||
assert not userdata.exists()
|
||||
|
||||
|
||||
def test_uninstall_gui_keeps_userdata_when_requested(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
_make_agent(hermes_home)
|
||||
userdata = tmp_path / "Hermes-userdata"
|
||||
userdata.mkdir()
|
||||
|
||||
monkeypatch.setattr(gu, "packaged_gui_app_paths", lambda: [])
|
||||
monkeypatch.setattr(gu, "desktop_userdata_dir", lambda: userdata)
|
||||
|
||||
gu.uninstall_gui(hermes_home, remove_userdata=False)
|
||||
assert userdata.exists()
|
||||
|
||||
|
||||
def test_uninstall_gui_removes_packaged_bundle(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
_make_agent(hermes_home)
|
||||
bundle = tmp_path / "Hermes.app"
|
||||
(bundle / "Contents").mkdir(parents=True)
|
||||
|
||||
monkeypatch.setattr(gu, "packaged_gui_app_paths", lambda: [bundle])
|
||||
monkeypatch.setattr(gu, "desktop_userdata_dir", lambda: tmp_path / "none")
|
||||
|
||||
removed = gu.uninstall_gui(hermes_home)
|
||||
assert not bundle.exists()
|
||||
assert bundle in removed
|
||||
|
||||
|
||||
def test_gui_install_summary_shape(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
_make_agent(hermes_home)
|
||||
_make_gui_build(hermes_home)
|
||||
monkeypatch.setattr(gu, "packaged_gui_app_paths", lambda: [])
|
||||
monkeypatch.setattr(gu, "desktop_userdata_dir", lambda: tmp_path / "none")
|
||||
|
||||
summary = gu.gui_install_summary(hermes_home)
|
||||
# JSON-serializable primitives the desktop UI gates on.
|
||||
assert summary["agent_installed"] is True
|
||||
assert summary["gui_installed"] is True
|
||||
assert isinstance(summary["source_built_artifacts"], list)
|
||||
assert all(isinstance(p, str) for p in summary["source_built_artifacts"])
|
||||
assert summary["hermes_home"] == str(hermes_home)
|
||||
assert summary["platform"] == sys.platform
|
||||
|
||||
|
||||
def test_userdata_dir_per_platform(monkeypatch):
|
||||
"""userData path matches Electron's app.getPath('userData') for "Hermes"."""
|
||||
home = Path("/home/tester")
|
||||
monkeypatch.setattr(Path, "home", classmethod(lambda cls: home))
|
||||
|
||||
monkeypatch.setattr(gu.sys, "platform", "darwin")
|
||||
assert gu.desktop_userdata_dir() == home / "Library" / "Application Support" / "Hermes"
|
||||
|
||||
monkeypatch.setattr(gu.sys, "platform", "linux")
|
||||
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
|
||||
assert gu.desktop_userdata_dir() == home / ".config" / "Hermes"
|
||||
|
||||
|
||||
def test_userdata_dir_windows(monkeypatch):
|
||||
home = Path("/home/tester")
|
||||
monkeypatch.setattr(Path, "home", classmethod(lambda cls: home))
|
||||
monkeypatch.setattr(gu.sys, "platform", "win32")
|
||||
monkeypatch.setenv("APPDATA", r"C:\Users\tester\AppData\Roaming")
|
||||
assert gu.desktop_userdata_dir() == Path(r"C:\Users\tester\AppData\Roaming") / "Hermes"
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlink semantics")
|
||||
def test_remove_path_handles_symlink(tmp_path):
|
||||
target = tmp_path / "real"
|
||||
target.mkdir()
|
||||
link = tmp_path / "link"
|
||||
link.symlink_to(target)
|
||||
assert gu._remove_path(link) is True
|
||||
assert not link.exists()
|
||||
# The symlink is gone but its target is untouched.
|
||||
assert target.exists()
|
||||
|
||||
|
||||
class _Args:
|
||||
"""Minimal argparse-Namespace stand-in for run_uninstall."""
|
||||
|
||||
def __init__(self, *, yes=False, full=False, gui=False, gui_summary=False):
|
||||
self.yes = yes
|
||||
self.full = full
|
||||
self.gui = gui
|
||||
self.gui_summary = gui_summary
|
||||
|
||||
|
||||
def test_run_uninstall_yes_keep_data_is_non_interactive(tmp_path, monkeypatch):
|
||||
"""``--yes`` (no ``--full``) runs with no prompt, sweeps the GUI, keeps data.
|
||||
|
||||
We DO NOT spawn the real CLI here (its project_root removal would delete the
|
||||
test checkout) — we call run_uninstall in-process against a throwaway
|
||||
HERMES_HOME with all the destructive externals stubbed out.
|
||||
"""
|
||||
import hermes_cli.uninstall as uninstall
|
||||
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
agent_root = hermes_home / "hermes-agent"
|
||||
(agent_root / "hermes_cli").mkdir(parents=True)
|
||||
(hermes_home / "config.yaml").write_text("x: 1\n")
|
||||
desktop = agent_root / "apps" / "desktop"
|
||||
(desktop / "release").mkdir(parents=True)
|
||||
(hermes_home / "desktop-build-stamp.json").write_text("{}")
|
||||
fake_code = tmp_path / "checkout"
|
||||
fake_code.mkdir()
|
||||
|
||||
# Stub every destructive external so the test only exercises the control
|
||||
# flow + the real GUI sweep (which is safe inside tmp_path).
|
||||
monkeypatch.setattr(uninstall, "get_hermes_home", lambda: hermes_home)
|
||||
monkeypatch.setattr(uninstall, "get_project_root", lambda: fake_code)
|
||||
monkeypatch.setattr(uninstall, "uninstall_gateway_service", lambda: False)
|
||||
monkeypatch.setattr(uninstall, "remove_path_from_shell_configs", lambda: [])
|
||||
monkeypatch.setattr(uninstall, "remove_wrapper_script", lambda: [])
|
||||
monkeypatch.setattr(uninstall, "remove_node_symlinks", lambda h: [])
|
||||
monkeypatch.setattr(uninstall, "_discover_named_profiles", lambda: [])
|
||||
# Make input() blow up so a regression that reaches a prompt fails loudly.
|
||||
monkeypatch.setattr("builtins.input", lambda *a, **k: pytest.fail("prompted in --yes mode"))
|
||||
|
||||
from hermes_cli import gui_uninstall as gu_mod
|
||||
monkeypatch.setattr(gu_mod, "packaged_gui_app_paths", lambda: [])
|
||||
monkeypatch.setattr(gu_mod, "desktop_userdata_dir", lambda: tmp_path / "none")
|
||||
|
||||
uninstall.run_uninstall(_Args(yes=True, full=False))
|
||||
|
||||
# Code checkout removed, GUI artifacts swept, but user data preserved.
|
||||
assert not fake_code.exists()
|
||||
assert not (hermes_home / "desktop-build-stamp.json").exists()
|
||||
assert not (desktop / "release").exists()
|
||||
assert (hermes_home / "config.yaml").exists()
|
||||
assert hermes_home.exists()
|
||||
|
||||
|
||||
def test_run_uninstall_yes_full_wipes_home(tmp_path, monkeypatch):
|
||||
"""``--yes --full`` removes the whole HERMES_HOME non-interactively."""
|
||||
import hermes_cli.uninstall as uninstall
|
||||
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
(hermes_home / "hermes-agent" / "hermes_cli").mkdir(parents=True)
|
||||
(hermes_home / "config.yaml").write_text("x: 1\n")
|
||||
fake_code = tmp_path / "checkout"
|
||||
fake_code.mkdir()
|
||||
|
||||
monkeypatch.setattr(uninstall, "get_hermes_home", lambda: hermes_home)
|
||||
monkeypatch.setattr(uninstall, "get_project_root", lambda: fake_code)
|
||||
monkeypatch.setattr(uninstall, "uninstall_gateway_service", lambda: False)
|
||||
monkeypatch.setattr(uninstall, "remove_path_from_shell_configs", lambda: [])
|
||||
monkeypatch.setattr(uninstall, "remove_wrapper_script", lambda: [])
|
||||
monkeypatch.setattr(uninstall, "remove_node_symlinks", lambda h: [])
|
||||
monkeypatch.setattr(uninstall, "_discover_named_profiles", lambda: [])
|
||||
monkeypatch.setattr("builtins.input", lambda *a, **k: pytest.fail("prompted in --yes mode"))
|
||||
|
||||
from hermes_cli import gui_uninstall as gu_mod
|
||||
monkeypatch.setattr(gu_mod, "packaged_gui_app_paths", lambda: [])
|
||||
monkeypatch.setattr(gu_mod, "desktop_userdata_dir", lambda: tmp_path / "none")
|
||||
|
||||
uninstall.run_uninstall(_Args(yes=True, full=True))
|
||||
|
||||
assert not hermes_home.exists()
|
||||
|
||||
|
||||
def test_uninstall_module_main_gui_mode(tmp_path, monkeypatch):
|
||||
"""`python -m hermes_cli.uninstall --mode gui` runs the GUI-only path.
|
||||
|
||||
This is the lightweight, venv-independent entrypoint the desktop launches
|
||||
with a system Python (so lite/full don't rmtree their own running venv on
|
||||
Windows). Verify it dispatches by mode without prompting.
|
||||
"""
|
||||
import hermes_cli.uninstall as uninstall
|
||||
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
agent_root = hermes_home / "hermes-agent"
|
||||
(agent_root / "hermes_cli").mkdir(parents=True)
|
||||
desktop = agent_root / "apps" / "desktop"
|
||||
(desktop / "release").mkdir(parents=True)
|
||||
(hermes_home / "desktop-build-stamp.json").write_text("{}")
|
||||
(hermes_home / "config.yaml").write_text("x: 1\n")
|
||||
|
||||
monkeypatch.setattr(uninstall, "get_hermes_home", lambda: hermes_home)
|
||||
from hermes_cli import gui_uninstall as gu_mod
|
||||
monkeypatch.setattr(gu_mod, "packaged_gui_app_paths", lambda: [])
|
||||
monkeypatch.setattr(gu_mod, "desktop_userdata_dir", lambda: tmp_path / "none")
|
||||
monkeypatch.setattr(gu_mod, "get_hermes_home", lambda: hermes_home)
|
||||
monkeypatch.setattr("builtins.input", lambda *a, **k: pytest.fail("prompted in module main"))
|
||||
|
||||
rc = uninstall.main(["--mode", "gui"])
|
||||
assert rc == 0
|
||||
# GUI swept, agent + config kept (gui-only contract).
|
||||
assert not (desktop / "release").exists()
|
||||
assert not (hermes_home / "desktop-build-stamp.json").exists()
|
||||
assert (agent_root / "hermes_cli").exists()
|
||||
assert (hermes_home / "config.yaml").exists()
|
||||
|
||||
|
||||
def test_uninstall_module_main_rejects_bad_mode():
|
||||
"""An invalid --mode exits non-zero (argparse), never silently full-wipes."""
|
||||
import hermes_cli.uninstall as uninstall
|
||||
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
uninstall.main(["--mode", "nuke"])
|
||||
assert exc.value.code != 0
|
||||
|
||||
|
||||
def test_uninstall_args_namespace_mode_mapping():
|
||||
"""_UninstallArgs maps mode → the gui/full flags run_uninstall reads."""
|
||||
import hermes_cli.uninstall as uninstall
|
||||
|
||||
gui = uninstall._UninstallArgs(mode="gui")
|
||||
assert gui.gui is True and gui.full is False and gui.yes is True
|
||||
|
||||
lite = uninstall._UninstallArgs(mode="lite")
|
||||
assert lite.gui is False and lite.full is False and lite.yes is True
|
||||
|
||||
full = uninstall._UninstallArgs(mode="full")
|
||||
assert full.gui is False and full.full is True and full.yes is True
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
from contextlib import redirect_stdout
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
@@ -69,18 +69,19 @@ class TestPluginPickerInjection:
|
||||
assert "Myimg" in names
|
||||
assert "myimg" in plugin_names
|
||||
|
||||
def test_fal_skipped_to_avoid_duplicate(self, monkeypatch):
|
||||
def test_fal_surfaced_alongside_other_plugins(self, monkeypatch):
|
||||
from hermes_cli import tools_config
|
||||
|
||||
# Simulate a FAL plugin being registered — the picker already has
|
||||
# hardcoded FAL rows in TOOL_CATEGORIES, so plugin-FAL must be
|
||||
# skipped to avoid showing FAL twice.
|
||||
# After #26241, FAL is itself a plugin (`plugins/image_gen/fal/`)
|
||||
# and the hardcoded `TOOL_CATEGORIES["image_gen"]` FAL row is
|
||||
# gone. The plugin-row builder therefore surfaces it like any
|
||||
# other backend — no deduplication step needed.
|
||||
image_gen_registry.register_provider(_FakeProvider("fal"))
|
||||
image_gen_registry.register_provider(_FakeProvider("openai"))
|
||||
|
||||
rows = tools_config._plugin_image_gen_providers()
|
||||
names = [r.get("image_gen_plugin_name") for r in rows]
|
||||
assert "fal" not in names
|
||||
assert "fal" in names
|
||||
assert "openai" in names
|
||||
|
||||
def test_visible_providers_includes_plugins_for_image_gen(self, monkeypatch):
|
||||
@@ -236,7 +237,7 @@ class TestConfigWriting:
|
||||
monkeypatch.setattr(
|
||||
tools_config,
|
||||
"get_nous_subscription_features",
|
||||
lambda config: SimpleNamespace(
|
||||
lambda config, **kwargs: SimpleNamespace(
|
||||
features={"image_gen": SimpleNamespace(managed_by_nous=True)}
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for ``install_cua_driver`` upgrade semantics.
|
||||
"""Tests for ``install_cua_driver`` upgrade semantics and architecture pre-check.
|
||||
|
||||
The cua-driver upstream installer always pulls the latest release tag, so
|
||||
re-running it is the canonical upgrade path. ``install_cua_driver(upgrade=True)``
|
||||
@@ -10,18 +10,18 @@ must:
|
||||
fix for the "we only pulled cua-driver once on enable" complaint).
|
||||
* Preserve original ``upgrade=False`` behaviour for the toolset-enable flow:
|
||||
skip if installed, install otherwise, warn on non-macOS.
|
||||
* Pre-check architecture compatibility before downloading to avoid raw 404
|
||||
errors on Intel macOS when the upstream release lacks x86_64 assets.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
class TestInstallCuaDriverUpgrade:
|
||||
def test_upgrade_on_non_macos_is_silent_noop(self):
|
||||
"""``hermes update`` calls install_cua_driver(upgrade=True) for every
|
||||
user. On Linux/Windows it must return False without printing the
|
||||
"macOS-only; skipping" warning that the toolset-enable path emits."""
|
||||
from hermes_cli import tools_config
|
||||
|
||||
with patch.object(tools_config, "_print_warning") as warn, \
|
||||
@@ -30,8 +30,6 @@ class TestInstallCuaDriverUpgrade:
|
||||
warn.assert_not_called()
|
||||
|
||||
def test_non_upgrade_on_non_macos_warns(self):
|
||||
"""The toolset-enable path (upgrade=False) should still warn loudly
|
||||
when the user tries to enable Computer Use on a non-macOS host."""
|
||||
from hermes_cli import tools_config
|
||||
|
||||
with patch.object(tools_config, "_print_warning") as warn, \
|
||||
@@ -40,43 +38,36 @@ class TestInstallCuaDriverUpgrade:
|
||||
warn.assert_called()
|
||||
|
||||
def test_upgrade_on_macos_with_binary_runs_installer(self):
|
||||
"""When cua-driver is already on PATH and upgrade=True, we must
|
||||
re-run the upstream installer (this is the fix for the bug report).
|
||||
"""
|
||||
from hermes_cli import tools_config
|
||||
|
||||
with patch("platform.system", return_value="Darwin"), \
|
||||
patch.object(tools_config.shutil, "which",
|
||||
side_effect=lambda n: "/usr/local/bin/" + n
|
||||
if n in {"cua-driver", "curl"} else None), \
|
||||
patch.object(tools_config, "_check_cua_driver_asset_for_arch",
|
||||
return_value=True), \
|
||||
patch.object(tools_config, "_run_cua_driver_installer",
|
||||
return_value=True) as runner, \
|
||||
patch("subprocess.run"):
|
||||
assert tools_config.install_cua_driver(upgrade=True) is True
|
||||
runner.assert_called_once()
|
||||
# Refresh path uses non-verbose mode so we don't re-print the
|
||||
# "grant macOS permissions" block on every `hermes update`.
|
||||
kwargs = runner.call_args.kwargs
|
||||
assert kwargs.get("verbose") is False
|
||||
|
||||
def test_upgrade_on_macos_without_binary_runs_installer(self):
|
||||
"""upgrade=True with cua-driver missing must still trigger an
|
||||
install — equivalent to a fresh install. (Don't silently no-op.)"""
|
||||
from hermes_cli import tools_config
|
||||
|
||||
with patch("platform.system", return_value="Darwin"), \
|
||||
patch.object(tools_config.shutil, "which",
|
||||
side_effect=lambda n: "/usr/bin/curl" if n == "curl" else None), \
|
||||
patch.object(tools_config, "_check_cua_driver_asset_for_arch",
|
||||
return_value=True), \
|
||||
patch.object(tools_config, "_run_cua_driver_installer",
|
||||
return_value=True) as runner:
|
||||
assert tools_config.install_cua_driver(upgrade=True) is True
|
||||
runner.assert_called_once()
|
||||
|
||||
def test_non_upgrade_on_macos_with_binary_skips_install(self):
|
||||
"""Original toolset-enable behaviour: cua-driver already installed
|
||||
+ upgrade=False → confirm and return without re-running installer.
|
||||
This is the behaviour that ``hermes tools`` (re)enable depends on,
|
||||
so the new helper must not regress it."""
|
||||
from hermes_cli import tools_config
|
||||
|
||||
with patch("platform.system", return_value="Darwin"), \
|
||||
@@ -89,27 +80,133 @@ class TestInstallCuaDriverUpgrade:
|
||||
runner.assert_not_called()
|
||||
|
||||
def test_non_upgrade_on_macos_without_binary_runs_installer(self):
|
||||
"""Original fresh-install path must still work."""
|
||||
from hermes_cli import tools_config
|
||||
|
||||
with patch("platform.system", return_value="Darwin"), \
|
||||
patch.object(tools_config.shutil, "which",
|
||||
side_effect=lambda n: "/usr/bin/curl" if n == "curl" else None), \
|
||||
patch.object(tools_config, "_check_cua_driver_asset_for_arch",
|
||||
return_value=True), \
|
||||
patch.object(tools_config, "_run_cua_driver_installer",
|
||||
return_value=True) as runner:
|
||||
assert tools_config.install_cua_driver(upgrade=False) is True
|
||||
runner.assert_called_once()
|
||||
|
||||
def test_upgrade_without_curl_does_not_crash(self):
|
||||
"""If curl isn't on PATH we can't refresh — must warn and return
|
||||
the current install state, not raise."""
|
||||
|
||||
class TestCheckCuaDriverAssetForArch:
|
||||
def test_arm64_always_returns_true(self):
|
||||
from hermes_cli import tools_config
|
||||
|
||||
# cua-driver present, curl missing.
|
||||
def _which(name):
|
||||
return "/usr/local/bin/cua-driver" if name == "cua-driver" else None
|
||||
with patch("platform.machine", return_value="arm64"):
|
||||
assert tools_config._check_cua_driver_asset_for_arch() is True
|
||||
|
||||
def test_x86_64_with_asset_returns_true(self):
|
||||
from hermes_cli import tools_config
|
||||
|
||||
release = {
|
||||
"tag_name": "cua-driver-v0.1.6",
|
||||
"assets": [
|
||||
{"name": "cua-driver-0.1.6-darwin-arm64.tar.gz"},
|
||||
{"name": "cua-driver-0.1.6-darwin-x86_64.tar.gz"},
|
||||
],
|
||||
}
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = json.dumps(release).encode()
|
||||
mock_resp.__enter__ = lambda s: s
|
||||
mock_resp.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch("platform.machine", return_value="x86_64"), \
|
||||
patch("urllib.request.urlopen", return_value=mock_resp):
|
||||
assert tools_config._check_cua_driver_asset_for_arch() is True
|
||||
|
||||
def test_x86_64_without_asset_returns_false(self):
|
||||
from hermes_cli import tools_config
|
||||
|
||||
release = {
|
||||
"tag_name": "cua-driver-v0.1.6",
|
||||
"assets": [
|
||||
{"name": "cua-driver-0.1.6-darwin-arm64.tar.gz"},
|
||||
{"name": "cua-driver.tar.gz"},
|
||||
],
|
||||
}
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = json.dumps(release).encode()
|
||||
mock_resp.__enter__ = lambda s: s
|
||||
mock_resp.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch("platform.machine", return_value="x86_64"), \
|
||||
patch("urllib.request.urlopen", return_value=mock_resp), \
|
||||
patch.object(tools_config, "_print_warning") as warn, \
|
||||
patch.object(tools_config, "_print_info"):
|
||||
assert tools_config._check_cua_driver_asset_for_arch() is False
|
||||
warn.assert_called_once()
|
||||
assert "no Intel" in warn.call_args[0][0].lower() or "x86_64" in warn.call_args[0][0]
|
||||
|
||||
def test_x86_64_api_failure_returns_true(self):
|
||||
"""Network failure should fail open — let the installer handle it."""
|
||||
from hermes_cli import tools_config
|
||||
|
||||
with patch("platform.machine", return_value="x86_64"), \
|
||||
patch("urllib.request.urlopen", side_effect=Exception("timeout")):
|
||||
assert tools_config._check_cua_driver_asset_for_arch() is True
|
||||
|
||||
def test_fresh_install_x86_64_no_asset_skips_installer(self):
|
||||
"""When the latest release has no Intel asset, skip the installer."""
|
||||
from hermes_cli import tools_config
|
||||
|
||||
release = {
|
||||
"tag_name": "cua-driver-v0.1.6",
|
||||
"assets": [{"name": "cua-driver-0.1.6-darwin-arm64.tar.gz"}],
|
||||
}
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = json.dumps(release).encode()
|
||||
mock_resp.__enter__ = lambda s: s
|
||||
mock_resp.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch("platform.system", return_value="Darwin"), \
|
||||
patch.object(tools_config.shutil, "which", side_effect=_which), \
|
||||
patch.object(tools_config, "_print_warning"):
|
||||
patch.object(tools_config.shutil, "which",
|
||||
side_effect=lambda n: "/usr/bin/curl" if n == "curl" else None), \
|
||||
patch("platform.machine", return_value="x86_64"), \
|
||||
patch("urllib.request.urlopen", return_value=mock_resp), \
|
||||
patch.object(tools_config, "_print_warning"), \
|
||||
patch.object(tools_config, "_print_info"), \
|
||||
patch.object(tools_config, "_run_cua_driver_installer") as runner:
|
||||
assert tools_config.install_cua_driver(upgrade=False) is False
|
||||
runner.assert_not_called()
|
||||
|
||||
def test_upgrade_x86_64_no_asset_returns_existing_status(self):
|
||||
"""On upgrade with no Intel asset, return whether binary existed."""
|
||||
from hermes_cli import tools_config
|
||||
|
||||
release = {
|
||||
"tag_name": "cua-driver-v0.1.6",
|
||||
"assets": [{"name": "cua-driver-0.1.6-darwin-arm64.tar.gz"}],
|
||||
}
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = json.dumps(release).encode()
|
||||
mock_resp.__enter__ = lambda s: s
|
||||
mock_resp.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
# With binary installed — returns True (binary exists)
|
||||
with patch("platform.system", return_value="Darwin"), \
|
||||
patch.object(tools_config.shutil, "which",
|
||||
side_effect=lambda n: "/usr/local/bin/" + n
|
||||
if n in ("cua-driver", "curl") else None), \
|
||||
patch("platform.machine", return_value="x86_64"), \
|
||||
patch("urllib.request.urlopen", return_value=mock_resp), \
|
||||
patch.object(tools_config, "_print_warning"), \
|
||||
patch.object(tools_config, "_print_info"), \
|
||||
patch.object(tools_config, "_run_cua_driver_installer") as runner:
|
||||
assert tools_config.install_cua_driver(upgrade=True) is True
|
||||
runner.assert_not_called()
|
||||
|
||||
# Without binary — returns False
|
||||
with patch("platform.system", return_value="Darwin"), \
|
||||
patch.object(tools_config.shutil, "which",
|
||||
side_effect=lambda n: "/usr/bin/curl" if n == "curl" else None), \
|
||||
patch("platform.machine", return_value="x86_64"), \
|
||||
patch("urllib.request.urlopen", return_value=mock_resp), \
|
||||
patch.object(tools_config, "_print_warning"), \
|
||||
patch.object(tools_config, "_print_info"), \
|
||||
patch.object(tools_config, "_run_cua_driver_installer") as runner:
|
||||
assert tools_config.install_cua_driver(upgrade=True) is False
|
||||
runner.assert_not_called()
|
||||
|
||||
@@ -21,7 +21,6 @@ from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.inventory import (
|
||||
ConfigContext,
|
||||
@@ -142,6 +141,18 @@ def _list_auth_returning(rows: list[dict]):
|
||||
)
|
||||
|
||||
|
||||
def _nous_row(model: str = "openai/gpt-5.5") -> dict:
|
||||
return {
|
||||
"slug": "nous",
|
||||
"name": "Nous",
|
||||
"models": [model],
|
||||
"total_models": 1,
|
||||
"is_current": True,
|
||||
"is_user_defined": False,
|
||||
"source": "built-in",
|
||||
}
|
||||
|
||||
|
||||
def test_build_models_payload_returns_expected_shape():
|
||||
rows = [
|
||||
{"slug": "openrouter", "name": "OpenRouter", "models": ["m1"],
|
||||
@@ -158,8 +169,11 @@ def test_build_models_payload_returns_expected_shape():
|
||||
|
||||
|
||||
def test_build_models_payload_does_not_call_provider_model_ids():
|
||||
"""Curated lists must come from list_authenticated_providers, not
|
||||
provider_model_ids — that would pull TTS/embeddings/etc.
|
||||
"""``build_models_payload`` is a thin shape adapter — it delegates the
|
||||
actual curation to ``list_authenticated_providers`` (which DOES call
|
||||
``cached_provider_model_ids`` internally for live discovery, with disk
|
||||
caching). ``build_models_payload`` itself must not call the live fetcher
|
||||
directly; the test pins that boundary.
|
||||
"""
|
||||
rows = [{"slug": "nous", "name": "Nous", "models": ["hermes-4-405b"],
|
||||
"total_models": 1, "is_current": False, "is_user_defined": False,
|
||||
@@ -171,6 +185,98 @@ def test_build_models_payload_does_not_call_provider_model_ids():
|
||||
mock_pm.assert_not_called()
|
||||
|
||||
|
||||
def test_build_models_payload_uses_cached_nous_tier_by_default():
|
||||
"""Picker payloads should not force fresh Nous account checks.
|
||||
|
||||
Desktop/status picker opens are request/response UI paths. They can hit
|
||||
the short free-tier cache; explicit model/auth flows can still opt into a
|
||||
fresh account check when needed.
|
||||
"""
|
||||
ctx = _empty_ctx(provider="nous", model="openai/gpt-5.5")
|
||||
rows = [_nous_row()]
|
||||
with patch(
|
||||
"hermes_cli.model_switch.list_authenticated_providers",
|
||||
return_value=rows,
|
||||
) as mock_list:
|
||||
build_models_payload(ctx)
|
||||
|
||||
mock_list.assert_called_once()
|
||||
assert mock_list.call_args.kwargs["force_fresh_nous_tier"] is False
|
||||
|
||||
|
||||
def test_build_models_payload_can_force_fresh_nous_tier():
|
||||
ctx = _empty_ctx(provider="nous", model="openai/gpt-5.5")
|
||||
rows = [_nous_row()]
|
||||
with patch(
|
||||
"hermes_cli.model_switch.list_authenticated_providers",
|
||||
return_value=rows,
|
||||
) as mock_list:
|
||||
build_models_payload(ctx, force_fresh_nous_tier=True)
|
||||
|
||||
mock_list.assert_called_once()
|
||||
assert mock_list.call_args.kwargs["force_fresh_nous_tier"] is True
|
||||
|
||||
|
||||
def test_list_authenticated_providers_force_fresh_is_keyword_only():
|
||||
"""``force_fresh_nous_tier`` must be keyword-only on the public listing API.
|
||||
|
||||
It was inserted between ``custom_providers`` and ``max_models``; making it
|
||||
keyword-only ensures no positional caller passing ``max_models`` as the 5th
|
||||
arg silently mis-binds it to the tier-refresh flag. Pin the contract so a
|
||||
future signature edit that drops the ``*`` separator is caught.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
sig = inspect.signature(list_authenticated_providers)
|
||||
param = sig.parameters["force_fresh_nous_tier"]
|
||||
assert param.kind is inspect.Parameter.KEYWORD_ONLY
|
||||
assert param.default is False
|
||||
|
||||
|
||||
def test_pricing_uses_cached_nous_tier_by_default():
|
||||
rows = [_nous_row()]
|
||||
ctx = _empty_ctx(provider="nous", model="openai/gpt-5.5")
|
||||
with (
|
||||
_list_auth_returning(rows),
|
||||
patch(
|
||||
"hermes_cli.models.get_pricing_for_provider",
|
||||
return_value={
|
||||
"openai/gpt-5.5": {
|
||||
"prompt": "0.000001",
|
||||
"completion": "0.000002",
|
||||
},
|
||||
},
|
||||
),
|
||||
patch("hermes_cli.models.check_nous_free_tier", return_value=False) as mock_free,
|
||||
):
|
||||
build_models_payload(ctx, pricing=True)
|
||||
|
||||
mock_free.assert_called_once_with(force_fresh=False)
|
||||
|
||||
|
||||
def test_pricing_can_force_fresh_nous_tier():
|
||||
rows = [_nous_row()]
|
||||
ctx = _empty_ctx(provider="nous", model="openai/gpt-5.5")
|
||||
with (
|
||||
_list_auth_returning(rows),
|
||||
patch(
|
||||
"hermes_cli.models.get_pricing_for_provider",
|
||||
return_value={
|
||||
"openai/gpt-5.5": {
|
||||
"prompt": "0.000001",
|
||||
"completion": "0.000002",
|
||||
},
|
||||
},
|
||||
),
|
||||
patch("hermes_cli.models.check_nous_free_tier", return_value=False) as mock_free,
|
||||
):
|
||||
build_models_payload(ctx, pricing=True, force_fresh_nous_tier=True)
|
||||
|
||||
mock_free.assert_called_once_with(force_fresh=True)
|
||||
|
||||
|
||||
def test_include_unconfigured_appends_canonical_skeletons():
|
||||
"""include_unconfigured=True adds CANONICAL_PROVIDERS rows that
|
||||
list_authenticated_providers didn't emit. Skeleton rows have empty
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Tests for inventory._apply_pricing — the pricing/tier enrichment that
|
||||
|
||||
feeds the desktop GUI model picker (and onboarding) so it can show $/Mtok
|
||||
columns + Free/Pro badges and gate paid models on free Nous accounts, the
|
||||
same way the `hermes model` CLI picker does.
|
||||
"""
|
||||
|
||||
import hermes_cli.inventory as inv
|
||||
import hermes_cli.models as models_mod
|
||||
|
||||
|
||||
def _patch_pricing(monkeypatch, *, free_tier, pricing, unavailable=None):
|
||||
monkeypatch.setattr(models_mod, "get_pricing_for_provider", lambda slug, **kw: pricing.get(slug, {}))
|
||||
monkeypatch.setattr(models_mod, "check_nous_free_tier", lambda *, force_fresh=False: free_tier)
|
||||
monkeypatch.setattr(
|
||||
models_mod, "partition_nous_models_by_tier",
|
||||
lambda ids, pr, free_tier: (
|
||||
[m for m in ids if m not in (unavailable or [])],
|
||||
list(unavailable or []),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_apply_pricing_formats_per_model_prices(monkeypatch):
|
||||
"""Each model gets formatted input/output/cache + a free flag."""
|
||||
_patch_pricing(
|
||||
monkeypatch,
|
||||
free_tier=False,
|
||||
pricing={
|
||||
"openrouter": {
|
||||
"a/paid": {"prompt": "0.000003", "completion": "0.000015", "input_cache_read": "0.0000003"},
|
||||
"b/free": {"prompt": "0", "completion": "0"},
|
||||
}
|
||||
},
|
||||
)
|
||||
rows = [{"slug": "openrouter", "models": ["a/paid", "b/free"]}]
|
||||
inv._apply_pricing(rows)
|
||||
|
||||
pricing = rows[0]["pricing"]
|
||||
assert pricing["a/paid"] == {"input": "$3.00", "output": "$15.00", "cache": "$0.30", "free": False}
|
||||
assert pricing["b/free"]["free"] is True
|
||||
assert pricing["b/free"]["input"] == "free"
|
||||
|
||||
|
||||
def test_apply_pricing_nous_free_tier_gates_paid_models(monkeypatch):
|
||||
"""A free-tier Nous account marks paid models unavailable and sets the flag."""
|
||||
_patch_pricing(
|
||||
monkeypatch,
|
||||
free_tier=True,
|
||||
pricing={
|
||||
"nous": {
|
||||
"free/model": {"prompt": "0", "completion": "0"},
|
||||
"paid/model": {"prompt": "0.000005", "completion": "0.00001"},
|
||||
}
|
||||
},
|
||||
unavailable=["paid/model"],
|
||||
)
|
||||
rows = [{"slug": "nous", "models": ["free/model", "paid/model"]}]
|
||||
inv._apply_pricing(rows)
|
||||
|
||||
assert rows[0]["free_tier"] is True
|
||||
assert rows[0]["unavailable_models"] == ["paid/model"]
|
||||
assert rows[0]["pricing"]["free/model"]["free"] is True
|
||||
|
||||
|
||||
def test_apply_pricing_nous_paid_tier_no_gating(monkeypatch):
|
||||
"""A paid Nous account gates nothing."""
|
||||
_patch_pricing(
|
||||
monkeypatch,
|
||||
free_tier=False,
|
||||
pricing={"nous": {"x/model": {"prompt": "0.000001", "completion": "0.000002"}}},
|
||||
)
|
||||
rows = [{"slug": "nous", "models": ["x/model"]}]
|
||||
inv._apply_pricing(rows)
|
||||
|
||||
assert rows[0]["free_tier"] is False
|
||||
assert rows[0]["unavailable_models"] == []
|
||||
|
||||
|
||||
def test_apply_pricing_skips_providers_without_pricing(monkeypatch):
|
||||
"""A provider with no live pricing simply gets no pricing key."""
|
||||
_patch_pricing(monkeypatch, free_tier=False, pricing={})
|
||||
rows = [{"slug": "anthropic", "models": ["claude-x"]}]
|
||||
inv._apply_pricing(rows)
|
||||
|
||||
assert "pricing" not in rows[0]
|
||||
|
||||
|
||||
def test_apply_pricing_failure_is_swallowed(monkeypatch):
|
||||
"""A pricing fetch that raises must not break the whole payload."""
|
||||
def boom(slug, **kw):
|
||||
raise RuntimeError("network down")
|
||||
|
||||
monkeypatch.setattr(models_mod, "get_pricing_for_provider", boom)
|
||||
rows = [{"slug": "openrouter", "models": ["a/b"]}]
|
||||
inv._apply_pricing(rows) # must not raise
|
||||
|
||||
assert "pricing" not in rows[0]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user