Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
"""Fixtures shared across hermes_cli kanban tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def all_assignees_spawnable(monkeypatch):
|
||||
"""Pretend every assignee maps to a real Hermes profile.
|
||||
|
||||
Most dispatcher tests use synthetic assignees ("alice", "bob") that
|
||||
don't correspond to actual profile directories on disk. Without this
|
||||
patch, the dispatcher's profile-exists guard (PR #20105) routes
|
||||
those tasks into ``skipped_nonspawnable`` instead of spawning, which
|
||||
would break tests that assert spawn behavior.
|
||||
"""
|
||||
from hermes_cli import profiles
|
||||
monkeypatch.setattr(profiles, "profile_exists", lambda name: True)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _suppress_concurrent_hermes_gate(request, monkeypatch):
|
||||
"""Default ``_detect_concurrent_hermes_instances`` to ``[]`` for every test.
|
||||
|
||||
The Windows update path now refuses to proceed when another
|
||||
``hermes.exe`` is detected (issue #26670). On a developer's Windows
|
||||
machine running the test suite via ``hermes`` itself, this would
|
||||
flag the running agent as a concurrent instance and abort every
|
||||
``cmd_update`` test. Tests that want to exercise the gate explicitly
|
||||
re-patch ``_detect_concurrent_hermes_instances`` with their own
|
||||
return value — autouse here gives a clean default without touching
|
||||
the rest of the suite.
|
||||
|
||||
Tests that need to call the REAL function (e.g. unit tests for the
|
||||
helper itself) opt out with ``@pytest.mark.real_concurrent_gate``.
|
||||
"""
|
||||
if request.node.get_closest_marker("real_concurrent_gate"):
|
||||
return
|
||||
try:
|
||||
from hermes_cli import main as _cli_main
|
||||
except Exception:
|
||||
return
|
||||
# raising=False: under pytest's per-test spawn isolation, a concurrent
|
||||
# xdist worker importing a module that transitively touches hermes_cli.main
|
||||
# can briefly expose a partially-initialized module object here — one where
|
||||
# _detect_concurrent_hermes_instances isn't defined yet. A bare setattr
|
||||
# would raise AttributeError and error the (unrelated) test. The attribute
|
||||
# always exists once main.py finishes importing, so a no-op when it's
|
||||
# transiently absent is the correct, race-free default.
|
||||
monkeypatch.setattr(
|
||||
_cli_main,
|
||||
"_detect_concurrent_hermes_instances",
|
||||
lambda *_a, **_k: [],
|
||||
raising=False,
|
||||
)
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Stub auth provider + shared fixtures for dashboard-auth tests.
|
||||
|
||||
NOT a pytest conftest.py — this is an importable helper module. Phase 2
|
||||
of the dashboard-OAuth plan; used by Phase 3's end-to-end gate tests.
|
||||
|
||||
Import via::
|
||||
|
||||
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
|
||||
|
||||
The stub bounces straight back to the callback with a fake code so tests
|
||||
can complete the OAuth round trip in-process without external network.
|
||||
|
||||
Tokens are HMAC-signed JSON blobs (not real JWTs) — just enough structure
|
||||
for ``verify_session`` to detect tampering and expiry.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from hermes_cli.dashboard_auth.base import (
|
||||
DashboardAuthProvider,
|
||||
InvalidCodeError,
|
||||
LoginStart,
|
||||
RefreshExpiredError,
|
||||
Session,
|
||||
)
|
||||
|
||||
_STUB_SECRET = b"stub-test-secret-not-for-prod"
|
||||
# Length of HMAC-SHA256 digest. We append this many trailing bytes of
|
||||
# signature after ``raw`` in ``_sign``; ``_unsign`` slices them back off
|
||||
# rather than splitting on a separator. (A separator byte chosen
|
||||
# arbitrarily, e.g. ``b"."``, fails ~12% of the time when the HMAC
|
||||
# digest happens to contain that byte — ``bytes.rsplit`` then splits at
|
||||
# the wrong index and HMAC verification spuriously rejects the token.)
|
||||
_SIG_LEN = hashlib.sha256().digest_size
|
||||
|
||||
|
||||
def _sign(payload: dict) -> str:
|
||||
"""Produce a tamper-evident opaque token.
|
||||
|
||||
Not a real JWT — just a base64(JSON || HMAC-SHA256) blob with enough
|
||||
structure to round-trip through verify_session. The signature is
|
||||
appended as a fixed-length suffix (no separator) so binary HMAC bytes
|
||||
can't be confused with a delimiter.
|
||||
"""
|
||||
raw = json.dumps(payload, separators=(",", ":")).encode()
|
||||
sig = hmac.new(_STUB_SECRET, raw, hashlib.sha256).digest()
|
||||
return base64.urlsafe_b64encode(raw + sig).decode()
|
||||
|
||||
|
||||
def _unsign(token: str) -> dict | None:
|
||||
"""Inverse of ``_sign``; returns None on any tamper/decode failure."""
|
||||
try:
|
||||
blob = base64.urlsafe_b64decode(token.encode())
|
||||
if len(blob) <= _SIG_LEN:
|
||||
return None
|
||||
raw, sig = blob[:-_SIG_LEN], blob[-_SIG_LEN:]
|
||||
expected = hmac.new(_STUB_SECRET, raw, hashlib.sha256).digest()
|
||||
if not hmac.compare_digest(sig, expected):
|
||||
return None
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
class StubAuthProvider(DashboardAuthProvider):
|
||||
"""Local fake IDP for E2E tests.
|
||||
|
||||
``start_login`` returns a redirect to
|
||||
``{redirect_uri}?code=stub_code&state={s}`` so the test harness can
|
||||
walk the full round trip in-process without talking to anything
|
||||
external. ``access_token`` is an HMAC-signed JSON blob;
|
||||
``verify_session`` decodes and checks ``exp``.
|
||||
"""
|
||||
|
||||
name = "stub"
|
||||
display_name = "Stub IdP (test only)"
|
||||
|
||||
def __init__(self, default_ttl: int = 3600):
|
||||
self._default_ttl = default_ttl
|
||||
# state → verifier mapping, cleared on complete_login
|
||||
self._state_to_verifier: dict[str, str] = {}
|
||||
|
||||
def start_login(self, *, redirect_uri: str) -> LoginStart:
|
||||
state = secrets.token_urlsafe(16)
|
||||
verifier = secrets.token_urlsafe(32)
|
||||
self._state_to_verifier[state] = verifier
|
||||
return LoginStart(
|
||||
redirect_url=f"{redirect_uri}?code=stub_code&state={state}",
|
||||
cookie_payload={
|
||||
"hermes_session_pkce": f"state={state};verifier={verifier}",
|
||||
},
|
||||
)
|
||||
|
||||
def complete_login(
|
||||
self, *, code: str, state: str, code_verifier: str, redirect_uri: str,
|
||||
) -> Session:
|
||||
if code != "stub_code":
|
||||
raise InvalidCodeError(
|
||||
f"stub expects code='stub_code', got {code!r}"
|
||||
)
|
||||
expected_verifier = self._state_to_verifier.get(state)
|
||||
if expected_verifier is None or expected_verifier != code_verifier:
|
||||
raise InvalidCodeError("stub state/verifier mismatch")
|
||||
del self._state_to_verifier[state]
|
||||
|
||||
now = int(time.time())
|
||||
exp = now + self._default_ttl
|
||||
return Session(
|
||||
user_id="stub-user-1",
|
||||
email="stub@example.test",
|
||||
display_name="Stub User",
|
||||
org_id="stub-org-1",
|
||||
provider=self.name,
|
||||
expires_at=exp,
|
||||
access_token=_sign({
|
||||
"sub": "stub-user-1",
|
||||
"email": "stub@example.test",
|
||||
"name": "Stub User",
|
||||
"org_id": "stub-org-1",
|
||||
"exp": exp,
|
||||
}),
|
||||
refresh_token=_sign({
|
||||
"sub": "stub-user-1",
|
||||
"kind": "refresh",
|
||||
"exp": now + 30 * 86400,
|
||||
}),
|
||||
)
|
||||
|
||||
def verify_session(self, *, access_token: str):
|
||||
payload = _unsign(access_token)
|
||||
# ``<=`` so default_ttl=0 produces a born-expired token. This
|
||||
# matches what Phase 6's silent-refresh tests need ("set a 0-TTL
|
||||
# access token; the next request should refresh transparently").
|
||||
if payload is None or payload.get("exp", 0) <= int(time.time()):
|
||||
return None
|
||||
return Session(
|
||||
user_id=payload["sub"],
|
||||
email=payload["email"],
|
||||
display_name=payload["name"],
|
||||
org_id=payload["org_id"],
|
||||
provider=self.name,
|
||||
expires_at=payload["exp"],
|
||||
access_token=access_token,
|
||||
refresh_token="", # not surfaced on verify
|
||||
)
|
||||
|
||||
def refresh_session(self, *, refresh_token: str) -> Session:
|
||||
payload = _unsign(refresh_token)
|
||||
# ``<=`` for symmetry with verify_session — a 0-TTL token is
|
||||
# treated as expired.
|
||||
if payload is None or payload.get("exp", 0) <= int(time.time()):
|
||||
raise RefreshExpiredError("stub refresh token expired/invalid")
|
||||
now = int(time.time())
|
||||
exp = now + self._default_ttl
|
||||
return Session(
|
||||
user_id=payload["sub"],
|
||||
email="stub@example.test",
|
||||
display_name="Stub User",
|
||||
org_id="stub-org-1",
|
||||
provider=self.name,
|
||||
expires_at=exp,
|
||||
access_token=_sign({
|
||||
"sub": payload["sub"],
|
||||
"email": "stub@example.test",
|
||||
"name": "Stub User",
|
||||
"org_id": "stub-org-1",
|
||||
"exp": exp,
|
||||
}),
|
||||
refresh_token=_sign({
|
||||
"sub": payload["sub"],
|
||||
"kind": "refresh",
|
||||
"exp": now + 30 * 86400,
|
||||
}),
|
||||
)
|
||||
|
||||
def revoke_session(self, *, refresh_token: str) -> None:
|
||||
# Stub is in-memory; nothing to revoke server-side.
|
||||
return None
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Frozen legacy plugin used by the behavior compatibility suite.
|
||||
|
||||
Keep this callback in its old, narrow form: it intentionally predates additive
|
||||
hook payload fields and does not accept ``**kwargs``.
|
||||
"""
|
||||
|
||||
received_sessions = []
|
||||
|
||||
|
||||
def _on_session_start(*, session_id):
|
||||
received_sessions.append(session_id)
|
||||
return {"legacy_session_id": session_id}
|
||||
|
||||
|
||||
def register(ctx):
|
||||
ctx.register_hook("on_session_start", _on_session_start)
|
||||
@@ -0,0 +1,8 @@
|
||||
name: legacy-contract-fixture
|
||||
version: "0.1.0"
|
||||
description: Frozen plugin fixture for native behavior compatibility
|
||||
provides_hooks:
|
||||
- on_session_start
|
||||
future_manifest_field:
|
||||
schema: 2
|
||||
additive: true
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Regression tests for #25106: CLI `/model <name> --global` never persisted
|
||||
``model.base_url``/``model.api_mode`` to config.yaml, so a global provider
|
||||
switch left the PREVIOUS provider's endpoint/wire-protocol on disk. The next
|
||||
`hermes` launch re-read the stale base_url and routed the new model at the
|
||||
old host.
|
||||
|
||||
Both ``_handle_model_switch`` (typed ``/model <name>``) and
|
||||
``_apply_model_switch_result`` (interactive picker) shared the same gap: the
|
||||
persistence block wrote ``model.default``/``model.provider`` but never
|
||||
touched ``base_url``/``api_mode`` at all. Fix: sync both on every global
|
||||
switch, clearing to ``None`` when the resolved result doesn't need them —
|
||||
mirroring the already-correct ``tui_gateway/server.py:_persist_model_switch``
|
||||
pattern (fixed for #48305).
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.model_switch import ModelSwitchResult
|
||||
|
||||
|
||||
def _make_result(*, base_url="https://api.minimax.io/v1", api_mode="chat_completions", provider_changed=True):
|
||||
return ModelSwitchResult(
|
||||
success=True,
|
||||
new_model="MiniMax-M3",
|
||||
target_provider="custom:minimax",
|
||||
provider_changed=provider_changed,
|
||||
api_key="sk-minimax",
|
||||
base_url=base_url,
|
||||
api_mode=api_mode,
|
||||
warning_message="",
|
||||
provider_label="MiniMax (custom)",
|
||||
resolved_via_alias=False,
|
||||
capabilities=None,
|
||||
model_info=None,
|
||||
is_global=True,
|
||||
)
|
||||
|
||||
|
||||
class _StubCLI:
|
||||
"""Minimum attrs/methods `_handle_model_switch` reads or calls on self."""
|
||||
|
||||
agent = None
|
||||
model = "old-model"
|
||||
provider = "copilot"
|
||||
requested_provider = "copilot"
|
||||
api_key = "sk-old"
|
||||
base_url = "https://api.githubcopilot.com"
|
||||
api_mode = "chat_completions"
|
||||
_explicit_api_key = ""
|
||||
_explicit_base_url = ""
|
||||
conversation_history = []
|
||||
_pending_model_switch_note = ""
|
||||
|
||||
def _confirm_expensive_model_switch(self, result) -> bool:
|
||||
return True
|
||||
|
||||
def _confirm_and_apply_cli_model_switch(
|
||||
self, result, persist_global, one_turn, custom_provs=None
|
||||
):
|
||||
import cli as cli_mod
|
||||
|
||||
return cli_mod.HermesCLI._confirm_and_apply_cli_model_switch(
|
||||
self, result, persist_global, one_turn, custom_provs
|
||||
)
|
||||
|
||||
def _open_model_picker(self, *a, **k):
|
||||
raise AssertionError("picker should not open when a model name is given")
|
||||
|
||||
|
||||
def _run_switch(monkeypatch, result, cmd="/model MiniMax-M3 --global"):
|
||||
import cli as cli_mod
|
||||
|
||||
monkeypatch.setattr(cli_mod, "_cprint", lambda *a, **k: None)
|
||||
saved: dict[str, object] = {}
|
||||
|
||||
def _fake_save(key, value):
|
||||
saved[key] = value
|
||||
|
||||
monkeypatch.setattr(cli_mod, "save_config_value", _fake_save)
|
||||
monkeypatch.setattr("hermes_cli.model_switch.switch_model", lambda **kw: result)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.inventory.load_picker_context",
|
||||
lambda: (_ for _ in ()).throw(RuntimeError("no picker context in test")),
|
||||
)
|
||||
cli_mod.HermesCLI._handle_model_switch(_StubCLI(), cmd)
|
||||
return saved
|
||||
|
||||
|
||||
def test_global_switch_persists_base_url_and_api_mode(monkeypatch):
|
||||
"""The core #25106 fix: a --global switch to a new provider/endpoint must
|
||||
write the newly resolved base_url and api_mode, not just default/provider."""
|
||||
saved = _run_switch(monkeypatch, _make_result())
|
||||
|
||||
assert saved["model.default"] == "MiniMax-M3"
|
||||
assert saved["model.provider"] == "custom:minimax"
|
||||
assert saved["model.base_url"] == "https://api.minimax.io/v1"
|
||||
assert saved["model.api_mode"] == "chat_completions"
|
||||
|
||||
|
||||
def test_session_only_switch_does_not_touch_config(monkeypatch):
|
||||
"""--session must not call save_config_value at all — persistence stays
|
||||
entirely in-memory."""
|
||||
import cli as cli_mod
|
||||
|
||||
monkeypatch.setattr(cli_mod, "_cprint", lambda *a, **k: None)
|
||||
save_calls = []
|
||||
monkeypatch.setattr(cli_mod, "save_config_value", lambda *a, **k: save_calls.append(a))
|
||||
monkeypatch.setattr("hermes_cli.model_switch.switch_model", lambda **kw: _make_result())
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.inventory.load_picker_context",
|
||||
lambda: (_ for _ in ()).throw(RuntimeError("no picker context in test")),
|
||||
)
|
||||
|
||||
cli_mod.HermesCLI._handle_model_switch(_StubCLI(), "/model MiniMax-M3 --session")
|
||||
|
||||
assert save_calls == []
|
||||
|
||||
|
||||
def _run_apply(monkeypatch, result, persist_global=True):
|
||||
"""Drives `_apply_model_switch_result` directly — the interactive-picker
|
||||
sibling of `_handle_model_switch`. Unlike the tests above, and unlike
|
||||
`test_apply_model_switch_result_context.py` (which only ever calls this
|
||||
method with `persist_global=False`), this exercises the `persist_global=True`
|
||||
branch that actually writes to config.yaml."""
|
||||
import cli as cli_mod
|
||||
|
||||
monkeypatch.setattr(cli_mod, "_cprint", lambda *a, **k: None)
|
||||
saved: dict[str, object] = {}
|
||||
|
||||
def _fake_save(key, value):
|
||||
saved[key] = value
|
||||
|
||||
monkeypatch.setattr(cli_mod, "save_config_value", _fake_save)
|
||||
cli_mod.HermesCLI._apply_model_switch_result(_StubCLI(), result, persist_global)
|
||||
return saved
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,648 @@
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import active_sessions
|
||||
|
||||
|
||||
|
||||
def _backdate_leases(*homes, age_seconds=600.0):
|
||||
"""Age every lease in the given registries past the self-orphan grace."""
|
||||
for home in homes:
|
||||
state_path = active_sessions._state_path(home)
|
||||
entries = active_sessions._read_entries(state_path)
|
||||
for entry in entries:
|
||||
entry["started_at"] = time.time() - age_seconds
|
||||
active_sessions._write_entries(state_path, entries)
|
||||
|
||||
|
||||
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_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()
|
||||
results_dir = tmp_path / "results"
|
||||
results_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"
|
||||
"worker_count = int(os.environ['WORKER_COUNT'])\n"
|
||||
"delayed_worker = os.environ.get('DELAYED_WORKER_INDEX')\n"
|
||||
"ready_dir = Path(os.environ['READY_DIR'])\n"
|
||||
"results_dir = Path(os.environ['RESULTS_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"
|
||||
"if idx == delayed_worker:\n"
|
||||
" time.sleep(2.5)\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"
|
||||
" (results_dir / idx).write_text('BLOCK', encoding='utf-8')\n"
|
||||
" print('BLOCK', flush=True)\n"
|
||||
"else:\n"
|
||||
" (results_dir / idx).write_text('OK', encoding='utf-8')\n"
|
||||
" print('OK', flush=True)\n"
|
||||
" deadline = time.time() + 10\n"
|
||||
" while len(list(results_dir.iterdir())) < worker_count:\n"
|
||||
" if time.time() > deadline:\n"
|
||||
" raise RuntimeError('timed out waiting for all workers to attempt acquire')\n"
|
||||
" time.sleep(0.01)\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["WORKER_COUNT"] = "6"
|
||||
worker_env["DELAYED_WORKER_INDEX"] = "5"
|
||||
worker_env["READY_DIR"] = str(ready_dir)
|
||||
worker_env["RESULTS_DIR"] = str(results_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_release_orphaned_leases_reclaims_only_unowned_own_pid_entries(tmp_path, monkeypatch):
|
||||
"""A long-lived server must reclaim leases whose session skipped teardown.
|
||||
|
||||
``_prune_dead`` only fires when the owning pid dies, so a ``hermes
|
||||
dashboard`` running for days holds a leaked lease until restart. The
|
||||
process reconciles against the leases it still owns instead.
|
||||
"""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
cfg = {"max_concurrent_sessions": 5}
|
||||
kept, orphan = (
|
||||
active_sessions.try_acquire_active_session(
|
||||
session_id=sid, surface="desktop", config=cfg
|
||||
)[0]
|
||||
for sid in ("kept", "orphaned")
|
||||
)
|
||||
# Another live process's lease is not ours to reclaim.
|
||||
active_sessions._write_entries(
|
||||
active_sessions._state_path(),
|
||||
active_sessions._read_entries(active_sessions._state_path())
|
||||
+ [{"lease_id": "elsewhere", "session_id": "other", "surface": "cli", "pid": os.getpid() }],
|
||||
)
|
||||
|
||||
_backdate_leases(tmp_path / ".hermes")
|
||||
assert active_sessions.release_orphaned_leases({kept.lease_id, "elsewhere"}) == 1
|
||||
assert sorted(
|
||||
entry["session_id"]
|
||||
for entry in active_sessions.active_session_registry_snapshot()
|
||||
) == ["kept", "other"]
|
||||
assert orphan is not None
|
||||
|
||||
|
||||
def test_release_orphaned_leases_sweeps_profile_runtime_registries(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
root = tmp_path / "hermes"
|
||||
profile = root / "profiles" / "worker"
|
||||
profile.mkdir(parents=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(root))
|
||||
|
||||
root_lease, root_error = active_sessions.try_acquire_active_session(
|
||||
session_id="root-orphan", surface="desktop", config={}, registry_home=root
|
||||
)
|
||||
profile_lease, profile_error = active_sessions.try_acquire_active_session(
|
||||
session_id="profile-orphan",
|
||||
surface="desktop",
|
||||
config={},
|
||||
registry_home=profile,
|
||||
)
|
||||
assert root_lease is not None and root_error is None
|
||||
assert profile_lease is not None and profile_error is None
|
||||
|
||||
# A lease written seconds ago is never an orphan: a sibling finalize that
|
||||
# snapshotted its live ids before this acquire must not reap it (#101415).
|
||||
assert active_sessions.release_orphaned_leases(set()) == 0
|
||||
_backdate_leases(root, profile)
|
||||
assert active_sessions.release_orphaned_leases(set()) == 2
|
||||
assert active_sessions.active_session_registry_snapshot(root) == []
|
||||
assert active_sessions.active_session_registry_snapshot(profile) == []
|
||||
|
||||
|
||||
def test_drop_self_orphans_spares_foreign_and_vouched_leases():
|
||||
own = os.getpid()
|
||||
entries = [
|
||||
{"lease_id": "orphan", "pid": own},
|
||||
{"lease_id": "live", "pid": own},
|
||||
{"lease_id": "foreign", "pid": own + 1},
|
||||
]
|
||||
|
||||
assert active_sessions._drop_self_orphans(entries, None) == entries
|
||||
assert active_sessions._drop_self_orphans(entries, {"live"}) == entries[1:]
|
||||
|
||||
|
||||
def test_release_under_profile_home_override_targets_acquisition_registry(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""Regression for #85431: a lease acquired against the root HERMES_HOME
|
||||
must release from the root registry even when ``release()`` runs inside a
|
||||
profile home override (native multiplex runs agent cleanup under
|
||||
``_profile_runtime_scope``). Before the fix the root entry survived and
|
||||
the session cap filled with phantom leases."""
|
||||
from hermes_constants import (
|
||||
reset_hermes_home_override,
|
||||
set_hermes_home_override,
|
||||
)
|
||||
|
||||
root = tmp_path / "hermes"
|
||||
profile = root / "profiles" / "worker"
|
||||
profile.mkdir(parents=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(root))
|
||||
|
||||
lease, error = active_sessions.try_acquire_active_session(
|
||||
session_id="agent:worker:telegram:dm:synthetic",
|
||||
surface="gateway:telegram",
|
||||
config={"max_concurrent_sessions": 2},
|
||||
)
|
||||
assert lease is not None and error is None
|
||||
root_registry = root / "runtime" / "active_sessions.json"
|
||||
assert root_registry.exists()
|
||||
|
||||
token = set_hermes_home_override(str(profile))
|
||||
try:
|
||||
lease.release()
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
|
||||
assert lease.released is True
|
||||
remaining = active_sessions._read_entries(root_registry)
|
||||
assert remaining == []
|
||||
# No phantom registry created under the profile home.
|
||||
assert not (profile / "runtime" / "active_sessions.json").exists()
|
||||
|
||||
|
||||
def test_transfer_under_profile_home_override_targets_acquisition_registry(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""Sibling site of #85431: transfer must also update the registry the
|
||||
lease was acquired against, not one resolved from the current override."""
|
||||
from hermes_constants import (
|
||||
reset_hermes_home_override,
|
||||
set_hermes_home_override,
|
||||
)
|
||||
|
||||
root = tmp_path / "hermes"
|
||||
profile = root / "profiles" / "worker"
|
||||
profile.mkdir(parents=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(root))
|
||||
|
||||
lease, error = active_sessions.try_acquire_active_session(
|
||||
session_id="before",
|
||||
surface="gateway:telegram",
|
||||
config={"max_concurrent_sessions": 2},
|
||||
)
|
||||
assert lease is not None and error is None
|
||||
|
||||
token = set_hermes_home_override(str(profile))
|
||||
try:
|
||||
assert active_sessions.transfer_active_session(lease, session_id="after")
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
|
||||
root_registry = root / "runtime" / "active_sessions.json"
|
||||
entries = active_sessions._read_entries(root_registry)
|
||||
assert [entry["session_id"] for entry in entries] == ["after"]
|
||||
|
||||
|
||||
def test_liveness_registry_corruption_fails_closed_without_overwrite(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
home = tmp_path / ".hermes"
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
state_path = home / "runtime" / "active_sessions.json"
|
||||
state_path.parent.mkdir(parents=True)
|
||||
corrupt = "{not-json"
|
||||
state_path.write_text(corrupt, encoding="utf-8")
|
||||
|
||||
with pytest.raises(active_sessions.ActiveSessionRegistryError):
|
||||
with active_sessions.active_session_liveness_guard("session-1"):
|
||||
pass
|
||||
|
||||
with pytest.raises(active_sessions.ActiveSessionRegistryError):
|
||||
active_sessions.active_session_registry_snapshot()
|
||||
|
||||
assert state_path.read_text(encoding="utf-8") == corrupt
|
||||
|
||||
with pytest.raises(active_sessions.ActiveSessionRegistryError):
|
||||
active_sessions.try_acquire_active_session(
|
||||
session_id="desktop-1",
|
||||
surface="desktop",
|
||||
config={},
|
||||
track_liveness=True,
|
||||
)
|
||||
assert state_path.read_text(encoding="utf-8") == corrupt
|
||||
|
||||
# Ownership uncertainty fails CLOSED on every path now (#94595): a corrupt
|
||||
# registry must refuse the session — with a typed reason — rather than
|
||||
# silently readmitting a possible second writer. It still must not erase
|
||||
# the evidence.
|
||||
lease, message = active_sessions.try_acquire_active_session(
|
||||
session_id="cli-1",
|
||||
surface="cli",
|
||||
config={"max_concurrent_sessions": 1},
|
||||
)
|
||||
assert lease is None
|
||||
assert getattr(message, "reason", None) == (
|
||||
active_sessions.SESSION_COORDINATION_UNAVAILABLE
|
||||
)
|
||||
assert state_path.read_text(encoding="utf-8") == corrupt
|
||||
|
||||
|
||||
def test_strict_registry_rejects_structurally_invalid_entries(tmp_path, monkeypatch):
|
||||
home = tmp_path / ".hermes"
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
state_path = home / "runtime" / "active_sessions.json"
|
||||
base = {
|
||||
"lease_id": "lease-1",
|
||||
"session_id": "session-1",
|
||||
"surface": "desktop",
|
||||
"pid": os.getpid(),
|
||||
"track_liveness": True,
|
||||
}
|
||||
invalid_entries = (
|
||||
{key: value for key, value in base.items() if key != "lease_id"},
|
||||
{**base, "lease_id": ""},
|
||||
{key: value for key, value in base.items() if key != "session_id"},
|
||||
{**base, "session_id": " "},
|
||||
{**base, "pid": 0},
|
||||
{**base, "pid": 1.5},
|
||||
{**base, "surface": 1},
|
||||
{**base, "track_liveness": "yes"},
|
||||
{**base, "metadata": []},
|
||||
{**base, "process_start_time": "not-a-number"},
|
||||
{**base, "process_start_time": "nan"},
|
||||
)
|
||||
|
||||
for entry in invalid_entries:
|
||||
active_sessions._write_entries(state_path, [entry])
|
||||
original = state_path.read_text(encoding="utf-8")
|
||||
with pytest.raises(active_sessions.ActiveSessionRegistryError):
|
||||
with active_sessions.active_session_liveness_guard("session-1"):
|
||||
pass
|
||||
assert state_path.read_text(encoding="utf-8") == original
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"second_session_id",
|
||||
("session-a", "session-b"),
|
||||
ids=("exact-duplicate", "conflicting-duplicate"),
|
||||
)
|
||||
def test_strict_registry_rejects_duplicate_lease_ids(
|
||||
tmp_path, monkeypatch, second_session_id
|
||||
):
|
||||
home = tmp_path / ".hermes"
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
state_path = home / "runtime" / "active_sessions.json"
|
||||
active_sessions._write_entries(
|
||||
state_path,
|
||||
[
|
||||
{
|
||||
"lease_id": "duplicate-lease",
|
||||
"session_id": "session-a",
|
||||
"surface": "desktop",
|
||||
"pid": os.getpid(),
|
||||
"track_liveness": True,
|
||||
},
|
||||
{
|
||||
"lease_id": "duplicate-lease",
|
||||
"session_id": second_session_id,
|
||||
"surface": "desktop",
|
||||
"pid": os.getpid(),
|
||||
"track_liveness": True,
|
||||
},
|
||||
],
|
||||
)
|
||||
original = state_path.read_text(encoding="utf-8")
|
||||
|
||||
with pytest.raises(active_sessions.ActiveSessionRegistryError):
|
||||
active_sessions.active_session_registry_snapshot()
|
||||
|
||||
assert state_path.read_text(encoding="utf-8") == original
|
||||
|
||||
|
||||
def test_cap_transfer_does_not_overwrite_registry_corruption(tmp_path, monkeypatch):
|
||||
home = tmp_path / ".hermes"
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
state_path = home / "runtime" / "active_sessions.json"
|
||||
lease, message = active_sessions.try_acquire_active_session(
|
||||
session_id="cli-old",
|
||||
surface="cli",
|
||||
config={"max_concurrent_sessions": 1},
|
||||
)
|
||||
assert lease is not None and message is None
|
||||
|
||||
corrupt = "{not-json"
|
||||
state_path.write_text(corrupt, encoding="utf-8")
|
||||
assert not active_sessions.transfer_active_session(
|
||||
lease,
|
||||
session_id="cli-new",
|
||||
)
|
||||
assert lease.session_id == "cli-old"
|
||||
assert state_path.read_text(encoding="utf-8") == corrupt
|
||||
|
||||
lease.release()
|
||||
assert lease.released is True
|
||||
assert state_path.read_text(encoding="utf-8") == corrupt
|
||||
|
||||
|
||||
def test_liveness_guard_rejects_unknown_pid_state(tmp_path, monkeypatch):
|
||||
home = tmp_path / ".hermes"
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
state_path = home / "runtime" / "active_sessions.json"
|
||||
active_sessions._write_entries(
|
||||
state_path,
|
||||
[
|
||||
{
|
||||
"lease_id": "unknown-owner",
|
||||
"session_id": "session-1",
|
||||
"surface": "desktop",
|
||||
"pid": 12345,
|
||||
"track_liveness": True,
|
||||
}
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"gateway.status._pid_exists",
|
||||
lambda _pid: (_ for _ in ()).throw(OSError("pid lookup unavailable")),
|
||||
)
|
||||
|
||||
with pytest.raises(active_sessions.ActiveSessionRegistryError):
|
||||
with active_sessions.active_session_liveness_guard("session-1"):
|
||||
pass
|
||||
|
||||
original = state_path.read_text(encoding="utf-8")
|
||||
# An unknown pid state means dead-owner pruning cannot be trusted, which
|
||||
# means ownership cannot be proven. Fail closed (#94595), preserve the file.
|
||||
lease, message = active_sessions.try_acquire_active_session(
|
||||
session_id="cli-cap-session",
|
||||
surface="cli",
|
||||
config={"max_concurrent_sessions": 1},
|
||||
)
|
||||
assert lease is None
|
||||
assert getattr(message, "reason", None) == (
|
||||
active_sessions.SESSION_COORDINATION_UNAVAILABLE
|
||||
)
|
||||
assert state_path.read_text(encoding="utf-8") == original
|
||||
|
||||
|
||||
def test_liveness_release_failure_is_retryable(tmp_path, monkeypatch):
|
||||
home = tmp_path / ".hermes"
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
lease, message = active_sessions.try_acquire_active_session(
|
||||
session_id="session-1",
|
||||
surface="desktop",
|
||||
config={},
|
||||
track_liveness=True,
|
||||
)
|
||||
assert lease is not None and message is None
|
||||
|
||||
original_write = active_sessions._write_entries
|
||||
monkeypatch.setattr(
|
||||
active_sessions,
|
||||
"_write_entries",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("replace failed")),
|
||||
)
|
||||
with pytest.raises(OSError, match="replace failed"):
|
||||
lease.release()
|
||||
assert lease.released is False
|
||||
|
||||
monkeypatch.setattr(active_sessions, "_write_entries", original_write)
|
||||
lease.release()
|
||||
assert lease.released is True
|
||||
assert active_sessions.active_session_registry_snapshot() == []
|
||||
|
||||
|
||||
def test_liveness_transfer_upserts_missing_entry_without_consuming_a_new_slot(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
home = tmp_path / ".hermes"
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
lease, message = active_sessions.try_acquire_active_session(
|
||||
session_id="session-old",
|
||||
surface="desktop",
|
||||
config={"max_concurrent_sessions": 1},
|
||||
track_liveness=True,
|
||||
)
|
||||
assert lease is not None and message is None
|
||||
(home / "runtime" / "active_sessions.json").unlink()
|
||||
|
||||
assert active_sessions.transfer_active_session(lease, session_id="session-new")
|
||||
snapshot = active_sessions.active_session_registry_snapshot()
|
||||
assert [(entry["lease_id"], entry["session_id"]) for entry in snapshot] == [
|
||||
(lease.lease_id, "session-new")
|
||||
]
|
||||
|
||||
blocked, limit_message = active_sessions.try_acquire_active_session(
|
||||
session_id="session-other",
|
||||
surface="desktop",
|
||||
config={"max_concurrent_sessions": 1},
|
||||
track_liveness=True,
|
||||
)
|
||||
assert blocked is None
|
||||
assert limit_message is not None
|
||||
lease.release()
|
||||
|
||||
|
||||
def test_liveness_transfer_write_failure_keeps_old_id_for_retry(tmp_path, monkeypatch):
|
||||
home = tmp_path / ".hermes"
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
lease, message = active_sessions.try_acquire_active_session(
|
||||
session_id="session-old",
|
||||
surface="desktop",
|
||||
config={},
|
||||
track_liveness=True,
|
||||
)
|
||||
assert lease is not None and message is None
|
||||
|
||||
original_write = active_sessions._write_entries
|
||||
monkeypatch.setattr(
|
||||
active_sessions,
|
||||
"_write_entries",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("replace failed")),
|
||||
)
|
||||
with pytest.raises(OSError, match="replace failed"):
|
||||
active_sessions.transfer_active_session(lease, session_id="session-new")
|
||||
assert lease.session_id == "session-old"
|
||||
|
||||
monkeypatch.setattr(active_sessions, "_write_entries", original_write)
|
||||
assert active_sessions.transfer_active_session(lease, session_id="session-new")
|
||||
assert lease.session_id == "session-new"
|
||||
lease.release()
|
||||
|
||||
|
||||
def test_release_wins_against_transfer_waiting_on_same_lease_lock(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
home = tmp_path / ".hermes"
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
lease, message = active_sessions.try_acquire_active_session(
|
||||
session_id="session-old",
|
||||
surface="desktop",
|
||||
config={},
|
||||
track_liveness=True,
|
||||
)
|
||||
assert lease is not None and message is None
|
||||
|
||||
release_wrote = threading.Event()
|
||||
allow_release = threading.Event()
|
||||
transfer_at_lock = threading.Event()
|
||||
original_write = active_sessions._write_entries
|
||||
original_enter = active_sessions._FileLock.__enter__
|
||||
|
||||
def _blocking_write(path, entries):
|
||||
original_write(path, entries)
|
||||
if threading.current_thread().name == "lease-release":
|
||||
release_wrote.set()
|
||||
assert allow_release.wait(timeout=5)
|
||||
|
||||
def _instrumented_enter(lock):
|
||||
if threading.current_thread().name == "lease-transfer":
|
||||
transfer_at_lock.set()
|
||||
return original_enter(lock)
|
||||
|
||||
monkeypatch.setattr(active_sessions, "_write_entries", _blocking_write)
|
||||
monkeypatch.setattr(active_sessions._FileLock, "__enter__", _instrumented_enter)
|
||||
transfer_result: list[bool] = []
|
||||
release_thread = threading.Thread(target=lease.release, name="lease-release")
|
||||
transfer_thread = threading.Thread(
|
||||
target=lambda: transfer_result.append(
|
||||
active_sessions.transfer_active_session(lease, session_id="session-new")
|
||||
),
|
||||
name="lease-transfer",
|
||||
)
|
||||
|
||||
release_thread.start()
|
||||
assert release_wrote.wait(timeout=5)
|
||||
transfer_thread.start()
|
||||
assert transfer_at_lock.wait(timeout=5)
|
||||
allow_release.set()
|
||||
release_thread.join(timeout=5)
|
||||
transfer_thread.join(timeout=5)
|
||||
|
||||
assert not release_thread.is_alive()
|
||||
assert not transfer_thread.is_alive()
|
||||
assert transfer_result == [False]
|
||||
assert lease.released is True
|
||||
assert active_sessions.active_session_registry_snapshot() == []
|
||||
|
||||
|
||||
|
||||
def test_liveness_guard_keeps_a_just_acquired_own_lease_it_cannot_vouch_for(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""Race in #101415's fix: the finalizing session snapshots its live lease
|
||||
ids, then a sibling session acquires a lease before the registry lock is
|
||||
taken. That lease is absent from the snapshot but is not an orphan."""
|
||||
home = tmp_path / ".hermes"
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
fresh, error = active_sessions.try_acquire_active_session(
|
||||
session_id="fresh", surface="desktop", config={}, registry_home=home
|
||||
)
|
||||
assert fresh is not None and error is None
|
||||
|
||||
with active_sessions.active_session_liveness_guard(
|
||||
"fresh", registry_home=home, own_live_lease_ids=set()
|
||||
) as active:
|
||||
assert active is True
|
||||
assert [e["lease_id"] for e in active_sessions.active_session_registry_snapshot(home)] == [fresh.lease_id]
|
||||
|
||||
_backdate_leases(home)
|
||||
with active_sessions.active_session_liveness_guard(
|
||||
"fresh", registry_home=home, own_live_lease_ids=set()
|
||||
) as active:
|
||||
assert active is False
|
||||
assert active_sessions.active_session_registry_snapshot(home) == []
|
||||
@@ -0,0 +1,373 @@
|
||||
"""Regression tests for the Actual Computer provider wiring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
from agent.auxiliary_client import _normalize_aux_provider
|
||||
from hermes_cli import runtime_provider as rp
|
||||
from hermes_cli.auth import (
|
||||
ACTUAL_LOCAL_NOAUTH_PLACEHOLDER,
|
||||
DEFAULT_ACTUAL_BASE_URL,
|
||||
DEFAULT_ACTUAL_LOCAL_BASE_URL,
|
||||
get_api_key_provider_status,
|
||||
normalize_actual_base_url,
|
||||
resolve_api_key_provider_credentials,
|
||||
resolve_provider,
|
||||
)
|
||||
from hermes_cli.models import normalize_provider as normalize_model_provider
|
||||
from hermes_cli.models import provider_model_ids
|
||||
from hermes_cli.providers import determine_api_mode
|
||||
from hermes_cli.providers import get_label
|
||||
from hermes_cli.providers import normalize_provider as normalize_overlay_provider
|
||||
from providers import get_provider_profile
|
||||
|
||||
|
||||
def _clear_actual_env(monkeypatch):
|
||||
monkeypatch.delenv("ACTUAL_API_KEY", raising=False)
|
||||
monkeypatch.delenv("ACTUAL_BASE_URL", raising=False)
|
||||
|
||||
|
||||
def test_actual_aliases_and_profile_metadata():
|
||||
profile = get_provider_profile("actual-computer")
|
||||
|
||||
assert profile is not None
|
||||
assert profile.name == "actual"
|
||||
assert profile.display_name == "Actual Computer"
|
||||
assert profile.base_url == DEFAULT_ACTUAL_BASE_URL
|
||||
assert profile.api_mode == "codex_responses"
|
||||
assert profile.auth_type == "api_key"
|
||||
assert profile.env_vars == ("ACTUAL_API_KEY", "ACTUAL_BASE_URL")
|
||||
assert normalize_overlay_provider("aci") == "actual"
|
||||
assert normalize_model_provider("actualcomputer") == "actual"
|
||||
assert resolve_provider("actual-computer") == "actual"
|
||||
assert _normalize_aux_provider("aci") == "actual"
|
||||
assert get_label("actual") == "Actual Computer"
|
||||
assert determine_api_mode("actual", "https://api.actual.inc") == "codex_responses"
|
||||
|
||||
|
||||
def test_actual_base_url_normalization():
|
||||
assert (
|
||||
normalize_actual_base_url("https://api.actual.inc") == DEFAULT_ACTUAL_BASE_URL
|
||||
)
|
||||
assert (
|
||||
normalize_actual_base_url("https://api.actual.inc/v1")
|
||||
== DEFAULT_ACTUAL_BASE_URL
|
||||
)
|
||||
assert (
|
||||
normalize_actual_base_url("http://127.0.0.1:8080")
|
||||
== DEFAULT_ACTUAL_LOCAL_BASE_URL
|
||||
)
|
||||
assert (
|
||||
normalize_actual_base_url("http://127.0.0.1:8080/v1")
|
||||
== DEFAULT_ACTUAL_LOCAL_BASE_URL
|
||||
)
|
||||
assert (
|
||||
normalize_actual_base_url("http://localhost:8080/")
|
||||
== "http://localhost:8080/v1"
|
||||
)
|
||||
|
||||
|
||||
def test_actual_credentials_default_to_hosted_api(monkeypatch):
|
||||
_clear_actual_env(monkeypatch)
|
||||
monkeypatch.setenv("ACTUAL_API_KEY", "actual-test-key")
|
||||
|
||||
creds = resolve_api_key_provider_credentials("actual")
|
||||
|
||||
assert creds["provider"] == "actual"
|
||||
assert creds["api_key"] == "actual-test-key"
|
||||
assert creds["base_url"] == DEFAULT_ACTUAL_BASE_URL
|
||||
|
||||
|
||||
def test_actual_local_loopback_allows_no_auth(monkeypatch):
|
||||
_clear_actual_env(monkeypatch)
|
||||
monkeypatch.setenv("ACTUAL_BASE_URL", "http://127.0.0.1:8080")
|
||||
|
||||
creds = resolve_api_key_provider_credentials("actual")
|
||||
status = get_api_key_provider_status("actual")
|
||||
|
||||
assert creds["api_key"] == ACTUAL_LOCAL_NOAUTH_PLACEHOLDER
|
||||
assert creds["base_url"] == DEFAULT_ACTUAL_LOCAL_BASE_URL
|
||||
assert creds["source"] == "local-offline"
|
||||
assert status["configured"] is True
|
||||
assert status["logged_in"] is True
|
||||
assert status["key_source"] == "local-offline"
|
||||
assert status["base_url"] == DEFAULT_ACTUAL_LOCAL_BASE_URL
|
||||
|
||||
|
||||
def test_actual_runtime_uses_hosted_default(monkeypatch):
|
||||
_clear_actual_env(monkeypatch)
|
||||
monkeypatch.setenv("ACTUAL_API_KEY", "actual-test-key")
|
||||
monkeypatch.setattr(
|
||||
rp,
|
||||
"_get_model_config",
|
||||
lambda: {"provider": "actual", "default": "actual/test-model"},
|
||||
)
|
||||
|
||||
resolved = rp.resolve_runtime_provider(requested="actual")
|
||||
|
||||
assert resolved["provider"] == "actual"
|
||||
assert resolved["api_mode"] == "codex_responses"
|
||||
assert resolved["api_key"] == "actual-test-key"
|
||||
assert resolved["base_url"] == DEFAULT_ACTUAL_BASE_URL
|
||||
|
||||
|
||||
def test_actual_runtime_uses_local_env_without_key(monkeypatch):
|
||||
_clear_actual_env(monkeypatch)
|
||||
monkeypatch.setenv("ACTUAL_BASE_URL", "http://127.0.0.1:8080")
|
||||
monkeypatch.setattr(
|
||||
rp,
|
||||
"_get_model_config",
|
||||
lambda: {"provider": "actual", "default": "actual/local-model"},
|
||||
)
|
||||
|
||||
resolved = rp.resolve_runtime_provider(requested="actual")
|
||||
|
||||
assert resolved["provider"] == "actual"
|
||||
assert resolved["api_mode"] == "codex_responses"
|
||||
assert resolved["api_key"] == ACTUAL_LOCAL_NOAUTH_PLACEHOLDER
|
||||
assert resolved["base_url"] == DEFAULT_ACTUAL_LOCAL_BASE_URL
|
||||
|
||||
|
||||
def test_actual_runtime_uses_local_config_without_key(monkeypatch):
|
||||
_clear_actual_env(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
rp,
|
||||
"_get_model_config",
|
||||
lambda: {
|
||||
"provider": "actual",
|
||||
"base_url": "http://127.0.0.1:8080",
|
||||
"default": "actual/local-model",
|
||||
},
|
||||
)
|
||||
|
||||
resolved = rp.resolve_runtime_provider(requested="actual")
|
||||
|
||||
assert resolved["provider"] == "actual"
|
||||
assert resolved["api_mode"] == "codex_responses"
|
||||
assert resolved["api_key"] == ACTUAL_LOCAL_NOAUTH_PLACEHOLDER
|
||||
assert resolved["base_url"] == DEFAULT_ACTUAL_LOCAL_BASE_URL
|
||||
|
||||
|
||||
def test_actual_runtime_normalizes_explicit_hosted_base_url(monkeypatch):
|
||||
_clear_actual_env(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
rp,
|
||||
"_get_model_config",
|
||||
lambda: {"provider": "actual", "default": "actual/test-model"},
|
||||
)
|
||||
|
||||
resolved = rp.resolve_runtime_provider(
|
||||
requested="actual",
|
||||
explicit_api_key="actual-test-key",
|
||||
explicit_base_url="https://api.actual.inc",
|
||||
)
|
||||
|
||||
assert resolved["provider"] == "actual"
|
||||
assert resolved["api_mode"] == "codex_responses"
|
||||
assert resolved["api_key"] == "actual-test-key"
|
||||
assert resolved["base_url"] == DEFAULT_ACTUAL_BASE_URL
|
||||
assert resolved["source"] == "explicit"
|
||||
|
||||
|
||||
def test_actual_profile_fetch_models_normalizes_env_base_url(monkeypatch):
|
||||
_clear_actual_env(monkeypatch)
|
||||
monkeypatch.setenv("ACTUAL_BASE_URL", "http://127.0.0.1:8080")
|
||||
profile = get_provider_profile("actual")
|
||||
seen = {}
|
||||
|
||||
class _Response:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return None
|
||||
|
||||
def read(self):
|
||||
return json.dumps({"data": [{"id": "actual/local-model"}]}).encode()
|
||||
|
||||
def _open(req, timeout=0):
|
||||
seen["url"] = req.full_url
|
||||
seen["auth"] = req.get_header("Authorization")
|
||||
seen["timeout"] = timeout
|
||||
return _Response()
|
||||
|
||||
monkeypatch.setattr("hermes_cli.urllib_security.open_credentialed_url", _open)
|
||||
|
||||
assert profile.fetch_models(api_key=None, timeout=1.5) == ["actual/local-model"]
|
||||
assert seen["url"] == DEFAULT_ACTUAL_LOCAL_BASE_URL + "/models"
|
||||
assert seen["auth"] is None
|
||||
assert seen["timeout"] == 1.5
|
||||
|
||||
|
||||
def test_actual_profile_fetch_models_sends_credential_only_to_original_origin(
|
||||
monkeypatch,
|
||||
):
|
||||
"""fetch_models must route through the shared redirect-credential guard.
|
||||
|
||||
ActualProfile overrides ProviderProfile.fetch_models with its own
|
||||
base_url resolution, and previously called raw urllib.request.urlopen
|
||||
directly instead of the base class's open_credentialed_url — losing the
|
||||
protection that strips the Authorization header when a redirect leaves
|
||||
the original host. Exercises the real SafeCredentialRedirectHandler
|
||||
(no mocking of open_credentialed_url itself) against a local HTTP
|
||||
server that 302s to a different origin, mirroring
|
||||
test_urllib_security.py's end-to-end redirect tests.
|
||||
"""
|
||||
import http.server
|
||||
import threading
|
||||
|
||||
_clear_actual_env(monkeypatch)
|
||||
profile = get_provider_profile("actual")
|
||||
|
||||
source_auth_headers: list[str | None] = []
|
||||
target_auth_headers: list[str | None] = []
|
||||
|
||||
class _RedirectTargetHandler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
target_auth_headers.append(self.headers.get("Authorization"))
|
||||
body = json.dumps({"data": [{"id": "should-not-be-trusted"}]}).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *_args):
|
||||
pass
|
||||
|
||||
target_server = http.server.HTTPServer(("127.0.0.1", 0), _RedirectTargetHandler)
|
||||
target_thread = threading.Thread(target=target_server.serve_forever, daemon=True)
|
||||
target_thread.start()
|
||||
target_port = target_server.server_address[1]
|
||||
|
||||
class _RedirectingHandler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
source_auth_headers.append(self.headers.get("Authorization"))
|
||||
self.send_response(302)
|
||||
self.send_header("Location", f"http://127.0.0.1:{target_port}/models")
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, *_args):
|
||||
pass
|
||||
|
||||
redirect_server = http.server.HTTPServer(("127.0.0.1", 0), _RedirectingHandler)
|
||||
redirect_thread = threading.Thread(
|
||||
target=redirect_server.serve_forever, daemon=True
|
||||
)
|
||||
redirect_thread.start()
|
||||
redirect_port = redirect_server.server_address[1]
|
||||
|
||||
try:
|
||||
result = profile.fetch_models(
|
||||
api_key="actual-secret-token",
|
||||
base_url=f"http://127.0.0.1:{redirect_port}",
|
||||
timeout=5.0,
|
||||
)
|
||||
finally:
|
||||
redirect_server.shutdown()
|
||||
target_server.shutdown()
|
||||
redirect_thread.join(timeout=2.0)
|
||||
target_thread.join(timeout=2.0)
|
||||
|
||||
assert result == ["should-not-be-trusted"], (
|
||||
"sanity check: the redirect must actually have been followed"
|
||||
)
|
||||
assert source_auth_headers == ["Bearer actual-secret-token"]
|
||||
assert target_auth_headers == [None], (
|
||||
"Authorization header leaked to a different origin after a redirect"
|
||||
)
|
||||
|
||||
|
||||
def test_actual_provider_model_ids_use_local_profile_catalog(monkeypatch):
|
||||
_clear_actual_env(monkeypatch)
|
||||
monkeypatch.setenv("ACTUAL_BASE_URL", "http://127.0.0.1:8080")
|
||||
profile = get_provider_profile("actual")
|
||||
|
||||
with patch.object(
|
||||
profile, "fetch_models", return_value=["actual/local-model"]
|
||||
) as fetch:
|
||||
assert provider_model_ids("actual") == ["actual/local-model"]
|
||||
|
||||
fetch.assert_called_once_with(
|
||||
api_key=ACTUAL_LOCAL_NOAUTH_PLACEHOLDER,
|
||||
base_url=DEFAULT_ACTUAL_LOCAL_BASE_URL,
|
||||
)
|
||||
|
||||
|
||||
def test_actual_hosted_model_ids_send_resolved_credential(monkeypatch):
|
||||
_clear_actual_env(monkeypatch)
|
||||
monkeypatch.setenv("ACTUAL_API_KEY", "actual-test-key")
|
||||
profile = get_provider_profile("actual")
|
||||
|
||||
with patch.object(
|
||||
profile, "fetch_models", return_value=["actual/hosted-model"]
|
||||
) as fetch:
|
||||
assert provider_model_ids("actual") == ["actual/hosted-model"]
|
||||
|
||||
fetch.assert_called_once_with(
|
||||
api_key="actual-test-key",
|
||||
base_url=DEFAULT_ACTUAL_BASE_URL,
|
||||
)
|
||||
|
||||
|
||||
def test_actual_hosted_model_ids_do_not_probe_without_credentials(monkeypatch):
|
||||
_clear_actual_env(monkeypatch)
|
||||
profile = get_provider_profile("actual")
|
||||
|
||||
with patch.object(profile, "fetch_models") as fetch:
|
||||
assert provider_model_ids("actual") == []
|
||||
|
||||
fetch.assert_not_called()
|
||||
|
||||
|
||||
def test_actual_codex_transport_clamps_reasoning_effort():
|
||||
"""Actual's SGLang/vLLM backends only accept none/low/medium/high/max.
|
||||
|
||||
A global xhigh/ultra reasoning_effort must be clamped before the wire,
|
||||
otherwise every request fails with a wrapped HTTP 400. Regression for
|
||||
the field-reported reasoning_effort trap.
|
||||
"""
|
||||
from agent.transports.codex import ResponsesApiTransport
|
||||
|
||||
t = ResponsesApiTransport()
|
||||
for requested, expected in (("xhigh", "high"), ("ultra", "max"), ("high", "high")):
|
||||
kwargs = t.build_kwargs(
|
||||
model="glm-5.2-nvfp4",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
tools=None,
|
||||
provider="actual",
|
||||
reasoning_config={"effort": requested},
|
||||
base_url=DEFAULT_ACTUAL_BASE_URL,
|
||||
)
|
||||
assert kwargs["reasoning"]["effort"] == expected, requested
|
||||
|
||||
# Other providers keep the wider values untouched on the generic path.
|
||||
kwargs = t.build_kwargs(
|
||||
model="some-model",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
tools=None,
|
||||
provider="openai-codex",
|
||||
reasoning_config={"effort": "xhigh"},
|
||||
)
|
||||
assert kwargs["reasoning"]["effort"] == "xhigh"
|
||||
|
||||
|
||||
def test_actual_runtime_config_local_base_url_without_key(monkeypatch):
|
||||
"""Config-driven loopback base_url (not just env) reaches the no-auth path."""
|
||||
_clear_actual_env(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
rp,
|
||||
"_get_model_config",
|
||||
lambda: {
|
||||
"provider": "actual",
|
||||
"base_url": "http://localhost:8080",
|
||||
"default": "actual/local-model",
|
||||
},
|
||||
)
|
||||
|
||||
resolved = rp.resolve_runtime_provider(requested="actual")
|
||||
|
||||
assert resolved["api_key"] == ACTUAL_LOCAL_NOAUTH_PLACEHOLDER
|
||||
assert resolved["api_mode"] == "codex_responses"
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Tests for the AI_AGENT / HERMES_AGENT harness-attribution env vars.
|
||||
|
||||
Port of earendil-works/pi#7493: entry points advertise the agent harness to
|
||||
child processes via the cross-agent ``AI_AGENT`` standard plus a
|
||||
Hermes-specific marker, without clobbering an outer harness.
|
||||
|
||||
The AI_AGENT value must equal Hermes' id in the public agent-harness
|
||||
registry (``hermes-agent`` in huggingface.js ``agent-harnesses.ts``) —
|
||||
standard-var matching there is exact, so any other value is attributed to
|
||||
"unknown".
|
||||
|
||||
The terminal backends additionally export both vars inside every wrapped
|
||||
shell command (``BaseEnvironment._wrap_command``) so the marker reaches
|
||||
REMOTE backends (Docker/SSH/Modal/Daytona/Singularity/Vercel) whose exec
|
||||
environment does not inherit the Hermes process env, and survives the
|
||||
cross-session leak guard that strips ``HERMES_SESSION_*`` from subprocess
|
||||
envs in engaged multi-session hosts.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from hermes_cli.main import _advertise_agent_env
|
||||
|
||||
# Registry id — must stay in sync with huggingface.js agent-harnesses.ts.
|
||||
HARNESS_ID = "hermes-agent"
|
||||
|
||||
|
||||
class TestAdvertiseAgentEnv:
|
||||
def test_sets_both_vars_when_unset(self, monkeypatch):
|
||||
monkeypatch.delenv("AI_AGENT", raising=False)
|
||||
monkeypatch.delenv("HERMES_AGENT", raising=False)
|
||||
_advertise_agent_env()
|
||||
assert os.environ["AI_AGENT"] == HARNESS_ID
|
||||
assert os.environ["HERMES_AGENT"] == "true"
|
||||
|
||||
def test_does_not_clobber_outer_harness(self, monkeypatch):
|
||||
monkeypatch.setenv("AI_AGENT", "pi")
|
||||
monkeypatch.delenv("HERMES_AGENT", raising=False)
|
||||
_advertise_agent_env()
|
||||
assert os.environ["AI_AGENT"] == "pi"
|
||||
assert os.environ["HERMES_AGENT"] == "true"
|
||||
|
||||
def test_idempotent(self, monkeypatch):
|
||||
monkeypatch.delenv("AI_AGENT", raising=False)
|
||||
monkeypatch.delenv("HERMES_AGENT", raising=False)
|
||||
_advertise_agent_env()
|
||||
_advertise_agent_env()
|
||||
assert os.environ["AI_AGENT"] == HARNESS_ID
|
||||
assert os.environ["HERMES_AGENT"] == "true"
|
||||
|
||||
|
||||
class TestWrapCommandAdvertisesHarness:
|
||||
"""The shell-level export in BaseEnvironment._wrap_command."""
|
||||
|
||||
def _wrap(self, command: str) -> str:
|
||||
from tools.environments.local import LocalEnvironment
|
||||
|
||||
env = LocalEnvironment.__new__(LocalEnvironment)
|
||||
env._snapshot_ready = False
|
||||
env._session_id = "testsession0"
|
||||
env._cwd_marker = "__HERMES_CWD_testsession0__"
|
||||
env._snapshot_path = "/tmp/hermes-snap-testsession0.sh"
|
||||
env._snapshot_passthrough_names = set()
|
||||
return env._wrap_command(command, "/tmp")
|
||||
|
||||
def test_wrap_command_contains_export(self):
|
||||
wrapped = self._wrap("true")
|
||||
assert 'AI_AGENT="${AI_AGENT:-' + HARNESS_ID + '}"' in wrapped
|
||||
assert 'HERMES_AGENT="${HERMES_AGENT:-true}"' in wrapped
|
||||
|
||||
def test_export_precedes_user_command(self):
|
||||
wrapped = self._wrap("echo payload-sentinel")
|
||||
assert wrapped.index("AI_AGENT=") < wrapped.index("payload-sentinel")
|
||||
|
||||
def test_shell_sets_default_and_preserves_outer(self):
|
||||
"""Run the wrapped script through real bash both ways."""
|
||||
wrapped = self._wrap('echo "AI=$AI_AGENT HERMES=$HERMES_AGENT"')
|
||||
|
||||
clean_env = {k: v for k, v in os.environ.items()
|
||||
if k not in ("AI_AGENT", "HERMES_AGENT")}
|
||||
out = subprocess.run(
|
||||
["bash", "-c", wrapped], capture_output=True, text=True,
|
||||
env=clean_env, timeout=30,
|
||||
)
|
||||
assert f"AI={HARNESS_ID} HERMES=true" in out.stdout
|
||||
|
||||
outer_env = dict(clean_env, AI_AGENT="pi", HERMES_AGENT="false")
|
||||
out = subprocess.run(
|
||||
["bash", "-c", wrapped], capture_output=True, text=True,
|
||||
env=outer_env, timeout=30,
|
||||
)
|
||||
assert "AI=pi HERMES=false" in out.stdout
|
||||
@@ -0,0 +1,729 @@
|
||||
"""Tests for hermes_cli.agent_import — ``hermes import-agent``.
|
||||
|
||||
Covers: source detection, Claude Code and Codex parsing, mapping into the
|
||||
real Hermes stores (memories/MEMORY.md, config.yaml command_allowlist /
|
||||
approvals.deny / mcp_servers, skills/), dry-run write-nothing guarantees,
|
||||
malformed-input skip reports, and the never-import-secrets rule.
|
||||
|
||||
Uses the profile_env fixture pattern from tests/hermes_cli/test_profiles.py:
|
||||
Path.home() and HERMES_HOME are redirected to tmp_path so nothing touches
|
||||
the real ~/.hermes.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from hermes_cli.agent_import import (
|
||||
ENTRY_DELIMITER,
|
||||
AgentImporter,
|
||||
claude_rule_to_command_pattern,
|
||||
detect_agents,
|
||||
extract_markdown_entries,
|
||||
is_secret_key,
|
||||
parse_existing_memory_entries,
|
||||
sanitize_mcp_env,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared fixture: redirect Path.home() and HERMES_HOME (profile_env pattern)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture()
|
||||
def profile_env(tmp_path, monkeypatch):
|
||||
"""Isolated environment: Path.home() -> tmp_path, HERMES_HOME -> tmp/.hermes."""
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
default_home = tmp_path / ".hermes"
|
||||
default_home.mkdir(exist_ok=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(default_home))
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def hermes_home(profile_env):
|
||||
return profile_env / ".hermes"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fake source trees
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CLAUDE_MD = """# Global instructions
|
||||
|
||||
## Style
|
||||
- Always use type hints
|
||||
- Prefer pathlib over os.path
|
||||
|
||||
Run the linter before committing.
|
||||
"""
|
||||
|
||||
AGENTS_MD = """# Codex rules
|
||||
|
||||
- Never force-push to main
|
||||
- Keep commits atomic
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def claude_tree(profile_env):
|
||||
"""Build a fake ~/.claude tree (plus sibling ~/.claude.json)."""
|
||||
root = profile_env / ".claude"
|
||||
root.mkdir()
|
||||
(root / "CLAUDE.md").write_text(CLAUDE_MD, encoding="utf-8")
|
||||
(root / "settings.json").write_text(json.dumps({
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm run build)",
|
||||
"Bash(npm run test:*)",
|
||||
"Bash(git diff *)",
|
||||
"Read(~/.zshrc)", # non-Bash → unmapped
|
||||
],
|
||||
"deny": [
|
||||
"Bash(rm -rf *)",
|
||||
"WebFetch", # non-Bash → dropped
|
||||
],
|
||||
},
|
||||
"mcpServers": {
|
||||
"settings-server": {"command": "uvx", "args": ["settings-mcp"]},
|
||||
},
|
||||
}), encoding="utf-8")
|
||||
# mcpServers in the sibling ~/.claude.json (Claude's primary MCP store)
|
||||
(profile_env / ".claude.json").write_text(json.dumps({
|
||||
"mcpServers": {
|
||||
"github": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-github"],
|
||||
"env": {
|
||||
"GITHUB_TOKEN": "ghp_SECRET123",
|
||||
"GITHUB_HOST": "github.example.com",
|
||||
},
|
||||
},
|
||||
"remote": {
|
||||
"url": "https://mcp.example.com/sse",
|
||||
"headers": {
|
||||
"Authorization": "Bearer abc123",
|
||||
"X-Region": "us-east",
|
||||
},
|
||||
},
|
||||
},
|
||||
}), encoding="utf-8")
|
||||
# A credentials file that must never be read/imported
|
||||
(root / ".credentials.json").write_text(
|
||||
json.dumps({"api_key": "sk-ant-SUPERSECRET"}), encoding="utf-8")
|
||||
# Skills
|
||||
skill = root / "skills" / "deploy-helper"
|
||||
skill.mkdir(parents=True)
|
||||
(skill / "SKILL.md").write_text(
|
||||
"---\nname: deploy-helper\n---\n\nDeploy things.\n", encoding="utf-8")
|
||||
(root / "skills" / "not-a-skill").mkdir() # no SKILL.md → ignored
|
||||
# Slash commands (reported as skipped)
|
||||
commands = root / "commands"
|
||||
commands.mkdir()
|
||||
(commands / "review.md").write_text("Review this PR", encoding="utf-8")
|
||||
return root
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def codex_tree(profile_env):
|
||||
"""Build a fake ~/.codex tree."""
|
||||
root = profile_env / ".codex"
|
||||
root.mkdir()
|
||||
(root / "AGENTS.md").write_text(AGENTS_MD, encoding="utf-8")
|
||||
(root / "config.toml").write_text(
|
||||
'model = "gpt-5"\n'
|
||||
'approval_policy = "on-request"\n'
|
||||
"\n"
|
||||
"[mcp_servers.docs]\n"
|
||||
'command = "uvx"\n'
|
||||
'args = ["docs-mcp"]\n'
|
||||
"\n"
|
||||
"[mcp_servers.docs.env]\n"
|
||||
'DOCS_API_KEY = "secret-value"\n'
|
||||
'DOCS_REGION = "eu"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(root / "auth.json").write_text(
|
||||
json.dumps({"OPENAI_API_KEY": "sk-SECRET"}), encoding="utf-8")
|
||||
memories = root / "memories"
|
||||
memories.mkdir()
|
||||
(memories / "2026-01-01.md").write_text(
|
||||
"- User prefers tabs over spaces\n- Project uses PostgreSQL\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
skill = root / "skills" / "db-migrate"
|
||||
skill.mkdir(parents=True)
|
||||
(skill / "SKILL.md").write_text(
|
||||
"---\nname: db-migrate\n---\n\nMigrate databases.\n", encoding="utf-8")
|
||||
return root
|
||||
|
||||
|
||||
def snapshot_tree(root: Path) -> dict:
|
||||
"""Map of relative-path -> bytes for every file under root."""
|
||||
return {
|
||||
str(p.relative_to(root)): p.read_bytes()
|
||||
for p in sorted(root.rglob("*")) if p.is_file()
|
||||
}
|
||||
|
||||
|
||||
def run_import(agent, source, hermes_home, execute, overwrite=False):
|
||||
return AgentImporter(
|
||||
agent=agent,
|
||||
source_root=source,
|
||||
target_root=hermes_home,
|
||||
execute=execute,
|
||||
overwrite=overwrite,
|
||||
).run()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Detection & helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDetection:
|
||||
def test_detects_claude_and_codex(self, claude_tree, codex_tree):
|
||||
assert detect_agents() == ["claude-code", "codex"]
|
||||
|
||||
|
||||
def test_unsupported_agent_raises(self, hermes_home, tmp_path):
|
||||
with pytest.raises(ValueError):
|
||||
AgentImporter("cursor", tmp_path, hermes_home)
|
||||
|
||||
|
||||
class TestRuleMapping:
|
||||
def test_bash_rule_plain(self):
|
||||
assert claude_rule_to_command_pattern("Bash(npm run build)") == "npm run build"
|
||||
|
||||
|
||||
def test_non_bash_rule_is_none(self):
|
||||
assert claude_rule_to_command_pattern("Read(~/.zshrc)") is None
|
||||
assert claude_rule_to_command_pattern("WebFetch") is None
|
||||
|
||||
|
||||
class TestSecretDetection:
|
||||
@pytest.mark.parametrize("key", [
|
||||
"GITHUB_TOKEN", "OPENAI_API_KEY", "MY_SECRET", "DB_PASSWORD",
|
||||
"AWS_ACCESS_KEY", "AUTH_HEADER", "APIKEY",
|
||||
])
|
||||
def test_secret_keys(self, key):
|
||||
assert is_secret_key(key)
|
||||
|
||||
@pytest.mark.parametrize("key", ["GITHUB_HOST", "REGION", "DEBUG", "PORT"])
|
||||
def test_non_secret_keys(self, key):
|
||||
assert not is_secret_key(key)
|
||||
|
||||
def test_sanitize_env_splits(self):
|
||||
kept, stripped = sanitize_mcp_env(
|
||||
{"API_TOKEN": "x", "HOST": "h", "MY_KEY": "k"})
|
||||
assert kept == {"HOST": "h"}
|
||||
assert sorted(stripped) == ["API_TOKEN", "MY_KEY"]
|
||||
|
||||
|
||||
class TestMarkdownEntries:
|
||||
def test_extracts_bullets_and_paragraphs(self):
|
||||
entries = extract_markdown_entries(CLAUDE_MD)
|
||||
assert any("type hints" in e for e in entries)
|
||||
assert any("linter" in e for e in entries)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dry run writes NOTHING
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDryRun:
|
||||
def test_claude_dry_run_writes_nothing(self, claude_tree, hermes_home):
|
||||
before = snapshot_tree(hermes_home)
|
||||
report = run_import("claude-code", claude_tree, hermes_home, execute=False)
|
||||
assert snapshot_tree(hermes_home) == before
|
||||
assert report["dry_run"] is True
|
||||
assert report["summary"]["imported"] > 0
|
||||
|
||||
|
||||
def test_dry_run_and_real_run_plan_same_items(self, claude_tree, hermes_home):
|
||||
preview = run_import("claude-code", claude_tree, hermes_home, execute=False)
|
||||
applied = run_import("claude-code", claude_tree, hermes_home, execute=True)
|
||||
pk = [(i["kind"], i["status"]) for i in preview["items"]]
|
||||
ak = [(i["kind"], i["status"]) for i in applied["items"]]
|
||||
assert pk == ak
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Claude Code real run — every item lands in the right store
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestClaudeCodeImport:
|
||||
@pytest.fixture()
|
||||
def report(self, claude_tree, hermes_home):
|
||||
return run_import("claude-code", claude_tree, hermes_home, execute=True)
|
||||
|
||||
|
||||
def test_allowlist_lands_in_config_yaml(self, report, hermes_home):
|
||||
config = yaml.safe_load((hermes_home / "config.yaml").read_text())
|
||||
allow = config["command_allowlist"]
|
||||
assert "npm run build" in allow
|
||||
assert "npm run test*" in allow
|
||||
assert "git diff *" in allow
|
||||
# non-Bash rules must not leak in
|
||||
assert not any("Read(" in p for p in allow)
|
||||
|
||||
|
||||
|
||||
|
||||
def test_slash_commands_reported_skipped(self, report):
|
||||
items = {i["kind"]: i for i in report["items"]}
|
||||
assert items["slash-commands"]["status"] == "skipped"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Codex real run
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCodexImport:
|
||||
@pytest.fixture()
|
||||
def report(self, codex_tree, hermes_home):
|
||||
return run_import("codex", codex_tree, hermes_home, execute=True)
|
||||
|
||||
|
||||
def test_mcp_servers_from_config_toml(self, report, hermes_home):
|
||||
config = yaml.safe_load((hermes_home / "config.yaml").read_text())
|
||||
docs = config["mcp_servers"]["docs"]
|
||||
assert docs["command"] == "uvx"
|
||||
assert docs["args"] == ["docs-mcp"]
|
||||
# non-secret env survives, secret is stripped
|
||||
assert docs["env"] == {"DOCS_REGION": "eu"}
|
||||
|
||||
def test_skill_copied(self, report, hermes_home):
|
||||
assert (hermes_home / "skills" / "codex-imports" / "db-migrate" / "SKILL.md").exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Secrets are never copied
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSecretsNeverImported:
|
||||
def test_no_secret_values_anywhere_claude(self, claude_tree, hermes_home):
|
||||
run_import("claude-code", claude_tree, hermes_home, execute=True)
|
||||
blob = "".join(
|
||||
p.read_text(encoding="utf-8", errors="replace")
|
||||
for p in hermes_home.rglob("*") if p.is_file()
|
||||
)
|
||||
assert "ghp_SECRET123" not in blob
|
||||
assert "Bearer abc123" not in blob
|
||||
assert "sk-ant-SUPERSECRET" not in blob
|
||||
|
||||
def test_no_secret_values_anywhere_codex(self, codex_tree, hermes_home):
|
||||
run_import("codex", codex_tree, hermes_home, execute=True)
|
||||
blob = "".join(
|
||||
p.read_text(encoding="utf-8", errors="replace")
|
||||
for p in hermes_home.rglob("*") if p.is_file()
|
||||
)
|
||||
assert "secret-value" not in blob
|
||||
assert "sk-SECRET" not in blob
|
||||
|
||||
def test_stripped_secrets_reported(self, claude_tree, hermes_home):
|
||||
report = run_import("claude-code", claude_tree, hermes_home, execute=True)
|
||||
stripped = report.get("stripped_secrets", [])
|
||||
assert "mcp_servers.github.env.GITHUB_TOKEN" in stripped
|
||||
assert any("Authorization" in s for s in stripped)
|
||||
|
||||
def test_non_secret_header_kept(self, claude_tree, hermes_home):
|
||||
run_import("claude-code", claude_tree, hermes_home, execute=True)
|
||||
config = yaml.safe_load((hermes_home / "config.yaml").read_text())
|
||||
assert config["mcp_servers"]["remote"]["headers"] == {"X-Region": "us-east"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Malformed inputs: per-item skip/error reports, no crashes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMalformedInputs:
|
||||
def test_bad_settings_json_reports_error(self, profile_env, hermes_home):
|
||||
root = profile_env / ".claude"
|
||||
root.mkdir()
|
||||
(root / "settings.json").write_text("{not json!!", encoding="utf-8")
|
||||
(root / "CLAUDE.md").write_text("- still importable\n", encoding="utf-8")
|
||||
report = run_import("claude-code", root, hermes_home, execute=True)
|
||||
errors = [i for i in report["items"] if i["status"] == "error"]
|
||||
assert any(i["kind"] == "settings" for i in errors)
|
||||
# CLAUDE.md still imported despite bad settings.json
|
||||
assert "still importable" in (
|
||||
hermes_home / "memories" / "MEMORY.md").read_text()
|
||||
|
||||
|
||||
def test_empty_tree_all_skipped(self, profile_env, hermes_home):
|
||||
root = profile_env / ".codex"
|
||||
root.mkdir()
|
||||
report = run_import("codex", root, hermes_home, execute=True)
|
||||
assert report["summary"]["imported"] == 0
|
||||
assert report["summary"]["error"] == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Merge semantics & conflicts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMergeSemantics:
|
||||
|
||||
def test_existing_mcp_server_conflicts_without_overwrite(
|
||||
self, claude_tree, hermes_home):
|
||||
(hermes_home / "config.yaml").write_text(
|
||||
yaml.safe_dump({"mcp_servers": {"github": {"command": "mine"}}}),
|
||||
encoding="utf-8")
|
||||
report = run_import("claude-code", claude_tree, hermes_home, execute=True)
|
||||
config = yaml.safe_load((hermes_home / "config.yaml").read_text())
|
||||
assert config["mcp_servers"]["github"]["command"] == "mine"
|
||||
assert any(
|
||||
i["status"] == "conflict" and i["source"] == "github"
|
||||
for i in report["items"]
|
||||
)
|
||||
|
||||
|
||||
def test_existing_skill_conflicts_without_overwrite(
|
||||
self, claude_tree, hermes_home):
|
||||
dest = hermes_home / "skills" / "claude-code-imports" / "deploy-helper"
|
||||
dest.mkdir(parents=True)
|
||||
(dest / "SKILL.md").write_text("mine\n", encoding="utf-8")
|
||||
report = run_import("claude-code", claude_tree, hermes_home, execute=True)
|
||||
assert (dest / "SKILL.md").read_text() == "mine\n"
|
||||
assert any(
|
||||
i["kind"] == "skill" and i["status"] == "conflict"
|
||||
for i in report["items"]
|
||||
)
|
||||
|
||||
def test_reimport_is_idempotent_for_memory(self, claude_tree, hermes_home):
|
||||
run_import("claude-code", claude_tree, hermes_home, execute=True)
|
||||
first = (hermes_home / "memories" / "MEMORY.md").read_text()
|
||||
report = run_import("claude-code", claude_tree, hermes_home, execute=True)
|
||||
assert (hermes_home / "memories" / "MEMORY.md").read_text() == first
|
||||
memory_items = [i for i in report["items"] if i["kind"] == "claude-md"]
|
||||
assert memory_items[0]["status"] == "skipped"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The DESTINATION memories/MEMORY.md is a §-delimited store, not a document
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# A realistic hand-edited store: one entry, no "§", with a fenced code block
|
||||
# and a markdown table — exactly the content extract_markdown_entries() drops.
|
||||
EXISTING_MEMORY = """Homelab runbook. Restart the ingress controller with:
|
||||
|
||||
```bash
|
||||
kubectl -n ingress rollout restart deploy/nginx
|
||||
```
|
||||
|
||||
Escalation ladder:
|
||||
|
||||
| Severity | Contact | Window |
|
||||
|----------|---------|--------|
|
||||
| SEV1 | on-call | 15m |
|
||||
| SEV2 | #ops | 4h |
|
||||
|
||||
Never page for SEV3.
|
||||
"""
|
||||
|
||||
|
||||
class TestExistingMemoryStorePreserved:
|
||||
"""An import must not shred the memory store it merges into.
|
||||
|
||||
``memories/MEMORY.md`` is the entry-delimited store written by
|
||||
``MemoryStore._write_file``; a single-entry or hand-edited store contains
|
||||
no ``§`` delimiter. Parsing it with the *source* markdown extractor drops
|
||||
code blocks and table rows and splits one entry into fragments, and the
|
||||
merged result is written straight back over the file.
|
||||
"""
|
||||
|
||||
@pytest.fixture()
|
||||
def seeded_home(self, hermes_home):
|
||||
memory = hermes_home / "memories" / "MEMORY.md"
|
||||
memory.parent.mkdir(parents=True, exist_ok=True)
|
||||
memory.write_text(EXISTING_MEMORY, encoding="utf-8")
|
||||
return hermes_home
|
||||
|
||||
|
||||
|
||||
def test_import_preserves_existing_entry_verbatim(
|
||||
self, claude_tree, seeded_home):
|
||||
path = seeded_home / "memories" / "MEMORY.md"
|
||||
run_import("claude-code", claude_tree, seeded_home, execute=True)
|
||||
entries = path.read_text(encoding="utf-8").split(ENTRY_DELIMITER)
|
||||
# The pre-existing store survives byte-intact as a SINGLE entry ...
|
||||
assert entries[0] == EXISTING_MEMORY.strip()
|
||||
# ... including the parts the markdown extractor would have dropped.
|
||||
assert "kubectl -n ingress rollout restart deploy/nginx" in entries[0]
|
||||
assert "| SEV1 | on-call | 15m |" in entries[0]
|
||||
# ... and the imported entries are still appended after it.
|
||||
assert any("type hints" in e for e in entries[1:])
|
||||
|
||||
def test_import_backs_up_the_previous_store(self, claude_tree, seeded_home):
|
||||
memories = seeded_home / "memories"
|
||||
run_import("claude-code", claude_tree, seeded_home, execute=True)
|
||||
backups = sorted(memories.glob("MEMORY.md.bak.*"))
|
||||
assert len(backups) == 1
|
||||
assert backups[0].read_text(encoding="utf-8") == EXISTING_MEMORY
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# config.yaml preservation (sibling of the MEMORY.md case above)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
EXISTING_CONFIG = """\
|
||||
model: hermes-4-405b
|
||||
api_key_env: OPENROUTER_API_KEY
|
||||
command_allowlist:
|
||||
- ls *
|
||||
- cat *
|
||||
approvals:
|
||||
deny:
|
||||
- shutdown *
|
||||
mcp_servers:
|
||||
local-notes:
|
||||
command: uvx
|
||||
args:
|
||||
- notes-mcp
|
||||
telegram:
|
||||
enabled: true
|
||||
chat_id: 12345
|
||||
"""
|
||||
|
||||
MALFORMED_CONFIG = """\
|
||||
model: hermes-4-405b
|
||||
command_allowlist:
|
||||
- ls *
|
||||
- cat *
|
||||
approvals: [unclosed
|
||||
"""
|
||||
|
||||
CONFIG_WRITING_KINDS = ("command-allowlist", "command-denylist", "mcp-servers")
|
||||
|
||||
|
||||
class TestExistingConfigPreserved:
|
||||
"""An import must not destroy the config.yaml it merges into.
|
||||
|
||||
``load_yaml_file`` returned ``{}`` for an absent file AND for a present
|
||||
file it could not read or parse. The three importers below read
|
||||
config.yaml, merge one section into it, and write the whole mapping back —
|
||||
so a YAML syntax error or a permission problem meant every existing
|
||||
setting was replaced by just the merged section, reported as ``imported``.
|
||||
"""
|
||||
|
||||
@pytest.fixture()
|
||||
def config_path(self, hermes_home):
|
||||
return hermes_home / "config.yaml"
|
||||
|
||||
@staticmethod
|
||||
def items_for(report, kind):
|
||||
return [i for i in report["items"] if i["kind"] == kind]
|
||||
|
||||
# -- present but unreadable: refuse, change nothing --------------------
|
||||
|
||||
def test_malformed_config_is_left_byte_identical(
|
||||
self, claude_tree, hermes_home, config_path):
|
||||
config_path.write_text(MALFORMED_CONFIG, encoding="utf-8")
|
||||
before = config_path.read_bytes()
|
||||
|
||||
run_import("claude-code", claude_tree, hermes_home, execute=True)
|
||||
|
||||
assert config_path.read_bytes() == before
|
||||
|
||||
def test_malformed_config_records_an_error_not_an_import(
|
||||
self, claude_tree, hermes_home, config_path):
|
||||
config_path.write_text(MALFORMED_CONFIG, encoding="utf-8")
|
||||
|
||||
report = run_import("claude-code", claude_tree, hermes_home, execute=True)
|
||||
|
||||
for kind in CONFIG_WRITING_KINDS:
|
||||
items = self.items_for(report, kind)
|
||||
assert items, f"no {kind} item recorded"
|
||||
assert [i["status"] for i in items] == ["error"], kind
|
||||
reason = items[0]["reason"]
|
||||
assert str(config_path) in reason
|
||||
assert "not valid YAML" in reason
|
||||
# Points the user at a way out.
|
||||
assert "hermes config edit" in reason
|
||||
|
||||
def test_unreadable_config_is_left_byte_identical(
|
||||
self, claude_tree, hermes_home, config_path):
|
||||
"""A permission problem is the other half of the same root cause."""
|
||||
import os
|
||||
|
||||
if os.name != "posix":
|
||||
pytest.skip("chmod-based permission denial is POSIX-only")
|
||||
if getattr(os, "geteuid", lambda: 1)() == 0:
|
||||
pytest.skip("root bypasses file permissions")
|
||||
config_path.write_text(EXISTING_CONFIG, encoding="utf-8")
|
||||
before = config_path.read_bytes()
|
||||
config_path.chmod(0o000)
|
||||
try:
|
||||
report = run_import("claude-code", claude_tree, hermes_home,
|
||||
execute=True)
|
||||
statuses = {
|
||||
i["status"]
|
||||
for kind in CONFIG_WRITING_KINDS
|
||||
for i in self.items_for(report, kind)
|
||||
}
|
||||
assert statuses == {"error"}
|
||||
finally:
|
||||
config_path.chmod(0o600)
|
||||
assert config_path.read_bytes() == before
|
||||
|
||||
def test_non_mapping_config_is_refused(
|
||||
self, claude_tree, hermes_home, config_path):
|
||||
"""Valid YAML that is not a mapping was also collapsed to {}."""
|
||||
config_path.write_text("- just\n- a\n- list\n", encoding="utf-8")
|
||||
before = config_path.read_bytes()
|
||||
|
||||
report = run_import("claude-code", claude_tree, hermes_home, execute=True)
|
||||
|
||||
assert config_path.read_bytes() == before
|
||||
reasons = [i["reason"] for i in self.items_for(report, "command-allowlist")]
|
||||
assert any("YAML mapping" in r for r in reasons)
|
||||
|
||||
def test_dry_run_reports_the_refusal_rather_than_a_preview(
|
||||
self, claude_tree, hermes_home, config_path):
|
||||
"""--dry-run must not promise an import that would destroy the file."""
|
||||
config_path.write_text(MALFORMED_CONFIG, encoding="utf-8")
|
||||
|
||||
report = run_import("claude-code", claude_tree, hermes_home, execute=False)
|
||||
|
||||
for kind in CONFIG_WRITING_KINDS:
|
||||
statuses = [i["status"] for i in self.items_for(report, kind)]
|
||||
assert statuses == ["error"], kind
|
||||
assert "imported" not in statuses
|
||||
|
||||
# -- the regression that actually pins the data loss -------------------
|
||||
|
||||
def test_import_preserves_every_pre_existing_config_key(
|
||||
self, claude_tree, hermes_home, config_path):
|
||||
config_path.write_text(EXISTING_CONFIG, encoding="utf-8")
|
||||
|
||||
run_import("claude-code", claude_tree, hermes_home, execute=True)
|
||||
|
||||
merged = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
# Untouched sections survive verbatim.
|
||||
assert merged["model"] == "hermes-4-405b"
|
||||
assert merged["api_key_env"] == "OPENROUTER_API_KEY"
|
||||
assert merged["telegram"] == {"enabled": True, "chat_id": 12345}
|
||||
# Merged-into sections keep their existing members ...
|
||||
assert "ls *" in merged["command_allowlist"]
|
||||
assert "shutdown *" in merged["approvals"]["deny"]
|
||||
assert "local-notes" in merged["mcp_servers"]
|
||||
# ... alongside the imported ones.
|
||||
assert "npm run build" in merged["command_allowlist"]
|
||||
assert "github" in merged["mcp_servers"]
|
||||
|
||||
def test_absent_config_is_still_created(
|
||||
self, claude_tree, hermes_home, config_path):
|
||||
"""The guard must not break first-time creation."""
|
||||
assert not config_path.exists()
|
||||
|
||||
run_import("claude-code", claude_tree, hermes_home, execute=True)
|
||||
|
||||
created = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
assert "npm run build" in created["command_allowlist"]
|
||||
|
||||
def test_empty_config_is_treated_as_absent(
|
||||
self, claude_tree, hermes_home, config_path):
|
||||
config_path.write_text("", encoding="utf-8")
|
||||
|
||||
run_import("claude-code", claude_tree, hermes_home, execute=True)
|
||||
|
||||
created = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
assert "npm run build" in created["command_allowlist"]
|
||||
|
||||
# -- the write itself --------------------------------------------------
|
||||
|
||||
def test_config_write_is_atomic_and_leaves_no_temp_files(
|
||||
self, claude_tree, hermes_home, config_path):
|
||||
config_path.write_text(EXISTING_CONFIG, encoding="utf-8")
|
||||
|
||||
run_import("claude-code", claude_tree, hermes_home, execute=True)
|
||||
|
||||
leftovers = [p.name for p in hermes_home.glob(".tmp*")]
|
||||
assert leftovers == []
|
||||
|
||||
def test_symlinked_config_stays_a_symlink(
|
||||
self, claude_tree, hermes_home, tmp_path, config_path):
|
||||
"""A non-atomic ``write_text`` would follow it; ``os.replace`` would
|
||||
clobber it. ``atomic_replace`` keeps the link and writes the target."""
|
||||
real = tmp_path / "real-config.yaml"
|
||||
real.write_text(EXISTING_CONFIG, encoding="utf-8")
|
||||
config_path.symlink_to(real)
|
||||
|
||||
run_import("claude-code", claude_tree, hermes_home, execute=True)
|
||||
|
||||
assert config_path.is_symlink()
|
||||
assert "npm run build" in real.read_text(encoding="utf-8")
|
||||
|
||||
def test_failed_write_does_not_truncate_the_existing_config(
|
||||
self, hermes_home, config_path, monkeypatch):
|
||||
"""An interrupted dump leaves the previous file complete."""
|
||||
from hermes_cli import agent_import
|
||||
|
||||
config_path.write_text(EXISTING_CONFIG, encoding="utf-8")
|
||||
before = config_path.read_bytes()
|
||||
|
||||
def boom(*_args, **_kwargs):
|
||||
raise OSError("no space left on device")
|
||||
|
||||
monkeypatch.setattr(agent_import, "atomic_yaml_write", boom)
|
||||
with pytest.raises(OSError):
|
||||
agent_import.dump_yaml_file(config_path, {"model": "replacement"})
|
||||
|
||||
assert config_path.read_bytes() == before
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI wiring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCliWiring:
|
||||
def test_parser_builds_and_parses(self):
|
||||
import argparse
|
||||
from hermes_cli.subcommands.import_agent import build_import_agent_parser
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
called = {}
|
||||
build_import_agent_parser(
|
||||
subparsers, cmd_import_agent=lambda a: called.setdefault("ok", a))
|
||||
args = parser.parse_args(
|
||||
["import-agent", "claude-code", "--dry-run", "--source", "/tmp/x"])
|
||||
assert args.agent == "claude-code"
|
||||
assert args.dry_run is True
|
||||
assert args.source == "/tmp/x"
|
||||
args.func(args)
|
||||
assert "ok" in called
|
||||
|
||||
def test_rejects_unknown_agent(self):
|
||||
import argparse
|
||||
from hermes_cli.subcommands.import_agent import build_import_agent_parser
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
build_import_agent_parser(subparsers, cmd_import_agent=lambda a: None)
|
||||
with pytest.raises(SystemExit):
|
||||
parser.parse_args(["import-agent", "cursor"])
|
||||
|
||||
def test_command_dry_run_via_cli_writes_nothing(
|
||||
self, claude_tree, hermes_home, capsys):
|
||||
"""End-to-end through import_agent_command with --dry-run."""
|
||||
import types
|
||||
from hermes_cli.agent_import import import_agent_command
|
||||
|
||||
args = types.SimpleNamespace(
|
||||
agent="claude-code", source=str(claude_tree), dry_run=True,
|
||||
overwrite=False, yes=False)
|
||||
import_agent_command(args)
|
||||
out = capsys.readouterr().out
|
||||
assert "Dry Run Results" in out
|
||||
assert "command-allowlist" in out
|
||||
# Baseline config.yaml/SOUL.md may be seeded by save_config() before
|
||||
# the preview runs — but nothing from the IMPORT itself may land:
|
||||
assert not (hermes_home / "memories" / "MEMORY.md").exists()
|
||||
assert not (hermes_home / "skills" / "claude-code-imports").exists()
|
||||
config_text = (hermes_home / "config.yaml").read_text(encoding="utf-8") \
|
||||
if (hermes_home / "config.yaml").exists() else ""
|
||||
assert "npm run build" not in config_text
|
||||
assert "github" not in config_text
|
||||
@@ -0,0 +1,560 @@
|
||||
"""Agent Plugins v1 portable package validation tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.agent_plugins import (
|
||||
MCP_SCHEMA_V1,
|
||||
PLUGIN_SCHEMA_V1,
|
||||
AgentPluginError,
|
||||
has_enabled_agent_plugin_mcp,
|
||||
load_agent_plugin,
|
||||
)
|
||||
|
||||
|
||||
def _write_json(path: Path, value: object) -> None:
|
||||
path.write_text(json.dumps(value), encoding="utf-8")
|
||||
|
||||
|
||||
def _manifest(**overrides: object) -> dict:
|
||||
value = {"$schema": PLUGIN_SCHEMA_V1, "name": "portable.test"}
|
||||
value.update(overrides)
|
||||
return value
|
||||
|
||||
|
||||
def _write_skill(root: Path, directory: str = "summarize", **fields: object) -> Path:
|
||||
skill_dir = root / "skills" / directory
|
||||
skill_dir.mkdir(parents=True)
|
||||
metadata = {"name": directory, "description": "Summarizes reports."}
|
||||
metadata.update(fields)
|
||||
import yaml
|
||||
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
f"---\n{yaml.safe_dump(metadata, sort_keys=False)}---\nInstructions.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return skill_dir
|
||||
|
||||
|
||||
def test_loads_manifest_skill_and_stdio_server(tmp_path: Path) -> None:
|
||||
root = tmp_path / "plugin"
|
||||
root.mkdir()
|
||||
_write_json(root / "plugin.json", _manifest(version="1.2.3"))
|
||||
skill_dir = _write_skill(root)
|
||||
_write_json(
|
||||
root / "mcp.json",
|
||||
{
|
||||
"$schema": MCP_SCHEMA_V1,
|
||||
"mcpServers": {
|
||||
"worker": {
|
||||
"type": "stdio",
|
||||
"command": "python",
|
||||
"args": ["${PLUGIN_ROOT}/server.py", "${UNKNOWN}"],
|
||||
"env": {"CACHE": "${PLUGIN_DATA}/cache"},
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
package = load_agent_plugin(root, tmp_path / "data")
|
||||
|
||||
assert package.name == "portable.test"
|
||||
assert package.version == "1.2.3"
|
||||
assert package.skills[0].root == skill_dir.resolve()
|
||||
server = package.mcp_servers["worker"]
|
||||
assert server["command"] == "python"
|
||||
assert server["args"] == [str(root.resolve() / "server.py"), "${UNKNOWN}"]
|
||||
assert server["cwd"] == str(root.resolve())
|
||||
assert server["env"]["PLUGIN_ROOT"] == str(root.resolve())
|
||||
assert server["env"]["PLUGIN_DATA"] == str((tmp_path / "data").resolve())
|
||||
assert server["env"]["CACHE"] == str((tmp_path / "data").resolve() / "cache")
|
||||
assert (tmp_path / "data").is_dir()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"manifest",
|
||||
[
|
||||
[],
|
||||
{"name": "valid-name"},
|
||||
{"$schema": "https://example.test/schema.json", "name": "valid-name"},
|
||||
{"$schema": PLUGIN_SCHEMA_V1},
|
||||
{"$schema": PLUGIN_SCHEMA_V1, "name": "Bad_Name"},
|
||||
{"$schema": PLUGIN_SCHEMA_V1, "name": "a--b"},
|
||||
{"$schema": PLUGIN_SCHEMA_V1, "name": "a", "keywords": [1]},
|
||||
{"$schema": PLUGIN_SCHEMA_V1, "name": "a", "author": {"handle": "x"}},
|
||||
],
|
||||
)
|
||||
def test_rejects_invalid_manifests(tmp_path: Path, manifest: object) -> None:
|
||||
_write_json(tmp_path / "plugin.json", manifest)
|
||||
with pytest.raises(AgentPluginError):
|
||||
load_agent_plugin(tmp_path, tmp_path / "data")
|
||||
|
||||
|
||||
def test_unknown_fields_and_non_object_extensions_are_nonfatal(tmp_path: Path) -> None:
|
||||
_write_json(
|
||||
tmp_path / "plugin.json",
|
||||
_manifest(unknown=True, extensions="ignored"),
|
||||
)
|
||||
package = load_agent_plugin(tmp_path, tmp_path / "data")
|
||||
assert len(package.diagnostics) == 2
|
||||
|
||||
|
||||
def test_invalid_skill_does_not_hide_valid_sibling(tmp_path: Path) -> None:
|
||||
_write_json(tmp_path / "plugin.json", _manifest())
|
||||
_write_skill(tmp_path, "valid-skill", license="MIT", compatibility="Linux")
|
||||
_write_skill(tmp_path, "wrong-dir", name="different")
|
||||
|
||||
package = load_agent_plugin(tmp_path, tmp_path / "data")
|
||||
|
||||
assert [skill.name for skill in package.skills] == ["valid-skill"]
|
||||
assert any(d.scope == "skill:wrong-dir" for d in package.diagnostics)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value"),
|
||||
[
|
||||
("license", ["MIT"]),
|
||||
("compatibility", ""),
|
||||
("compatibility", 1),
|
||||
("metadata", []),
|
||||
("allowed-tools", ["terminal"]),
|
||||
],
|
||||
)
|
||||
def test_rejects_invalid_optional_skill_fields(
|
||||
tmp_path: Path, field: str, value: object
|
||||
) -> None:
|
||||
_write_json(tmp_path / "plugin.json", _manifest())
|
||||
_write_skill(tmp_path, "bad-skill", **{field: value})
|
||||
package = load_agent_plugin(tmp_path, tmp_path / "data")
|
||||
assert package.skills == ()
|
||||
|
||||
|
||||
def test_symlink_escape_is_isolated_to_component(tmp_path: Path) -> None:
|
||||
root = tmp_path / "plugin"
|
||||
root.mkdir()
|
||||
_write_json(root / "plugin.json", _manifest())
|
||||
_write_skill(root)
|
||||
outside = tmp_path / "outside.json"
|
||||
_write_json(outside, {"$schema": MCP_SCHEMA_V1, "mcpServers": {}})
|
||||
(root / "mcp.json").symlink_to(outside)
|
||||
|
||||
package = load_agent_plugin(root, tmp_path / "data")
|
||||
|
||||
assert len(package.skills) == 1
|
||||
assert package.mcp_servers == {}
|
||||
assert any(d.scope == "mcp" for d in package.diagnostics)
|
||||
|
||||
|
||||
def test_stdio_command_and_data_cwd_containment(tmp_path: Path) -> None:
|
||||
root = tmp_path / "plugin"
|
||||
root.mkdir()
|
||||
_write_json(root / "plugin.json", _manifest())
|
||||
(root / "bin").mkdir()
|
||||
(root / "bin" / "server").write_text("", encoding="utf-8")
|
||||
_write_json(
|
||||
root / "mcp.json",
|
||||
{
|
||||
"$schema": MCP_SCHEMA_V1,
|
||||
"mcpServers": {
|
||||
"valid": {
|
||||
"type": "stdio",
|
||||
"command": "./bin/server",
|
||||
"cwd": "${PLUGIN_DATA}/state",
|
||||
},
|
||||
"opaque-command": {
|
||||
"type": "stdio",
|
||||
"command": "./${PLUGIN_ROOT}",
|
||||
},
|
||||
"escape": {
|
||||
"type": "stdio",
|
||||
"command": "./../outside",
|
||||
},
|
||||
"mixed-root": {
|
||||
"type": "stdio",
|
||||
"command": "python",
|
||||
"cwd": "./${PLUGIN_DATA}/state",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
package = load_agent_plugin(root, tmp_path / "data")
|
||||
assert set(package.mcp_servers) == {"opaque-command", "valid"}
|
||||
assert package.mcp_servers["valid"]["cwd"] == str(
|
||||
(tmp_path / "data" / "state").resolve()
|
||||
)
|
||||
assert (tmp_path / "data" / "state").is_dir()
|
||||
assert "agent_plugin" not in package.mcp_servers["valid"]
|
||||
assert package.mcp_servers["opaque-command"]["command"] == str(
|
||||
(root / "${PLUGIN_ROOT}").resolve()
|
||||
)
|
||||
|
||||
|
||||
def test_stdio_cwd_directory_failure_isolated_to_server(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_write_json(tmp_path / "plugin.json", _manifest())
|
||||
_write_json(
|
||||
tmp_path / "mcp.json",
|
||||
{
|
||||
"$schema": MCP_SCHEMA_V1,
|
||||
"mcpServers": {
|
||||
"broken": {
|
||||
"type": "stdio",
|
||||
"command": "python",
|
||||
"cwd": "${PLUGIN_DATA}/broken",
|
||||
},
|
||||
"valid": {
|
||||
"type": "stdio",
|
||||
"command": "python",
|
||||
"cwd": "${PLUGIN_DATA}/valid",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
data_root = (tmp_path / "data").resolve()
|
||||
original_mkdir = Path.mkdir
|
||||
|
||||
def fail_broken(path: Path, *args: object, **kwargs: object) -> None:
|
||||
if path == data_root / "broken":
|
||||
raise PermissionError("broken cwd")
|
||||
original_mkdir(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "mkdir", fail_broken)
|
||||
|
||||
package = load_agent_plugin(tmp_path, data_root)
|
||||
|
||||
assert set(package.mcp_servers) == {"valid"}
|
||||
assert (data_root / "valid").is_dir()
|
||||
assert any(d.scope == "mcp:broken" for d in package.diagnostics)
|
||||
|
||||
|
||||
def test_malformed_skill_yaml_is_skipped(tmp_path: Path) -> None:
|
||||
_write_json(tmp_path / "plugin.json", _manifest())
|
||||
skill = tmp_path / "skills" / "broken"
|
||||
skill.mkdir(parents=True)
|
||||
(skill / "SKILL.md").write_text(
|
||||
"---\nname: broken\ndescription: [unterminated\n---\nBody.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
package = load_agent_plugin(tmp_path, tmp_path / "data")
|
||||
|
||||
assert package.skills == ()
|
||||
assert any(d.scope == "skill:broken" for d in package.diagnostics)
|
||||
|
||||
|
||||
def test_data_directory_failure_preserves_valid_skills(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_write_json(tmp_path / "plugin.json", _manifest())
|
||||
_write_skill(tmp_path)
|
||||
_write_json(
|
||||
tmp_path / "mcp.json",
|
||||
{
|
||||
"$schema": MCP_SCHEMA_V1,
|
||||
"mcpServers": {"worker": {"type": "stdio", "command": "python"}},
|
||||
},
|
||||
)
|
||||
data_root = tmp_path / "data"
|
||||
original_mkdir = Path.mkdir
|
||||
|
||||
def fail_data_mkdir(path: Path, *args: object, **kwargs: object) -> None:
|
||||
if path == data_root:
|
||||
raise PermissionError("read-only profile")
|
||||
original_mkdir(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "mkdir", fail_data_mkdir)
|
||||
|
||||
package = load_agent_plugin(tmp_path, data_root)
|
||||
|
||||
assert len(package.skills) == 1
|
||||
assert package.mcp_servers == {}
|
||||
assert any(d.scope == "mcp:worker" for d in package.diagnostics)
|
||||
|
||||
|
||||
def test_invalid_entries_and_unsupported_remote_preserve_valid_stdio(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_write_json(tmp_path / "plugin.json", _manifest())
|
||||
_write_json(
|
||||
tmp_path / "mcp.json",
|
||||
{
|
||||
"$schema": MCP_SCHEMA_V1,
|
||||
"mcpServers": {
|
||||
"valid": {"type": "stdio", "command": "python"},
|
||||
"invalid": {"type": "stdio", "command": "python", "extra": True},
|
||||
"multi-token": {"type": "stdio", "command": "python -m worker"},
|
||||
"reserved-root": {
|
||||
"type": "stdio",
|
||||
"command": "python",
|
||||
"env": {"PLUGIN_ROOT": "override"},
|
||||
},
|
||||
"reserved-data": {
|
||||
"type": "stdio",
|
||||
"command": "python",
|
||||
"env": {"PLUGIN_DATA": "override"},
|
||||
},
|
||||
"remote": {
|
||||
"type": "streamable-http",
|
||||
"url": "https://example.test/mcp",
|
||||
"headers": {"X-Tenant": "a", "x-tenant": "b"},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
package = load_agent_plugin(tmp_path, tmp_path / "data")
|
||||
assert set(package.mcp_servers) == {"valid"}
|
||||
assert {d.scope for d in package.diagnostics} >= {
|
||||
"mcp:invalid",
|
||||
"mcp:multi-token",
|
||||
"mcp:reserved-root",
|
||||
"mcp:reserved-data",
|
||||
"mcp:remote",
|
||||
}
|
||||
|
||||
|
||||
def test_invalid_mcp_top_level_preserves_skills(tmp_path: Path) -> None:
|
||||
_write_json(tmp_path / "plugin.json", _manifest())
|
||||
_write_skill(tmp_path)
|
||||
_write_json(
|
||||
tmp_path / "mcp.json",
|
||||
{"$schema": "https://example.test/mcp.json", "mcpServers": {}},
|
||||
)
|
||||
package = load_agent_plugin(tmp_path, tmp_path / "data")
|
||||
assert len(package.skills) == 1
|
||||
assert package.mcp_servers == {}
|
||||
|
||||
|
||||
def test_enabled_portable_mcp_probe_does_not_load_plugins(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
home = tmp_path / "home"
|
||||
plugin = home / "plugins" / "portable"
|
||||
plugin.mkdir(parents=True)
|
||||
_write_json(plugin / "plugin.json", _manifest())
|
||||
_write_json(
|
||||
plugin / "mcp.json",
|
||||
{
|
||||
"$schema": MCP_SCHEMA_V1,
|
||||
"mcpServers": {"worker": {"type": "stdio", "command": "python"}},
|
||||
},
|
||||
)
|
||||
bundled = tmp_path / "bundled"
|
||||
bundled.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setenv("HERMES_BUNDLED_PLUGINS", str(bundled))
|
||||
|
||||
assert has_enabled_agent_plugin_mcp(
|
||||
{"plugins": {"enabled": ["portable.test"]}}
|
||||
)
|
||||
assert not has_enabled_agent_plugin_mcp(
|
||||
{
|
||||
"plugins": {
|
||||
"enabled": ["portable.test"],
|
||||
"disabled": ["portable.test"],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_portable_mcp_probe_counts_streamable_http_config(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
home = tmp_path / "home"
|
||||
plugin = home / "plugins" / "portable"
|
||||
plugin.mkdir(parents=True)
|
||||
_write_json(plugin / "plugin.json", _manifest())
|
||||
_write_json(
|
||||
plugin / "mcp.json",
|
||||
{
|
||||
"$schema": MCP_SCHEMA_V1,
|
||||
"mcpServers": {
|
||||
"remote": {
|
||||
"type": "streamable-http",
|
||||
"url": "https://example.test/mcp",
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
bundled = tmp_path / "bundled"
|
||||
bundled.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setenv("HERMES_BUNDLED_PLUGINS", str(bundled))
|
||||
|
||||
assert has_enabled_agent_plugin_mcp(
|
||||
{"plugins": {"enabled": ["portable.test"]}}
|
||||
)
|
||||
|
||||
|
||||
def test_portable_mcp_probe_ignores_unsupported_only_config(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
home = tmp_path / "home"
|
||||
plugin = home / "plugins" / "portable"
|
||||
plugin.mkdir(parents=True)
|
||||
_write_json(plugin / "plugin.json", _manifest())
|
||||
_write_json(
|
||||
plugin / "mcp.json",
|
||||
{
|
||||
"$schema": MCP_SCHEMA_V1,
|
||||
"mcpServers": {
|
||||
"remote": {
|
||||
"type": "sse",
|
||||
"url": "https://example.test/sse",
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
bundled = tmp_path / "bundled"
|
||||
bundled.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setenv("HERMES_BUNDLED_PLUGINS", str(bundled))
|
||||
|
||||
assert not has_enabled_agent_plugin_mcp(
|
||||
{"plugins": {"enabled": ["portable.test"]}}
|
||||
)
|
||||
|
||||
|
||||
def test_streamable_http_translates_to_native_remote_config(tmp_path: Path) -> None:
|
||||
_write_json(tmp_path / "plugin.json", _manifest())
|
||||
_write_json(
|
||||
tmp_path / "mcp.json",
|
||||
{
|
||||
"$schema": MCP_SCHEMA_V1,
|
||||
"mcpServers": {
|
||||
"deploy": {
|
||||
"type": "streamable-http",
|
||||
"url": "https://deploy.example.test/mcp",
|
||||
"headers": {"X-Tenant": "public-tenant"},
|
||||
},
|
||||
"local": {
|
||||
"type": "streamable-http",
|
||||
"url": "http://127.0.0.1:8000/mcp",
|
||||
},
|
||||
"bare": {
|
||||
"type": "streamable-http",
|
||||
"url": "https://bare.example.test/mcp",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
package = load_agent_plugin(tmp_path, tmp_path / "data")
|
||||
|
||||
assert set(package.mcp_servers) == {"deploy", "local", "bare"}
|
||||
deploy = package.mcp_servers["deploy"]
|
||||
assert deploy == {
|
||||
"url": "https://deploy.example.test/mcp",
|
||||
"headers": {"X-Tenant": "public-tenant"},
|
||||
"strict_redirect_headers": True,
|
||||
}
|
||||
assert "command" not in deploy
|
||||
assert package.mcp_servers["bare"] == {
|
||||
"url": "https://bare.example.test/mcp",
|
||||
"strict_redirect_headers": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"",
|
||||
"ftp://example.test/mcp",
|
||||
"https://user:pass@example.test/mcp",
|
||||
"https://example.test/mcp#fragment",
|
||||
"http://example.test/mcp", # non-loopback plain HTTP
|
||||
"http://10.0.0.5/mcp",
|
||||
"https:///mcp", # empty host
|
||||
],
|
||||
)
|
||||
def test_streamable_http_rejects_invalid_urls(tmp_path: Path, url: str) -> None:
|
||||
_write_json(tmp_path / "plugin.json", _manifest())
|
||||
_write_json(
|
||||
tmp_path / "mcp.json",
|
||||
{
|
||||
"$schema": MCP_SCHEMA_V1,
|
||||
"mcpServers": {
|
||||
"remote": {"type": "streamable-http", "url": url},
|
||||
"valid": {"type": "stdio", "command": "python"},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
package = load_agent_plugin(tmp_path, tmp_path / "data")
|
||||
|
||||
assert set(package.mcp_servers) == {"valid"}
|
||||
assert any(d.scope == "mcp:remote" for d in package.diagnostics)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"http://localhost:8000/mcp",
|
||||
"http://127.0.0.1/mcp",
|
||||
"http://[::1]:9000/mcp",
|
||||
"https://remote.example.test/mcp",
|
||||
],
|
||||
)
|
||||
def test_streamable_http_accepts_loopback_http_and_https(
|
||||
tmp_path: Path, url: str
|
||||
) -> None:
|
||||
_write_json(tmp_path / "plugin.json", _manifest())
|
||||
_write_json(
|
||||
tmp_path / "mcp.json",
|
||||
{
|
||||
"$schema": MCP_SCHEMA_V1,
|
||||
"mcpServers": {"remote": {"type": "streamable-http", "url": url}},
|
||||
},
|
||||
)
|
||||
|
||||
package = load_agent_plugin(tmp_path, tmp_path / "data")
|
||||
|
||||
assert set(package.mcp_servers) == {"remote"}
|
||||
assert package.mcp_servers["remote"]["url"] == url
|
||||
|
||||
|
||||
def test_sse_remains_unsupported(tmp_path: Path) -> None:
|
||||
_write_json(tmp_path / "plugin.json", _manifest())
|
||||
_write_json(
|
||||
tmp_path / "mcp.json",
|
||||
{
|
||||
"$schema": MCP_SCHEMA_V1,
|
||||
"mcpServers": {
|
||||
"legacy": {"type": "sse", "url": "https://legacy.example.test/sse"}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
package = load_agent_plugin(tmp_path, tmp_path / "data")
|
||||
|
||||
assert package.mcp_servers == {}
|
||||
assert any(
|
||||
d.scope == "mcp:legacy" and "not supported" in d.message
|
||||
for d in package.diagnostics
|
||||
)
|
||||
|
||||
|
||||
def test_portable_mcp_probe_honors_native_precedence(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
home = tmp_path / "home"
|
||||
plugin = home / "plugins" / "portable"
|
||||
plugin.mkdir(parents=True)
|
||||
_write_json(plugin / "plugin.json", _manifest())
|
||||
(plugin / "plugin.yaml").write_text("name: native\n", encoding="utf-8")
|
||||
_write_json(
|
||||
plugin / "mcp.json",
|
||||
{"$schema": MCP_SCHEMA_V1, "mcpServers": {}},
|
||||
)
|
||||
bundled = tmp_path / "bundled"
|
||||
bundled.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setenv("HERMES_BUNDLED_PLUGINS", str(bundled))
|
||||
|
||||
assert not has_enabled_agent_plugin_mcp(
|
||||
{"plugins": {"enabled": ["portable.test"]}}
|
||||
)
|
||||
@@ -0,0 +1,161 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""alibaba-coding-plan and alibaba-coding-plan-cn must not both appear in the
|
||||
/model picker off a single shared key (#101122).
|
||||
|
||||
The CN profile now has its own ALIBABA_CODING_PLAN_CN_API_KEY (checked first),
|
||||
keeping the shared ALIBABA_CODING_PLAN_API_KEY / DASHSCOPE_API_KEY as ordered
|
||||
fallbacks so existing CN users are not broken. The picker hides a ``-cn`` row
|
||||
whose only lit vars are shared with a lit non-CN sibling row.
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
_CLEAR = {k: "" for k in ("ALIBABA_CODING_PLAN_API_KEY", "ALIBABA_CODING_PLAN_CN_API_KEY", "DASHSCOPE_API_KEY")}
|
||||
|
||||
|
||||
def _alibaba_slugs(current_provider=""):
|
||||
return [p["slug"] for p in list_authenticated_providers(current_provider=current_provider) if "coding-plan" in p["slug"]]
|
||||
|
||||
|
||||
@patch.dict(os.environ, {**_CLEAR, "ALIBABA_CODING_PLAN_CN_API_KEY": "sk-cn-fake"}, clear=False)
|
||||
def test_alibaba_cn_appears_when_only_cn_key_set():
|
||||
assert _alibaba_slugs() == ["alibaba-coding-plan-cn"]
|
||||
|
||||
|
||||
@patch.dict(os.environ, {**_CLEAR, "ALIBABA_CODING_PLAN_API_KEY": "sk-intl-fake"}, clear=False)
|
||||
def test_alibaba_cn_does_not_appear_when_only_intl_key_set():
|
||||
"""#101122: the shared intl key alone must light only the intl row."""
|
||||
assert _alibaba_slugs() == ["alibaba-coding-plan"]
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Tests for Bug #12905 fix — stale OAuth token detection in hermes model flow.
|
||||
|
||||
Bug 3: `hermes model` with `provider=anthropic` skips OAuth re-authentication
|
||||
when a stale ANTHROPIC_TOKEN exists in ~/.hermes/.env but no valid
|
||||
Claude Code credentials are available. The fast-path silently proceeds to
|
||||
model selection with a broken token instead of offering re-auth.
|
||||
"""
|
||||
|
||||
|
||||
from hermes_cli.config import save_env_value
|
||||
|
||||
|
||||
class TestStaleOAuthTokenDetection:
|
||||
"""Bug 3: stale OAuth token must trigger needs_auth=True in _model_flow_anthropic."""
|
||||
|
||||
def test_stale_oauth_token_triggers_reauth(self, tmp_path, monkeypatch, capsys):
|
||||
"""
|
||||
Scenario: ANTHROPIC_TOKEN is an expired OAuth token and there are no
|
||||
valid Claude Code credentials anywhere. The flow MUST offer re-auth
|
||||
instead of silently skipping to model selection.
|
||||
"""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
# Pre-load .env with an expired OAuth token (sk-ant- prefix = OAuth)
|
||||
save_env_value("ANTHROPIC_TOKEN", "sk-ant-oat-ExpiredToken00000")
|
||||
save_env_value("ANTHROPIC_API_KEY", "")
|
||||
|
||||
# No valid Claude Code credentials available (expired, no refresh token)
|
||||
monkeypatch.setattr(
|
||||
"agent.anthropic_adapter.read_claude_code_credentials",
|
||||
lambda: {
|
||||
"accessToken": "expired-cc-token",
|
||||
"refreshToken": "", # No refresh — can't recover
|
||||
"expiresAt": 0, # Already expired
|
||||
"source": "claude_code_credentials_file",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"agent.anthropic_adapter.is_claude_code_token_valid",
|
||||
lambda creds: False, # Explicitly expired
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"agent.anthropic_adapter._is_oauth_token",
|
||||
lambda key: key.startswith("sk-ant-"),
|
||||
)
|
||||
# _resolve_claude_code_token_from_credentials has no valid path
|
||||
monkeypatch.setattr(
|
||||
"agent.anthropic_adapter._resolve_claude_code_token_from_credentials",
|
||||
lambda creds=None: None,
|
||||
)
|
||||
|
||||
# Simulate user types "3" (Cancel) when prompted for re-auth
|
||||
monkeypatch.setattr("builtins.input", lambda _: "3")
|
||||
monkeypatch.setattr("hermes_cli.secret_prompt.masked_secret_prompt", lambda _: "")
|
||||
|
||||
from hermes_cli.main import _model_flow_anthropic
|
||||
cfg = {}
|
||||
|
||||
_model_flow_anthropic(cfg)
|
||||
|
||||
output = capsys.readouterr().out
|
||||
# Must show auth method choice since token is stale
|
||||
assert "subscription" in output or "API key" in output, (
|
||||
f"Expected auth method menu but got: {output!r}"
|
||||
)
|
||||
|
||||
def test_valid_api_key_skips_stale_check(self, tmp_path, monkeypatch, capsys):
|
||||
"""
|
||||
A non-OAuth ANTHROPIC_API_KEY (regular pay-per-token key) must NOT be
|
||||
flagged as stale even when cc_creds are invalid. Regular API keys don't
|
||||
expire the same way OAuth tokens do.
|
||||
"""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
# Regular API key — NOT an OAuth token
|
||||
save_env_value("ANTHROPIC_API_KEY", "sk-ant-api03-RegularPayPerTokenKey")
|
||||
save_env_value("ANTHROPIC_TOKEN", "")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"agent.anthropic_adapter.read_claude_code_credentials",
|
||||
lambda: None, # No CC creds
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"agent.anthropic_adapter.is_claude_code_token_valid",
|
||||
lambda creds: False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"agent.anthropic_adapter._is_oauth_token",
|
||||
lambda key: key.startswith("sk-ant-") and "oat" in key,
|
||||
)
|
||||
|
||||
# Simulate user picks "1" (use existing)
|
||||
monkeypatch.setattr("builtins.input", lambda _: "1")
|
||||
|
||||
from hermes_cli.main import _model_flow_anthropic
|
||||
cfg = {}
|
||||
|
||||
_model_flow_anthropic(cfg)
|
||||
|
||||
output = capsys.readouterr().out
|
||||
# Should show "Use existing credentials" menu, NOT auth method choice
|
||||
assert "Use existing" in output or "credentials" in output.lower()
|
||||
|
||||
|
||||
class TestStaleOAuthGuardLogic:
|
||||
"""Unit-level test of the stale-OAuth detection guard logic."""
|
||||
|
||||
def test_stale_oauth_flag_logic_no_cc_creds(self):
|
||||
"""
|
||||
When existing_key is OAuth and cc_available is False,
|
||||
existing_is_stale_oauth should be True → has_creds = False.
|
||||
"""
|
||||
existing_key = "sk-ant-oat-expiredtoken123"
|
||||
_is_oauth_token = lambda k: k.startswith("sk-ant-")
|
||||
cc_available = False
|
||||
|
||||
existing_is_stale_oauth = (
|
||||
bool(existing_key) and
|
||||
_is_oauth_token(existing_key) and
|
||||
not cc_available
|
||||
)
|
||||
has_creds = (bool(existing_key) and not existing_is_stale_oauth) or cc_available
|
||||
|
||||
assert existing_is_stale_oauth is True
|
||||
assert has_creds is False
|
||||
|
||||
def test_stale_oauth_flag_logic_with_valid_cc_creds(self):
|
||||
"""
|
||||
When existing_key is OAuth but cc_available is True (valid creds exist),
|
||||
has_creds should be True — the cc_creds will be used instead.
|
||||
"""
|
||||
existing_key = "sk-ant-oat-sometoken"
|
||||
_is_oauth_token = lambda k: k.startswith("sk-ant-")
|
||||
cc_available = True
|
||||
|
||||
existing_is_stale_oauth = (
|
||||
bool(existing_key) and
|
||||
_is_oauth_token(existing_key) and
|
||||
not cc_available
|
||||
)
|
||||
has_creds = (bool(existing_key) and not existing_is_stale_oauth) or cc_available
|
||||
|
||||
assert existing_is_stale_oauth is False
|
||||
assert has_creds is True
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Tests for Anthropic OAuth setup flow behavior."""
|
||||
|
||||
from hermes_cli.config import load_env, save_env_value
|
||||
|
||||
|
||||
def test_run_anthropic_oauth_flow_prefers_claude_code_credentials(tmp_path, monkeypatch, capsys):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setattr(
|
||||
"agent.anthropic_adapter.run_oauth_setup_token",
|
||||
lambda: "sk-ant-oat01-from-claude-setup",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"agent.anthropic_adapter.read_claude_code_credentials",
|
||||
lambda: {
|
||||
"accessToken": "cc-access-token",
|
||||
"refreshToken": "cc-refresh-token",
|
||||
"expiresAt": 9999999999999,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"agent.anthropic_adapter.is_claude_code_token_valid",
|
||||
lambda creds: True,
|
||||
)
|
||||
|
||||
from hermes_cli.main import _run_anthropic_oauth_flow
|
||||
|
||||
save_env_value("ANTHROPIC_TOKEN", "stale-env-token")
|
||||
assert _run_anthropic_oauth_flow(save_env_value) is True
|
||||
|
||||
env_vars = load_env()
|
||||
assert env_vars["ANTHROPIC_TOKEN"] == ""
|
||||
assert env_vars["ANTHROPIC_API_KEY"] == ""
|
||||
output = capsys.readouterr().out
|
||||
assert "Claude Code credentials linked" in output
|
||||
|
||||
|
||||
def test_run_anthropic_oauth_flow_manual_token_still_persists(tmp_path, monkeypatch, capsys):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setattr("agent.anthropic_adapter.run_oauth_setup_token", lambda: None)
|
||||
monkeypatch.setattr("agent.anthropic_adapter.read_claude_code_credentials", lambda: None)
|
||||
monkeypatch.setattr("agent.anthropic_adapter.is_claude_code_token_valid", lambda creds: False)
|
||||
monkeypatch.setattr("builtins.input", lambda _prompt="": "sk-ant-oat01-manual-token")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.secret_prompt.masked_secret_prompt",
|
||||
lambda _prompt="": "sk-ant-oat01-manual-token",
|
||||
)
|
||||
|
||||
from hermes_cli.main import _run_anthropic_oauth_flow
|
||||
|
||||
assert _run_anthropic_oauth_flow(save_env_value) is True
|
||||
|
||||
env_vars = load_env()
|
||||
assert env_vars["ANTHROPIC_TOKEN"] == "sk-ant-oat01-manual-token"
|
||||
output = capsys.readouterr().out
|
||||
assert "Setup-token saved" in output
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Regression coverage for issue #32243.
|
||||
|
||||
OAuth Pro/Max credentials must always reach Anthropic via the native
|
||||
``/v1/messages`` endpoint, never the OpenAI-compat ``/chat/completions``
|
||||
shim — the latter bills against a separate "extra usage" pool that
|
||||
Pro/Max subscriptions don't fund, so any request that lands on it 400s
|
||||
with "You're out of extra usage" the moment the gateway starts.
|
||||
|
||||
The root cause was an inconsistency between two URL→api_mode helpers:
|
||||
|
||||
* ``hermes_cli.providers.determine_api_mode`` correctly mapped
|
||||
``api.anthropic.com`` to ``anthropic_messages``.
|
||||
* ``hermes_cli.runtime_provider._detect_api_mode_for_url`` did NOT, so
|
||||
every code path that fell back to URL-only detection (named custom
|
||||
providers, direct-alias resolution, the api-key fallback inside
|
||||
``resolve_runtime_provider``) returned ``None`` for that host and
|
||||
defaulted to ``chat_completions``.
|
||||
|
||||
Exhaustive host-shape coverage for the helper itself lives in
|
||||
``test_detect_api_mode_for_url.py::TestDirectAnthropicHost``. The
|
||||
tests below pin the **integration contract**: every runtime branch
|
||||
that resolves an Anthropic endpoint must return
|
||||
``api_mode == "anthropic_messages"``, so a future refactor of any
|
||||
single branch cannot silently revert #32243.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from hermes_cli import runtime_provider as rp
|
||||
|
||||
|
||||
class TestExplicitRuntimeForAnthropic:
|
||||
"""``_resolve_explicit_runtime`` with provider='anthropic' must
|
||||
always return ``api_mode='anthropic_messages'`` regardless of
|
||||
base_url shape or stale persisted ``model.api_mode`` values.
|
||||
|
||||
Exercised whenever the user (or a Hermes subcommand) passes an
|
||||
explicit ``--api-key`` / ``--base-url`` override to the runtime
|
||||
resolver.
|
||||
"""
|
||||
|
||||
def test_explicit_args_route_to_messages_api(self):
|
||||
result = rp._resolve_explicit_runtime(
|
||||
provider="anthropic",
|
||||
requested_provider="anthropic",
|
||||
model_cfg={},
|
||||
explicit_api_key="sk-ant-oat01-foo",
|
||||
explicit_base_url="https://api.anthropic.com",
|
||||
)
|
||||
assert result is not None
|
||||
assert result["api_mode"] == "anthropic_messages"
|
||||
assert result["provider"] == "anthropic"
|
||||
assert result["base_url"] == "https://api.anthropic.com"
|
||||
|
||||
|
||||
def test_no_explicit_args_returns_none(self):
|
||||
# Guard the gating contract — _resolve_explicit_runtime only
|
||||
# fires when an explicit override is present; without one it
|
||||
# must return None so the caller falls through to the pool /
|
||||
# top-level anthropic branch.
|
||||
assert (
|
||||
rp._resolve_explicit_runtime(
|
||||
provider="anthropic",
|
||||
requested_provider="anthropic",
|
||||
model_cfg={"provider": "anthropic"},
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
class TestPoolEntryForAnthropic:
|
||||
"""``_resolve_runtime_from_pool_entry`` is what runs when a user
|
||||
has added an OAuth credential via ``hermes auth add anthropic
|
||||
--type oauth`` (the exact flow from #32243). Pin the contract
|
||||
alongside the URL-detector test so all three runtime branches
|
||||
stay aligned and a future refactor of one cannot diverge from
|
||||
the others.
|
||||
"""
|
||||
|
||||
def test_oauth_pool_entry_routes_to_messages_api(self):
|
||||
class _Entry:
|
||||
access_token = "sk-ant-oat01-pool"
|
||||
runtime_api_key = "sk-ant-oat01-pool"
|
||||
source = "manual:hermes_pkce"
|
||||
base_url = "https://api.anthropic.com"
|
||||
|
||||
resolved = rp._resolve_runtime_from_pool_entry(
|
||||
provider="anthropic",
|
||||
entry=_Entry(),
|
||||
requested_provider="anthropic",
|
||||
model_cfg={"provider": "anthropic"},
|
||||
)
|
||||
|
||||
assert resolved["provider"] == "anthropic"
|
||||
assert resolved["api_mode"] == "anthropic_messages"
|
||||
assert resolved["base_url"] == "https://api.anthropic.com"
|
||||
|
||||
def test_stale_chat_completions_api_mode_in_config_is_ignored(self):
|
||||
# Same regression as the explicit-runtime test above, but on
|
||||
# the pool path: a stale persisted chat_completions api_mode
|
||||
# must NOT override the provider-pin.
|
||||
class _Entry:
|
||||
access_token = "sk-ant-oat01-pool"
|
||||
runtime_api_key = "sk-ant-oat01-pool"
|
||||
source = "manual:hermes_pkce"
|
||||
base_url = "https://api.anthropic.com"
|
||||
|
||||
resolved = rp._resolve_runtime_from_pool_entry(
|
||||
provider="anthropic",
|
||||
entry=_Entry(),
|
||||
requested_provider="anthropic",
|
||||
model_cfg={
|
||||
"provider": "anthropic",
|
||||
"api_mode": "chat_completions",
|
||||
},
|
||||
)
|
||||
|
||||
assert resolved["api_mode"] == "anthropic_messages"
|
||||
|
||||
|
||||
class TestCustomProviderUrlFallback:
|
||||
"""The detector fix's actual reachable path: a user-defined
|
||||
``providers:`` / ``custom_providers:`` entry whose ``api`` URL
|
||||
points at ``api.anthropic.com``, with no explicit ``api_mode`` /
|
||||
``transport`` field.
|
||||
|
||||
Pre-fix: this falls through ``_try_resolve_from_custom_pool`` →
|
||||
``_detect_api_mode_for_url("https://api.anthropic.com")`` → None →
|
||||
default ``chat_completions`` → request lands on the OpenAI-compat
|
||||
shim → "out of extra usage" 400.
|
||||
|
||||
Post-fix: the detector returns ``anthropic_messages`` so the same
|
||||
config routes to ``/v1/messages`` where Pro/Max OAuth is billed.
|
||||
"""
|
||||
|
||||
def test_url_fallback_picks_messages_api(self, monkeypatch):
|
||||
class _Entry:
|
||||
access_token = "sk-ant-oat01-custom-pool"
|
||||
runtime_api_key = "sk-ant-oat01-custom-pool"
|
||||
source = "custom-pool"
|
||||
|
||||
class _Pool:
|
||||
def has_credentials(self):
|
||||
return True
|
||||
|
||||
def select(self):
|
||||
return _Entry()
|
||||
|
||||
monkeypatch.setattr(rp, "custom_provider_pool_key_candidates", lambda *a, **k: ["custom:my-claude"])
|
||||
monkeypatch.setattr(rp, "load_pool", lambda key: _Pool())
|
||||
|
||||
resolved = rp._try_resolve_from_custom_pool(
|
||||
"https://api.anthropic.com",
|
||||
"custom",
|
||||
)
|
||||
|
||||
assert resolved is not None
|
||||
assert resolved["api_mode"] == "anthropic_messages"
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""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
|
||||
assert "claude-sonnet-5" in curated # newest Sonnet 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
|
||||
assert "claude-sonnet-5" in result
|
||||
# Curated order is preserved at the front.
|
||||
assert result[:len(curated)] == list(curated)
|
||||
|
||||
|
||||
def test_anthropic_merge_dedupes_overlap_and_appends_live_only():
|
||||
"""Models in both lists appear once; live-only models are appended."""
|
||||
live = [
|
||||
"claude-opus-4-8", # overlaps curated
|
||||
"claude-sonnet-4-6", # overlaps curated
|
||||
"claude-future-9-99", # live-only, not curated
|
||||
]
|
||||
with patch.object(M, "_fetch_anthropic_models", return_value=live):
|
||||
result = M.provider_model_ids("anthropic")
|
||||
|
||||
# No duplicates introduced by the merge.
|
||||
assert result.count("claude-opus-4-8") == 1
|
||||
# Live-only entry is preserved (discovery still works for unknown models).
|
||||
assert "claude-future-9-99" in result
|
||||
# Curated entries lead, live-only trails.
|
||||
assert result.index("claude-fable-5") < result.index("claude-future-9-99")
|
||||
|
||||
|
||||
def test_anthropic_falls_back_to_curated_when_live_unavailable():
|
||||
"""No creds / live failure -> curated list verbatim (alias still present)."""
|
||||
with patch.object(M, "_fetch_anthropic_models", return_value=None):
|
||||
result = M.provider_model_ids("anthropic")
|
||||
|
||||
assert result == list(M._PROVIDER_MODELS["anthropic"])
|
||||
assert "claude-fable-5" in result
|
||||
@@ -0,0 +1,113 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from hermes_cli import models
|
||||
|
||||
|
||||
class _Response:
|
||||
def __init__(self, payload: dict):
|
||||
self._payload = payload
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
def read(self) -> bytes:
|
||||
return json.dumps(self._payload).encode()
|
||||
|
||||
|
||||
def test_anthropic_picker_discovers_models_with_pool_api_key(monkeypatch):
|
||||
"""A direct API key stored only in auth.json must reach /v1/models."""
|
||||
monkeypatch.setattr(models, "_get_model_config_dict", lambda: {"provider": "nous"})
|
||||
monkeypatch.setattr(
|
||||
"agent.anthropic_adapter.resolve_anthropic_token",
|
||||
lambda: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth.read_credential_pool",
|
||||
lambda provider: (
|
||||
[
|
||||
{
|
||||
"auth_type": "api_key",
|
||||
"access_token": "sk-ant-api03-pool-key",
|
||||
"base_url": "https://api.anthropic.com",
|
||||
}
|
||||
]
|
||||
if provider == "anthropic"
|
||||
else []
|
||||
),
|
||||
)
|
||||
|
||||
captured = {}
|
||||
|
||||
def _open(request, *, timeout):
|
||||
captured["url"] = request.full_url
|
||||
captured["headers"] = {
|
||||
key.lower(): value for key, value in request.header_items()
|
||||
}
|
||||
captured["timeout"] = timeout
|
||||
return _Response({"data": [{"id": "claude-opus-5"}]})
|
||||
|
||||
monkeypatch.setattr(models, "_urlopen_model_catalog_request", _open)
|
||||
|
||||
result = models.provider_model_ids("anthropic")
|
||||
|
||||
assert "claude-opus-5" in result
|
||||
assert captured["url"] == "https://api.anthropic.com/v1/models"
|
||||
assert captured["headers"]["x-api-key"] == "sk-ant-api03-pool-key"
|
||||
assert "authorization" not in captured["headers"]
|
||||
|
||||
|
||||
def test_anthropic_pool_api_key_overrides_conflicting_active_endpoint(monkeypatch):
|
||||
"""A pool-scoped key must never reach the active model's other endpoint."""
|
||||
active_endpoint = "https://active.example/anthropic/v1"
|
||||
pool_endpoint = "https://pool.example/anthropic/v1"
|
||||
monkeypatch.setattr(
|
||||
models,
|
||||
"_get_model_config_dict",
|
||||
lambda: {"provider": "anthropic", "base_url": active_endpoint},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"agent.anthropic_adapter.resolve_anthropic_token",
|
||||
lambda: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth.read_credential_pool",
|
||||
lambda provider: (
|
||||
[
|
||||
{
|
||||
"auth_type": "api_key",
|
||||
"access_token": "proxy-key",
|
||||
"base_url": pool_endpoint,
|
||||
}
|
||||
]
|
||||
if provider == "anthropic"
|
||||
else []
|
||||
),
|
||||
)
|
||||
|
||||
requests = []
|
||||
|
||||
def _open(request, *, timeout):
|
||||
requests.append((
|
||||
request.full_url,
|
||||
{key.lower(): value for key, value in request.header_items()},
|
||||
))
|
||||
return _Response({"data": [{"id": "claude-proxy-model"}]})
|
||||
|
||||
monkeypatch.setattr(models, "_urlopen_model_catalog_request", _open)
|
||||
|
||||
assert models.provider_model_ids("anthropic") == ["claude-proxy-model"]
|
||||
assert requests == [
|
||||
(
|
||||
f"{pool_endpoint}/models",
|
||||
{
|
||||
"anthropic-version": "2023-06-01",
|
||||
"x-api-key": "proxy-key",
|
||||
},
|
||||
)
|
||||
]
|
||||
assert all(not url.startswith(active_endpoint) for url, _headers in requests)
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Tests for Anthropic credential persistence helpers."""
|
||||
|
||||
from hermes_cli.config import load_env
|
||||
|
||||
|
||||
def test_save_anthropic_oauth_token_uses_token_slot_and_clears_api_key(tmp_path, monkeypatch):
|
||||
home = tmp_path / "hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
|
||||
from hermes_cli.config import save_anthropic_oauth_token
|
||||
|
||||
save_anthropic_oauth_token("sk-ant-oat01-test-token")
|
||||
|
||||
env_vars = load_env()
|
||||
assert env_vars["ANTHROPIC_TOKEN"] == "sk-ant-oat01-test-token"
|
||||
assert env_vars["ANTHROPIC_API_KEY"] == ""
|
||||
|
||||
|
||||
def test_use_anthropic_claude_code_credentials_clears_env_slots(tmp_path, monkeypatch):
|
||||
home = tmp_path / "hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
|
||||
from hermes_cli.config import save_anthropic_oauth_token, use_anthropic_claude_code_credentials
|
||||
|
||||
save_anthropic_oauth_token("sk-ant-oat01-token")
|
||||
use_anthropic_claude_code_credentials()
|
||||
|
||||
env_vars = load_env()
|
||||
assert env_vars["ANTHROPIC_TOKEN"] == ""
|
||||
assert env_vars["ANTHROPIC_API_KEY"] == ""
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,120 @@
|
||||
"""Legacy ``api_mode`` spellings must keep selecting the transport they named.
|
||||
|
||||
Regression coverage for the silent api_mode vocabulary break: earlier
|
||||
releases accepted ``api_mode: openai`` on custom provider entries. The
|
||||
canonical set consumed by ``agent_init`` is now {chat_completions,
|
||||
codex_responses, anthropic_messages, bedrock_converse, codex_app_server},
|
||||
and an unrecognized value was silently ignored at BOTH consumption sites:
|
||||
|
||||
* ``hermes_cli.config._normalize_custom_provider_entry`` passed the raw
|
||||
string through, so ``agent_init``'s accepted-set check dropped it and
|
||||
fell through to hostname detection.
|
||||
* ``hermes_cli.runtime_provider._parse_api_mode`` returned None, with the
|
||||
same fall-through.
|
||||
|
||||
For a host with a detection rule (e.g. api.actual.inc -> codex_responses)
|
||||
the provider silently switched transports after an update and broke:
|
||||
observed live as every reasoning-bearing request to a relay's untested
|
||||
/v1/responses endpoint failing while chat_completions worked. See the
|
||||
#66543 discussion.
|
||||
|
||||
The fix canonicalizes known legacy/alias spellings through one shared map
|
||||
(``_canonical_api_mode``) at both sites. Unknown values still pass through
|
||||
unchanged (normalizer) / return None (runtime gate) so existing invalid
|
||||
config behavior is untouched.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.config import _canonical_api_mode, _normalize_custom_provider_entry
|
||||
from hermes_cli.runtime_provider import _parse_api_mode, _VALID_API_MODES
|
||||
|
||||
|
||||
class TestCanonicalApiMode:
|
||||
"""The shared alias map."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"alias, canonical",
|
||||
[
|
||||
("openai", "chat_completions"),
|
||||
("OpenAI", "chat_completions"),
|
||||
(" openai ", "chat_completions"),
|
||||
("openai_chat", "chat_completions"),
|
||||
("chat-completions", "chat_completions"),
|
||||
("responses", "codex_responses"),
|
||||
("openai_responses", "codex_responses"),
|
||||
("anthropic", "anthropic_messages"),
|
||||
("messages", "anthropic_messages"),
|
||||
("bedrock", "bedrock_converse"),
|
||||
],
|
||||
)
|
||||
def test_alias_maps_to_canonical(self, alias, canonical):
|
||||
assert _canonical_api_mode(alias) == canonical
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"canonical",
|
||||
sorted(_VALID_API_MODES),
|
||||
)
|
||||
def test_canonical_names_pass_through(self, canonical):
|
||||
assert _canonical_api_mode(canonical) == canonical
|
||||
|
||||
def test_unknown_value_passes_through_unchanged(self):
|
||||
assert _canonical_api_mode("weird_thing") == "weird_thing"
|
||||
|
||||
def test_every_alias_lands_in_the_valid_set(self):
|
||||
"""Contract: aliasing must never produce a value the runtime rejects."""
|
||||
from hermes_cli.config import _API_MODE_ALIASES
|
||||
|
||||
for target in _API_MODE_ALIASES.values():
|
||||
assert target in _VALID_API_MODES
|
||||
|
||||
|
||||
class TestNormalizedEntryCanonicalizes:
|
||||
"""Config-side consumption: _normalize_custom_provider_entry."""
|
||||
|
||||
def _entry(self, api_mode):
|
||||
return {
|
||||
"name": "relay",
|
||||
"api": "https://relay.example.invalid/v1",
|
||||
"api_mode": api_mode,
|
||||
}
|
||||
|
||||
def test_legacy_openai_becomes_chat_completions(self):
|
||||
normalized = _normalize_custom_provider_entry(
|
||||
self._entry("openai"), provider_key="relay"
|
||||
)
|
||||
assert normalized["api_mode"] == "chat_completions"
|
||||
|
||||
def test_canonical_value_unchanged(self):
|
||||
normalized = _normalize_custom_provider_entry(
|
||||
self._entry("codex_responses"), provider_key="relay"
|
||||
)
|
||||
assert normalized["api_mode"] == "codex_responses"
|
||||
|
||||
def test_transport_key_also_canonicalized(self):
|
||||
entry = {
|
||||
"name": "relay",
|
||||
"api": "https://relay.example.invalid/v1",
|
||||
"transport": "openai",
|
||||
}
|
||||
normalized = _normalize_custom_provider_entry(entry, provider_key="relay")
|
||||
assert normalized["api_mode"] == "chat_completions"
|
||||
|
||||
|
||||
class TestRuntimeParseApiMode:
|
||||
"""Runtime-side consumption: _parse_api_mode."""
|
||||
|
||||
def test_legacy_openai_is_valid_chat_completions(self):
|
||||
assert _parse_api_mode("openai") == "chat_completions"
|
||||
|
||||
def test_canonical_value_still_valid(self):
|
||||
assert _parse_api_mode("anthropic_messages") == "anthropic_messages"
|
||||
|
||||
def test_unknown_value_still_rejected(self):
|
||||
assert _parse_api_mode("bogus") is None
|
||||
|
||||
def test_non_string_still_rejected(self):
|
||||
assert _parse_api_mode(None) is None
|
||||
assert _parse_api_mode(42) is None
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Regression test for the `/model` picker confirmation display.
|
||||
|
||||
Bug (April 2026): after choosing a model from the interactive `/model` picker,
|
||||
``HermesCLI._apply_model_switch_result()`` printed ``ModelInfo.context_window``
|
||||
straight from models.dev, which always reports the vendor-wide value (e.g.
|
||||
gpt-5.5 = 1,050,000 on ``openai``). That ignored provider-specific caps — in
|
||||
particular, ChatGPT Codex OAuth enforces 272K on the same slug. The sibling
|
||||
``_handle_model_switch()`` (typed ``/model <name>``) was already fixed to use
|
||||
``resolve_display_context_length()``; the picker path was missed, causing
|
||||
"sometimes 1M, sometimes 272K" for the same model across sibling UI paths.
|
||||
|
||||
Fix: both display paths now go through ``resolve_display_context_length()``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from hermes_cli.model_switch import ModelSwitchResult
|
||||
|
||||
|
||||
class _FakeModelInfo:
|
||||
context_window = 1_050_000
|
||||
max_output = 0
|
||||
|
||||
def has_cost_data(self):
|
||||
return False
|
||||
|
||||
def format_capabilities(self):
|
||||
return ""
|
||||
|
||||
|
||||
class _StubCLI:
|
||||
"""Minimum attrs ``_apply_model_switch_result`` reads on ``self``."""
|
||||
agent = None
|
||||
model = ""
|
||||
provider = ""
|
||||
requested_provider = ""
|
||||
api_key = ""
|
||||
_explicit_api_key = ""
|
||||
base_url = ""
|
||||
_explicit_base_url = ""
|
||||
api_mode = ""
|
||||
_pending_model_switch_note = ""
|
||||
|
||||
|
||||
def _run_display(monkeypatch, result):
|
||||
import cli as cli_mod
|
||||
|
||||
captured: list[str] = []
|
||||
monkeypatch.setattr(cli_mod, "_cprint", lambda s, *a, **k: captured.append(str(s)))
|
||||
# Avoid writing to ~/.hermes/config.yaml during the test.
|
||||
monkeypatch.setattr(cli_mod, "save_config_value", lambda *a, **k: None)
|
||||
cli_mod.HermesCLI._apply_model_switch_result(_StubCLI(), result, False)
|
||||
return captured
|
||||
|
||||
|
||||
def test_picker_path_uses_provider_aware_context_on_codex(monkeypatch):
|
||||
"""``_apply_model_switch_result`` must prefer the provider-aware resolver
|
||||
(272K on Codex) over the raw models.dev value (1.05M for gpt-5.5).
|
||||
"""
|
||||
result = ModelSwitchResult(
|
||||
success=True,
|
||||
new_model="gpt-5.5",
|
||||
target_provider="openai-codex",
|
||||
provider_changed=True,
|
||||
api_key="",
|
||||
base_url="https://chatgpt.com/backend-api/codex",
|
||||
api_mode="codex_responses",
|
||||
warning_message="",
|
||||
provider_label="ChatGPT Codex",
|
||||
resolved_via_alias=False,
|
||||
capabilities=None,
|
||||
model_info=_FakeModelInfo(), # models.dev says 1.05M
|
||||
is_global=False,
|
||||
)
|
||||
with patch(
|
||||
"agent.model_metadata.get_model_context_length",
|
||||
return_value=272_000,
|
||||
):
|
||||
lines = _run_display(monkeypatch, result)
|
||||
|
||||
ctx_line = next((l for l in lines if "Context:" in l), "")
|
||||
assert "272,000" in ctx_line, (
|
||||
f"picker-path display must show Codex's 272K cap, got: {ctx_line!r}"
|
||||
)
|
||||
assert "1,050,000" not in ctx_line, (
|
||||
f"picker-path display leaked models.dev's 1.05M for Codex: {ctx_line!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_picker_path_falls_back_to_model_info_when_resolver_empty(monkeypatch):
|
||||
"""If ``get_model_context_length`` returns nothing (rare — truly unknown
|
||||
endpoint), the display still surfaces ``ModelInfo.context_window`` so the
|
||||
user sees *something* rather than a silent blank.
|
||||
"""
|
||||
result = ModelSwitchResult(
|
||||
success=True,
|
||||
new_model="some-model",
|
||||
target_provider="some-provider",
|
||||
provider_changed=True,
|
||||
api_key="",
|
||||
base_url="",
|
||||
api_mode="chat_completions",
|
||||
warning_message="",
|
||||
provider_label="Some Provider",
|
||||
resolved_via_alias=False,
|
||||
capabilities=None,
|
||||
model_info=_FakeModelInfo(), # context_window = 1_050_000
|
||||
is_global=False,
|
||||
)
|
||||
with patch(
|
||||
"agent.model_metadata.get_model_context_length",
|
||||
return_value=None,
|
||||
):
|
||||
lines = _run_display(monkeypatch, result)
|
||||
|
||||
ctx_line = next((l for l in lines if "Context:" in l), "")
|
||||
assert "1,050,000" in ctx_line, (
|
||||
f"resolver-empty path should fall back to ModelInfo, got: {ctx_line!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_global_switch_clears_context_pin_owned_by_previous_route(monkeypatch):
|
||||
import cli as cli_mod
|
||||
|
||||
writes = []
|
||||
monkeypatch.setattr(cli_mod, "_cprint", lambda *_a, **_k: None)
|
||||
monkeypatch.setattr(
|
||||
cli_mod,
|
||||
"save_config_value",
|
||||
lambda key, value: writes.append((key, value)),
|
||||
)
|
||||
cli = _StubCLI()
|
||||
cli.model = "shared-model"
|
||||
cli.provider = "custom"
|
||||
# Runtime may already diverge from persisted config through a session override.
|
||||
cli.base_url = "https://small.example/v1"
|
||||
result = ModelSwitchResult(
|
||||
success=True,
|
||||
new_model="shared-model",
|
||||
target_provider="custom",
|
||||
provider_changed=False,
|
||||
api_key="",
|
||||
base_url="https://small.example/v1",
|
||||
api_mode="chat_completions",
|
||||
warning_message="",
|
||||
provider_label="Custom",
|
||||
resolved_via_alias=False,
|
||||
capabilities=None,
|
||||
model_info=_FakeModelInfo(),
|
||||
is_global=True,
|
||||
)
|
||||
|
||||
configured = {
|
||||
"model": {
|
||||
"default": "shared-model",
|
||||
"provider": "custom",
|
||||
"base_url": "https://large.example/v1",
|
||||
"context_length": 1_048_576,
|
||||
}
|
||||
}
|
||||
with (
|
||||
patch(
|
||||
"agent.model_metadata.get_model_context_length",
|
||||
return_value=256_000,
|
||||
),
|
||||
patch("hermes_cli.config.load_config_readonly", return_value=configured),
|
||||
):
|
||||
cli_mod.HermesCLI._apply_model_switch_result(cli, result, True)
|
||||
|
||||
assert ("model.context_length", None) in writes
|
||||
@@ -0,0 +1,288 @@
|
||||
"""Regression tests for _apply_profile_override HERMES_HOME guard (issue #22502).
|
||||
|
||||
When HERMES_HOME is set to the hermes root (e.g. systemd hardcodes
|
||||
HERMES_HOME=/root/.hermes), _apply_profile_override must still read
|
||||
active_profile and update HERMES_HOME to the profile directory.
|
||||
|
||||
When HERMES_HOME is already a profile directory (.../profiles/<name>),
|
||||
_apply_profile_override must trust it and return without re-reading
|
||||
active_profile (child-process inheritance contract).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
def _run_apply_profile_override(
|
||||
tmp_path, monkeypatch, *, hermes_home: str | None, active_profile: str | None,
|
||||
argv: list[str] | None = None, extra_env: dict[str, str] | None = None,
|
||||
):
|
||||
"""Run _apply_profile_override in isolation.
|
||||
|
||||
Returns the value of os.environ["HERMES_HOME"] after the call,
|
||||
or None if unset.
|
||||
"""
|
||||
hermes_root = tmp_path / ".hermes"
|
||||
hermes_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if active_profile is not None:
|
||||
(hermes_root / "active_profile").write_text(active_profile)
|
||||
|
||||
if active_profile and active_profile != "default":
|
||||
(hermes_root / "profiles" / active_profile).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
if hermes_home is not None:
|
||||
monkeypatch.setenv("HERMES_HOME", hermes_home)
|
||||
else:
|
||||
monkeypatch.delenv("HERMES_HOME", raising=False)
|
||||
|
||||
monkeypatch.setattr(sys, "argv", argv or ["hermes", "gateway", "start"])
|
||||
|
||||
# Scrub supervisor markers the host environment may carry (systemd-run
|
||||
# CI runners export INVOCATION_ID) so each test controls them explicitly.
|
||||
for var in (
|
||||
"HERMES_SUPERVISED_CHILD",
|
||||
"HERMES_S6_SUPERVISED_CHILD",
|
||||
"INVOCATION_ID",
|
||||
"HERMES_GATEWAY_EXTERNAL_SUPERVISOR",
|
||||
):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
for key, value in (extra_env or {}).items():
|
||||
monkeypatch.setenv(key, value)
|
||||
|
||||
from hermes_cli.main import _apply_profile_override
|
||||
_apply_profile_override()
|
||||
|
||||
return os.environ.get("HERMES_HOME")
|
||||
|
||||
|
||||
class TestApplyProfileOverrideHermesHomeGuard:
|
||||
"""Regression guard for issue #22502.
|
||||
|
||||
Verifies that HERMES_HOME pointing to the hermes root does NOT suppress
|
||||
the active_profile check, while HERMES_HOME already pointing to a
|
||||
profile directory IS trusted as-is.
|
||||
"""
|
||||
|
||||
def test_hermes_home_at_root_with_active_profile_is_redirected(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""HERMES_HOME=/root/.hermes + active_profile=coder must redirect
|
||||
HERMES_HOME to .../profiles/coder.
|
||||
|
||||
Bug scenario from #22502: systemd sets HERMES_HOME to the hermes root
|
||||
and the user switches to a profile via `hermes profile use`.
|
||||
Before the fix, the guard returned early and active_profile was ignored.
|
||||
"""
|
||||
hermes_root = tmp_path / ".hermes"
|
||||
hermes_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
result = _run_apply_profile_override(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
hermes_home=str(hermes_root),
|
||||
active_profile="coder",
|
||||
)
|
||||
|
||||
assert result is not None, "HERMES_HOME must be set after profile redirect"
|
||||
assert "profiles" in result, (
|
||||
f"Expected HERMES_HOME to point into profiles/ dir, got: {result!r}"
|
||||
)
|
||||
assert result.endswith("coder"), (
|
||||
f"Expected HERMES_HOME to end with 'coder', got: {result!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_sudo_explicit_profile_resolves_invoking_users_profile(self, tmp_path, monkeypatch):
|
||||
"""sudo elias ... should resolve `-p elias` under SUDO_USER, not root."""
|
||||
root_home = tmp_path / "root"
|
||||
user_home = tmp_path / "home" / "hermes"
|
||||
profile_dir = user_home / ".hermes" / "profiles" / "elias"
|
||||
profile_dir.mkdir(parents=True, exist_ok=True)
|
||||
(root_home / ".hermes").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
monkeypatch.setattr(Path, "home", lambda: root_home)
|
||||
monkeypatch.setenv("SUDO_USER", "hermes")
|
||||
monkeypatch.delenv("HERMES_HOME", raising=False)
|
||||
monkeypatch.setattr(os, "geteuid", lambda: 0, raising=False)
|
||||
monkeypatch.setattr(sys, "argv", ["hermes", "-p", "elias", "gateway", "install", "--system"])
|
||||
|
||||
import pwd
|
||||
|
||||
monkeypatch.setattr(pwd, "getpwnam", lambda name: SimpleNamespace(pw_dir=str(user_home)))
|
||||
|
||||
from hermes_cli.main import _apply_profile_override
|
||||
_apply_profile_override()
|
||||
|
||||
assert os.environ.get("HERMES_HOME") == str(profile_dir)
|
||||
assert sys.argv == ["hermes", "gateway", "install", "--system"]
|
||||
|
||||
|
||||
|
||||
|
||||
class TestSupervisedChildIgnoresStickyProfile:
|
||||
"""The reserved default gateway s6 slot must not follow active_profile.
|
||||
|
||||
Inside the Docker s6 image the ``gateway-default`` service slot runs a
|
||||
bare ``hermes gateway run`` (no ``-p``) to mean "the root HERMES_HOME
|
||||
profile". The run-script exports ``HERMES_S6_SUPERVISED_CHILD=1``.
|
||||
Without a guard, ``_apply_profile_override`` would read the sticky
|
||||
``active_profile`` file (set by e.g. the dashboard profile switcher) and
|
||||
redirect the reserved default gateway into that profile — producing a
|
||||
duplicate gateway for the active profile and no real default gateway.
|
||||
"""
|
||||
|
||||
|
||||
def test_non_supervised_run_still_follows_active_profile(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""Without the sentinel, a normal `hermes gateway run` still honors
|
||||
active_profile — the guard is scoped strictly to supervised children."""
|
||||
result = _run_apply_profile_override(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
hermes_home=None,
|
||||
active_profile="briefer",
|
||||
argv=["hermes", "gateway", "run"],
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.endswith("briefer")
|
||||
|
||||
def test_supervised_named_profile_flag_still_wins(self, tmp_path, monkeypatch):
|
||||
"""A supervised named-profile slot passes ``-p <name>`` explicitly;
|
||||
that must still resolve (the sentinel guard only skips the sticky
|
||||
active_profile fallback, never an explicit flag)."""
|
||||
hermes_root = tmp_path / ".hermes"
|
||||
hermes_root.mkdir(parents=True, exist_ok=True)
|
||||
(hermes_root / "active_profile").write_text("briefer")
|
||||
(hermes_root / "profiles" / "briefer").mkdir(parents=True, exist_ok=True)
|
||||
(hermes_root / "profiles" / "coder").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.delenv("HERMES_HOME", raising=False)
|
||||
monkeypatch.setenv("HERMES_S6_SUPERVISED_CHILD", "1")
|
||||
monkeypatch.setattr(sys, "argv", ["hermes", "-p", "coder", "gateway", "run"])
|
||||
|
||||
from hermes_cli.main import _apply_profile_override
|
||||
_apply_profile_override()
|
||||
|
||||
result = os.environ.get("HERMES_HOME")
|
||||
assert result is not None
|
||||
assert result.endswith("coder")
|
||||
|
||||
|
||||
|
||||
class TestGeneralizedSupervisorMarkers:
|
||||
"""Regression tests for issue #74872.
|
||||
|
||||
A systemd/launchd/Scheduled-Task supervised gateway launch pins its
|
||||
profile identity via the unit's HERMES_HOME (root home for the default
|
||||
profile). It must NEVER follow the sticky ``active_profile`` file —
|
||||
otherwise the default-profile gateway silently assumes another profile's
|
||||
identity (logs + Telegram bot token) and double-polls that profile's
|
||||
token. Markers: HERMES_SUPERVISED_CHILD (generalized, exported by
|
||||
generated units), INVOCATION_ID (systemd, gateway commands only), and
|
||||
HERMES_GATEWAY_EXTERNAL_SUPERVISOR (explicit opt-in).
|
||||
"""
|
||||
|
||||
def _root_home(self, tmp_path):
|
||||
hermes_root = tmp_path / ".hermes"
|
||||
hermes_root.mkdir(parents=True, exist_ok=True)
|
||||
return hermes_root
|
||||
|
||||
def test_supervised_child_marker_skips_active_profile(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""HERMES_SUPERVISED_CHILD=1 + root HERMES_HOME must keep the
|
||||
default profile's home even when active_profile names another
|
||||
profile (the #74872 identity-assumption vector)."""
|
||||
hermes_root = self._root_home(tmp_path)
|
||||
result = _run_apply_profile_override(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
hermes_home=str(hermes_root),
|
||||
active_profile="telegram_nick",
|
||||
argv=["hermes", "gateway", "run"],
|
||||
extra_env={"HERMES_SUPERVISED_CHILD": "1"},
|
||||
)
|
||||
assert result == str(hermes_root), (
|
||||
f"supervised default gateway was redirected to {result!r}"
|
||||
)
|
||||
|
||||
def test_systemd_invocation_id_skips_active_profile_for_gateway(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""INVOCATION_ID (systemd service child) must suppress the sticky
|
||||
redirect for gateway commands — covers units installed before the
|
||||
HERMES_SUPERVISED_CHILD marker existed."""
|
||||
hermes_root = self._root_home(tmp_path)
|
||||
result = _run_apply_profile_override(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
hermes_home=str(hermes_root),
|
||||
active_profile="telegram_nick",
|
||||
argv=["hermes", "gateway", "run"],
|
||||
extra_env={"INVOCATION_ID": "deadbeef" * 4},
|
||||
)
|
||||
assert result == str(hermes_root)
|
||||
|
||||
def test_invocation_id_does_not_affect_non_gateway_commands(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""INVOCATION_ID leaks into every descendant of a systemd-launched
|
||||
process (CI runners, user services). Non-gateway commands must keep
|
||||
honoring the sticky active_profile."""
|
||||
hermes_root = self._root_home(tmp_path)
|
||||
result = _run_apply_profile_override(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
hermes_home=str(hermes_root),
|
||||
active_profile="coder",
|
||||
argv=["hermes", "chat"],
|
||||
extra_env={"INVOCATION_ID": "deadbeef" * 4},
|
||||
)
|
||||
assert result is not None
|
||||
assert result.endswith("coder")
|
||||
|
||||
def test_external_supervisor_marker_skips_active_profile(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
hermes_root = self._root_home(tmp_path)
|
||||
result = _run_apply_profile_override(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
hermes_home=str(hermes_root),
|
||||
active_profile="telegram_nick",
|
||||
argv=["hermes", "gateway", "run"],
|
||||
extra_env={"HERMES_GATEWAY_EXTERNAL_SUPERVISOR": "1"},
|
||||
)
|
||||
assert result == str(hermes_root)
|
||||
|
||||
def test_generated_systemd_unit_exports_supervised_marker(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""The generated systemd unit must carry the marker so fresh installs
|
||||
are protected without relying on the INVOCATION_ID heuristic."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
|
||||
(tmp_path / "home").mkdir()
|
||||
from hermes_cli.gateway import generate_systemd_unit
|
||||
|
||||
unit = generate_systemd_unit()
|
||||
assert 'Environment="HERMES_SUPERVISED_CHILD=1"' in unit
|
||||
|
||||
def test_generated_launchd_plist_exports_supervised_marker(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
|
||||
(tmp_path / "home").mkdir()
|
||||
from hermes_cli.gateway import generate_launchd_plist
|
||||
|
||||
plist = generate_launchd_plist()
|
||||
assert "<key>HERMES_SUPERVISED_CHILD</key>" in plist
|
||||
@@ -0,0 +1,562 @@
|
||||
"""Approval transport plugin contract and fail-closed host routing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from hermes_cli.plugins import PluginContext, PluginManager, PluginManifest
|
||||
|
||||
|
||||
def _manifest(name: str = "fixture-approval") -> PluginManifest:
|
||||
return PluginManifest(
|
||||
name=name,
|
||||
version="1.0.0",
|
||||
description="fixture",
|
||||
source="user",
|
||||
key=name,
|
||||
)
|
||||
|
||||
|
||||
def _context(manager: PluginManager, name: str = "fixture-approval") -> PluginContext:
|
||||
return PluginContext(_manifest(name), manager)
|
||||
|
||||
|
||||
def _request():
|
||||
from hermes_cli.approval_transport import ApprovalRequest
|
||||
|
||||
return ApprovalRequest.create(
|
||||
command="rm -rf /tmp/example",
|
||||
description="recursive delete",
|
||||
pattern_key="rm_recursive",
|
||||
pattern_keys=("rm_recursive",),
|
||||
session_key="session-a",
|
||||
surface="cli",
|
||||
allow_session=True,
|
||||
allow_permanent=True,
|
||||
)
|
||||
|
||||
|
||||
def test_context_registers_owned_approval_transport():
|
||||
manager = PluginManager()
|
||||
callback = lambda request: request.respond("deny")
|
||||
|
||||
_context(manager).register_approval_transport("phone", callback)
|
||||
|
||||
registered = manager.get_approval_transport("phone")
|
||||
assert registered is not None
|
||||
assert registered.name == "phone"
|
||||
assert registered.plugin_id == "fixture-approval"
|
||||
assert registered.present is callback
|
||||
|
||||
|
||||
def test_transport_names_are_unique_and_builtin_is_reserved():
|
||||
manager = PluginManager()
|
||||
_context(manager, "first").register_approval_transport("phone", lambda request: None)
|
||||
|
||||
with pytest.raises(ValueError, match="already registered"):
|
||||
_context(manager, "second").register_approval_transport("phone", lambda request: None)
|
||||
with pytest.raises(ValueError, match="reserved"):
|
||||
_context(manager).register_approval_transport("builtin", lambda request: None)
|
||||
|
||||
|
||||
def test_force_reload_clears_transport_registry(monkeypatch):
|
||||
manager = PluginManager()
|
||||
_context(manager).register_approval_transport("phone", lambda request: None)
|
||||
manager._discovered = True
|
||||
monkeypatch.setattr(manager, "_discover_and_load_inner", lambda: None)
|
||||
|
||||
manager.discover_and_load(force=True)
|
||||
|
||||
assert manager.get_approval_transport("phone") is None
|
||||
|
||||
|
||||
def test_transport_registry_is_manager_and_profile_isolated(monkeypatch, tmp_path):
|
||||
first = PluginManager()
|
||||
second = PluginManager()
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "profiles" / "work"))
|
||||
_context(first).register_approval_transport("phone", lambda request: None)
|
||||
|
||||
assert first.get_approval_transport("phone") is not None
|
||||
assert second.get_approval_transport("phone") is None
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "profiles" / "personal"))
|
||||
assert first.get_approval_transport("phone") is None
|
||||
|
||||
|
||||
def test_host_accepts_bound_sync_and_async_decisions():
|
||||
from hermes_cli.approval_transport import invoke_approval_transport
|
||||
|
||||
request = _request()
|
||||
sync_result = invoke_approval_transport(
|
||||
lambda received: received.respond("session"), request, timeout_seconds=1
|
||||
)
|
||||
|
||||
async def present(received):
|
||||
await asyncio.sleep(0)
|
||||
return received.respond("once")
|
||||
|
||||
async_result = invoke_approval_transport(present, request, timeout_seconds=1)
|
||||
|
||||
assert sync_result.choice == "session"
|
||||
assert sync_result.failure is None
|
||||
assert async_result.choice == "once"
|
||||
assert async_result.failure is None
|
||||
|
||||
|
||||
def test_host_rejects_scope_not_offered_by_request():
|
||||
from hermes_cli.approval_transport import ApprovalRequest, invoke_approval_transport
|
||||
|
||||
request = ApprovalRequest.create(
|
||||
command="dangerous",
|
||||
description="dangerous",
|
||||
pattern_key="danger",
|
||||
pattern_keys=("danger",),
|
||||
session_key="session-a",
|
||||
surface="cli",
|
||||
allow_session=False,
|
||||
allow_permanent=False,
|
||||
)
|
||||
|
||||
result = invoke_approval_transport(
|
||||
lambda received: received.respond("always"), request, timeout_seconds=1
|
||||
)
|
||||
|
||||
assert result.choice == "deny"
|
||||
assert result.failure == "invalid"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("present", "failure"),
|
||||
[
|
||||
(lambda request: {"choice": "once"}, "invalid"),
|
||||
(lambda request: request.respond("bogus"), "invalid"),
|
||||
(
|
||||
lambda request: type(request.respond("once"))(
|
||||
request_id="stale",
|
||||
request_digest=request.digest,
|
||||
choice="once",
|
||||
),
|
||||
"stale",
|
||||
),
|
||||
(
|
||||
lambda request: type(request.respond("once"))(
|
||||
request_id=request.request_id,
|
||||
request_digest="changed",
|
||||
choice="once",
|
||||
),
|
||||
"stale",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_host_rejects_invalid_or_stale_decisions(present, failure):
|
||||
from hermes_cli.approval_transport import invoke_approval_transport
|
||||
|
||||
result = invoke_approval_transport(present, _request(), timeout_seconds=1)
|
||||
|
||||
assert result.choice == "deny"
|
||||
assert result.failure == failure
|
||||
|
||||
|
||||
def test_host_timeout_and_exception_deny_without_waiting_forever():
|
||||
from hermes_cli.approval_transport import invoke_approval_transport
|
||||
|
||||
def hangs(_request):
|
||||
time.sleep(1)
|
||||
|
||||
def crashes(_request):
|
||||
raise RuntimeError("transport offline")
|
||||
|
||||
started = time.monotonic()
|
||||
timeout = invoke_approval_transport(hangs, _request(), timeout_seconds=0.02)
|
||||
exception = invoke_approval_transport(crashes, _request(), timeout_seconds=1)
|
||||
|
||||
assert time.monotonic() - started < 0.5
|
||||
assert (timeout.choice, timeout.failure) == ("deny", "timeout")
|
||||
assert (exception.choice, exception.failure) == ("deny", "error")
|
||||
|
||||
|
||||
def test_host_transport_wait_is_interruptible_and_pollable():
|
||||
from hermes_cli.approval_transport import invoke_approval_transport
|
||||
|
||||
polls = []
|
||||
|
||||
def hangs(_request):
|
||||
time.sleep(1)
|
||||
|
||||
result = invoke_approval_transport(
|
||||
hangs,
|
||||
_request(),
|
||||
timeout_seconds=1,
|
||||
poll_interval=0.01,
|
||||
on_poll=lambda: polls.append(1),
|
||||
is_interrupted=lambda: len(polls) >= 1,
|
||||
)
|
||||
|
||||
assert result.choice == "deny"
|
||||
assert result.failure == "interrupted"
|
||||
assert polls
|
||||
|
||||
|
||||
def test_host_rejects_decision_completed_after_deadline(monkeypatch):
|
||||
import hermes_cli.approval_transport as transport_module
|
||||
|
||||
main_thread = threading.get_ident()
|
||||
|
||||
def clock():
|
||||
# The host starts at t=0 with a one-second budget. The worker reports
|
||||
# completion at t=2 before the host dequeues the result. Acceptance is
|
||||
# bound to completion time, not to a queue/scheduler race.
|
||||
return 0.0 if threading.get_ident() == main_thread else 2.0
|
||||
|
||||
monkeypatch.setattr(transport_module.time, "monotonic", clock)
|
||||
|
||||
result = transport_module.invoke_approval_transport(
|
||||
lambda request: request.respond("once"),
|
||||
_request(),
|
||||
timeout_seconds=1,
|
||||
)
|
||||
|
||||
assert result.choice == "deny"
|
||||
assert result.failure == "timeout"
|
||||
|
||||
|
||||
def test_host_caps_hung_transport_workers():
|
||||
from hermes_cli.approval_transport import (
|
||||
_MAX_ACTIVE_TRANSPORT_WORKERS,
|
||||
invoke_approval_transport,
|
||||
)
|
||||
|
||||
release = threading.Event()
|
||||
|
||||
def hangs(_request):
|
||||
release.wait()
|
||||
|
||||
try:
|
||||
results = [
|
||||
invoke_approval_transport(hangs, _request(), timeout_seconds=0.001)
|
||||
for _ in range(_MAX_ACTIVE_TRANSPORT_WORKERS + 1)
|
||||
]
|
||||
finally:
|
||||
release.set()
|
||||
|
||||
assert results[-1].choice == "deny"
|
||||
assert results[-1].failure == "busy"
|
||||
|
||||
|
||||
def _configure_manual_guard(monkeypatch, approval_module, manager, *, fallback=None):
|
||||
monkeypatch.setattr(approval_module, "_get_approval_mode", lambda: "manual")
|
||||
monkeypatch.setattr(approval_module, "_is_interactive_cli", lambda: True)
|
||||
monkeypatch.setattr(approval_module, "_is_gateway_approval_context", lambda: False)
|
||||
monkeypatch.setattr(approval_module, "detect_hardline_command", lambda command: (False, ""))
|
||||
monkeypatch.setattr(approval_module, "_check_sudo_stdin_guard", lambda command: (False, ""))
|
||||
monkeypatch.setattr(approval_module, "_match_user_deny_rule", lambda command: None)
|
||||
monkeypatch.setattr(approval_module, "_command_matches_permanent_allowlist", lambda command: False)
|
||||
monkeypatch.setattr(approval_module, "detect_dangerous_command", lambda command: (True, "danger", "dangerous"))
|
||||
monkeypatch.setattr(
|
||||
approval_module,
|
||||
"get_current_session_key",
|
||||
lambda *args, **kwargs: "session-a",
|
||||
)
|
||||
monkeypatch.setattr(approval_module, "is_approved", lambda *args: False)
|
||||
monkeypatch.setattr(approval_module, "get_plugin_manager", lambda: manager, raising=False)
|
||||
monkeypatch.setattr(
|
||||
approval_module,
|
||||
"_get_approval_transport_config",
|
||||
lambda: ("phone", fallback),
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr("tools.tirith_security.check_command_security", lambda command: {"action": "allow"})
|
||||
|
||||
|
||||
def test_cli_selected_transport_replaces_builtin_prompt(monkeypatch):
|
||||
from tools import approval
|
||||
|
||||
manager = PluginManager()
|
||||
seen = []
|
||||
_context(manager).register_approval_transport(
|
||||
"phone", lambda request: seen.append(request) or request.respond("once")
|
||||
)
|
||||
_configure_manual_guard(monkeypatch, approval, manager)
|
||||
|
||||
def builtin(*args, **kwargs):
|
||||
raise AssertionError("builtin prompt must not materialize")
|
||||
|
||||
result = approval.check_all_command_guards(
|
||||
"rm -rf /tmp/example", "local", approval_callback=builtin
|
||||
)
|
||||
|
||||
assert result["approved"] is True
|
||||
assert result["user_approved"] is True
|
||||
assert len(seen) == 1
|
||||
assert seen[0].command == "rm -rf /tmp/example"
|
||||
assert seen[0].allowed_choices == ("once", "session", "always", "deny")
|
||||
|
||||
|
||||
def test_gateway_selected_transport_does_not_require_gateway_notifier(monkeypatch):
|
||||
from tools import approval
|
||||
|
||||
manager = PluginManager()
|
||||
seen = []
|
||||
_context(manager).register_approval_transport(
|
||||
"phone", lambda request: seen.append(request) or request.respond("once")
|
||||
)
|
||||
_configure_manual_guard(monkeypatch, approval, manager)
|
||||
monkeypatch.setattr(approval, "_is_interactive_cli", lambda: False)
|
||||
monkeypatch.setattr(approval, "_is_gateway_approval_context", lambda: True)
|
||||
monkeypatch.setattr(approval, "_gateway_notify_cbs", {})
|
||||
|
||||
result = approval.check_all_command_guards("rm -rf /tmp/example", "local")
|
||||
|
||||
assert result["approved"] is True
|
||||
assert len(seen) == 1
|
||||
assert seen[0].surface == "gateway"
|
||||
|
||||
|
||||
def test_execute_code_gateway_uses_selected_transport(monkeypatch):
|
||||
from tools import approval
|
||||
|
||||
manager = PluginManager()
|
||||
seen = []
|
||||
_context(manager).register_approval_transport(
|
||||
"phone", lambda request: seen.append(request) or request.respond("once")
|
||||
)
|
||||
monkeypatch.setattr(approval, "_get_approval_mode", lambda: "manual")
|
||||
monkeypatch.setattr(approval, "_is_gateway_approval_context", lambda: True)
|
||||
monkeypatch.setattr(approval, "_is_cron_approval_context", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
approval, "get_current_session_key", lambda *args, **kwargs: "session-a"
|
||||
)
|
||||
monkeypatch.setattr(approval, "is_approved", lambda *args: False)
|
||||
monkeypatch.setattr(approval, "get_plugin_manager", lambda: manager)
|
||||
monkeypatch.setattr(
|
||||
approval, "_get_approval_transport_config", lambda: ("phone", None)
|
||||
)
|
||||
monkeypatch.setattr(approval, "_gateway_notify_cbs", {})
|
||||
|
||||
result = approval.check_execute_code_guard("print('ok')", "local")
|
||||
|
||||
assert result["approved"] is True
|
||||
assert len(seen) == 1
|
||||
assert seen[0].pattern_key == "execute_code"
|
||||
assert seen[0].surface == "gateway"
|
||||
|
||||
|
||||
def test_transport_failure_denies_without_builtin_fallback(monkeypatch):
|
||||
from tools import approval
|
||||
|
||||
manager = PluginManager()
|
||||
_context(manager).register_approval_transport("phone", lambda request: {"choice": "once"})
|
||||
_configure_manual_guard(monkeypatch, approval, manager)
|
||||
builtin_calls = []
|
||||
|
||||
result = approval.check_all_command_guards(
|
||||
"rm -rf /tmp/example",
|
||||
"local",
|
||||
approval_callback=lambda *args, **kwargs: builtin_calls.append(1) or "once",
|
||||
)
|
||||
|
||||
assert result["approved"] is False
|
||||
assert result["outcome"] == "transport_invalid"
|
||||
assert builtin_calls == []
|
||||
|
||||
|
||||
def test_transport_resolution_error_does_not_log_plugin_exception(monkeypatch, caplog):
|
||||
from tools import approval
|
||||
|
||||
monkeypatch.setattr(
|
||||
approval, "_get_approval_transport_config", lambda: ("phone", None)
|
||||
)
|
||||
|
||||
def broken_manager():
|
||||
raise RuntimeError("plugin-owned-secret-value")
|
||||
|
||||
monkeypatch.setattr(approval, "get_plugin_manager", broken_manager)
|
||||
|
||||
result = approval._present_with_selected_transport(
|
||||
command="rm -rf /tmp/example",
|
||||
description="dangerous",
|
||||
pattern_key="rm_recursive",
|
||||
pattern_keys=["rm_recursive"],
|
||||
session_key="session-a",
|
||||
surface="cli",
|
||||
allow_session=True,
|
||||
allow_permanent=True,
|
||||
)
|
||||
|
||||
assert result["choice"] == "deny"
|
||||
assert result["failure"] == "unavailable"
|
||||
assert "plugin-owned-secret-value" not in caplog.text
|
||||
|
||||
|
||||
def test_redaction_failure_denies_before_transport_callback(monkeypatch):
|
||||
import agent.redact
|
||||
from tools import approval
|
||||
|
||||
manager = PluginManager()
|
||||
calls = []
|
||||
_context(manager).register_approval_transport(
|
||||
"phone", lambda request: calls.append(request) or request.respond("once")
|
||||
)
|
||||
_configure_manual_guard(monkeypatch, approval, manager)
|
||||
|
||||
def redaction_failed(text, *, force=False):
|
||||
assert force is True
|
||||
if text in {"rm -rf /tmp/example", "dangerous"}:
|
||||
raise RuntimeError("redactor unavailable")
|
||||
return text
|
||||
|
||||
monkeypatch.setattr(agent.redact, "redact_sensitive_text", redaction_failed)
|
||||
|
||||
result = approval.check_all_command_guards("rm -rf /tmp/example", "local")
|
||||
|
||||
assert result["approved"] is False
|
||||
assert result["outcome"] == "transport_error"
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_explicit_builtin_fallback_uses_existing_surface(monkeypatch):
|
||||
from tools import approval
|
||||
|
||||
manager = PluginManager()
|
||||
_context(manager).register_approval_transport("phone", lambda request: {"choice": "once"})
|
||||
_configure_manual_guard(monkeypatch, approval, manager, fallback="builtin")
|
||||
builtin_calls = []
|
||||
|
||||
result = approval.check_all_command_guards(
|
||||
"rm -rf /tmp/example",
|
||||
"local",
|
||||
approval_callback=lambda *args, **kwargs: builtin_calls.append(1) or "once",
|
||||
)
|
||||
|
||||
assert result["approved"] is True
|
||||
assert builtin_calls == [1]
|
||||
|
||||
|
||||
def test_live_temp_home_fixture_plugin_routes_and_hardline_stays_core_owned(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""Real discovery + config + guard path under an isolated HERMES_HOME."""
|
||||
import hermes_cli.plugins as plugins_module
|
||||
from tools import approval
|
||||
|
||||
home = tmp_path / "hermes-home"
|
||||
plugin_dir = home / "plugins" / "fixture-approval"
|
||||
bundled = tmp_path / "empty-bundled"
|
||||
plugin_dir.mkdir(parents=True)
|
||||
bundled.mkdir()
|
||||
(plugin_dir / "plugin.yaml").write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"name": "fixture-approval",
|
||||
"version": "1.0.0",
|
||||
"description": "approval transport fixture",
|
||||
}
|
||||
)
|
||||
)
|
||||
(plugin_dir / "__init__.py").write_text(
|
||||
"""import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def present(request):
|
||||
output = Path(os.environ["HERMES_HOME"]) / "transport-invocations.jsonl"
|
||||
with output.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps({
|
||||
"request_id": request.request_id,
|
||||
"digest": request.digest,
|
||||
"command": request.command,
|
||||
"surface": request.surface,
|
||||
"timeout_seconds": request.timeout_seconds,
|
||||
}) + "\\n")
|
||||
return request.respond("once")
|
||||
|
||||
|
||||
def register(ctx):
|
||||
ctx.register_approval_transport("fixture", present)
|
||||
"""
|
||||
)
|
||||
home.mkdir(exist_ok=True)
|
||||
(home / "config.yaml").write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"plugins": {"enabled": ["fixture-approval"]},
|
||||
"approvals": {"mode": "manual", "timeout": 2},
|
||||
"security": {
|
||||
"tirith_enabled": False,
|
||||
"approval": {"transport": "fixture"},
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setenv("HERMES_BUNDLED_PLUGINS", str(bundled))
|
||||
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
||||
monkeypatch.setattr(approval, "_YOLO_MODE_FROZEN", False)
|
||||
manager = PluginManager()
|
||||
monkeypatch.setattr(plugins_module, "_plugin_manager", manager)
|
||||
token = approval.set_hermes_interactive_context(True)
|
||||
approval.clear_session("local")
|
||||
approval._permanent_approved.clear()
|
||||
try:
|
||||
routed = approval.check_all_command_guards(
|
||||
"rm -rf /tmp/hermes-approval-transport-fixture", "local"
|
||||
)
|
||||
manager.discover_and_load(force=True)
|
||||
reloaded = approval.check_all_command_guards(
|
||||
"rm -rf /tmp/hermes-approval-transport-fixture-reloaded", "local"
|
||||
)
|
||||
gateway_token = approval.set_hermes_interactive_context(False)
|
||||
monkeypatch.setenv("HERMES_GATEWAY_SESSION", "1")
|
||||
try:
|
||||
gateway_routed = approval.check_all_command_guards(
|
||||
"rm -rf /tmp/hermes-approval-transport-fixture-gateway", "local"
|
||||
)
|
||||
finally:
|
||||
approval.reset_hermes_interactive_context(gateway_token)
|
||||
hardline = approval.check_all_command_guards("rm -rf /", "local")
|
||||
finally:
|
||||
approval.reset_hermes_interactive_context(token)
|
||||
|
||||
records = [
|
||||
json.loads(line)
|
||||
for line in (home / "transport-invocations.jsonl")
|
||||
.read_text(encoding="utf-8")
|
||||
.splitlines()
|
||||
]
|
||||
assert routed["approved"] is True
|
||||
assert reloaded["approved"] is True
|
||||
assert gateway_routed["approved"] is True
|
||||
assert records[0]["request_id"]
|
||||
assert records[0]["digest"]
|
||||
assert records[0]["surface"] == "cli"
|
||||
assert records[0]["timeout_seconds"] == 2
|
||||
assert records[2]["surface"] == "gateway"
|
||||
assert hardline["approved"] is False
|
||||
assert len(records) == 3
|
||||
|
||||
|
||||
def test_hardline_blocks_before_selected_transport(monkeypatch):
|
||||
from tools import approval
|
||||
|
||||
manager = PluginManager()
|
||||
calls = []
|
||||
_context(manager).register_approval_transport(
|
||||
"phone", lambda request: calls.append(request) or request.respond("once")
|
||||
)
|
||||
_configure_manual_guard(monkeypatch, approval, manager)
|
||||
monkeypatch.setattr(
|
||||
approval,
|
||||
"detect_hardline_command",
|
||||
lambda command: (True, "recursive delete of root filesystem"),
|
||||
)
|
||||
|
||||
result = approval.check_all_command_guards("rm -rf /", "local")
|
||||
|
||||
assert result["approved"] is False
|
||||
assert calls == []
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Cross-surface contract for the persistent /approvals mode command."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import yaml
|
||||
|
||||
from cli import HermesCLI
|
||||
from hermes_cli.commands import (
|
||||
GATEWAY_KNOWN_COMMANDS,
|
||||
SUBCOMMANDS,
|
||||
SlashCommandCompleter,
|
||||
gateway_help_lines,
|
||||
resolve_command,
|
||||
telegram_bot_commands,
|
||||
)
|
||||
from prompt_toolkit.completion import CompleteEvent
|
||||
from prompt_toolkit.document import Document
|
||||
|
||||
|
||||
def _completions(text: str) -> set[str]:
|
||||
return {
|
||||
item.text
|
||||
for item in SlashCommandCompleter().get_completions(
|
||||
Document(text=text), CompleteEvent(completion_requested=True)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def test_approvals_registry_drives_help_menu_and_autocomplete():
|
||||
command = resolve_command("approvals")
|
||||
assert command is not None
|
||||
assert command.category == "Configuration"
|
||||
assert command.args_hint == "[manual|smart|off]"
|
||||
assert SUBCOMMANDS["/approvals"] == ["manual", "smart", "off"]
|
||||
assert "approvals" in GATEWAY_KNOWN_COMMANDS
|
||||
assert any("/approvals" in line for line in gateway_help_lines())
|
||||
assert "approvals" in {name for name, _ in telegram_bot_commands()}
|
||||
assert _completions("/approvals ") == {"manual", "smart", "off"}
|
||||
|
||||
|
||||
def _isolate_config(monkeypatch, home):
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setenv("HERMES_MANAGED_DIR", str(home / "missing-managed"))
|
||||
from hermes_cli import managed_scope
|
||||
from hermes_cli.config import _LOAD_CONFIG_CACHE, _RAW_CONFIG_CACHE
|
||||
|
||||
_LOAD_CONFIG_CACHE.clear()
|
||||
_RAW_CONFIG_CACHE.clear()
|
||||
managed_scope.invalidate_managed_cache()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_shared_command_refuses_managed_mode_override(tmp_path, monkeypatch):
|
||||
from hermes_cli import managed_scope
|
||||
from hermes_cli.approval_mode import run_approval_mode_command
|
||||
|
||||
home = tmp_path / "home"
|
||||
managed = tmp_path / "managed"
|
||||
home.mkdir()
|
||||
managed.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setenv("HERMES_MANAGED_DIR", str(managed))
|
||||
(managed / "config.yaml").write_text("approvals:\n mode: manual\n", encoding="utf-8")
|
||||
managed_scope.invalidate_managed_cache()
|
||||
|
||||
result = run_approval_mode_command("off")
|
||||
|
||||
assert result.ok is False
|
||||
assert result.mode == "manual"
|
||||
assert result.changed is False
|
||||
assert "managed" in result.message.lower()
|
||||
assert not (home / "config.yaml").exists()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
"""Tests for ``hermes approvals suggest`` (hermes_cli/approvals_suggest.py).
|
||||
|
||||
Approval history in Hermes is implied, not ledgered: the session DB
|
||||
(state.db) stores every assistant ``terminal`` tool call plus its paired
|
||||
``role='tool'`` result. A dangerous-classified command whose result is not a
|
||||
BLOCKED/denied/pending marker ran with user consent. These tests build
|
||||
synthetic state.db fixtures and verify scanning, ranking, safety exclusions,
|
||||
and the --apply merge path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
from argparse import Namespace
|
||||
|
||||
import pytest
|
||||
|
||||
import tools.approval as approval_module
|
||||
from hermes_cli.approvals_suggest import (
|
||||
Proposal,
|
||||
apply_proposals,
|
||||
build_proposals,
|
||||
derive_glob,
|
||||
is_unsafe_class,
|
||||
normalize_command,
|
||||
parse_apply_indices,
|
||||
scan_approval_history,
|
||||
suggest_command,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixture helpers: synthetic session DB
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_db(path):
|
||||
con = sqlite3.connect(path)
|
||||
con.executescript(
|
||||
"""
|
||||
CREATE TABLE messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT,
|
||||
tool_call_id TEXT,
|
||||
tool_calls TEXT,
|
||||
timestamp REAL NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
con.commit()
|
||||
return con
|
||||
|
||||
|
||||
_ID_COUNTER = [0]
|
||||
|
||||
|
||||
def _add_terminal_call(con, command, result="ok: done", ts=None):
|
||||
"""Insert an assistant terminal tool call + its paired tool result."""
|
||||
_ID_COUNTER[0] += 1
|
||||
call_id = f"call_{_ID_COUNTER[0]}"
|
||||
ts = ts if ts is not None else time.time()
|
||||
tool_calls = json.dumps(
|
||||
[
|
||||
{
|
||||
"id": call_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "terminal",
|
||||
"arguments": json.dumps({"command": command}),
|
||||
},
|
||||
}
|
||||
]
|
||||
)
|
||||
con.execute(
|
||||
"INSERT INTO messages (session_id, role, content, tool_calls, timestamp) "
|
||||
"VALUES ('s1', 'assistant', '', ?, ?)",
|
||||
(tool_calls, ts),
|
||||
)
|
||||
con.execute(
|
||||
"INSERT INTO messages (session_id, role, content, tool_call_id, timestamp) "
|
||||
"VALUES ('s1', 'tool', ?, ?, ?)",
|
||||
(result, call_id, ts + 1),
|
||||
)
|
||||
con.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_path(tmp_path):
|
||||
path = tmp_path / "state.db"
|
||||
con = _make_db(path)
|
||||
yield path, con
|
||||
con.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_allowlist(monkeypatch):
|
||||
"""Fake config-backed allowlist store so no real config.yaml is touched."""
|
||||
store = {"patterns": set(), "saves": 0}
|
||||
|
||||
def fake_load():
|
||||
return set(store["patterns"])
|
||||
|
||||
def fake_save(patterns):
|
||||
store["patterns"] = set(patterns)
|
||||
store["saves"] += 1
|
||||
|
||||
monkeypatch.setattr(approval_module, "load_permanent_allowlist", fake_load)
|
||||
monkeypatch.setattr(approval_module, "save_permanent_allowlist", fake_save)
|
||||
# apply_proposals also syncs into the in-process set; keep it isolated.
|
||||
saved = approval_module._permanent_approved.copy()
|
||||
approval_module._permanent_approved.clear()
|
||||
yield store
|
||||
approval_module._permanent_approved.clear()
|
||||
approval_module._permanent_approved.update(saved)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scan
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestScan:
|
||||
def test_scan_finds_approved_dangerous_commands(self, db_path):
|
||||
path, con = db_path
|
||||
for _ in range(3):
|
||||
_add_terminal_call(con, "git push --force origin main")
|
||||
# A benign command never enters approval history.
|
||||
_add_terminal_call(con, "ls -la")
|
||||
records = scan_approval_history(path, days=0)
|
||||
commands = [c for c, _ in records]
|
||||
assert len(commands) == 3
|
||||
assert all("git push" in c for c in commands)
|
||||
|
||||
|
||||
def test_days_window_filters_old_history(self, db_path):
|
||||
path, con = db_path
|
||||
old_ts = time.time() - 200 * 86400
|
||||
_add_terminal_call(con, "git push --force origin main", ts=old_ts)
|
||||
_add_terminal_call(con, "git push --force origin main")
|
||||
assert len(scan_approval_history(path, days=90)) == 1
|
||||
assert len(scan_approval_history(path, days=0)) == 2
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Normalize / glob derivation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNormalizeAndGlob:
|
||||
def test_normalize_folds_home_prefix(self):
|
||||
import os
|
||||
|
||||
home = os.path.expanduser("~")
|
||||
normalized = normalize_command(f"git checkout -- {home}/project/file.txt")
|
||||
assert "~/project/file.txt" in normalized
|
||||
assert home not in normalized
|
||||
|
||||
def test_derive_glob_uses_first_two_tokens(self):
|
||||
assert derive_glob("git push --force origin main") == "git push *"
|
||||
assert derive_glob("docker restart web") == "docker restart *"
|
||||
|
||||
|
||||
def test_derive_glob_never_anchors_unsafe_binaries(self):
|
||||
assert derive_glob("rm -rf ./build") is None
|
||||
assert derive_glob("sudo apt install foo") is None
|
||||
assert derive_glob("/bin/rm -rf ./build") is None
|
||||
assert derive_glob("mkfs.ext4 /dev/sdb1") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ranking + safety exclusion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRankingAndSafety:
|
||||
def test_ranking_orders_by_frequency(self, db_path):
|
||||
path, con = db_path
|
||||
for _ in range(5):
|
||||
_add_terminal_call(con, "docker restart web")
|
||||
for _ in range(2):
|
||||
_add_terminal_call(con, "git push --force origin main")
|
||||
records = scan_approval_history(path, days=0)
|
||||
proposals = build_proposals(records, min_count=2)
|
||||
assert [p.pattern for p in proposals] == ["docker restart *", "git push *"]
|
||||
assert proposals[0].count == 5
|
||||
assert proposals[1].count == 2
|
||||
|
||||
def test_min_count_threshold(self, db_path):
|
||||
path, con = db_path
|
||||
_add_terminal_call(con, "git push --force origin main")
|
||||
records = scan_approval_history(path, days=0)
|
||||
assert build_proposals(records, min_count=2) == []
|
||||
assert len(build_proposals(records, min_count=1)) == 1
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --apply / dry-run
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _args(db, **kw):
|
||||
base = dict(
|
||||
db=str(db), days=0, min_count=1, limit=20,
|
||||
apply_indices=None, json=False,
|
||||
)
|
||||
base.update(kw)
|
||||
return Namespace(**base)
|
||||
|
||||
|
||||
class TestApply:
|
||||
|
||||
def test_apply_merges_and_persists(self, isolated_allowlist):
|
||||
isolated_allowlist["patterns"] = {"podman *"}
|
||||
proposals = [
|
||||
Proposal(pattern="git push *", kind="glob", count=5),
|
||||
Proposal(pattern="docker restart *", kind="glob", count=3),
|
||||
]
|
||||
merged = apply_proposals(proposals, [0])
|
||||
assert merged == {"podman *", "git push *"}
|
||||
assert isolated_allowlist["patterns"] == {"podman *", "git push *"}
|
||||
assert isolated_allowlist["saves"] == 1
|
||||
# In-process allowlist synced too (long-lived process consistency).
|
||||
assert "git push *" in approval_module._permanent_approved
|
||||
|
||||
def test_suggest_apply_end_to_end(self, db_path, isolated_allowlist, capsys):
|
||||
path, con = db_path
|
||||
for _ in range(4):
|
||||
_add_terminal_call(con, "git push --force origin main")
|
||||
for _ in range(3):
|
||||
_add_terminal_call(con, "docker restart web")
|
||||
rc = suggest_command(_args(path, apply_indices="1,2"))
|
||||
assert rc == 0
|
||||
assert isolated_allowlist["patterns"] == {"git push *", "docker restart *"}
|
||||
out = capsys.readouterr().out
|
||||
assert "git push *" in out and "docker restart *" in out
|
||||
|
||||
|
||||
|
||||
class TestJsonOutput:
|
||||
def test_json_proposal_output(self, db_path, isolated_allowlist, capsys):
|
||||
path, con = db_path
|
||||
for _ in range(4):
|
||||
_add_terminal_call(con, "git push --force origin main")
|
||||
rc = suggest_command(_args(path, json=True))
|
||||
assert rc == 0
|
||||
payload = json.loads(capsys.readouterr().out)
|
||||
assert payload["proposals"][0]["pattern"] == "git push *"
|
||||
assert payload["proposals"][0]["count"] == 4
|
||||
assert payload["proposals"][0]["n"] == 1
|
||||
assert isolated_allowlist["saves"] == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parser wiring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParserWiring:
|
||||
def test_build_approvals_parser_wires_suggest(self):
|
||||
import argparse
|
||||
|
||||
from hermes_cli.subcommands.approvals import build_approvals_parser
|
||||
|
||||
sentinel = object()
|
||||
parser = argparse.ArgumentParser()
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
build_approvals_parser(subparsers, cmd_approvals=lambda a: sentinel)
|
||||
|
||||
args = parser.parse_args(
|
||||
["approvals", "suggest", "--apply", "1,3", "--json",
|
||||
"--days", "30", "--min-count", "5", "--limit", "10"]
|
||||
)
|
||||
assert args.approvals_command == "suggest"
|
||||
assert args.apply_indices == "1,3"
|
||||
assert args.json is True
|
||||
assert args.days == 30
|
||||
assert args.min_count == 5
|
||||
assert args.limit == 10
|
||||
assert args.func(args) is sentinel
|
||||
|
||||
def test_suggest_defaults_are_dry_and_bounded(self):
|
||||
import argparse
|
||||
|
||||
from hermes_cli.subcommands.approvals import build_approvals_parser
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
build_approvals_parser(subparsers, cmd_approvals=lambda a: None)
|
||||
args = parser.parse_args(["approvals", "suggest"])
|
||||
assert args.apply_indices is None
|
||||
assert args.json is False
|
||||
assert args.days == 90
|
||||
assert args.min_count == 2
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Tests for ``hermes approvals test`` — dry-run approval verdict CLI.
|
||||
|
||||
The tester must compose the REAL runtime evaluators from ``tools.approval``
|
||||
(detect_hardline_command, _match_user_deny_rule, detect_dangerous_command,
|
||||
the container-skip gate, and the same ``_command_detection_variants``
|
||||
normalization/de-obfuscation path) — never reimplement them. It is strictly
|
||||
read-only: nothing is executed, no prompt fires, nothing is persisted.
|
||||
|
||||
Exit-code contract (script-friendly, documented in the CLI help):
|
||||
0 = allow, 2 = ask-approval, 3 = deny (hardline / user deny rule).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
import tools.approval as A
|
||||
from hermes_cli import approvals_test as at
|
||||
|
||||
|
||||
def _args(command, env_type="local", as_json=False):
|
||||
return argparse.Namespace(
|
||||
command_words=list(command) if isinstance(command, (list, tuple)) else [command],
|
||||
env_type=env_type,
|
||||
json=as_json,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_approvals(monkeypatch):
|
||||
"""Isolate the evaluators from the dev machine's real config/state."""
|
||||
monkeypatch.setattr(A, "_get_approval_config", lambda: {"mode": "manual"})
|
||||
monkeypatch.setattr(A, "_YOLO_MODE_FROZEN", False)
|
||||
monkeypatch.setattr(A, "is_current_session_yolo_enabled", lambda: False)
|
||||
monkeypatch.setattr(A, "load_permanent_allowlist", lambda: set())
|
||||
saved = set(A._permanent_approved)
|
||||
A._permanent_approved.clear()
|
||||
# The tester must NEVER prompt or persist — make any attempt explode.
|
||||
def _boom(*_a, **_kw): # pragma: no cover - failure path
|
||||
raise AssertionError("read-only tester touched a prompt/persistence path")
|
||||
monkeypatch.setattr(A, "prompt_dangerous_approval", _boom)
|
||||
monkeypatch.setattr(A, "save_permanent_allowlist", _boom)
|
||||
monkeypatch.setattr(A, "submit_pending", _boom, raising=False)
|
||||
yield A
|
||||
A._permanent_approved.clear()
|
||||
A._permanent_approved.update(saved)
|
||||
|
||||
|
||||
class TestVerdicts:
|
||||
def test_benign_command_allows_with_exit_0(self, isolated_approvals, capsys):
|
||||
rc = at.approvals_test_command(_args(["ls", "-la"]))
|
||||
out = capsys.readouterr().out
|
||||
assert rc == 0
|
||||
assert "allow" in out
|
||||
|
||||
def test_hardline_command_denies_with_rule_name(self, isolated_approvals, capsys):
|
||||
rc = at.approvals_test_command(_args(["sudo", "re" + "boot"]))
|
||||
out = capsys.readouterr().out
|
||||
assert rc == 3
|
||||
assert "hardline-deny" in out
|
||||
assert "system shutdown/reboot" in out
|
||||
|
||||
def test_dangerous_command_asks_with_exit_2(self, isolated_approvals, capsys):
|
||||
rc = at.approvals_test_command(_args(["rm", "-rf", "~/project/build"]))
|
||||
out = capsys.readouterr().out
|
||||
assert rc == 2
|
||||
assert "ask-approval" in out
|
||||
assert "recursive delete" in out
|
||||
|
||||
def test_user_deny_rule_from_config_honored(self, isolated_approvals, capsys,
|
||||
monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
A, "_get_approval_config",
|
||||
lambda: {"mode": "manual", "deny": ["git push *"]})
|
||||
rc = at.approvals_test_command(_args(["git", "push", "origin", "main"]))
|
||||
out = capsys.readouterr().out
|
||||
assert rc == 3
|
||||
assert "user-deny" in out
|
||||
assert "git push *" in out
|
||||
|
||||
def test_container_env_type_skips_guards_like_runtime(self, isolated_approvals,
|
||||
capsys):
|
||||
# Mirrors check_all_command_guards: isolated docker skips BEFORE the
|
||||
# hardline floor, so even a catastrophic command reports allow.
|
||||
rc = at.approvals_test_command(_args(["rm", "-rf", "/"], env_type="docker"))
|
||||
out = capsys.readouterr().out
|
||||
assert rc == 0
|
||||
assert "allow" in out
|
||||
assert "container" in out or "isolated" in out
|
||||
|
||||
def test_mode_off_bypasses_dangerous_but_not_hardline(self, isolated_approvals,
|
||||
capsys, monkeypatch):
|
||||
monkeypatch.setattr(A, "_get_approval_config", lambda: {"mode": "off"})
|
||||
rc = at.approvals_test_command(_args(["rm", "-rf", "~/project/build"]))
|
||||
out = capsys.readouterr().out
|
||||
assert rc == 0
|
||||
assert "off" in out
|
||||
rc = at.approvals_test_command(_args(["sudo", "re" + "boot"]))
|
||||
assert rc == 3
|
||||
|
||||
|
||||
class TestNormalizationParity:
|
||||
"""The tester must run the same de-obfuscation path as the runtime."""
|
||||
|
||||
def test_obfuscated_command_matches_plain_verdict(self, isolated_approvals,
|
||||
capsys):
|
||||
rc_plain = at.approvals_test_command(_args(["rm", "-rf", "/"]))
|
||||
out_plain = capsys.readouterr().out
|
||||
rc_obf = at.approvals_test_command(_args(["r\\m", "-rf", "/"]))
|
||||
out_obf = capsys.readouterr().out
|
||||
assert rc_plain == rc_obf == 3
|
||||
assert "recursive delete of root filesystem" in out_plain
|
||||
assert "recursive delete of root filesystem" in out_obf
|
||||
# The trace must show the de-obfuscated form the runtime evaluated.
|
||||
assert "rm -rf /" in out_obf
|
||||
|
||||
def test_normalized_trace_shown_when_command_normalizes(self,
|
||||
isolated_approvals,
|
||||
capsys):
|
||||
rc = at.approvals_test_command(_args(['git', 'st""atus']))
|
||||
out = capsys.readouterr().out
|
||||
assert rc == 0
|
||||
assert "git status" in out
|
||||
|
||||
def test_composes_real_runtime_detectors(self, isolated_approvals, capsys,
|
||||
monkeypatch):
|
||||
"""Prove the tester calls the real evaluators, not a reimplementation."""
|
||||
calls = {}
|
||||
|
||||
def _spy(name, real):
|
||||
def wrapper(c):
|
||||
calls[name] = c
|
||||
return real(c)
|
||||
return wrapper
|
||||
|
||||
monkeypatch.setattr(A, "detect_hardline_command",
|
||||
_spy("hardline", A.detect_hardline_command))
|
||||
monkeypatch.setattr(A, "detect_dangerous_command",
|
||||
_spy("dangerous", A.detect_dangerous_command))
|
||||
monkeypatch.setattr(A, "_match_user_deny_rule",
|
||||
_spy("deny", A._match_user_deny_rule))
|
||||
monkeypatch.setattr(A, "_command_detection_variants",
|
||||
_spy("variants", A._command_detection_variants))
|
||||
cmd = "rm -rf ~/project/build"
|
||||
at.approvals_test_command(_args(cmd.split()))
|
||||
capsys.readouterr()
|
||||
assert calls.get("hardline") == cmd
|
||||
assert calls.get("dangerous") == cmd
|
||||
assert calls.get("deny") == cmd
|
||||
assert calls.get("variants") == cmd
|
||||
|
||||
|
||||
class TestReadOnly:
|
||||
def test_nothing_executed(self, isolated_approvals, capsys, tmp_path):
|
||||
sentinel = tmp_path / "must_not_exist"
|
||||
rc = at.approvals_test_command(_args(["touch", str(sentinel)]))
|
||||
capsys.readouterr()
|
||||
assert rc == 0
|
||||
assert not sentinel.exists()
|
||||
|
||||
def test_dangerous_command_never_prompts_or_persists(self, isolated_approvals,
|
||||
capsys):
|
||||
# isolated_approvals wires prompt/persistence to AssertionError; a
|
||||
# dangerous command must complete without touching either.
|
||||
rc = at.approvals_test_command(_args(["rm", "-rf", "~/project/build"]))
|
||||
capsys.readouterr()
|
||||
assert rc == 2
|
||||
|
||||
|
||||
class TestOutputAndWiring:
|
||||
def test_json_output_is_machine_readable(self, isolated_approvals, capsys):
|
||||
rc = at.approvals_test_command(_args(["sudo", "re" + "boot"], as_json=True))
|
||||
payload = json.loads(capsys.readouterr().out)
|
||||
assert rc == 3
|
||||
assert payload["verdict"] == "hardline-deny"
|
||||
assert payload["exit_code"] == 3
|
||||
assert payload["rule"] == "system shutdown/reboot"
|
||||
assert payload["command"] == "sudo re" + "boot"
|
||||
assert isinstance(payload["normalized_variants"], list)
|
||||
|
||||
def test_empty_command_is_usage_error(self, isolated_approvals, capsys):
|
||||
rc = at.approvals_test_command(_args([]))
|
||||
assert rc == 1
|
||||
|
||||
def test_dispatcher_routes_test_subcommand(self, isolated_approvals, capsys):
|
||||
from hermes_cli.approvals_suggest import approvals_command
|
||||
args = _args(["ls"])
|
||||
args.approvals_command = "test"
|
||||
rc = approvals_command(args)
|
||||
out = capsys.readouterr().out
|
||||
assert rc == 0
|
||||
assert "allow" in out
|
||||
|
||||
def test_parser_wires_test_subcommand(self, isolated_approvals, capsys):
|
||||
from hermes_cli.subcommands.approvals import build_approvals_parser
|
||||
parser = argparse.ArgumentParser()
|
||||
sub = parser.add_subparsers()
|
||||
sentinel = []
|
||||
build_approvals_parser(sub, cmd_approvals=lambda a: sentinel.append(a) or 0)
|
||||
args = parser.parse_args(
|
||||
["approvals", "test", "--env-type", "ssh", "--", "ls", "-la"])
|
||||
assert args.approvals_command == "test"
|
||||
assert args.env_type == "ssh"
|
||||
# argparse REMAINDER keeps the leading "--"; the handler strips it.
|
||||
# dest is command_words (NOT command) so main.py's startup path can
|
||||
# keep reading args.command as the top-level subcommand name.
|
||||
assert args.command_words == ["--", "ls", "-la"]
|
||||
# The subparser must NOT claim the "command" dest — main.py's startup
|
||||
# path reads args.command as the top-level subcommand name.
|
||||
assert getattr(args, "command", None) != ["--", "ls", "-la"]
|
||||
args.func(args)
|
||||
assert sentinel
|
||||
|
||||
def test_leading_separator_stripped_from_command(self, isolated_approvals,
|
||||
capsys):
|
||||
rc = at.approvals_test_command(_args(["--", "ls", "-la"]))
|
||||
out = capsys.readouterr().out
|
||||
assert rc == 0
|
||||
assert "ls -la" in out
|
||||
assert "-- ls" not in out
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Tests for Arcee AI provider support — standard direct API provider."""
|
||||
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.auth import (
|
||||
PROVIDER_REGISTRY,
|
||||
resolve_provider,
|
||||
get_api_key_provider_status,
|
||||
resolve_api_key_provider_credentials,
|
||||
)
|
||||
|
||||
|
||||
_OTHER_PROVIDER_KEYS = (
|
||||
"OPENAI_API_KEY", "ANTHROPIC_API_KEY", "DEEPSEEK_API_KEY",
|
||||
"GOOGLE_API_KEY", "GEMINI_API_KEY", "DASHSCOPE_API_KEY",
|
||||
"XAI_API_KEY", "KIMI_API_KEY", "KIMI_CN_API_KEY",
|
||||
"MINIMAX_API_KEY", "MINIMAX_CN_API_KEY", "AI_GATEWAY_API_KEY",
|
||||
"KILOCODE_API_KEY", "HF_TOKEN", "GLM_API_KEY", "ZAI_API_KEY",
|
||||
"XIAOMI_API_KEY", "TOKENHUB_API_KEY", "COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Provider Registry
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestArceeProviderRegistry:
|
||||
def test_registered(self):
|
||||
assert "arcee" in PROVIDER_REGISTRY
|
||||
|
||||
|
||||
def test_inference_base_url(self):
|
||||
assert PROVIDER_REGISTRY["arcee"].inference_base_url == "https://api.arcee.ai/api/v1"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Aliases
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestArceeAliases:
|
||||
@pytest.mark.parametrize("alias", ["arcee", "arcee-ai", "arceeai"])
|
||||
def test_alias_resolves(self, alias, monkeypatch):
|
||||
for key in _OTHER_PROVIDER_KEYS + ("OPENROUTER_API_KEY",):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
monkeypatch.setenv("ARCEEAI_API_KEY", "arc-test-12345")
|
||||
assert resolve_provider(alias) == "arcee"
|
||||
|
||||
def test_normalize_provider_models_py(self):
|
||||
from hermes_cli.models import normalize_provider
|
||||
assert normalize_provider("arcee-ai") == "arcee"
|
||||
assert normalize_provider("arceeai") == "arcee"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Credentials
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestArceeCredentials:
|
||||
def test_status_configured(self, monkeypatch):
|
||||
monkeypatch.setenv("ARCEEAI_API_KEY", "arc-test")
|
||||
status = get_api_key_provider_status("arcee")
|
||||
assert status["configured"]
|
||||
|
||||
|
||||
def test_openrouter_key_does_not_make_arcee_configured(self, monkeypatch):
|
||||
"""OpenRouter users should NOT see arcee as configured."""
|
||||
monkeypatch.delenv("ARCEEAI_API_KEY", raising=False)
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test")
|
||||
status = get_api_key_provider_status("arcee")
|
||||
assert not status["configured"]
|
||||
|
||||
def test_resolve_credentials(self, monkeypatch):
|
||||
monkeypatch.setenv("ARCEEAI_API_KEY", "arc-direct-key")
|
||||
monkeypatch.delenv("ARCEE_BASE_URL", raising=False)
|
||||
creds = resolve_api_key_provider_credentials("arcee")
|
||||
assert creds["api_key"] == "arc-direct-key"
|
||||
assert creds["base_url"] == "https://api.arcee.ai/api/v1"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Model catalog
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestArceeModelCatalog:
|
||||
def test_static_model_list(self):
|
||||
"""Arcee has a static _PROVIDER_MODELS catalog entry. Specific model
|
||||
names change with releases and don't belong in tests.
|
||||
"""
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
assert "arcee" in _PROVIDER_MODELS
|
||||
assert len(_PROVIDER_MODELS["arcee"]) >= 1
|
||||
|
||||
def test_canonical_provider_entry(self):
|
||||
from hermes_cli.models import CANONICAL_PROVIDERS
|
||||
slugs = [p.slug for p in CANONICAL_PROVIDERS]
|
||||
assert "arcee" in slugs
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Model normalization
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestArceeNormalization:
|
||||
def test_in_matching_prefix_strip_set(self):
|
||||
from hermes_cli.model_normalize import _MATCHING_PREFIX_STRIP_PROVIDERS
|
||||
assert "arcee" in _MATCHING_PREFIX_STRIP_PROVIDERS
|
||||
|
||||
|
||||
def test_bare_name_unchanged(self):
|
||||
from hermes_cli.model_normalize import normalize_model_for_provider
|
||||
assert normalize_model_for_provider("trinity-mini", "arcee") == "trinity-mini"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# URL mapping
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestArceeURLMapping:
|
||||
|
||||
def test_provider_prefixes(self):
|
||||
from agent.model_metadata import _PROVIDER_PREFIXES
|
||||
assert "arcee" in _PROVIDER_PREFIXES
|
||||
assert "arcee-ai" in _PROVIDER_PREFIXES
|
||||
assert "arceeai" in _PROVIDER_PREFIXES
|
||||
|
||||
def test_trajectory_compressor_detects_arcee(self):
|
||||
import trajectory_compressor as tc
|
||||
comp = tc.TrajectoryCompressor.__new__(tc.TrajectoryCompressor)
|
||||
comp.config = types.SimpleNamespace(base_url="https://api.arcee.ai/api/v1")
|
||||
assert comp._detect_provider() == "arcee"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# providers.py overlay + aliases
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestArceeProvidersModule:
|
||||
def test_overlay_exists(self):
|
||||
from hermes_cli.providers import HERMES_OVERLAYS
|
||||
assert "arcee" in HERMES_OVERLAYS
|
||||
overlay = HERMES_OVERLAYS["arcee"]
|
||||
assert overlay.transport == "openai_chat"
|
||||
assert overlay.base_url_env_var == "ARCEE_BASE_URL"
|
||||
assert not overlay.is_aggregator
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Auxiliary client — main-model-first design
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Tests for the shared tar.gz writer (``hermes_cli.archive_safe.make_targz``).
|
||||
|
||||
``make_targz`` backs both ``hermes profile export`` and ``hermes kanban
|
||||
export``. The contract pinned down here: a failure partway through writing
|
||||
the archive (disk full, permission loss, interruption) must never destroy a
|
||||
pre-existing file at the destination path — the same failure-atomicity
|
||||
guarantee the desktop gateway-download path already has.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_WORKTREE = Path(__file__).resolve().parents[2]
|
||||
if str(_WORKTREE) not in sys.path:
|
||||
sys.path.insert(0, str(_WORKTREE))
|
||||
|
||||
from hermes_cli.archive_safe import make_targz
|
||||
|
||||
|
||||
def _stage_source(tmp_path: Path) -> None:
|
||||
payload = tmp_path / "src" / "inner"
|
||||
payload.mkdir(parents=True)
|
||||
(payload / "file.txt").write_text("hello")
|
||||
|
||||
|
||||
def test_make_targz_preserves_existing_file_on_mid_write_failure(tmp_path, monkeypatch):
|
||||
_stage_source(tmp_path)
|
||||
base = str(tmp_path / "out")
|
||||
archive_path = Path(f"{base}.tar.gz")
|
||||
sentinel = b"PRE-EXISTING ARCHIVE THAT MUST SURVIVE A FAILED RE-EXPORT"
|
||||
archive_path.write_bytes(sentinel)
|
||||
|
||||
def _boom(self, *a, **k):
|
||||
raise RuntimeError("simulated failure mid-add (disk full / permission loss)")
|
||||
|
||||
monkeypatch.setattr(tarfile.TarFile, "add", _boom)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
make_targz(base, str(tmp_path), "src")
|
||||
|
||||
assert archive_path.read_bytes() == sentinel, (
|
||||
"a mid-write failure must not truncate/replace a pre-existing archive"
|
||||
)
|
||||
leftovers = [p for p in tmp_path.iterdir() if p.name.startswith(".archive_")]
|
||||
assert leftovers == [], f"temp file was not cleaned up on failure: {leftovers}"
|
||||
|
||||
|
||||
def test_make_targz_round_trips_content(tmp_path):
|
||||
_stage_source(tmp_path)
|
||||
base = str(tmp_path / "out")
|
||||
|
||||
result = make_targz(base, str(tmp_path), "src")
|
||||
|
||||
assert result == f"{base}.tar.gz"
|
||||
with tarfile.open(result, "r:gz") as tf:
|
||||
names = sorted(m.name for m in tf.getmembers())
|
||||
assert "src/inner/file.txt" in names
|
||||
member = tf.extractfile("src/inner/file.txt")
|
||||
assert member is not None
|
||||
assert member.read() == b"hello"
|
||||
|
||||
|
||||
def test_make_targz_overwrites_existing_file_on_success(tmp_path):
|
||||
_stage_source(tmp_path)
|
||||
base = str(tmp_path / "out")
|
||||
archive_path = Path(f"{base}.tar.gz")
|
||||
archive_path.write_bytes(b"stale archive from a previous export")
|
||||
|
||||
make_targz(base, str(tmp_path), "src")
|
||||
|
||||
with tarfile.open(archive_path, "r:gz") as tf:
|
||||
assert "src/inner/file.txt" in {m.name for m in tf.getmembers()}
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Tests for parent→subparser flag propagation.
|
||||
|
||||
When flags like --yolo, -w, -s exist on both the parent parser and the 'chat'
|
||||
subparser, placing the flag BEFORE the subcommand (e.g. 'hermes --yolo chat')
|
||||
must not silently drop the flag value.
|
||||
|
||||
Regression test for: argparse subparser default=False overwriting parent's
|
||||
parsed True when the same argument is defined on both parsers.
|
||||
|
||||
Fix: chat subparser uses default=argparse.SUPPRESS for all duplicated flags,
|
||||
so the subparser only sets the attribute when the user explicitly provides it.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _build_parser():
|
||||
"""Build the hermes argument parser from the real code.
|
||||
|
||||
We import the real main() and extract the parser it builds.
|
||||
Since main() is a large function that does much more than parse args,
|
||||
we replicate just the parser structure here to avoid side effects.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(prog="hermes")
|
||||
parser.add_argument("--resume", "-r", metavar="SESSION", default=None)
|
||||
parser.add_argument(
|
||||
"--continue", "-c", dest="continue_last", nargs="?",
|
||||
const=True, default=None, metavar="SESSION_NAME",
|
||||
)
|
||||
parser.add_argument("--worktree", "-w", action="store_true", default=False)
|
||||
parser.add_argument("--skills", "-s", action="append", default=None)
|
||||
parser.add_argument("--yolo", action="store_true", default=False)
|
||||
parser.add_argument("--pass-session-id", action="store_true", default=False)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
chat = subparsers.add_parser("chat")
|
||||
# These MUST use argparse.SUPPRESS to avoid overwriting parent values
|
||||
chat.add_argument("--yolo", action="store_true",
|
||||
default=argparse.SUPPRESS)
|
||||
chat.add_argument("--worktree", "-w", action="store_true",
|
||||
default=argparse.SUPPRESS)
|
||||
chat.add_argument("--skills", "-s", action="append",
|
||||
default=argparse.SUPPRESS)
|
||||
chat.add_argument("--pass-session-id", action="store_true",
|
||||
default=argparse.SUPPRESS)
|
||||
chat.add_argument("--resume", "-r", metavar="SESSION_ID",
|
||||
default=argparse.SUPPRESS)
|
||||
chat.add_argument(
|
||||
"--continue", "-c", dest="continue_last", nargs="?",
|
||||
const=True, default=argparse.SUPPRESS, metavar="SESSION_NAME",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
class TestChatVerboseArg:
|
||||
"""Verify chat --verbose preserves config fallback when absent."""
|
||||
|
||||
def test_chat_without_verbose_leaves_attribute_unset(self):
|
||||
from hermes_cli._parser import build_top_level_parser
|
||||
|
||||
parser, _subparsers, _chat_parser = build_top_level_parser()
|
||||
args = parser.parse_args(["chat"])
|
||||
|
||||
assert not hasattr(args, "verbose")
|
||||
|
||||
|
||||
def test_cmd_chat_forwards_none_when_verbose_is_absent(self, monkeypatch):
|
||||
import types
|
||||
import sys
|
||||
|
||||
import hermes_cli.main as main_mod
|
||||
from hermes_cli._parser import build_top_level_parser
|
||||
|
||||
parser, _subparsers, chat_parser = build_top_level_parser()
|
||||
chat_parser.set_defaults(func=main_mod.cmd_chat)
|
||||
args = parser.parse_args(["chat"])
|
||||
captured = {}
|
||||
fake_cli = types.ModuleType("cli")
|
||||
|
||||
def fake_main(**kwargs):
|
||||
captured.update(kwargs)
|
||||
|
||||
setattr(fake_cli, "main", fake_main)
|
||||
fake_banner = types.ModuleType("hermes_cli.banner")
|
||||
setattr(fake_banner, "prefetch_update_check", lambda: None)
|
||||
fake_skills_sync = types.ModuleType("tools.skills_sync")
|
||||
setattr(fake_skills_sync, "sync_skills", lambda quiet=True: None)
|
||||
|
||||
monkeypatch.setitem(sys.modules, "cli", fake_cli)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli.banner", fake_banner)
|
||||
monkeypatch.setitem(sys.modules, "tools.skills_sync", fake_skills_sync)
|
||||
monkeypatch.setattr(main_mod, "_has_any_provider_configured", lambda: True)
|
||||
monkeypatch.setattr(main_mod, "_pin_kanban_board_env", lambda: None)
|
||||
|
||||
main_mod.cmd_chat(args)
|
||||
|
||||
assert captured["quiet"] is False
|
||||
assert "verbose" not in captured
|
||||
|
||||
|
||||
class TestYoloEnvVar:
|
||||
"""Verify --yolo sets HERMES_YOLO_MODE regardless of flag position.
|
||||
|
||||
This tests the actual cmd_chat logic pattern (getattr → os.environ).
|
||||
"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_env(self):
|
||||
os.environ.pop("HERMES_YOLO_MODE", None)
|
||||
yield
|
||||
os.environ.pop("HERMES_YOLO_MODE", None)
|
||||
|
||||
def _simulate_cmd_chat_yolo_check(self, args):
|
||||
"""Replicate the exact check from cmd_chat in main.py."""
|
||||
if getattr(args, "yolo", False):
|
||||
os.environ["HERMES_YOLO_MODE"] = "1"
|
||||
|
||||
def test_yolo_before_chat_sets_env(self):
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["--yolo", "chat"])
|
||||
self._simulate_cmd_chat_yolo_check(args)
|
||||
assert os.environ.get("HERMES_YOLO_MODE") == "1"
|
||||
|
||||
|
||||
class TestAcceptHooksOnAgentSubparsers:
|
||||
"""Verify --accept-hooks is accepted at every agent-subcommand
|
||||
position (before the subcommand, between group/subcommand, and
|
||||
after the leaf subcommand) for gateway/cron/mcp/acp. Regression
|
||||
against prior behaviour where the flag only worked on the root
|
||||
parser and `chat`, so `hermes gateway run --accept-hooks` failed
|
||||
with `unrecognized arguments`."""
|
||||
|
||||
ARGVS = [
|
||||
["--accept-hooks", "gateway", "run", "--help"],
|
||||
["gateway", "--accept-hooks", "run", "--help"],
|
||||
["gateway", "run", "--accept-hooks", "--help"],
|
||||
["--accept-hooks", "cron", "tick", "--help"],
|
||||
["cron", "--accept-hooks", "tick", "--help"],
|
||||
["cron", "tick", "--accept-hooks", "--help"],
|
||||
["cron", "run", "--accept-hooks", "dummy-id", "--help"],
|
||||
["--accept-hooks", "mcp", "serve", "--help"],
|
||||
["mcp", "--accept-hooks", "serve", "--help"],
|
||||
["mcp", "serve", "--accept-hooks", "--help"],
|
||||
["acp", "--accept-hooks", "--help"],
|
||||
]
|
||||
|
||||
# One driver subprocess parses ALL argvs: hermes_cli.main is a very heavy
|
||||
# import (previously 11 separate `python -m hermes_cli.main` spawns with a
|
||||
# 15s timeout each — a cold import on a loaded CI worker regularly blew
|
||||
# that deadline, making this test flaky). Importing once and parsing 11
|
||||
# times removes the repeated-import cost entirely; the generous timeout
|
||||
# only trips on a genuine hang. `--help` exits via SystemExit(0), which
|
||||
# the driver catches per argv.
|
||||
_DRIVER = r"""
|
||||
import io, json, sys
|
||||
from contextlib import redirect_stdout, redirect_stderr
|
||||
|
||||
import hermes_cli.main as main_mod
|
||||
|
||||
argvs = json.loads(sys.argv[1])
|
||||
results = []
|
||||
for argv in argvs:
|
||||
sys.argv = ["hermes", *argv]
|
||||
out, err = io.StringIO(), io.StringIO()
|
||||
code = 0
|
||||
try:
|
||||
with redirect_stdout(out), redirect_stderr(err):
|
||||
main_mod.main()
|
||||
except SystemExit as exc:
|
||||
code = int(exc.code or 0)
|
||||
except Exception as exc: # noqa: BLE001 - report, don't crash the driver
|
||||
code = -1
|
||||
err.write(repr(exc))
|
||||
results.append({"argv": argv, "code": code, "stderr": err.getvalue()[:300]})
|
||||
print(json.dumps(results))
|
||||
"""
|
||||
|
||||
def test_accepted_at_every_position(self):
|
||||
"""Every `hermes <argv>` must exit 0 (help) rather than failing
|
||||
with `unrecognized arguments`."""
|
||||
import json
|
||||
import subprocess
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", self._DRIVER, json.dumps(self.ARGVS)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=180,
|
||||
)
|
||||
assert result.returncode == 0, (
|
||||
f"driver failed rc={result.returncode}\n"
|
||||
f"stdout: {result.stdout[:500]}\nstderr: {result.stderr[:500]}"
|
||||
)
|
||||
for entry in json.loads(result.stdout.strip().splitlines()[-1]):
|
||||
assert entry["code"] == 0, (
|
||||
f"argv={entry['argv']!r} returned {entry['code']}\n"
|
||||
f"stderr: {entry['stderr']}"
|
||||
)
|
||||
assert "unrecognized arguments" not in entry["stderr"]
|
||||
|
||||
|
||||
class TestChatSubparserInheritedValueFlags:
|
||||
"""Verify -t/--toolsets, -m/--model and --provider survive parent→chat
|
||||
subparser dispatch.
|
||||
|
||||
Regression test for #28780: `hermes -t web chat` silently dropped the
|
||||
toolset because the chat subparser re-declared `-t/--toolsets` with
|
||||
`default=None`, which clobbered the top-level parser's value during
|
||||
subparser dispatch.
|
||||
|
||||
Uses the real `hermes_cli._parser.build_top_level_parser()` rather than
|
||||
the hand-rolled replica above so this also fails if the production
|
||||
parser drifts back to `default=None` on these flags.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def real_parser(self):
|
||||
from hermes_cli._parser import build_top_level_parser
|
||||
parser, _subparsers, _chat = build_top_level_parser()
|
||||
return parser
|
||||
|
||||
|
||||
|
||||
|
||||
def test_all_three_flags_before_chat(self, real_parser):
|
||||
"""Issue #28780 reporter's case generalized: passing every inherited
|
||||
value flag before `chat` must preserve all of them simultaneously."""
|
||||
args, _ = real_parser.parse_known_args([
|
||||
"-t", "web",
|
||||
"-m", "anthropic/claude-sonnet-4",
|
||||
"--provider", "openrouter",
|
||||
"chat",
|
||||
])
|
||||
assert args.toolsets == "web"
|
||||
assert args.model == "anthropic/claude-sonnet-4"
|
||||
assert args.provider == "openrouter"
|
||||
|
||||
|
||||
def test_chat_subparser_inherited_value_flags_use_suppress(self):
|
||||
"""Contract test for the underlying invariant.
|
||||
|
||||
Any chat-subparser flag whose `dest` also exists on the top-level
|
||||
parser MUST declare `default=argparse.SUPPRESS`, otherwise the
|
||||
subparser silently overwrites the top-level value with its own
|
||||
default during dispatch. This is the structural class behind #28780.
|
||||
"""
|
||||
from hermes_cli._parser import build_top_level_parser
|
||||
parser, _subparsers, chat_parser = build_top_level_parser()
|
||||
|
||||
top_level_dests = {
|
||||
a.dest for a in parser._actions
|
||||
if a.option_strings and a.dest != "help"
|
||||
}
|
||||
|
||||
offenders = []
|
||||
for action in chat_parser._actions:
|
||||
if not action.option_strings or action.dest == "help":
|
||||
continue
|
||||
if action.dest not in top_level_dests:
|
||||
continue
|
||||
if action.default is not argparse.SUPPRESS:
|
||||
offenders.append((action.option_strings, action.dest, action.default))
|
||||
|
||||
assert not offenders, (
|
||||
"Chat subparser redeclares these top-level flags without "
|
||||
"default=argparse.SUPPRESS; they will silently clobber the "
|
||||
"top-level value when used as `hermes <flag> <value> chat`:\n "
|
||||
+ "\n ".join(f"{opts} dest={dest} default={d!r}"
|
||||
for opts, dest, d in offenders)
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Regression test: `@folder:` completion must only surface directories and
|
||||
`@file:` must only surface regular files.
|
||||
|
||||
Reported during TUI v2 blitz testing: typing `@folder:` showed .dockerignore,
|
||||
.env, .gitignore, etc. alongside the actual directories because the path-
|
||||
completion branch yielded every entry regardless of the explicit prefix, and
|
||||
auto-switched the completion kind based on `is_dir`. That defeated the user's
|
||||
explicit choice and rendered the `@folder:` / `@file:` prefixes useless for
|
||||
filtering.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
from hermes_cli.commands import SlashCommandCompleter
|
||||
|
||||
|
||||
def _run(tmp_path: Path, word: str) -> list[tuple[str, str]]:
|
||||
(tmp_path / "readme.md").write_text("x")
|
||||
(tmp_path / ".env").write_text("x")
|
||||
(tmp_path / "src").mkdir()
|
||||
(tmp_path / "docs").mkdir()
|
||||
|
||||
completer = SlashCommandCompleter.__new__(SlashCommandCompleter)
|
||||
completions: Iterable = completer._context_completions(word)
|
||||
|
||||
return [(c.text, c.display_meta) for c in completions if c.text.startswith(("@file:", "@folder:"))]
|
||||
|
||||
|
||||
def test_at_folder_only_yields_directories(tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
texts = [t for t, _ in _run(tmp_path, "@folder:")]
|
||||
|
||||
assert all(t.startswith("@folder:") for t in texts), texts
|
||||
assert any(t == "@folder:src/" for t in texts)
|
||||
assert any(t == "@folder:docs/" for t in texts)
|
||||
assert not any(t == "@folder:readme.md" for t in texts)
|
||||
assert not any(t == "@folder:.env" for t in texts)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_at_file_bare_without_colon_lists_files(tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
texts = [t for t, _ in _run(tmp_path, "@file")]
|
||||
|
||||
assert any(t == "@file:readme.md" for t in texts), texts
|
||||
assert not any(t == "@file:src/" for t in texts)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Tests for utils.atomic_json_write — crash-safe JSON file writes."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from utils import atomic_json_write
|
||||
|
||||
|
||||
class TestAtomicJsonWrite:
|
||||
"""Core atomic write behavior."""
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_cleans_up_temp_file_on_baseexception(self, tmp_path):
|
||||
class SimulatedAbort(BaseException):
|
||||
pass
|
||||
|
||||
target = tmp_path / "data.json"
|
||||
original = {"preserved": True}
|
||||
target.write_text(json.dumps(original), encoding="utf-8")
|
||||
|
||||
with patch("utils.json.dump", side_effect=SimulatedAbort):
|
||||
with pytest.raises(SimulatedAbort):
|
||||
atomic_json_write(target, {"new": True})
|
||||
|
||||
tmp_files = [f for f in tmp_path.iterdir() if ".tmp" in f.name]
|
||||
assert len(tmp_files) == 0
|
||||
assert json.loads(target.read_text(encoding="utf-8")) == original
|
||||
|
||||
|
||||
|
||||
|
||||
def test_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_concurrent_writes_dont_corrupt(self, tmp_path):
|
||||
"""Multiple rapid writes should each produce valid JSON."""
|
||||
import threading
|
||||
|
||||
target = tmp_path / "concurrent.json"
|
||||
errors = []
|
||||
|
||||
def writer(n):
|
||||
try:
|
||||
atomic_json_write(target, {"writer": n, "data": list(range(100))})
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
threads = [threading.Thread(target=writer, args=(i,)) for i in range(10)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert not errors
|
||||
# File should contain valid JSON from one of the writers
|
||||
result = json.loads(target.read_text())
|
||||
assert "writer" in result
|
||||
assert len(result["data"]) == 100
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Tests for utils.atomic_yaml_write — crash-safe YAML file writes."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from utils import atomic_yaml_write
|
||||
|
||||
|
||||
class TestAtomicYamlWrite:
|
||||
def test_writes_valid_yaml(self, tmp_path):
|
||||
target = tmp_path / "data.yaml"
|
||||
data = {"key": "value", "nested": {"a": 1}}
|
||||
|
||||
atomic_yaml_write(target, data)
|
||||
|
||||
assert yaml.safe_load(target.read_text(encoding="utf-8")) == data
|
||||
|
||||
def test_cleans_up_temp_file_on_baseexception(self, tmp_path):
|
||||
class SimulatedAbort(BaseException):
|
||||
pass
|
||||
|
||||
target = tmp_path / "data.yaml"
|
||||
original = {"preserved": True}
|
||||
target.write_text(yaml.safe_dump(original), encoding="utf-8")
|
||||
|
||||
with patch("utils.yaml.dump", side_effect=SimulatedAbort):
|
||||
with pytest.raises(SimulatedAbort):
|
||||
atomic_yaml_write(target, {"new": True})
|
||||
|
||||
tmp_files = [f for f in tmp_path.iterdir() if ".tmp" in f.name]
|
||||
assert len(tmp_files) == 0
|
||||
assert yaml.safe_load(target.read_text(encoding="utf-8")) == original
|
||||
|
||||
|
||||
def test_writes_unicode_unescaped_and_round_trips(self, tmp_path):
|
||||
"""Emoji/kaomoji are written as real UTF-8, not fragile escape sequences.
|
||||
|
||||
Regression for GitHub #51356: without allow_unicode=True, PyYAML emitted
|
||||
astral-plane chars (emoji) as 8-digit `\\UXXXXXXXX` escapes inside
|
||||
multi-line double-quoted strings wrapped with `\\` continuations, which
|
||||
stricter/non-PyYAML parsers and hand-edits broke into unclosed quotes,
|
||||
corrupting the entire config.
|
||||
"""
|
||||
target = tmp_path / "config.yaml"
|
||||
# Mirrors the default personalities + skin cursor shipped in cli.py.
|
||||
data = {
|
||||
"personalities": {
|
||||
"kawaii": "kawaii desu~! (◕‿◕) ★ ♪ ヽ(>∀<☆)ノ",
|
||||
"catgirl": "nya~! (=^・ω・^=) ฅ^•ﻌ•^ฅ",
|
||||
"surfer": "Cowabunga! 🤙 totally rad bro",
|
||||
"hype": "LET'S GOOOO!!! 🔥 LEGENDARY!",
|
||||
},
|
||||
"display": {"cursor": " ▉"},
|
||||
}
|
||||
|
||||
atomic_yaml_write(target, data)
|
||||
|
||||
text = target.read_text(encoding="utf-8")
|
||||
# No escape artifacts of any kind — real characters on disk.
|
||||
assert "\\U" not in text
|
||||
assert "\\u" not in text
|
||||
# Real glyphs are present verbatim.
|
||||
assert "🔥" in text
|
||||
assert "(=^・ω・^=)" in text
|
||||
# And it reloads to exactly what was written.
|
||||
assert yaml.safe_load(text) == data
|
||||
@@ -0,0 +1,613 @@
|
||||
"""Tests for Codex auth — tokens stored in Hermes auth store (~/.hermes/auth.json)."""
|
||||
|
||||
import json
|
||||
import time
|
||||
import base64
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.auth import (
|
||||
AuthError,
|
||||
DEFAULT_CODEX_BASE_URL,
|
||||
PROVIDER_REGISTRY,
|
||||
_read_codex_tokens,
|
||||
_save_codex_tokens,
|
||||
_import_codex_cli_tokens,
|
||||
_login_openai_codex,
|
||||
refresh_codex_oauth_pure,
|
||||
resolve_codex_runtime_credentials,
|
||||
resolve_provider,
|
||||
)
|
||||
|
||||
|
||||
def _setup_hermes_auth(hermes_home: Path, *, access_token: str = "access", refresh_token: str = "refresh"):
|
||||
"""Write Codex tokens into the Hermes auth store."""
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
auth_store = {
|
||||
"version": 1,
|
||||
"active_provider": "openai-codex",
|
||||
"providers": {
|
||||
"openai-codex": {
|
||||
"tokens": {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
},
|
||||
"last_refresh": "2026-02-26T00:00:00Z",
|
||||
"auth_mode": "chatgpt",
|
||||
},
|
||||
},
|
||||
}
|
||||
auth_file = hermes_home / "auth.json"
|
||||
auth_file.write_text(json.dumps(auth_store, indent=2))
|
||||
return auth_file
|
||||
|
||||
|
||||
def _jwt_with_exp(exp_epoch: int) -> str:
|
||||
payload = {"exp": exp_epoch}
|
||||
encoded = base64.urlsafe_b64encode(json.dumps(payload).encode("utf-8")).rstrip(b"=").decode("utf-8")
|
||||
return f"h.{encoded}.s"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_resolve_codex_runtime_credentials_missing_access_token(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / "hermes"
|
||||
_setup_hermes_auth(hermes_home, access_token="")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "missing-codex"))
|
||||
|
||||
with pytest.raises(AuthError) as exc:
|
||||
resolve_codex_runtime_credentials()
|
||||
assert exc.value.code == "codex_auth_missing_access_token"
|
||||
assert exc.value.relogin_required is True
|
||||
|
||||
|
||||
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_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_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_codex_tokens_not_written_to_shared_file(tmp_path, monkeypatch):
|
||||
"""Verify _save_codex_tokens writes only to Hermes auth store, not ~/.codex/."""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
codex_home = tmp_path / "codex-cli"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
codex_home.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
(hermes_home / "auth.json").write_text(json.dumps({"version": 1, "providers": {}}))
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setenv("CODEX_HOME", str(codex_home))
|
||||
|
||||
_save_codex_tokens({"access_token": "hermes-at", "refresh_token": "hermes-rt"})
|
||||
|
||||
# ~/.codex/auth.json should NOT exist — _save_codex_tokens only touches Hermes store
|
||||
assert not (codex_home / "auth.json").exists()
|
||||
|
||||
# Hermes auth store should have the tokens
|
||||
data = _read_codex_tokens()
|
||||
assert data["tokens"]["access_token"] == "hermes-at"
|
||||
|
||||
|
||||
def test_resolve_returns_hermes_auth_store_source(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / "hermes"
|
||||
_setup_hermes_auth(hermes_home)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
creds = resolve_codex_runtime_credentials()
|
||||
assert creds["source"] == "hermes-auth-store"
|
||||
assert creds["provider"] == "openai-codex"
|
||||
assert creds["base_url"] == DEFAULT_CODEX_BASE_URL
|
||||
|
||||
|
||||
class _StubHTTPResponse:
|
||||
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):
|
||||
if isinstance(self._payload, Exception):
|
||||
raise self._payload
|
||||
return self._payload
|
||||
|
||||
|
||||
class _StubHTTPClient:
|
||||
def __init__(self, response):
|
||||
self._response = response
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def post(self, *args, **kwargs):
|
||||
return self._response
|
||||
|
||||
|
||||
def _patch_httpx(monkeypatch, response):
|
||||
def _factory(*args, **kwargs):
|
||||
return _StubHTTPClient(response)
|
||||
|
||||
monkeypatch.setattr("hermes_cli.auth.httpx.Client", _factory)
|
||||
|
||||
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
||||
|
||||
class _FakeResp:
|
||||
def __init__(self, status_code, json_data=None, headers=None):
|
||||
self.status_code = status_code
|
||||
self._json = json_data or {}
|
||||
self.headers = headers or {}
|
||||
|
||||
def json(self):
|
||||
return self._json
|
||||
|
||||
|
||||
def _patch_httpx_post(monkeypatch, responses):
|
||||
"""Patch hermes_cli.auth.httpx.Client so .post() returns queued responses."""
|
||||
seq = iter(responses)
|
||||
|
||||
class _FakeClient:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def post(self, *args, **kwargs):
|
||||
return next(seq)
|
||||
|
||||
monkeypatch.setattr("hermes_cli.auth.httpx.Client", lambda *a, **k: _FakeClient())
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
"""Tests for the Codex upstream-quota-restored probe and cooldown clearing.
|
||||
|
||||
Covers issue #43747 (externally-reset variant): Codex 429s persist a
|
||||
``last_error_reset_at`` that can be days in the future, but the upstream
|
||||
window can reopen early (banked reset redeemed, plan upgrade, upstream
|
||||
reset). Hermes must detect that and lift the stale local cooldown instead
|
||||
of refusing requests until re-auth.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_cli.auth as auth_mod
|
||||
from hermes_cli.auth import (
|
||||
AuthError,
|
||||
_codex_usage_probe_url,
|
||||
_is_codex_rate_limit_shaped,
|
||||
_probe_codex_quota_restored,
|
||||
clear_codex_pool_quota_cooldowns,
|
||||
resolve_codex_runtime_credentials,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_probe_cache():
|
||||
auth_mod._codex_quota_probe_cache.clear()
|
||||
yield
|
||||
auth_mod._codex_quota_probe_cache.clear()
|
||||
|
||||
|
||||
def _jwt(claims: dict) -> str:
|
||||
def _part(payload: dict) -> str:
|
||||
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||
|
||||
return f"{_part({'alg': 'none'})}.{_part(claims)}.sig"
|
||||
|
||||
|
||||
class _StubResponse:
|
||||
def __init__(self, status_code: int, payload: dict):
|
||||
self.status_code = status_code
|
||||
self._payload = payload
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
def raise_for_status(self):
|
||||
if self.status_code >= 400:
|
||||
import httpx
|
||||
|
||||
raise httpx.HTTPStatusError(
|
||||
f"HTTP {self.status_code}", request=None, response=self # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
class _StubClient:
|
||||
def __init__(self, calls, response):
|
||||
self._calls = calls
|
||||
self._response = response
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def get(self, url, headers=None):
|
||||
self._calls.append({"url": url, "headers": dict(headers or {})})
|
||||
return self._response
|
||||
|
||||
|
||||
def _patch_httpx(monkeypatch, response, calls=None):
|
||||
calls = calls if calls is not None else []
|
||||
monkeypatch.setattr(
|
||||
auth_mod.httpx, "Client", lambda **kwargs: _StubClient(calls, response)
|
||||
)
|
||||
return calls
|
||||
|
||||
|
||||
def _usage_payload(primary_used: float, secondary_used: float) -> dict:
|
||||
return {
|
||||
"rate_limit": {
|
||||
"primary_window": {"used_percent": primary_used},
|
||||
"secondary_window": {"used_percent": secondary_used},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _is_codex_rate_limit_shaped
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _codex_usage_probe_url
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _probe_codex_quota_restored
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
def test_probe_sends_chatgpt_account_id_from_jwt(monkeypatch):
|
||||
calls = _patch_httpx(monkeypatch, _StubResponse(200, _usage_payload(0.0, 0.0)))
|
||||
token = _jwt(
|
||||
{
|
||||
"exp": time.time() + 3600,
|
||||
"https://api.openai.com/auth": {"chatgpt_account_id": "acct-123"},
|
||||
}
|
||||
)
|
||||
assert _probe_codex_quota_restored(token) is True
|
||||
assert calls[0]["headers"].get("ChatGPT-Account-Id") == "acct-123"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# clear_codex_pool_quota_cooldowns
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _write_auth_store(hermes_home, payload):
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
(hermes_home / "auth.json").write_text(json.dumps(payload, indent=2))
|
||||
|
||||
|
||||
def _exhausted_pool_store(now=None):
|
||||
now = now or time.time()
|
||||
return {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"credential_pool": {
|
||||
"openai-codex": [
|
||||
{
|
||||
"id": "cred-quota",
|
||||
"label": "quota-frozen",
|
||||
"auth_type": "oauth",
|
||||
"priority": 0,
|
||||
"source": "device_code",
|
||||
"access_token": "tok-quota",
|
||||
"last_status": "exhausted",
|
||||
"last_status_at": now,
|
||||
"last_error_code": 429,
|
||||
"last_error_reason": "usage_limit_reached",
|
||||
"last_error_message": "The usage limit has been reached",
|
||||
"last_error_reset_at": now + 6 * 24 * 3600,
|
||||
},
|
||||
{
|
||||
"id": "cred-dead",
|
||||
"label": "revoked",
|
||||
"auth_type": "oauth",
|
||||
"priority": 1,
|
||||
"source": "device_code",
|
||||
"access_token": "tok-dead",
|
||||
"last_status": "dead",
|
||||
"last_status_at": now,
|
||||
"last_error_code": 401,
|
||||
"last_error_reason": "token_invalidated",
|
||||
},
|
||||
{
|
||||
"id": "cred-auth",
|
||||
"label": "auth-failure",
|
||||
"auth_type": "oauth",
|
||||
"priority": 2,
|
||||
"source": "device_code",
|
||||
"access_token": "tok-auth",
|
||||
"last_status": "exhausted",
|
||||
"last_status_at": now,
|
||||
"last_error_code": 401,
|
||||
"last_error_reason": "token_expired",
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_codex_runtime_credentials — stale cooldown lifted by live probe
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _pool_only_rate_limited_store(now=None):
|
||||
now = now or time.time()
|
||||
return {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"credential_pool": {
|
||||
"openai-codex": [
|
||||
{
|
||||
"id": "cred-quota",
|
||||
"label": "quota-frozen",
|
||||
"auth_type": "oauth",
|
||||
"priority": 0,
|
||||
"source": "device_code",
|
||||
"access_token": "tok-quota",
|
||||
"last_status": "exhausted",
|
||||
"last_status_at": now,
|
||||
"last_error_code": 429,
|
||||
"last_error_reason": "usage_limit_reached",
|
||||
"last_error_message": "The usage limit has been reached",
|
||||
"last_error_reset_at": now + 3 * 24 * 3600,
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_resolver_recovers_when_probe_confirms_reset(tmp_path, monkeypatch):
|
||||
"""The screenshot bug: pool-only cooldown raises `quota exhausted (429);
|
||||
retry after Ns` even though the upstream window already reset. A positive
|
||||
probe must clear the cooldown and return the pool credential."""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
_write_auth_store(hermes_home, _pool_only_rate_limited_store())
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
monkeypatch.setattr(
|
||||
auth_mod, "_probe_codex_quota_restored", lambda token, **kw: True
|
||||
)
|
||||
|
||||
resolved = resolve_codex_runtime_credentials()
|
||||
assert resolved["api_key"] == "tok-quota"
|
||||
assert resolved["source"] == "credential_pool"
|
||||
|
||||
store = json.loads((hermes_home / "auth.json").read_text())
|
||||
entry = store["credential_pool"]["openai-codex"][0]
|
||||
assert entry["last_status"] is None
|
||||
assert entry["last_error_reset_at"] is None
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CredentialPool._available_entries — frozen entry recovers via probe
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
def test_pool_probe_not_fired_for_non_quota_exhaustion(tmp_path, monkeypatch):
|
||||
"""Entries frozen by auth-shaped failures must not trigger the probe."""
|
||||
now = time.time()
|
||||
store = _pool_only_rate_limited_store(now)
|
||||
entry = store["credential_pool"]["openai-codex"][0]
|
||||
entry["last_error_code"] = 401
|
||||
entry["last_error_reason"] = "token_expired"
|
||||
entry["last_error_message"] = "expired"
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
_write_auth_store(tmp_path / "hermes", store)
|
||||
|
||||
from agent.credential_pool import load_pool
|
||||
|
||||
pool = load_pool("openai-codex")
|
||||
probes = []
|
||||
|
||||
def _spy(token, **kw):
|
||||
probes.append(token)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(auth_mod, "_probe_codex_quota_restored", _spy)
|
||||
pool._available_entries(clear_expired=True, refresh=False)
|
||||
assert probes == []
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /usage reset redemption clears persisted pool cooldowns
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Regression tests for Codex refresh_token self-heal (cross-store rotation).
|
||||
|
||||
Hermes keeps its OWN copy of the Codex OAuth token (per profile + top-level),
|
||||
separate from the Codex CLI's ``~/.codex/auth.json``. OAuth refresh_tokens are
|
||||
single-use, so when the Codex CLI (or another Hermes process) rotates the shared
|
||||
token, the frozen copy's refresh_token goes stale and ``refresh_codex_oauth_pure``
|
||||
fails with a relogin-required error. ``_refresh_codex_auth_tokens`` must then
|
||||
recover by re-importing the canonical token from ``~/.codex/auth.json`` instead of
|
||||
surfacing a hard 401 — but ONLY for relogin-required failures, never for transient
|
||||
ones (e.g. 429 quota, where the stored token is still valid).
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_cli.auth as auth
|
||||
from hermes_cli.auth import AuthError, _refresh_codex_auth_tokens, resolve_codex_runtime_credentials
|
||||
|
||||
STALE = {"access_token": "stale-access", "refresh_token": "stale-refresh"}
|
||||
|
||||
|
||||
def test_self_heals_on_stale_refresh_token(monkeypatch):
|
||||
"""invalid_grant (relogin-required) → reimport from ~/.codex and persist it."""
|
||||
saved = {}
|
||||
fresh = {
|
||||
"access_token": "fresh-access",
|
||||
"refresh_token": "fresh-refresh",
|
||||
"last_refresh": "2026-06-12T00:00:00Z",
|
||||
}
|
||||
|
||||
def _rejected(*_a, **_k):
|
||||
raise AuthError(
|
||||
"refresh token rejected",
|
||||
provider="openai-codex",
|
||||
code="invalid_grant",
|
||||
relogin_required=True,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(auth, "refresh_codex_oauth_pure", _rejected)
|
||||
monkeypatch.setattr(auth, "_import_codex_cli_tokens", lambda: dict(fresh))
|
||||
monkeypatch.setattr(auth, "_save_codex_tokens", lambda t, *a, **k: saved.update(t))
|
||||
|
||||
out = _refresh_codex_auth_tokens(STALE, 20.0)
|
||||
|
||||
assert out["access_token"] == "fresh-access"
|
||||
assert out["refresh_token"] == "fresh-refresh"
|
||||
# the recovered token was persisted to the Hermes auth store
|
||||
assert saved["access_token"] == "fresh-access"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_self_heals_missing_singleton_access_token_from_codex_cli(tmp_path, monkeypatch):
|
||||
"""Exact cron failure path: Hermes auth has refresh_token but missing access_token."""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
codex_home = tmp_path / "codex"
|
||||
hermes_home.mkdir()
|
||||
codex_home.mkdir()
|
||||
(hermes_home / "auth.json").write_text(json.dumps({
|
||||
"version": 1,
|
||||
"providers": {
|
||||
"openai-codex": {
|
||||
"tokens": {"refresh_token": "stale-refresh"},
|
||||
"last_refresh": "2026-06-01T00:00:00Z",
|
||||
"auth_mode": "chatgpt",
|
||||
},
|
||||
},
|
||||
}))
|
||||
(codex_home / "auth.json").write_text(json.dumps({
|
||||
"tokens": {
|
||||
"access_token": "fresh-access",
|
||||
"refresh_token": "fresh-refresh",
|
||||
},
|
||||
}))
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setenv("CODEX_HOME", str(codex_home))
|
||||
|
||||
resolved = resolve_codex_runtime_credentials()
|
||||
|
||||
assert resolved["api_key"] == "fresh-access"
|
||||
assert resolved["source"] == "hermes-auth-store"
|
||||
stored = json.loads((hermes_home / "auth.json").read_text())
|
||||
tokens = stored["providers"]["openai-codex"]["tokens"]
|
||||
assert tokens["access_token"] == "fresh-access"
|
||||
assert tokens["refresh_token"] == "fresh-refresh"
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,176 @@
|
||||
"""Regression tests for #93593: a malformed provider key in .env must not
|
||||
silently shadow a valid credential-pool key.
|
||||
|
||||
Before the fix, ``_resolve_api_key_provider_secret`` returned the FIRST env
|
||||
value that passed ``has_usable_secret`` (length + placeholder check only), so
|
||||
an obviously malformed OPENROUTER_API_KEY (e.g. a truncated paste or another
|
||||
provider's key) in ~/.hermes/.env won over a valid pool entry and produced
|
||||
opaque ``401 Missing Authentication header`` errors.
|
||||
|
||||
The fix:
|
||||
- providers with a declared key prefix (KNOWN_PROVIDER_KEY_PREFIXES) skip
|
||||
env values that don't match, with a WARNING naming the env var, and fall
|
||||
through to the next env var / credential pool;
|
||||
- the credential-pool fallback iterates entries instead of only peek(), so a
|
||||
malformed pool entry doesn't block a valid one either;
|
||||
- providers WITHOUT a declared prefix are fail-open (unchanged behavior);
|
||||
- a VALID env key still wins over the pool (precedence unchanged).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_pconfig(provider_id, env_vars=None):
|
||||
from hermes_cli.auth import ProviderConfig
|
||||
return ProviderConfig(
|
||||
id=provider_id,
|
||||
name=provider_id.title(),
|
||||
auth_type="api_key",
|
||||
api_key_env_vars=tuple(env_vars or [f"{provider_id.upper()}_API_KEY"]),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_hermes_home(tmp_path, monkeypatch):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
for key in ["OPENROUTER_API_KEY", "DEEPSEEK_API_KEY", "OPENAI_API_KEY"]:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
return home
|
||||
|
||||
|
||||
def _write_env_file(home: Path, **kwargs) -> None:
|
||||
lines = [f"{k}={v}" for k, v in kwargs.items()]
|
||||
(home / ".env").write_text("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
def _mock_pool(*entries):
|
||||
pool = MagicMock()
|
||||
pool.has_credentials.return_value = bool(entries)
|
||||
pool.peek.return_value = entries[0] if entries else None
|
||||
pool.entries.return_value = list(entries)
|
||||
return pool
|
||||
|
||||
|
||||
def _entry(token):
|
||||
e = MagicMock()
|
||||
e.access_token = token
|
||||
e.runtime_api_key = ""
|
||||
return e
|
||||
|
||||
|
||||
class TestMalformedEnvKeySkipped:
|
||||
"""Declared-prefix mismatch in .env → warn + fall through to pool."""
|
||||
|
||||
def test_malformed_openrouter_env_key_falls_back_to_pool(
|
||||
self, isolated_hermes_home, caplog
|
||||
):
|
||||
_write_env_file(isolated_hermes_home, OPENROUTER_API_KEY="not-a-real-openrouter-key")
|
||||
pool = _mock_pool(_entry("sk-or-v1-valid-pool-key-abc123"))
|
||||
|
||||
from hermes_cli.auth import _resolve_api_key_provider_secret
|
||||
with patch("agent.credential_pool.load_pool", return_value=pool):
|
||||
with caplog.at_level(logging.WARNING):
|
||||
key, source = _resolve_api_key_provider_secret(
|
||||
provider_id="openrouter",
|
||||
pconfig=_make_pconfig("openrouter"),
|
||||
)
|
||||
assert key == "sk-or-v1-valid-pool-key-abc123"
|
||||
assert source == "credential_pool:openrouter"
|
||||
warnings = [r for r in caplog.records if "OPENROUTER_API_KEY" in r.getMessage()]
|
||||
assert warnings, "expected a WARNING naming the malformed env var"
|
||||
assert "sk-or-" in warnings[0].getMessage()
|
||||
|
||||
def test_malformed_env_key_with_empty_pool_returns_empty(
|
||||
self, isolated_hermes_home, caplog
|
||||
):
|
||||
"""Malformed env key + no pool → empty result, never the bad key."""
|
||||
_write_env_file(isolated_hermes_home, OPENROUTER_API_KEY="sk-proj-wrong-provider-key")
|
||||
pool = _mock_pool()
|
||||
|
||||
from hermes_cli.auth import _resolve_api_key_provider_secret
|
||||
with patch("agent.credential_pool.load_pool", return_value=pool):
|
||||
with caplog.at_level(logging.WARNING):
|
||||
key, source = _resolve_api_key_provider_secret(
|
||||
provider_id="openrouter",
|
||||
pconfig=_make_pconfig("openrouter"),
|
||||
)
|
||||
assert key == ""
|
||||
assert source == ""
|
||||
|
||||
def test_malformed_pool_entry_skipped_for_valid_one(self, isolated_hermes_home):
|
||||
"""Pool iteration: a malformed first entry must not block a valid second."""
|
||||
pool = _mock_pool(
|
||||
_entry("garbage-pool-entry"),
|
||||
_entry("sk-or-v1-second-entry-good"),
|
||||
)
|
||||
|
||||
from hermes_cli.auth import _resolve_api_key_provider_secret
|
||||
with patch("agent.credential_pool.load_pool", return_value=pool):
|
||||
key, source = _resolve_api_key_provider_secret(
|
||||
provider_id="openrouter",
|
||||
pconfig=_make_pconfig("openrouter"),
|
||||
)
|
||||
assert key == "sk-or-v1-second-entry-good"
|
||||
assert source == "credential_pool:openrouter"
|
||||
|
||||
|
||||
class TestNoDeclaredPrefixUnaffected:
|
||||
"""Providers without a declared prefix keep today's fail-open behavior."""
|
||||
|
||||
def test_undeclared_provider_env_key_returned_verbatim(self, isolated_hermes_home):
|
||||
_write_env_file(isolated_hermes_home, DEEPSEEK_API_KEY="totally-unknown-format-key")
|
||||
|
||||
from hermes_cli.auth import _resolve_api_key_provider_secret
|
||||
pool = _mock_pool(_entry("pool-key-should-not-win"))
|
||||
with patch("agent.credential_pool.load_pool", return_value=pool) as mp:
|
||||
key, source = _resolve_api_key_provider_secret(
|
||||
provider_id="deepseek",
|
||||
pconfig=_make_pconfig("deepseek"),
|
||||
)
|
||||
assert key == "totally-unknown-format-key"
|
||||
assert source == "DEEPSEEK_API_KEY"
|
||||
mp.assert_not_called()
|
||||
|
||||
|
||||
class TestValidEnvKeyStillWins:
|
||||
"""Precedence unchanged: a valid env key beats the credential pool."""
|
||||
|
||||
def test_valid_openrouter_env_key_wins_over_pool(self, isolated_hermes_home):
|
||||
_write_env_file(isolated_hermes_home, OPENROUTER_API_KEY="sk-or-v1-env-key-wins")
|
||||
pool = _mock_pool(_entry("sk-or-v1-pool-key-loses"))
|
||||
|
||||
from hermes_cli.auth import _resolve_api_key_provider_secret
|
||||
with patch("agent.credential_pool.load_pool", return_value=pool) as mp:
|
||||
key, source = _resolve_api_key_provider_secret(
|
||||
provider_id="openrouter",
|
||||
pconfig=_make_pconfig("openrouter"),
|
||||
)
|
||||
assert key == "sk-or-v1-env-key-wins"
|
||||
assert source == "OPENROUTER_API_KEY"
|
||||
mp.assert_not_called()
|
||||
|
||||
def test_second_env_var_wins_when_first_is_malformed(self, isolated_hermes_home):
|
||||
"""Malformed first env var falls through to a valid SECOND env var."""
|
||||
_write_env_file(
|
||||
isolated_hermes_home,
|
||||
OPENROUTER_API_KEY="malformed-first-var",
|
||||
OPENROUTER_KEY="sk-or-v1-second-var-good",
|
||||
)
|
||||
|
||||
from hermes_cli.auth import _resolve_api_key_provider_secret
|
||||
key, source = _resolve_api_key_provider_secret(
|
||||
provider_id="openrouter",
|
||||
pconfig=_make_pconfig(
|
||||
"openrouter", env_vars=["OPENROUTER_API_KEY", "OPENROUTER_KEY"]
|
||||
),
|
||||
)
|
||||
assert key == "sk-or-v1-second-var-good"
|
||||
assert source == "OPENROUTER_KEY"
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Unit tests for _print_loopback_ssh_hint() in hermes_cli/auth.py.
|
||||
|
||||
The helper warns users that loopback OAuth flows (Spotify) don't work over
|
||||
SSH unless they set up an `ssh -L` port forward between their laptop's
|
||||
browser and the remote host's loopback listener.
|
||||
|
||||
xAI Grok OAuth no longer uses this helper — its login is device-code-only —
|
||||
but the Spotify integration still relies on it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import contextlib
|
||||
import socket
|
||||
|
||||
|
||||
from hermes_cli import auth as auth_mod
|
||||
|
||||
|
||||
def _cap(fn):
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
fn()
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_loopback_ssh_hint_silent_when_not_remote(monkeypatch):
|
||||
monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: False)
|
||||
out = _cap(lambda: auth_mod._print_loopback_ssh_hint(
|
||||
"http://127.0.0.1:43827/spotify/callback", docs_url=auth_mod.SPOTIFY_DOCS_URL
|
||||
))
|
||||
assert out == ""
|
||||
|
||||
|
||||
def test_loopback_ssh_hint_has_visual_header(monkeypatch):
|
||||
"""The hint should print a divider and header so it stands out in noisy output."""
|
||||
monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True)
|
||||
out = _cap(lambda: auth_mod._print_loopback_ssh_hint(
|
||||
"http://127.0.0.1:43827/callback"
|
||||
))
|
||||
assert "Remote session detected" in out
|
||||
assert "---" in out # divider is present
|
||||
|
||||
|
||||
class TestSshUserAtHost:
|
||||
def test_resolves_user_and_hostname(self, monkeypatch):
|
||||
monkeypatch.setenv("USER", "alice")
|
||||
monkeypatch.delenv("LOGNAME", raising=False)
|
||||
monkeypatch.setattr(socket, "gethostname", lambda: "myserver")
|
||||
assert auth_mod._ssh_user_at_host() == "alice@myserver"
|
||||
|
||||
|
||||
def test_placeholder_when_no_env_vars(self, monkeypatch):
|
||||
monkeypatch.delenv("USER", raising=False)
|
||||
monkeypatch.delenv("LOGNAME", raising=False)
|
||||
monkeypatch.setattr(socket, "gethostname", lambda: "host1")
|
||||
assert auth_mod._ssh_user_at_host() == "<user>@host1"
|
||||
|
||||
|
||||
def test_placeholder_when_empty_hostname(self, monkeypatch):
|
||||
monkeypatch.setenv("USER", "dave")
|
||||
monkeypatch.setattr(socket, "gethostname", lambda: "")
|
||||
assert auth_mod._ssh_user_at_host() == "dave@<this-host>"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,290 @@
|
||||
"""Tests for cross-profile auth fallback.
|
||||
|
||||
When ``HERMES_HOME`` points to a named profile, ``read_credential_pool()``
|
||||
and ``get_provider_auth_state()`` fall back to the global-root
|
||||
``auth.json`` per-provider when the profile has no entries for that
|
||||
provider. Writes still target the profile only.
|
||||
|
||||
See the #18594 follow-up report: profile workers couldn't see providers
|
||||
authenticated only at the global root.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_auth_store(pool: dict | None = None, providers: dict | None = None) -> dict:
|
||||
store: dict = {"version": 1}
|
||||
if pool is not None:
|
||||
store["credential_pool"] = pool
|
||||
if providers is not None:
|
||||
store["providers"] = providers
|
||||
return store
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def profile_env(tmp_path, monkeypatch):
|
||||
"""Set up a global root + an active profile under Path.home()/.hermes/profiles/coder.
|
||||
|
||||
* Path.home() -> tmp_path
|
||||
* Global root -> tmp_path/.hermes (has its own auth.json fixture)
|
||||
* Profile -> tmp_path/.hermes/profiles/coder (active, HERMES_HOME points here)
|
||||
|
||||
This mirrors the real "named profile mounted under the default root"
|
||||
layout that profile users actually have on disk.
|
||||
"""
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
global_root = tmp_path / ".hermes"
|
||||
global_root.mkdir()
|
||||
profile_dir = global_root / "profiles" / "coder"
|
||||
profile_dir.mkdir(parents=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(profile_dir))
|
||||
return {"global": global_root, "profile": profile_dir}
|
||||
|
||||
|
||||
def _write(path: Path, payload: dict) -> None:
|
||||
path.write_text(json.dumps(payload, indent=2))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# read_credential_pool — provider-slice reads
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_missing_global_auth_file_is_safe(profile_env):
|
||||
"""Profile processes that never had a global auth.json still work."""
|
||||
from hermes_cli.auth import read_credential_pool
|
||||
|
||||
# No global auth.json written at all.
|
||||
_write(profile_env["profile"] / "auth.json", _make_auth_store(pool={
|
||||
"openrouter": [{
|
||||
"id": "prof-1",
|
||||
"label": "profile",
|
||||
"auth_type": "api_key",
|
||||
"priority": 0,
|
||||
"source": "manual",
|
||||
"access_token": "sk-profile",
|
||||
}],
|
||||
}))
|
||||
|
||||
assert read_credential_pool("openrouter")[0]["id"] == "prof-1"
|
||||
assert read_credential_pool("anthropic") == []
|
||||
|
||||
|
||||
def test_malformed_global_auth_file_does_not_break_profile_read(profile_env):
|
||||
(profile_env["global"] / "auth.json").write_text("{not valid json")
|
||||
_write(profile_env["profile"] / "auth.json", _make_auth_store(pool={
|
||||
"openrouter": [{
|
||||
"id": "prof-1",
|
||||
"label": "profile",
|
||||
"auth_type": "api_key",
|
||||
"priority": 0,
|
||||
"source": "manual",
|
||||
"access_token": "sk-profile",
|
||||
}],
|
||||
}))
|
||||
|
||||
from hermes_cli.auth import read_credential_pool
|
||||
|
||||
# Profile reads still work; malformed global is silently ignored.
|
||||
assert read_credential_pool("openrouter")[0]["id"] == "prof-1"
|
||||
# And no fallback for anthropic since global is unreadable.
|
||||
assert read_credential_pool("anthropic") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# read_credential_pool — whole-pool reads (provider_id=None)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_provider_auth_state — singleton fallback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_provider_auth_state_falls_back_to_global_when_profile_has_none(profile_env):
|
||||
from hermes_cli.auth import get_provider_auth_state
|
||||
|
||||
_write(profile_env["global"] / "auth.json", _make_auth_store(providers={
|
||||
"nous": {"access_token": "nous-global", "refresh_token": "rt-global"},
|
||||
}))
|
||||
_write(profile_env["profile"] / "auth.json", _make_auth_store(providers={}))
|
||||
|
||||
state = get_provider_auth_state("nous")
|
||||
assert state is not None
|
||||
assert state["access_token"] == "nous-global"
|
||||
|
||||
|
||||
def test_provider_auth_state_returns_none_when_neither_has_it(profile_env):
|
||||
from hermes_cli.auth import get_provider_auth_state
|
||||
|
||||
_write(profile_env["global"] / "auth.json", _make_auth_store(providers={}))
|
||||
_write(profile_env["profile"] / "auth.json", _make_auth_store(providers={}))
|
||||
|
||||
assert get_provider_auth_state("nous") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _load_provider_state — internal global fallback (issue #18594 follow-up)
|
||||
#
|
||||
# Several runtime helpers (notably ``resolve_nous_runtime_credentials`` and
|
||||
# ``resolve_nous_access_token``) call ``_load_provider_state`` directly with
|
||||
# a profile-loaded auth store rather than going through
|
||||
# ``get_provider_auth_state``. Without the fallback wired into
|
||||
# ``_load_provider_state`` itself, those helpers raise ``"Hermes is not
|
||||
# logged into Nous Portal"`` even though the user has a valid global Nous
|
||||
# login. These tests pin the per-provider shadowing into the helper.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Classic mode — no fallback path should ever trigger
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Writes stay scoped to the profile
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_write_credential_pool_targets_profile_not_global(profile_env):
|
||||
from hermes_cli.auth import read_credential_pool, write_credential_pool
|
||||
|
||||
_write(profile_env["global"] / "auth.json", _make_auth_store(pool={
|
||||
"openrouter": [{
|
||||
"id": "glob-1",
|
||||
"label": "global",
|
||||
"auth_type": "api_key",
|
||||
"priority": 0,
|
||||
"source": "manual",
|
||||
"access_token": "sk-global",
|
||||
}],
|
||||
}))
|
||||
|
||||
write_credential_pool("openrouter", [{
|
||||
"id": "prof-new",
|
||||
"label": "profile-new",
|
||||
"auth_type": "api_key",
|
||||
"priority": 0,
|
||||
"source": "manual",
|
||||
"access_token": "sk-profile-new",
|
||||
}])
|
||||
|
||||
# Global auth.json unchanged.
|
||||
global_data = json.loads((profile_env["global"] / "auth.json").read_text())
|
||||
assert global_data["credential_pool"]["openrouter"][0]["id"] == "glob-1"
|
||||
|
||||
# Profile auth.json holds the new entry.
|
||||
profile_data = json.loads((profile_env["profile"] / "auth.json").read_text())
|
||||
assert profile_data["credential_pool"]["openrouter"][0]["id"] == "prof-new"
|
||||
|
||||
# Subsequent read returns profile (shadows global).
|
||||
assert [e["id"] for e in read_credential_pool("openrouter")] == ["prof-new"]
|
||||
|
||||
|
||||
|
||||
|
||||
def test_auth_lock_reentrancy_is_scoped_after_profile_context_switch(profile_env):
|
||||
"""Changing profile context cannot inherit another store's lock depth."""
|
||||
import hermes_cli.auth as auth
|
||||
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
||||
|
||||
profile_b = profile_env["global"] / "profiles" / "reviewer"
|
||||
profile_b.mkdir(parents=True)
|
||||
profile_b_lock = profile_b / "auth.lock"
|
||||
|
||||
with auth._auth_store_lock():
|
||||
holder_a = auth._auth_lock_holder_for(profile_env["profile"] / "auth.json")
|
||||
assert getattr(holder_a, "depth", 0) == 1
|
||||
|
||||
token = set_hermes_home_override(profile_b)
|
||||
try:
|
||||
holder_b = auth._auth_lock_holder_for(profile_b / "auth.json")
|
||||
assert holder_b is not holder_a
|
||||
assert getattr(holder_b, "depth", 0) == 0
|
||||
assert not profile_b_lock.exists()
|
||||
|
||||
with auth._auth_store_lock():
|
||||
assert profile_b_lock.exists()
|
||||
assert getattr(holder_b, "depth", 0) == 1
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
|
||||
assert getattr(holder_a, "depth", 0) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# write_credential_pool — stale-snapshot cooldown merge
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def classic_env(tmp_path, monkeypatch):
|
||||
"""Classic single-root layout (HERMES_HOME != ~/.hermes, no profiles)."""
|
||||
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))
|
||||
return hermes_home
|
||||
|
||||
|
||||
def _pool_entry(**overrides) -> dict:
|
||||
entry = {
|
||||
"id": "cred-x",
|
||||
"label": "key-x",
|
||||
"auth_type": "api_key",
|
||||
"priority": 0,
|
||||
"source": "manual",
|
||||
"access_token": "sk-x",
|
||||
}
|
||||
entry.update(overrides)
|
||||
return entry
|
||||
|
||||
|
||||
|
||||
|
||||
def test_write_pool_never_merges_cooldown_onto_reauthed_entry(classic_env):
|
||||
"""A token change means re-auth: the old cooldown must never carry over.
|
||||
|
||||
A fresh login intentionally clears the entry's status; resurrecting the
|
||||
stale cooldown onto the new credentials would bench a just-authorized key.
|
||||
"""
|
||||
from hermes_cli.auth import write_credential_pool
|
||||
|
||||
_write(classic_env / "auth.json", _make_auth_store(pool={
|
||||
"openrouter": [_pool_entry(
|
||||
access_token="sk-old",
|
||||
last_status="exhausted",
|
||||
last_status_at=time.time() - 60, # newer AND unexpired
|
||||
last_error_code=429,
|
||||
)],
|
||||
}))
|
||||
|
||||
# Same entry id, freshly re-authed with a new token and cleared status.
|
||||
write_credential_pool("openrouter", [_pool_entry(access_token="sk-new")])
|
||||
|
||||
data = json.loads((classic_env / "auth.json").read_text())
|
||||
persisted = data["credential_pool"]["openrouter"][0]
|
||||
assert persisted["access_token"] == "sk-new"
|
||||
assert persisted.get("last_status") != "exhausted"
|
||||
assert persisted.get("last_error_code") is None
|
||||
@@ -0,0 +1,281 @@
|
||||
"""Tests for is_provider_explicitly_configured()."""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
|
||||
|
||||
def _write_config(tmp_path, config: dict) -> None:
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
import yaml
|
||||
(hermes_home / "config.yaml").write_text(yaml.dump(config))
|
||||
|
||||
|
||||
def _write_auth_store(tmp_path, payload: dict) -> None:
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
(hermes_home / "auth.json").write_text(json.dumps(payload, indent=2))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_anthropic_env(monkeypatch):
|
||||
"""Strip Anthropic env vars so CI secrets don't leak into tests."""
|
||||
for key in ("ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_ambient_pool_source_does_not_count_as_explicit(tmp_path, monkeypatch):
|
||||
"""gh_cli-seeded Copilot pool entries are ambient, not explicit config (#56974)."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
_write_auth_store(tmp_path, {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"active_provider": None,
|
||||
"credential_pool": {
|
||||
"copilot": [{
|
||||
"id": "abc123",
|
||||
"source": "gh_cli",
|
||||
"auth_type": "api_key",
|
||||
"access_token": "ghu_sometoken",
|
||||
}],
|
||||
},
|
||||
})
|
||||
|
||||
from hermes_cli.auth import is_provider_explicitly_configured
|
||||
assert is_provider_explicitly_configured("copilot") is False
|
||||
|
||||
|
||||
def test_vertex_adc_counts_as_explicit_when_config_present(tmp_path, monkeypatch):
|
||||
"""A keyless Vertex provider is explicitly configured when the user pointed
|
||||
Hermes at it (VERTEX_PROJECT_ID / vertex.project_id / VERTEX_CREDENTIALS_PATH),
|
||||
even when it is NOT the current provider — otherwise it silently vanishes
|
||||
from explicit-only pickers (desktop chat model menu) unless already selected."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
for var in ("VERTEX_PROJECT_ID", "VERTEX_CREDENTIALS_PATH", "GOOGLE_APPLICATION_CREDENTIALS"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
_write_auth_store(tmp_path, {"version": 1, "providers": {}, "active_provider": None})
|
||||
|
||||
from hermes_cli.auth import is_provider_explicitly_configured
|
||||
|
||||
# vertex.project_id in config.yaml is a deliberate, Hermes-scoped signal.
|
||||
_write_config(tmp_path, {
|
||||
"model": {"provider": "anthropic", "default": "claude-opus-4-8"},
|
||||
"vertex": {"project_id": "my-gcp-project"},
|
||||
})
|
||||
assert is_provider_explicitly_configured("vertex") is True
|
||||
|
||||
# No Hermes-scoped Vertex config at all → stays hidden.
|
||||
_write_config(tmp_path, {"model": {"provider": "anthropic", "default": "claude-opus-4-8"}})
|
||||
assert is_provider_explicitly_configured("vertex") is False
|
||||
|
||||
|
||||
def test_vertex_ambient_google_creds_env_does_not_count_as_explicit(tmp_path, monkeypatch):
|
||||
"""An ambient GOOGLE_APPLICATION_CREDENTIALS path (commonly set globally for
|
||||
unrelated GCP work) must NOT mark Vertex explicit — only Hermes-scoped
|
||||
signals do. Regression guard for the picker gate (PR review feedback)."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
monkeypatch.delenv("VERTEX_PROJECT_ID", raising=False)
|
||||
monkeypatch.delenv("VERTEX_CREDENTIALS_PATH", raising=False)
|
||||
_write_config(tmp_path, {"model": {"provider": "anthropic", "default": "claude-opus-4-8"}})
|
||||
_write_auth_store(tmp_path, {"version": 1, "providers": {}, "active_provider": None})
|
||||
|
||||
# A real, existing SA file pointed to ONLY by the ambient Google var.
|
||||
sa = tmp_path / "adc.json"
|
||||
sa.write_text("{}")
|
||||
monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", str(sa))
|
||||
|
||||
from hermes_cli.auth import is_provider_explicitly_configured
|
||||
assert is_provider_explicitly_configured("vertex") is False
|
||||
|
||||
|
||||
def test_vertex_credentials_path_must_be_readable_file(tmp_path, monkeypatch):
|
||||
"""VERTEX_CREDENTIALS_PATH must point to an actual readable file, not a directory
|
||||
or non-existent path."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
monkeypatch.delenv("VERTEX_PROJECT_ID", raising=False)
|
||||
monkeypatch.delenv("GOOGLE_APPLICATION_CREDENTIALS", raising=False)
|
||||
_write_config(tmp_path, {"model": {"provider": "anthropic", "default": "claude-opus-4-8"}})
|
||||
_write_auth_store(tmp_path, {"version": 1, "providers": {}, "active_provider": None})
|
||||
|
||||
from hermes_cli.auth import is_provider_explicitly_configured
|
||||
|
||||
# Valid readable file -> True
|
||||
sa_file = tmp_path / "vertex_sa.json"
|
||||
sa_file.write_text("{}")
|
||||
monkeypatch.setenv("VERTEX_CREDENTIALS_PATH", str(sa_file))
|
||||
assert is_provider_explicitly_configured("vertex") is True
|
||||
|
||||
# Directory -> False
|
||||
sa_dir = tmp_path / "vertex_dir"
|
||||
sa_dir.mkdir()
|
||||
monkeypatch.setenv("VERTEX_CREDENTIALS_PATH", str(sa_dir))
|
||||
assert is_provider_explicitly_configured("vertex") is False
|
||||
|
||||
# Non-existent file -> False
|
||||
monkeypatch.setenv("VERTEX_CREDENTIALS_PATH", str(tmp_path / "nonexistent.json"))
|
||||
assert is_provider_explicitly_configured("vertex") is False
|
||||
|
||||
|
||||
def test_bedrock_region_counts_as_explicit(tmp_path, monkeypatch):
|
||||
"""Bedrock (AWS SDK auth, no API key) is explicitly configured once the
|
||||
user pins a region in config.yaml, mirroring the Vertex keyless case."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
_write_auth_store(tmp_path, {"version": 1, "providers": {}, "active_provider": None})
|
||||
|
||||
from hermes_cli.auth import is_provider_explicitly_configured
|
||||
|
||||
_write_config(tmp_path, {
|
||||
"model": {"provider": "anthropic", "default": "claude-opus-4-8"},
|
||||
"bedrock": {"region": "us-east-1"},
|
||||
})
|
||||
assert is_provider_explicitly_configured("bedrock") is True
|
||||
|
||||
_write_config(tmp_path, {
|
||||
"model": {"provider": "anthropic", "default": "claude-opus-4-8"},
|
||||
"bedrock": {"region": ""},
|
||||
})
|
||||
assert is_provider_explicitly_configured("bedrock") is False
|
||||
|
||||
|
||||
def test_returns_true_when_moa_reference_slot_uses_provider(tmp_path, monkeypatch):
|
||||
"""MoA advisor slots are explicit provider selections for auth gating."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
_write_config(tmp_path, {
|
||||
"model": {"provider": "openai-codex", "default": "gpt-5.5"},
|
||||
"moa": {
|
||||
"presets": {
|
||||
"default": {
|
||||
"reference_models": [
|
||||
{"provider": "anthropic", "model": "claude-opus-4-8"},
|
||||
{"provider": "opencode-go", "model": "glm-5.2"},
|
||||
],
|
||||
"aggregator": {"provider": "openai-codex", "model": "gpt-5.5"},
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
_write_auth_store(tmp_path, {"version": 1, "providers": {}, "active_provider": "openai-codex"})
|
||||
|
||||
from hermes_cli.auth import is_provider_explicitly_configured
|
||||
assert is_provider_explicitly_configured("anthropic") is True
|
||||
|
||||
|
||||
def test_stale_env_pool_entry_does_not_count_when_var_unset(tmp_path, monkeypatch):
|
||||
"""An env-seeded pool entry left in auth.json after the env var was removed
|
||||
must not mark the provider configured (#55790): the picker showed removed
|
||||
providers forever because the record existed even though no secret resolves."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False)
|
||||
_write_auth_store(tmp_path, {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"active_provider": None,
|
||||
"credential_pool": {
|
||||
"deepseek": [{
|
||||
"id": "aaa111",
|
||||
"source": "env:DEEPSEEK_API_KEY",
|
||||
"auth_type": "api_key",
|
||||
}],
|
||||
},
|
||||
})
|
||||
|
||||
from hermes_cli.auth import is_provider_explicitly_configured
|
||||
assert is_provider_explicitly_configured("deepseek") is False
|
||||
|
||||
|
||||
# ─── aws_sdk providers (Bedrock) ─────────────────────────────────────────
|
||||
#
|
||||
# Bedrock is registered with auth_type="aws_sdk" and an empty
|
||||
# api_key_env_vars tuple, so the api_key env-var loop never sees it.
|
||||
# Setting AWS_BEARER_TOKEN_BEDROCK (or an access-key pair) in .env is
|
||||
# exactly as explicit as pasting ANTHROPIC_API_KEY, and must count —
|
||||
# otherwise the desktop picker's explicit_only filter hides Bedrock even
|
||||
# though list_authenticated_providers builds a full row for it.
|
||||
#
|
||||
# Ambient AWS credential sources (SSO profiles, EC2 IMDS, container
|
||||
# credentials) must NOT count: aws_sdk detection here is env-var only,
|
||||
# never boto3's full chain.
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def _clean_aws_env(monkeypatch):
|
||||
"""Strip AWS env vars so developer/CI AWS credentials don't leak in."""
|
||||
for key in (
|
||||
"AWS_BEARER_TOKEN_BEDROCK",
|
||||
"AWS_ACCESS_KEY_ID",
|
||||
"AWS_SECRET_ACCESS_KEY",
|
||||
"AWS_SESSION_TOKEN",
|
||||
"AWS_PROFILE",
|
||||
"AWS_CONTAINER_CREDENTIALS_RELATIVE_URI",
|
||||
"AWS_WEB_IDENTITY_TOKEN_FILE",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
|
||||
def test_bedrock_not_explicit_without_aws_env(tmp_path, monkeypatch, _clean_aws_env):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
(tmp_path / "hermes").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
from hermes_cli.auth import is_provider_explicitly_configured
|
||||
assert is_provider_explicitly_configured("bedrock") is False
|
||||
|
||||
|
||||
def test_bedrock_bearer_token_counts_as_explicit(tmp_path, monkeypatch, _clean_aws_env):
|
||||
"""AWS_BEARER_TOKEN_BEDROCK is Bedrock-specific — the user set it for
|
||||
exactly this provider, so it gates like a provider API key."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
(tmp_path / "hermes").mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "ABSKexample-bearer-token-value")
|
||||
|
||||
from hermes_cli.auth import is_provider_explicitly_configured
|
||||
assert is_provider_explicitly_configured("bedrock") is True
|
||||
|
||||
|
||||
def test_bedrock_access_key_pair_counts_as_explicit(tmp_path, monkeypatch, _clean_aws_env):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
(tmp_path / "hermes").mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAEXAMPLE1234567890")
|
||||
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "examplesecretexamplesecretexample0000000")
|
||||
|
||||
from hermes_cli.auth import is_provider_explicitly_configured
|
||||
assert is_provider_explicitly_configured("bedrock") is True
|
||||
|
||||
|
||||
def test_bedrock_access_key_without_secret_is_not_explicit(tmp_path, monkeypatch, _clean_aws_env):
|
||||
"""A lone AWS_ACCESS_KEY_ID can't authenticate anything — require the pair."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
(tmp_path / "hermes").mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAEXAMPLE1234567890")
|
||||
|
||||
from hermes_cli.auth import is_provider_explicitly_configured
|
||||
assert is_provider_explicitly_configured("bedrock") is False
|
||||
|
||||
|
||||
def test_bedrock_ambient_aws_profile_is_not_explicit(tmp_path, monkeypatch, _clean_aws_env):
|
||||
"""AWS_PROFILE is ambient machine state (SSO / shared credentials file),
|
||||
not an explicit Hermes provider choice — same principle as gh_cli-seeded
|
||||
Copilot pool entries (#56974)."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
(tmp_path / "hermes").mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("AWS_PROFILE", "default")
|
||||
|
||||
from hermes_cli.auth import is_provider_explicitly_configured
|
||||
assert is_provider_explicitly_configured("bedrock") is False
|
||||
|
||||
|
||||
def test_aws_env_does_not_leak_into_other_providers(tmp_path, monkeypatch, _clean_aws_env):
|
||||
"""The aws_sdk env check must key on the provider's auth_type, not fire
|
||||
for every provider whenever AWS credentials happen to be present."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
(tmp_path / "hermes").mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "ABSKexample-bearer-token-value")
|
||||
|
||||
from hermes_cli.auth import is_provider_explicitly_configured
|
||||
assert is_provider_explicitly_configured("anthropic") is False
|
||||
@@ -0,0 +1,82 @@
|
||||
"""resolve_provider auto-detection must read provider keys through the
|
||||
profile secret scope under multiplex (#86917).
|
||||
|
||||
A secondary profile whose config uses ``model.provider: auto`` and whose
|
||||
API key lives only in its profile ``.env`` (installed per-turn as the
|
||||
secret scope) failed with "No LLM provider configured": the auto path
|
||||
read keys with bare ``os.getenv``, which under multiplex holds the
|
||||
DEFAULT profile's values.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import agent.secret_scope as ss
|
||||
from hermes_constants import (
|
||||
reset_hermes_home_override,
|
||||
set_hermes_home_override,
|
||||
)
|
||||
|
||||
|
||||
def _install_profile(tmp_path: Path, provider: str, env_text: str) -> Path:
|
||||
"""Create a profile home with a model section and a .env."""
|
||||
home = tmp_path / "profile"
|
||||
home.mkdir()
|
||||
(home / "config.yaml").write_text(
|
||||
f"model:\n provider: {provider}\n", encoding="utf-8"
|
||||
)
|
||||
(home / ".env").write_text(env_text, encoding="utf-8")
|
||||
return home
|
||||
|
||||
|
||||
def test_auto_resolution_sees_profile_scoped_key(tmp_path):
|
||||
"""The scoped provider key must be visible to auto-detection.
|
||||
|
||||
Before the fix: the DEEPSEEK_API_KEY lived only in the scope, bare
|
||||
os.getenv found nothing, and auto-resolution reported no provider.
|
||||
"""
|
||||
from hermes_cli.auth import resolve_provider
|
||||
|
||||
home = _install_profile(tmp_path, "auto", "DEEPSEEK_API_KEY=sk-scoped\n")
|
||||
|
||||
ss.set_multiplex_active(True)
|
||||
home_token = set_hermes_home_override(str(home))
|
||||
try:
|
||||
scope_token = ss.set_secret_scope(ss.build_profile_secret_scope(home))
|
||||
try:
|
||||
assert resolve_provider("auto") == "deepseek"
|
||||
finally:
|
||||
ss.reset_secret_scope(scope_token)
|
||||
finally:
|
||||
reset_hermes_home_override(home_token)
|
||||
ss.set_multiplex_active(False)
|
||||
|
||||
|
||||
def test_auto_resolution_falls_back_to_os_environ_when_unscoped(monkeypatch):
|
||||
"""Single-profile / CLI path unchanged: exported env keys still win."""
|
||||
from hermes_cli.auth import resolve_provider
|
||||
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-env")
|
||||
assert resolve_provider("auto") == "openrouter"
|
||||
|
||||
|
||||
def test_auto_resolution_honors_explicit_config_provider(tmp_path):
|
||||
"""An explicit config provider still wins over env-key detection."""
|
||||
from hermes_cli.auth import resolve_provider
|
||||
|
||||
home = _install_profile(tmp_path, "opencode-go", "OPENCODE_GO_API_KEY=sk\n")
|
||||
|
||||
ss.set_multiplex_active(True)
|
||||
home_token = set_hermes_home_override(str(home))
|
||||
try:
|
||||
scope_token = ss.set_secret_scope(ss.build_profile_secret_scope(home))
|
||||
try:
|
||||
assert resolve_provider("auto") == "opencode-go"
|
||||
finally:
|
||||
ss.reset_secret_scope(scope_token)
|
||||
finally:
|
||||
reset_hermes_home_override(home_token)
|
||||
ss.set_multiplex_active(False)
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Tests for Qwen OAuth provider authentication (hermes_cli/auth.py).
|
||||
|
||||
Covers: _qwen_cli_auth_path, _read_qwen_cli_tokens, _save_qwen_cli_tokens,
|
||||
_qwen_access_token_is_expiring, _refresh_qwen_cli_tokens,
|
||||
resolve_qwen_runtime_credentials, get_qwen_auth_status.
|
||||
"""
|
||||
|
||||
import json
|
||||
import stat
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.auth import (
|
||||
AuthError,
|
||||
DEFAULT_QWEN_BASE_URL,
|
||||
QWEN_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
|
||||
_qwen_cli_auth_path,
|
||||
_read_qwen_cli_tokens,
|
||||
_save_qwen_cli_tokens,
|
||||
_qwen_access_token_is_expiring,
|
||||
_refresh_qwen_cli_tokens,
|
||||
resolve_qwen_runtime_credentials,
|
||||
get_qwen_auth_status,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_qwen_tokens(
|
||||
access_token="test-access-token",
|
||||
refresh_token="test-refresh-token",
|
||||
expiry_date=None,
|
||||
**extra,
|
||||
):
|
||||
"""Create a minimal Qwen CLI OAuth credential dict."""
|
||||
if expiry_date is None:
|
||||
# 1 hour from now in milliseconds
|
||||
expiry_date = int((time.time() + 3600) * 1000)
|
||||
data = {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": "Bearer",
|
||||
"expiry_date": expiry_date,
|
||||
"resource_url": "portal.qwen.ai",
|
||||
}
|
||||
data.update(extra)
|
||||
return data
|
||||
|
||||
|
||||
def _write_qwen_creds(tmp_path, tokens=None):
|
||||
"""Write tokens to the Qwen CLI credentials file and return the path."""
|
||||
qwen_dir = tmp_path / ".qwen"
|
||||
qwen_dir.mkdir(parents=True, exist_ok=True)
|
||||
creds_path = qwen_dir / "oauth_creds.json"
|
||||
if tokens is None:
|
||||
tokens = _make_qwen_tokens()
|
||||
creds_path.write_text(json.dumps(tokens), encoding="utf-8")
|
||||
return creds_path
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def qwen_env(tmp_path, monkeypatch):
|
||||
"""Redirect _qwen_cli_auth_path to tmp_path/.qwen/oauth_creds.json."""
|
||||
creds_path = tmp_path / ".qwen" / "oauth_creds.json"
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth._qwen_cli_auth_path", lambda: creds_path
|
||||
)
|
||||
return tmp_path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _qwen_cli_auth_path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_qwen_cli_auth_path_returns_expected_location():
|
||||
path = _qwen_cli_auth_path()
|
||||
assert path == Path.home() / ".qwen" / "oauth_creds.json"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _read_qwen_cli_tokens
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _save_qwen_cli_tokens
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _qwen_access_token_is_expiring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _refresh_qwen_cli_tokens
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_qwen_runtime_credentials
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_resolve_qwen_runtime_credentials_fresh_token(qwen_env):
|
||||
tokens = _make_qwen_tokens(access_token="fresh-at")
|
||||
_write_qwen_creds(qwen_env, tokens)
|
||||
|
||||
creds = resolve_qwen_runtime_credentials(refresh_if_expiring=False)
|
||||
assert creds["provider"] == "qwen-oauth"
|
||||
assert creds["api_key"] == "fresh-at"
|
||||
assert creds["base_url"] == DEFAULT_QWEN_BASE_URL
|
||||
assert creds["source"] == "qwen-cli"
|
||||
|
||||
|
||||
def test_resolve_qwen_runtime_credentials_missing_access_token(qwen_env):
|
||||
tokens = _make_qwen_tokens(access_token="")
|
||||
_write_qwen_creds(qwen_env, tokens)
|
||||
|
||||
with pytest.raises(AuthError) as exc:
|
||||
resolve_qwen_runtime_credentials(refresh_if_expiring=False)
|
||||
assert exc.value.code == "qwen_access_token_missing"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_qwen_auth_status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_get_qwen_auth_status_logged_in(qwen_env):
|
||||
tokens = _make_qwen_tokens(access_token="status-at")
|
||||
_write_qwen_creds(qwen_env, tokens)
|
||||
|
||||
status = get_qwen_auth_status()
|
||||
assert status["logged_in"] is True
|
||||
assert status["api_key"] == "status-at"
|
||||
|
||||
|
||||
def test_get_qwen_auth_status_refreshes_expired_token(qwen_env):
|
||||
expired_ms = int((time.time() - 3600) * 1000)
|
||||
tokens = _make_qwen_tokens(access_token="old-at", expiry_date=expired_ms)
|
||||
_write_qwen_creds(qwen_env, tokens)
|
||||
|
||||
refreshed = _make_qwen_tokens(access_token="refreshed-at")
|
||||
|
||||
with patch(
|
||||
"hermes_cli.auth._refresh_qwen_cli_tokens", return_value=refreshed
|
||||
) as mock_refresh:
|
||||
status = get_qwen_auth_status()
|
||||
|
||||
mock_refresh.assert_called_once()
|
||||
assert status["logged_in"] is True
|
||||
assert status["api_key"] == "refreshed-at"
|
||||
|
||||
|
||||
def test_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,86 @@
|
||||
"""Tests for hermes_cli.auth._default_verify platform-aware fallback.
|
||||
|
||||
On macOS with Homebrew Python, the system OpenSSL cannot locate the
|
||||
system trust store, so we explicitly load certifi's bundle. On other
|
||||
platforms we defer to httpx's own default (which itself uses certifi).
|
||||
|
||||
Most tests use monkeypatching — no real SSL handshakes. A handful use
|
||||
an openssl-generated self-signed cert via the `real_bundle_file`
|
||||
fixture because `ssl.create_default_context(cafile=...)` parses the
|
||||
bundle and refuses stubs.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import ssl
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
from hermes_cli.auth import _default_verify, _resolve_verify
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def real_bundle_file(tmp_path: Path) -> str:
|
||||
"""Return a path to a real openssl-generated self-signed cert.
|
||||
|
||||
Skips the test when the `openssl` binary isn't on PATH, so CI images
|
||||
without it degrade gracefully instead of erroring out.
|
||||
"""
|
||||
if shutil.which("openssl") is None:
|
||||
pytest.skip("openssl binary not available")
|
||||
cert = tmp_path / "ca.pem"
|
||||
key = tmp_path / "key.pem"
|
||||
result = subprocess.run(
|
||||
[
|
||||
"openssl", "req", "-x509", "-newkey", "rsa:2048",
|
||||
"-keyout", str(key), "-out", str(cert),
|
||||
"-sha256", "-days", "1", "-nodes",
|
||||
"-subj", "/CN=test",
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
pytest.skip(f"openssl failed: {result.stderr.decode('utf-8', 'ignore')[:200]}")
|
||||
return str(cert)
|
||||
|
||||
|
||||
class TestDefaultVerify:
|
||||
@pytest.mark.macos_only
|
||||
def test_returns_ssl_context_on_darwin(self):
|
||||
result = _default_verify()
|
||||
assert isinstance(result, ssl.SSLContext)
|
||||
|
||||
|
||||
@pytest.mark.macos_only
|
||||
def test_darwin_falls_back_to_true_when_certifi_missing(self, monkeypatch):
|
||||
real_import = __import__
|
||||
|
||||
def fake_import(name, *args, **kwargs):
|
||||
if name == "certifi":
|
||||
raise ImportError("simulated missing certifi")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr("builtins.__import__", fake_import)
|
||||
assert _default_verify() is True
|
||||
|
||||
|
||||
class TestResolveVerifyIntegration:
|
||||
"""_resolve_verify should defer to _default_verify in the no-CA path."""
|
||||
|
||||
|
||||
@pytest.mark.linux_only
|
||||
def test_no_ca_uses_default_verify_on_linux(self, monkeypatch):
|
||||
for var in ("HERMES_CA_BUNDLE", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
assert _resolve_verify() is True
|
||||
|
||||
|
||||
|
||||
def test_insecure_wins_over_everything(self, monkeypatch, tmp_path):
|
||||
bundle = tmp_path / "ca.pem"
|
||||
bundle.write_text("stub")
|
||||
monkeypatch.setenv("HERMES_CA_BUNDLE", str(bundle))
|
||||
assert _resolve_verify(insecure=True) is False
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Regression test for a Windows-only PermissionError under lock contention.
|
||||
|
||||
``_file_lock()`` (backing ``_auth_store_lock()``) ensures its lock file has
|
||||
at least 1 byte of content before opening it for ``msvcrt.locking()`` (which
|
||||
requires a non-empty file). That "ensure content" write was unguarded: under
|
||||
real concurrency, one thread/process's ``msvcrt.locking()`` byte-range lock
|
||||
can already be held on the file at the exact moment another thread runs the
|
||||
same ensure-content check, and the write collides and raises
|
||||
``PermissionError`` -- uncaught, since it happens before the retry loop the
|
||||
rest of the function uses for exactly this kind of contention.
|
||||
|
||||
This was found by a stress test for the Anthropic OAuth credential-pool fix
|
||||
(``tests/agent/test_anthropic_oauth_stress.py``), which reliably reproduces
|
||||
it (16/20 concurrent refreshes hit PermissionError pre-fix, deterministically
|
||||
across repeated runs) because the surrounding CredentialPool work widens the
|
||||
race window enough for real OS-level thread interleaving to land on it. That
|
||||
test is the authoritative regression guard for the exact failure mode. This
|
||||
file is deliberately a minimal, isolated stress test against
|
||||
``_auth_store_lock()`` directly -- best-effort coverage that does not always
|
||||
land on the same narrow window in isolation, but exercises the SAME shared
|
||||
primitive Codex/xAI/Nous already depend on for their single-use-refresh-token
|
||||
protection, so this bug was never Anthropic-specific.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.auth import _auth_store_lock
|
||||
|
||||
CONCURRENCY = 40
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hermes_home(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.mark.windows_only
|
||||
def test_many_concurrent_lock_acquisitions_do_not_raise_permission_error(hermes_home):
|
||||
"""CONCURRENCY threads race to acquire/release the same auth-store lock.
|
||||
|
||||
None of them should ever see an uncaught PermissionError/OSError from
|
||||
the lock-file "ensure content" pre-check -- only the intended
|
||||
TimeoutError (never expected here, since each critical section is
|
||||
instant) should be a possible failure mode.
|
||||
"""
|
||||
errors: dict[int, BaseException] = {}
|
||||
entered = 0
|
||||
entered_lock = threading.Lock()
|
||||
# Synchronize all threads to hit the lock-file "ensure content" pre-check
|
||||
# at (as close as the OS scheduler allows to) the exact same instant --
|
||||
# the race only exists on the very first acquisition against a fresh
|
||||
# lock file, so a tight barrier maximizes the odds of reproducing it
|
||||
# instead of relying on incidental thread-start jitter.
|
||||
barrier = threading.Barrier(CONCURRENCY)
|
||||
|
||||
def _run(idx: int) -> None:
|
||||
nonlocal entered
|
||||
try:
|
||||
barrier.wait(timeout=10)
|
||||
with _auth_store_lock(timeout_seconds=10.0):
|
||||
with entered_lock:
|
||||
entered += 1
|
||||
except BaseException as exc: # pragma: no cover - failure diagnostics
|
||||
errors[idx] = exc
|
||||
|
||||
threads = [threading.Thread(target=_run, args=(i,)) for i in range(CONCURRENCY)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join(timeout=30)
|
||||
|
||||
still_alive = [t for t in threads if t.is_alive()]
|
||||
assert not still_alive, f"{len(still_alive)}/{CONCURRENCY} threads never finished"
|
||||
assert not errors, f"unexpected exceptions acquiring the lock concurrently: {errors!r}"
|
||||
assert entered == CONCURRENCY
|
||||
@@ -0,0 +1,98 @@
|
||||
"""A transient read failure on auth.json must not degrade to an empty store.
|
||||
|
||||
``_load_auth_store`` treated every exception as corruption and returned
|
||||
``{"version": ..., "providers": {}}``. This module does read-modify-write in
|
||||
roughly fifteen places, so an ``OSError`` (EMFILE under fd exhaustion, EACCES,
|
||||
EIO, a stalled mount) followed by any ``_save_auth_store`` rewrote auth.json
|
||||
with an empty provider set and destroyed every stored credential.
|
||||
|
||||
Genuine corruption still degrades, still preserves a copy, and now only claims
|
||||
to have preserved one when the copy actually landed.
|
||||
"""
|
||||
|
||||
import errno
|
||||
import json
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_cli.auth as auth
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store_file(tmp_path):
|
||||
f = tmp_path / "auth.json"
|
||||
f.write_text(
|
||||
json.dumps({"version": 1, "providers": {"nous": {"api_key": "secret"}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return f
|
||||
|
||||
|
||||
def _fail_read(exc):
|
||||
def _read(self, *args, **kwargs):
|
||||
raise exc
|
||||
return _read
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exc",
|
||||
[
|
||||
OSError(errno.EMFILE, "Too many open files"),
|
||||
PermissionError(errno.EACCES, "Permission denied"),
|
||||
OSError(errno.EIO, "Input/output error"),
|
||||
],
|
||||
ids=["emfile", "eacces", "eio"],
|
||||
)
|
||||
def test_read_failure_raises_and_leaves_the_store_alone(store_file, monkeypatch, exc):
|
||||
from pathlib import Path
|
||||
|
||||
before = store_file.read_bytes()
|
||||
monkeypatch.setattr(Path, "read_text", _fail_read(exc))
|
||||
|
||||
with pytest.raises(OSError):
|
||||
auth._load_auth_store(store_file)
|
||||
|
||||
assert store_file.read_bytes() == before, "the store on disk was modified"
|
||||
assert not store_file.with_suffix(".json.corrupt").exists(), (
|
||||
"a read failure is not corruption and must not write a .corrupt sidecar"
|
||||
)
|
||||
|
||||
|
||||
def test_unparseable_json_still_degrades_and_preserves_a_copy(store_file):
|
||||
store_file.write_text("{ not json", encoding="utf-8")
|
||||
|
||||
result = auth._load_auth_store(store_file)
|
||||
|
||||
assert result == {"version": auth.AUTH_STORE_VERSION, "providers": {}}
|
||||
corrupt = store_file.with_suffix(".json.corrupt")
|
||||
assert corrupt.exists(), "genuine corruption must still be preserved"
|
||||
assert corrupt.read_text(encoding="utf-8") == "{ not json"
|
||||
|
||||
|
||||
def test_healthy_store_is_returned_unchanged(store_file):
|
||||
result = auth._load_auth_store(store_file)
|
||||
assert result["providers"]["nous"]["api_key"] == "secret"
|
||||
|
||||
|
||||
def test_log_does_not_claim_a_backup_that_was_not_written(
|
||||
store_file, monkeypatch, caplog
|
||||
):
|
||||
"""The old message advertised the .corrupt path even when copy2 failed."""
|
||||
import shutil
|
||||
|
||||
store_file.write_text("{ not json", encoding="utf-8")
|
||||
|
||||
def _no_copy(*args, **kwargs):
|
||||
raise OSError(errno.EMFILE, "Too many open files")
|
||||
|
||||
monkeypatch.setattr(shutil, "copy2", _no_copy)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="hermes_cli.auth"):
|
||||
result = auth._load_auth_store(store_file)
|
||||
|
||||
assert result == {"version": auth.AUTH_STORE_VERSION, "providers": {}}
|
||||
assert not store_file.with_suffix(".json.corrupt").exists()
|
||||
text = caplog.text
|
||||
assert "could NOT be preserved" in text
|
||||
assert "Corrupt file preserved at" not in text
|
||||
@@ -0,0 +1,356 @@
|
||||
"""Regression tests for auth store encoding on Windows.
|
||||
|
||||
``_load_auth_store`` and the Codex/Nous shared-store readers previously called
|
||||
``Path.read_text()`` with no ``encoding=``, so the bytes were decoded with
|
||||
``locale.getpreferredencoding()`` — cp1252 on Windows. The store is *written* as
|
||||
UTF-8 (``_save_auth_store`` uses ``encoding="utf-8"``), so any non-ASCII byte
|
||||
(e.g. a CJK or emoji credential label) raised ``UnicodeDecodeError`` on read.
|
||||
|
||||
Worst case: ``_load_auth_store``'s broad ``except`` then copied the file to
|
||||
``.json.corrupt`` and returned an *empty* store — silently wiping every
|
||||
provider credential. These tests pin the round-trip so a non-ASCII label
|
||||
survives a save→load cycle, and assert the readers pass an explicit UTF-8
|
||||
encoding (the fix) instead of relying on the locale default.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_cli.auth as auth
|
||||
|
||||
|
||||
# --- helpers ---------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def hermes_home(tmp_path, monkeypatch):
|
||||
"""Point HERMES_HOME at a tmp dir so we never touch the real auth store.
|
||||
|
||||
Required because ``_auth_file_path()`` has a seat belt that refuses to
|
||||
resolve to the real user's ~/.hermes/auth.json under pytest.
|
||||
"""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _write_utf8(path: Path, payload: dict) -> None:
|
||||
"""Write JSON as UTF-8 with non-ASCII chars as real UTF-8 bytes.
|
||||
|
||||
Uses ``ensure_ascii=False`` so the file actually contains non-ASCII bytes
|
||||
(the trigger for the Windows cp1252 bug). The default ``ensure_ascii=True``
|
||||
would escape them to ``\\uXXXX`` ASCII, hiding the bug.
|
||||
"""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def windows_default_encoding(monkeypatch):
|
||||
"""Simulate the Windows locale default for ``Path.read_text()``.
|
||||
|
||||
On Windows, ``locale.getpreferredencoding()`` is ``cp1252``, so a bare
|
||||
``read_text()`` decodes UTF-8 bytes as cp1252 and raises
|
||||
``UnicodeDecodeError`` on any non-ASCII byte. POSIX test runners default to
|
||||
UTF-8, so the bug is invisible there — this fixture makes a no-encoding
|
||||
``read_text()`` behave like Windows (cp1252), while leaving calls that pass
|
||||
an explicit encoding untouched. That lets the regression tests actually
|
||||
catch the bug on any platform.
|
||||
"""
|
||||
real_read_text = Path.read_text
|
||||
|
||||
def _windows_read_text(self, *args, **kwargs):
|
||||
if "encoding" not in kwargs and not args:
|
||||
# No explicit encoding → force the Windows default.
|
||||
kwargs["encoding"] = "cp1252"
|
||||
return real_read_text(self, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "read_text", _windows_read_text)
|
||||
|
||||
|
||||
|
||||
|
||||
# --- the bug: a non-ASCII label must survive save → load -------------------
|
||||
|
||||
class TestAuthStoreEncodingRoundTrip:
|
||||
def test_load_reads_utf8_with_non_ascii_label(self, hermes_home, windows_default_encoding):
|
||||
"""A UTF-8 store with a CJK/emoji label loads intact (not wiped).
|
||||
|
||||
Under the Windows-default-encoding fixture, a no-encoding read_text()
|
||||
raises UnicodeDecodeError on the non-ASCII bytes and the broad except
|
||||
wipes the store — so this test catches the bug on any platform.
|
||||
"""
|
||||
store = {
|
||||
"version": auth.AUTH_STORE_VERSION,
|
||||
"providers": {
|
||||
"openai-codex": {
|
||||
"auth_mode": "chatgpt",
|
||||
"label": "工作账号 🔥", # non-ASCII: CJK + emoji
|
||||
"tokens": {"access_token": "a", "refresh_token": "r"},
|
||||
}
|
||||
},
|
||||
}
|
||||
auth_path = hermes_home / "auth.json"
|
||||
_write_utf8(auth_path, store)
|
||||
|
||||
loaded = auth._load_auth_store(auth_path)
|
||||
|
||||
# The label round-trips exactly — the provider is NOT lost.
|
||||
assert "openai-codex" in loaded["providers"]
|
||||
assert loaded["providers"]["openai-codex"]["label"] == "工作账号 🔥"
|
||||
|
||||
def test_load_does_not_corrupt_store_on_non_ascii(self, hermes_home, windows_default_encoding):
|
||||
"""The pre-fix bug wiped the store to empty on a UnicodeDecodeError.
|
||||
|
||||
After the fix, loading a valid UTF-8 store must never produce the empty
|
||||
fallback, and must never write a .json.corrupt sidecar.
|
||||
"""
|
||||
store = {
|
||||
"version": auth.AUTH_STORE_VERSION,
|
||||
"providers": {"x": {"label": "José's key"}},
|
||||
}
|
||||
auth_path = hermes_home / "auth.json"
|
||||
_write_utf8(auth_path, store)
|
||||
|
||||
auth._load_auth_store(auth_path)
|
||||
|
||||
assert not (hermes_home / "auth.json.corrupt").exists()
|
||||
# original file untouched — read it back and compare structurally
|
||||
# (json.dumps may escape non-ASCII, so compare parsed values, not text)
|
||||
on_disk = json.loads(auth_path.read_text(encoding="utf-8"))
|
||||
assert on_disk["providers"]["x"]["label"] == "José's key"
|
||||
|
||||
def test_load_handles_utf8_with_bom(self, hermes_home):
|
||||
"""A BOM (e.g. from Notepad editing) must not break the read."""
|
||||
store = {"version": auth.AUTH_STORE_VERSION, "providers": {"x": {"label": "café"}}}
|
||||
auth_path = hermes_home / "auth.json"
|
||||
payload = json.dumps(store)
|
||||
# write with utf-8-sig to prepend the BOM
|
||||
auth_path.write_text(payload, encoding="utf-8-sig")
|
||||
|
||||
loaded = auth._load_auth_store(auth_path)
|
||||
assert loaded["providers"]["x"]["label"] == "café"
|
||||
|
||||
|
||||
# --- the fix: readers pass an explicit encoding ---------------------------
|
||||
|
||||
class TestExplicitEncodingPassed:
|
||||
"""The readers must not rely on the locale default (cp1252 on Windows).
|
||||
|
||||
We assert read_text is called with an explicit UTF-8 encoding. This is the
|
||||
regression guard: a future refactor that drops the encoding kwarg would
|
||||
reintroduce the Windows data-loss bug.
|
||||
"""
|
||||
|
||||
def test_load_auth_store_passes_utf8_encoding(self, hermes_home):
|
||||
auth_path = hermes_home / "auth.json"
|
||||
_write_utf8(auth_path, {"version": auth.AUTH_STORE_VERSION, "providers": {}})
|
||||
|
||||
with mock.patch.object(
|
||||
Path, "read_text", wraps=Path.read_text
|
||||
) as spy:
|
||||
auth._load_auth_store(auth_path)
|
||||
|
||||
assert spy.call_count == 1
|
||||
kwargs = spy.call_args.kwargs
|
||||
assert "encoding" in kwargs, "read_text() must pass an explicit encoding"
|
||||
assert "utf-8" in str(kwargs["encoding"]).lower()
|
||||
|
||||
def test_codex_store_reader_passes_utf8_encoding(self, tmp_path, monkeypatch):
|
||||
"""The ~/.codex/auth.json reader must pass an explicit UTF-8 encoding."""
|
||||
codex_home = tmp_path / "codex"
|
||||
codex_home.mkdir()
|
||||
(codex_home / "auth.json").write_text(
|
||||
json.dumps({"tokens": {"access_token": "a", "refresh_token": "r"}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("CODEX_HOME", str(codex_home))
|
||||
# Bypass the JWT-expiry check so a fake token doesn't short-circuit.
|
||||
monkeypatch.setattr(auth, "_codex_access_token_is_expiring", lambda *a, **k: False)
|
||||
|
||||
with mock.patch.object(Path, "read_text", wraps=Path.read_text) as spy:
|
||||
auth._import_codex_cli_tokens()
|
||||
|
||||
# _import_codex_cli_tokens reads exactly one file; assert that read
|
||||
# carried an explicit UTF-8 encoding. (The bound-method spy captures
|
||||
# kwargs but not the bound `self`, so we check the single read directly.)
|
||||
assert spy.call_count >= 1, "expected a read of the codex auth.json"
|
||||
for call in spy.call_args_list:
|
||||
assert "encoding" in call.kwargs, "codex read_text() must pass encoding"
|
||||
assert "utf-8" in str(call.kwargs["encoding"]).lower()
|
||||
|
||||
|
||||
# --- sibling readers of the same ~/.hermes/auth.json in other modules -------
|
||||
#
|
||||
# _load_auth_store lives in hermes_cli/auth.py, but several other modules read
|
||||
# the same ~/.hermes/auth.json directly. They had the same UTF-8-vs-cp1252
|
||||
# asymmetry on Windows — these tests pin the sibling reads too.
|
||||
|
||||
class TestAuthJsonSiblingReaders:
|
||||
def test_has_xai_credentials_reads_non_ascii_store(self, hermes_home, windows_default_encoding, monkeypatch):
|
||||
"""tools/xai_http.has_xai_credentials must read a non-ASCII auth.json.
|
||||
|
||||
Pre-fix, a UTF-8 store with a non-ASCII label raised UnicodeDecodeError
|
||||
here (cp1252 default on Windows), the broad except swallowed it, and
|
||||
xAI OAuth silently looked absent.
|
||||
"""
|
||||
# No XAI_API_KEY env → force the auth.json code path.
|
||||
monkeypatch.delenv("XAI_API_KEY", raising=False)
|
||||
store = {
|
||||
"version": auth.AUTH_STORE_VERSION,
|
||||
"providers": {
|
||||
"xai-oauth": {
|
||||
# CJK label → UTF-8 bytes (e.g. 0xE5..) that cp1252 cannot
|
||||
# decode, so a no-encoding read raises UnicodeDecodeError.
|
||||
"label": "工作账号",
|
||||
"tokens": {"access_token": "tok"},
|
||||
}
|
||||
},
|
||||
}
|
||||
_write_utf8(hermes_home / "auth.json", store)
|
||||
|
||||
from tools.xai_http import has_xai_credentials
|
||||
|
||||
assert has_xai_credentials() is True
|
||||
|
||||
def test_auxiliary_nous_provider_reads_non_ascii_store(self, hermes_home, windows_default_encoding, monkeypatch):
|
||||
"""agent/auxiliary_client's Nous-provider lookup reads the same store.
|
||||
|
||||
The lookup returns None on any read failure, silently disabling Nous as
|
||||
the auxiliary (vision/summarization) provider. A non-ASCII label must
|
||||
not trigger that path.
|
||||
"""
|
||||
store = {
|
||||
"version": auth.AUTH_STORE_VERSION,
|
||||
"active_provider": "nous",
|
||||
"providers": {"nous": {"agent_key": "k", "label": "工作账号"}},
|
||||
}
|
||||
_write_utf8(hermes_home / "auth.json", store)
|
||||
|
||||
import agent.auxiliary_client as aux
|
||||
|
||||
# _AUTH_JSON_PATH is resolved at module import time, so the
|
||||
# HERMES_HOME env from the fixture doesn't reach it — point it at
|
||||
# the tmp store explicitly.
|
||||
monkeypatch.setattr(aux, "_AUTH_JSON_PATH", hermes_home / "auth.json")
|
||||
|
||||
# _read_nous_auth consults the credential pool FIRST and returns early
|
||||
# when a pool entry exists, never reaching the auth.json read. Force the
|
||||
# pool-absent path so the auth.json code under test actually runs.
|
||||
monkeypatch.setattr(aux, "_select_pool_entry", lambda _provider: (False, None))
|
||||
|
||||
provider = aux._read_nous_auth()
|
||||
assert provider is not None
|
||||
assert provider.get("agent_key") == "k"
|
||||
|
||||
def test_read_shared_nous_state_reads_non_ascii_store(
|
||||
self, tmp_path, monkeypatch, windows_default_encoding
|
||||
):
|
||||
"""hermes_cli.auth._read_shared_nous_state must read a non-ASCII store.
|
||||
|
||||
The shared Nous store (``nous_auth.json``) is written as UTF-8. A
|
||||
non-ASCII field (e.g. an accented display name) must not cause the
|
||||
read to raise under the Windows-default-encoding fixture and be
|
||||
silently swallowed — which would drop the user's shared OAuth
|
||||
credentials and force a device-code re-login.
|
||||
"""
|
||||
# The shared-store path has a seat belt that refuses to resolve to the
|
||||
# real user's store under pytest; pin it to a tmp dir explicitly.
|
||||
shared_dir = tmp_path / "shared"
|
||||
monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(shared_dir))
|
||||
|
||||
payload = {
|
||||
"refresh_token": "rt",
|
||||
"access_token": "at",
|
||||
# Non-ASCII display name → UTF-8 bytes cp1252 cannot decode.
|
||||
"display_name": "Réne — Noël",
|
||||
}
|
||||
_write_utf8(shared_dir / "nous_auth.json", payload)
|
||||
|
||||
provider = auth._read_shared_nous_state()
|
||||
assert provider is not None
|
||||
assert provider["access_token"] == "at"
|
||||
assert provider["refresh_token"] == "rt"
|
||||
# The non-ASCII field round-trips intact.
|
||||
assert provider["display_name"] == "Réne — Noël"
|
||||
|
||||
def test_has_any_provider_configured_reads_non_ascii_auth_store(
|
||||
self, hermes_home, monkeypatch, windows_default_encoding
|
||||
):
|
||||
"""hermes_cli.main._has_any_provider_configured reads auth.json.
|
||||
|
||||
When the active provider's auth.json store contains a non-ASCII
|
||||
label, the read must not raise under the Windows-default-encoding
|
||||
fixture (which would be swallowed and make a configured provider
|
||||
look absent, wrongly re-triggering the setup wizard).
|
||||
"""
|
||||
store = {
|
||||
"version": auth.AUTH_STORE_VERSION,
|
||||
"active_provider": "openai-codex",
|
||||
"providers": {
|
||||
"openai-codex": {
|
||||
"auth_mode": "chatgpt",
|
||||
"label": "工作账号", # non-ASCII CJK
|
||||
"tokens": {"access_token": "a", "refresh_token": "r"},
|
||||
}
|
||||
},
|
||||
}
|
||||
_write_utf8(hermes_home / "auth.json", store)
|
||||
|
||||
import hermes_cli.auth as auth_mod
|
||||
import hermes_cli.main as main_mod
|
||||
|
||||
# No provider env vars, no .env, and PROVIDER_REGISTRY lookups report
|
||||
# not-logged-in — so the function reaches the auth.json branch and the
|
||||
# result is driven by reading the (non-ASCII) store + the active
|
||||
# provider's status. The active provider IS logged in, so the UTF-8
|
||||
# read must succeed and surface True.
|
||||
def fake_status(provider_id=None):
|
||||
if provider_id == "openai-codex":
|
||||
return {"logged_in": True}
|
||||
return {"logged_in": False}
|
||||
|
||||
monkeypatch.setattr(auth_mod, "get_auth_status", fake_status)
|
||||
# _has_any_provider_configured imports get_auth_status lazily from
|
||||
# hermes_cli.auth; patch it on the source module so the local import
|
||||
# sees the fake.
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
||||
|
||||
assert main_mod._has_any_provider_configured() is True
|
||||
|
||||
def test_managed_tool_gateway_reads_non_ascii_nous_state(
|
||||
self, hermes_home, windows_default_encoding
|
||||
):
|
||||
"""tools.managed_tool_gateway._read_nous_provider_state reads auth.json.
|
||||
|
||||
The Nous provider entry can carry a non-ASCII label. Under the
|
||||
Windows-default-encoding fixture a no-encoding read raises and the
|
||||
broad except swallows it, returning None — so the gateway treats
|
||||
Nous as unconfigured.
|
||||
"""
|
||||
store = {
|
||||
"version": auth.AUTH_STORE_VERSION,
|
||||
"providers": {
|
||||
"nous": {
|
||||
"agent_key": "k",
|
||||
# Non-ASCII label → UTF-8 bytes cp1252 cannot decode.
|
||||
"label": "工作账号",
|
||||
}
|
||||
},
|
||||
}
|
||||
_write_utf8(hermes_home / "auth.json", store)
|
||||
|
||||
from tools.managed_tool_gateway import _read_nous_provider_state
|
||||
|
||||
nous = _read_nous_provider_state()
|
||||
assert nous is not None
|
||||
assert nous.get("agent_key") == "k"
|
||||
# The non-ASCII label round-trips intact.
|
||||
assert nous.get("label") == "工作账号"
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Regression tests for TOCTOU-safe credential file writers in ``hermes_cli.auth``.
|
||||
|
||||
Background
|
||||
==========
|
||||
The three writers below used to create a temp file via ``Path.write_text`` /
|
||||
``Path.open('w')`` and only ``chmod``'d it to ``0o600`` afterward. Between
|
||||
create and chmod the file existed at the process umask (typically ``0o644``),
|
||||
briefly exposing OAuth tokens to other local users on multi-user hosts. The
|
||||
fix switches them to ``os.open(O_EXCL, mode=0o600)`` + ``os.fdopen`` +
|
||||
``fsync`` so the file is atomic at ``0o600`` on creation. Mirrors the fixes
|
||||
shipped for ``agent/google_oauth.py`` (#19673) and ``tools/mcp_oauth.py``
|
||||
(#21148).
|
||||
|
||||
These tests stay green only while the token file and its parent directory
|
||||
end up at ``0o600`` / ``0o700`` after every write. POSIX-only — the mode-bit
|
||||
enforcement does not exist on Windows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
sys.platform.startswith("win"),
|
||||
reason="POSIX mode bits not enforced on Windows",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _save_auth_store (~/.hermes/auth.json — every native OAuth provider)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_save_auth_store_writes_0o600_with_0o700_parent(tmp_path, monkeypatch):
|
||||
"""``_save_auth_store`` must land ``auth.json`` at 0o600 and parent at 0o700."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
old_umask = os.umask(0o022) # make the race observable if it regresses
|
||||
try:
|
||||
from hermes_cli import auth as auth_mod
|
||||
|
||||
auth_store = {
|
||||
"version": auth_mod.AUTH_STORE_VERSION,
|
||||
"providers": {"openai-codex": {"tokens": {"access_token": "secret-x"}}},
|
||||
"active_provider": "openai-codex",
|
||||
}
|
||||
auth_path = auth_mod._save_auth_store(auth_store)
|
||||
finally:
|
||||
os.umask(old_umask)
|
||||
|
||||
mode = stat.S_IMODE(auth_path.stat().st_mode)
|
||||
parent_mode = stat.S_IMODE(auth_path.parent.stat().st_mode)
|
||||
|
||||
assert mode == 0o600, (
|
||||
f"auth.json mode 0o{mode:o} != 0o600 — TOCTOU race regressed"
|
||||
)
|
||||
assert parent_mode == 0o700, (
|
||||
f"auth.json parent dir mode 0o{parent_mode:o} != 0o700 — siblings can traverse"
|
||||
)
|
||||
|
||||
# Content survived the rewrite
|
||||
data = json.loads(auth_path.read_text())
|
||||
assert data["providers"]["openai-codex"]["tokens"]["access_token"] == "secret-x"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _save_qwen_cli_tokens (Qwen CLI OAuth tokens)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_save_qwen_cli_tokens_writes_0o600_with_0o700_parent(tmp_path, monkeypatch):
|
||||
"""``_save_qwen_cli_tokens`` must land the token file at 0o600 and parent at 0o700."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
# The Qwen CLI auth path lives under $HOME/.qwen by default — isolate it.
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
old_umask = os.umask(0o022)
|
||||
try:
|
||||
from hermes_cli import auth as auth_mod
|
||||
|
||||
tokens = {
|
||||
"access_token": "qwen-secret",
|
||||
"refresh_token": "qwen-refresh",
|
||||
"token_type": "Bearer",
|
||||
"expiry_date": 123,
|
||||
}
|
||||
auth_path = auth_mod._save_qwen_cli_tokens(tokens)
|
||||
finally:
|
||||
os.umask(old_umask)
|
||||
|
||||
mode = stat.S_IMODE(auth_path.stat().st_mode)
|
||||
parent_mode = stat.S_IMODE(auth_path.parent.stat().st_mode)
|
||||
|
||||
assert mode == 0o600, (
|
||||
f"Qwen token file mode 0o{mode:o} != 0o600 — TOCTOU race regressed"
|
||||
)
|
||||
assert parent_mode == 0o700, (
|
||||
f"Qwen token parent dir mode 0o{parent_mode:o} != 0o700"
|
||||
)
|
||||
|
||||
data = json.loads(auth_path.read_text())
|
||||
assert data["access_token"] == "qwen-secret"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Nous shared-credential store write (inside _write_shared_nous_state)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_shared_nous_store_writes_0o600_with_0o700_parent(tmp_path, monkeypatch):
|
||||
"""The Nous shared-credential store must land at 0o600 / parent 0o700."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
# _nous_shared_store_path() refuses to touch the real shared store during
|
||||
# pytest runs; redirect it into tmp_path explicitly. Use a distinct
|
||||
# subdirectory name (``shared_override``) so the guard's "real user
|
||||
# home" reference — which currently tracks HERMES_HOME via
|
||||
# get_default_hermes_root() — can't collide with our override and
|
||||
# falsely claim we're writing to the real user's shared store.
|
||||
monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared_override"))
|
||||
old_umask = os.umask(0o022)
|
||||
try:
|
||||
from hermes_cli import auth as auth_mod
|
||||
|
||||
state = {
|
||||
"access_token": "nous-access-xxx",
|
||||
"refresh_token": "nous-refresh-xxx",
|
||||
"token_type": "Bearer",
|
||||
"scope": "openid profile",
|
||||
"client_id": "test-client",
|
||||
"obtained_at": "2026-01-01T00:00:00Z",
|
||||
"expires_at": "2026-01-01T01:00:00Z",
|
||||
}
|
||||
auth_mod._write_shared_nous_state(state)
|
||||
path = auth_mod._nous_shared_store_path()
|
||||
finally:
|
||||
os.umask(old_umask)
|
||||
|
||||
assert path.exists(), "shared Nous store was not written"
|
||||
mode = stat.S_IMODE(path.stat().st_mode)
|
||||
parent_mode = stat.S_IMODE(path.parent.stat().st_mode)
|
||||
|
||||
assert mode == 0o600, (
|
||||
f"Nous shared store mode 0o{mode:o} != 0o600 — TOCTOU race regressed"
|
||||
)
|
||||
assert parent_mode == 0o700, (
|
||||
f"Nous shared store parent dir mode 0o{parent_mode:o} != 0o700"
|
||||
)
|
||||
|
||||
data = json.loads(path.read_text())
|
||||
assert data["refresh_token"] == "nous-refresh-xxx"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Atomicity: verify ``os.open`` is called with an explicit 0o600 mode.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_save_auth_store_uses_os_open_with_0o600_mode(tmp_path, monkeypatch):
|
||||
"""Regression: the writer must call ``os.open`` with an explicit restricted
|
||||
mode so the file is created at 0o600 atomically — closing the TOCTOU
|
||||
window the previous ``Path.open('w')`` left open (fd inherited process
|
||||
umask and was briefly 0o644 before post-write chmod)."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
observed_opens: list[tuple[str, int, int]] = []
|
||||
real_os_open = os.open
|
||||
|
||||
def spying_os_open(path, flags, mode=0o777, *args, **kwargs):
|
||||
observed_opens.append((str(path), flags, mode))
|
||||
return real_os_open(path, flags, mode, *args, **kwargs)
|
||||
|
||||
with patch.object(os, "open", spying_os_open):
|
||||
from hermes_cli import auth as auth_mod
|
||||
|
||||
auth_mod._save_auth_store(
|
||||
{"version": auth_mod.AUTH_STORE_VERSION, "providers": {}}
|
||||
)
|
||||
|
||||
auth_tmp_opens = [
|
||||
(p, fl, m) for (p, fl, m) in observed_opens if "auth.json.tmp" in p
|
||||
]
|
||||
assert auth_tmp_opens, (
|
||||
f"os.open was never called for the auth.json temp file; "
|
||||
f"observed={observed_opens!r}"
|
||||
)
|
||||
for path, flags, mode in auth_tmp_opens:
|
||||
assert flags & os.O_CREAT, f"auth.json temp open missing O_CREAT: path={path}"
|
||||
assert flags & os.O_EXCL, (
|
||||
f"auth.json temp open missing O_EXCL — TOCTOU-safe pattern regressed: "
|
||||
f"path={path}, flags={flags}"
|
||||
)
|
||||
# Must be exactly S_IRUSR | S_IWUSR (0o600) — no group/other bits.
|
||||
expected = stat.S_IRUSR | stat.S_IWUSR
|
||||
assert mode == expected, (
|
||||
f"auth.json temp open mode 0o{mode:o} != 0o{expected:o} — "
|
||||
f"umask would apply and potentially expose tokens"
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Tests for placeholder API key detection in hermes_cli.auth."""
|
||||
|
||||
from hermes_cli.auth import has_usable_secret
|
||||
|
||||
|
||||
def test_has_usable_secret_rejects_documented_placeholder_key() -> None:
|
||||
"""Network-exposed API server key must reject static documentation placeholders."""
|
||||
assert not has_usable_secret("your_api_key_here", min_length=8)
|
||||
|
||||
|
||||
def test_has_usable_secret_accepts_generated_key() -> None:
|
||||
"""Random-looking keys should still be accepted."""
|
||||
assert has_usable_secret("b4d59f7fe8b857d0b367ef0f5710b6a4", min_length=8)
|
||||
@@ -0,0 +1,992 @@
|
||||
"""Tests for xAI Grok OAuth — tokens stored in Hermes auth store (~/.hermes/auth.json)."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.auth import (
|
||||
AuthError,
|
||||
DEFAULT_XAI_OAUTH_BASE_URL,
|
||||
PROVIDER_REGISTRY,
|
||||
XAI_OAUTH_CLIENT_ID,
|
||||
XAI_OAUTH_SCOPE,
|
||||
_read_xai_oauth_tokens,
|
||||
_refresh_xai_oauth_tokens,
|
||||
_save_xai_oauth_tokens,
|
||||
_xai_access_token_is_expiring,
|
||||
_xai_oauth_poll_device_token,
|
||||
_xai_oauth_request_device_code,
|
||||
_xai_validate_inference_base_url,
|
||||
format_auth_error,
|
||||
get_xai_oauth_auth_status,
|
||||
refresh_xai_oauth_pure,
|
||||
resolve_provider,
|
||||
resolve_xai_oauth_runtime_credentials,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _setup_hermes_auth(
|
||||
hermes_home: Path,
|
||||
*,
|
||||
access_token: str = "access",
|
||||
refresh_token: str = "refresh",
|
||||
discovery: dict | None = None,
|
||||
auth_mode: str = "oauth_pkce",
|
||||
):
|
||||
"""Write xAI OAuth tokens into the Hermes auth store at the given root."""
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
state = {
|
||||
"tokens": {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"id_token": "",
|
||||
"expires_in": 3600,
|
||||
"token_type": "Bearer",
|
||||
},
|
||||
"last_refresh": "2026-05-14T00:00:00Z",
|
||||
"auth_mode": auth_mode,
|
||||
}
|
||||
if discovery is not None:
|
||||
state["discovery"] = discovery
|
||||
auth_store = {
|
||||
"version": 1,
|
||||
"active_provider": "xai-oauth",
|
||||
"providers": {"xai-oauth": state},
|
||||
}
|
||||
auth_file = hermes_home / "auth.json"
|
||||
auth_file.write_text(json.dumps(auth_store, indent=2))
|
||||
return auth_file
|
||||
|
||||
|
||||
def _jwt_with_exp(exp_epoch: int) -> str:
|
||||
"""Build a minimal JWT-shaped string with the given exp claim."""
|
||||
payload = {"exp": exp_epoch}
|
||||
encoded = (
|
||||
base64.urlsafe_b64encode(json.dumps(payload).encode("utf-8"))
|
||||
.rstrip(b"=")
|
||||
.decode("utf-8")
|
||||
)
|
||||
return f"h.{encoded}.s"
|
||||
|
||||
|
||||
class _StubHTTPResponse:
|
||||
def __init__(self, status_code: int, payload):
|
||||
self.status_code = status_code
|
||||
self._payload = payload
|
||||
self.text = json.dumps(payload) if isinstance(payload, (dict, list)) else str(payload)
|
||||
|
||||
def json(self):
|
||||
if isinstance(self._payload, Exception):
|
||||
raise self._payload
|
||||
return self._payload
|
||||
|
||||
|
||||
class _StubHTTPClient:
|
||||
def __init__(self, response):
|
||||
self._response = response
|
||||
self.last_call = None
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def post(self, *args, **kwargs):
|
||||
self.last_call = ("post", args, kwargs)
|
||||
return self._response
|
||||
|
||||
|
||||
def _patch_httpx_client(monkeypatch, response):
|
||||
holder = {"client": None}
|
||||
|
||||
def _factory(*args, **kwargs):
|
||||
client = _StubHTTPClient(response)
|
||||
holder["client"] = client
|
||||
return client
|
||||
|
||||
monkeypatch.setattr("hermes_cli.auth.httpx.Client", _factory)
|
||||
return holder
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants and registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_xai_oauth_provider_registered():
|
||||
assert "xai-oauth" in PROVIDER_REGISTRY
|
||||
pconfig = PROVIDER_REGISTRY["xai-oauth"]
|
||||
assert pconfig.id == "xai-oauth"
|
||||
assert pconfig.auth_type == "oauth_external"
|
||||
assert pconfig.inference_base_url == DEFAULT_XAI_OAUTH_BASE_URL
|
||||
|
||||
|
||||
def test_resolve_provider_normalizes_xai_oauth_aliases():
|
||||
assert resolve_provider("xai-oauth") == "xai-oauth"
|
||||
assert resolve_provider("grok-oauth") == "xai-oauth"
|
||||
assert resolve_provider("x-ai-oauth") == "xai-oauth"
|
||||
assert resolve_provider("xai-grok-oauth") == "xai-oauth"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JWT expiry detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device-code flow
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token roundtrip + reads
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_save_and_read_xai_oauth_tokens_roundtrip(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
(hermes_home / "auth.json").write_text(json.dumps({"version": 1, "providers": {}}))
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
_save_xai_oauth_tokens(
|
||||
{
|
||||
"access_token": "at-1",
|
||||
"refresh_token": "rt-1",
|
||||
"id_token": "",
|
||||
"expires_in": 3600,
|
||||
"token_type": "Bearer",
|
||||
},
|
||||
discovery={"token_endpoint": "https://auth.x.ai/oauth2/token"},
|
||||
redirect_uri="http://127.0.0.1:56121/callback",
|
||||
)
|
||||
data = _read_xai_oauth_tokens()
|
||||
assert data["tokens"]["access_token"] == "at-1"
|
||||
assert data["tokens"]["refresh_token"] == "rt-1"
|
||||
assert data["redirect_uri"] == "http://127.0.0.1:56121/callback"
|
||||
assert data["discovery"]["token_endpoint"] == "https://auth.x.ai/oauth2/token"
|
||||
|
||||
|
||||
def test_refresh_xai_oauth_tokens_preserves_active_provider(tmp_path, monkeypatch):
|
||||
"""Token refresh must not flip active_provider away from the chat provider."""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
near = _jwt_with_exp(int(time.time()) + 30)
|
||||
_setup_hermes_auth(hermes_home, access_token=near, refresh_token="rt-old")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
auth_path = hermes_home / "auth.json"
|
||||
raw = json.loads(auth_path.read_text())
|
||||
raw["active_provider"] = "openrouter"
|
||||
auth_path.write_text(json.dumps(raw))
|
||||
|
||||
new_access = _jwt_with_exp(int(time.time()) + 7200)
|
||||
|
||||
def _fake_pure(access_token, refresh_token, **kwargs):
|
||||
return {
|
||||
"access_token": new_access,
|
||||
"refresh_token": "rt-new",
|
||||
"id_token": "",
|
||||
"expires_in": 3600,
|
||||
"token_type": "Bearer",
|
||||
"last_refresh": "2026-07-25T12:00:00Z",
|
||||
}
|
||||
|
||||
monkeypatch.setattr("hermes_cli.auth.refresh_xai_oauth_pure", _fake_pure)
|
||||
|
||||
tokens = _read_xai_oauth_tokens()["tokens"]
|
||||
_refresh_xai_oauth_tokens(
|
||||
tokens,
|
||||
token_endpoint="https://auth.x.ai/oauth2/token",
|
||||
timeout_seconds=5.0,
|
||||
)
|
||||
|
||||
after = json.loads(auth_path.read_text())
|
||||
assert after["active_provider"] == "openrouter"
|
||||
assert after["providers"]["xai-oauth"]["tokens"]["access_token"] == new_access
|
||||
|
||||
|
||||
def test_read_xai_oauth_tokens_missing(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
(hermes_home / "auth.json").write_text(json.dumps({"version": 1, "providers": {}}))
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
with pytest.raises(AuthError) as exc:
|
||||
_read_xai_oauth_tokens()
|
||||
assert exc.value.code == "xai_auth_missing"
|
||||
assert exc.value.relogin_required is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runtime credential resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_resolve_xai_runtime_credentials_refreshes_expiring_token(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / "hermes"
|
||||
expiring = _jwt_with_exp(int(time.time()) - 10)
|
||||
_setup_hermes_auth(
|
||||
hermes_home,
|
||||
access_token=expiring,
|
||||
refresh_token="rt-old",
|
||||
discovery={"token_endpoint": "https://auth.x.ai/oauth2/token"},
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
new_access = _jwt_with_exp(int(time.time()) + 2 * 60 * 60)
|
||||
called = {"count": 0}
|
||||
|
||||
def _fake_refresh(tokens, **kwargs):
|
||||
called["count"] += 1
|
||||
updated = dict(tokens)
|
||||
updated["access_token"] = new_access
|
||||
updated["refresh_token"] = "rt-new"
|
||||
return updated
|
||||
|
||||
monkeypatch.setattr("hermes_cli.auth._refresh_xai_oauth_tokens", _fake_refresh)
|
||||
|
||||
creds = resolve_xai_oauth_runtime_credentials()
|
||||
assert called["count"] == 1
|
||||
assert creds["api_key"] == new_access
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inference base-URL host guard (xai-oauth bearer leak protection)
|
||||
#
|
||||
# The xAI OAuth bearer is a high-value, long-lived SuperGrok credential.
|
||||
# ``XAI_BASE_URL`` / ``HERMES_XAI_BASE_URL`` are a credential-leak vector
|
||||
# unless the host is pinned to the xAI origin. These tests cover the
|
||||
# accept/reject matrix for `_xai_validate_inference_base_url` and confirm
|
||||
# the runtime resolver falls back to the default on rejection rather than
|
||||
# leaking the bearer to an attacker-controlled endpoint.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Quarantine: terminal refresh failure clears dead tokens (#28155 sibling)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_STALE_XAI_OAUTH_STATE = {
|
||||
"tokens": {
|
||||
"access_token": "dead-access-token",
|
||||
"refresh_token": "dead-refresh-token",
|
||||
"id_token": "",
|
||||
"expires_in": 3600,
|
||||
"token_type": "Bearer",
|
||||
},
|
||||
"discovery": {"token_endpoint": "https://auth.x.ai/oauth2/token"},
|
||||
"redirect_uri": "http://127.0.0.1:51827/callback",
|
||||
"last_refresh": "2000-01-01T00:00:00Z",
|
||||
"auth_mode": "oauth_pkce",
|
||||
}
|
||||
|
||||
|
||||
def _seed_xai_oauth_state(
|
||||
hermes_home: Path, state: dict, *, active_provider: str = "xai-oauth"
|
||||
) -> None:
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
auth_store = {
|
||||
"version": 1,
|
||||
"active_provider": active_provider,
|
||||
"providers": {"xai-oauth": state},
|
||||
}
|
||||
(hermes_home / "auth.json").write_text(json.dumps(auth_store, indent=2))
|
||||
|
||||
|
||||
def test_resolve_credentials_quarantines_dead_tokens_on_terminal_refresh_failure(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Terminal refresh failure (relogin_required=True, code=xai_refresh_failed)
|
||||
must clear access_token/refresh_token from auth.json and write a
|
||||
last_auth_error marker so subsequent calls fail fast without a network retry.
|
||||
Mirrors the credential_pool.py quarantine for the singleton/direct resolve path.
|
||||
"""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
_seed_xai_oauth_state(hermes_home, dict(_STALE_XAI_OAUTH_STATE), active_provider="nous")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
def _terminal_refresh(tokens, **kwargs):
|
||||
raise AuthError(
|
||||
"xAI token refresh failed. Response: invalid_grant",
|
||||
provider="xai-oauth",
|
||||
code="xai_refresh_failed",
|
||||
relogin_required=True,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("hermes_cli.auth._refresh_xai_oauth_tokens", _terminal_refresh)
|
||||
|
||||
with pytest.raises(AuthError) as exc_info:
|
||||
resolve_xai_oauth_runtime_credentials(force_refresh=True)
|
||||
|
||||
assert exc_info.value.code == "xai_refresh_failed"
|
||||
assert exc_info.value.relogin_required is True
|
||||
|
||||
raw = json.loads((hermes_home / "auth.json").read_text())
|
||||
tokens = raw["providers"]["xai-oauth"]["tokens"]
|
||||
|
||||
# Dead OAuth fields must be cleared.
|
||||
assert "access_token" not in tokens
|
||||
assert "refresh_token" not in tokens
|
||||
|
||||
# Non-credential metadata must be preserved.
|
||||
assert tokens.get("token_type") == "Bearer"
|
||||
|
||||
# Structured diagnostic blob must be written.
|
||||
err = raw["providers"]["xai-oauth"].get("last_auth_error")
|
||||
assert isinstance(err, dict)
|
||||
assert err["provider"] == "xai-oauth"
|
||||
assert err["code"] == "xai_refresh_failed"
|
||||
assert err["reason"] == "runtime_refresh_failure"
|
||||
assert err["relogin_required"] is True
|
||||
assert "at" in err
|
||||
|
||||
# Active provider must be unchanged.
|
||||
assert raw["active_provider"] == "nous"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth status surface
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_xai_oauth_auth_status_logged_out(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
(hermes_home / "auth.json").write_text(json.dumps({"version": 1, "providers": {}}))
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
status = get_xai_oauth_auth_status()
|
||||
assert status["logged_in"] is False
|
||||
assert "error" in status
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# refresh_xai_oauth_pure error handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_refresh_xai_oauth_pure_403_marked_tier_denied_not_relogin(monkeypatch):
|
||||
"""403 from xAI's token endpoint is tier/entitlement, not stale tokens.
|
||||
|
||||
Regression test for #26847 — xAI's backend has been seen to 403
|
||||
standard SuperGrok subscribers despite the in-app subscription
|
||||
being active. Re-running ``hermes model`` won't help in that
|
||||
case, so the AuthError must NOT set ``relogin_required=True``,
|
||||
and must carry the dedicated ``xai_oauth_tier_denied`` code so
|
||||
``format_auth_error`` doesn't append the misleading re-auth hint.
|
||||
"""
|
||||
response = _StubHTTPResponse(403, {"error": "permission_denied"})
|
||||
_patch_httpx_client(monkeypatch, response)
|
||||
with pytest.raises(AuthError) as exc:
|
||||
refresh_xai_oauth_pure(
|
||||
"at", "rt", token_endpoint="https://auth.x.ai/oauth2/token"
|
||||
)
|
||||
assert exc.value.code == "xai_oauth_tier_denied"
|
||||
assert exc.value.relogin_required is False
|
||||
message = str(exc.value).lower()
|
||||
assert "403" in message
|
||||
assert "xai_api_key" in message
|
||||
assert "tier" in message
|
||||
|
||||
|
||||
def test_format_auth_error_tier_denied_does_not_suggest_relogin():
|
||||
"""``xai_oauth_tier_denied`` must not append the re-authenticate hint.
|
||||
|
||||
Regression for #26847: telling a tier-gated user to ``hermes model``
|
||||
is actively wrong — re-logging in won't change xAI's allowlist
|
||||
decision. The full message (with ``XAI_API_KEY`` fallback) is built
|
||||
into the error itself.
|
||||
"""
|
||||
err = AuthError(
|
||||
"xAI token refresh failed with HTTP 403. Response: forbidden. "
|
||||
"This OAuth account is not authorized for xAI API access — "
|
||||
"xAI may be restricting API/OAuth use to specific SuperGrok tiers. "
|
||||
"Set ``XAI_API_KEY`` and switch to ``provider: xai``.",
|
||||
provider="xai-oauth",
|
||||
code="xai_oauth_tier_denied",
|
||||
relogin_required=False,
|
||||
)
|
||||
rendered = format_auth_error(err)
|
||||
assert "re-authenticate" not in rendered.lower()
|
||||
assert "hermes model" not in rendered.lower()
|
||||
assert "XAI_API_KEY" in rendered
|
||||
|
||||
|
||||
def test_xai_oauth_discovery_raises_typed_error_on_malformed_json(monkeypatch):
|
||||
"""Discovery is a cold-start, one-time fetch. If the response is HTTP
|
||||
200 with a non-JSON body (corporate proxy / captive portal returning
|
||||
HTML), surface a typed AuthError rather than letting the
|
||||
``json.JSONDecodeError`` escape — so the message reads as an auth
|
||||
problem instead of an internal parsing crash."""
|
||||
from hermes_cli.auth import _xai_oauth_discovery
|
||||
|
||||
class _BadJSON:
|
||||
status_code = 200
|
||||
|
||||
def json(self):
|
||||
raise ValueError("Expecting value: line 1 column 1 (char 0)")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth.httpx.get",
|
||||
lambda *a, **kw: _BadJSON(),
|
||||
)
|
||||
with pytest.raises(AuthError) as exc:
|
||||
_xai_oauth_discovery()
|
||||
assert exc.value.code == "xai_discovery_invalid_json"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OIDC discovery endpoint origin/scheme validation (MITM hardening)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_refresh_xai_oauth_pure_rejects_non_https_token_endpoint(monkeypatch):
|
||||
"""A poisoned auth.json (from MITM during initial discovery, or an older
|
||||
Hermes that didn't validate) must not be silently honored on the refresh
|
||||
hot path. A non-HTTPS ``token_endpoint`` would leak the refresh_token in
|
||||
cleartext on every refresh; refuse before the POST."""
|
||||
# No HTTP stub installed — refresh must fail at validation, not at POST.
|
||||
with pytest.raises(AuthError) as exc:
|
||||
refresh_xai_oauth_pure(
|
||||
"at", "rt", token_endpoint="http://auth.x.ai/oauth2/token"
|
||||
)
|
||||
assert exc.value.code == "xai_discovery_invalid"
|
||||
|
||||
|
||||
|
||||
|
||||
def test_refresh_xai_oauth_pure_accepts_apex_and_subdomain_endpoints(monkeypatch):
|
||||
"""The validator must accept BOTH the bare xAI apex (``x.ai``) and any
|
||||
``*.x.ai`` subdomain (e.g. ``auth.x.ai`` today, future migrations to
|
||||
``accounts.x.ai`` etc.). Without subdomain support we'd lock the
|
||||
integration to whatever xAI happens to use today."""
|
||||
new_access = _jwt_with_exp(int(time.time()) + 2 * 60 * 60)
|
||||
response = _StubHTTPResponse(
|
||||
200,
|
||||
{"access_token": new_access, "expires_in": 3600, "token_type": "Bearer"},
|
||||
)
|
||||
_patch_httpx_client(monkeypatch, response)
|
||||
# auth.x.ai (current production)
|
||||
updated = refresh_xai_oauth_pure(
|
||||
"at", "rt", token_endpoint="https://auth.x.ai/oauth2/token"
|
||||
)
|
||||
assert updated["access_token"] == new_access
|
||||
# hypothetical migration to accounts.x.ai
|
||||
_patch_httpx_client(monkeypatch, response)
|
||||
updated2 = refresh_xai_oauth_pure(
|
||||
"at", "rt", token_endpoint="https://accounts.x.ai/token"
|
||||
)
|
||||
assert updated2["access_token"] == new_access
|
||||
|
||||
|
||||
def test_xai_oauth_discovery_validates_endpoints(monkeypatch):
|
||||
"""The discovery response itself goes through endpoint validation, so a
|
||||
one-time MITM during initial login cannot poison ``auth.json`` with an
|
||||
attacker-controlled ``token_endpoint``. (The persistence is what makes
|
||||
this attack worth defending against — one MITM = forever credential
|
||||
leak.)"""
|
||||
from hermes_cli.auth import _xai_oauth_discovery
|
||||
|
||||
class _StubGetResponse:
|
||||
status_code = 200
|
||||
|
||||
def __init__(self, payload):
|
||||
self._payload = payload
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
def _fake_get(url, headers=None, timeout=None):
|
||||
return _StubGetResponse({
|
||||
"authorization_endpoint": "https://auth.x.ai/oauth2/authorize",
|
||||
"token_endpoint": "https://evil.example.com/token", # poisoned
|
||||
})
|
||||
|
||||
monkeypatch.setattr("hermes_cli.auth.httpx.get", _fake_get)
|
||||
with pytest.raises(AuthError) as exc:
|
||||
_xai_oauth_discovery()
|
||||
assert exc.value.code == "xai_discovery_invalid"
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pool seeding from singleton
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_credential_pool_seeds_xai_oauth_from_singleton(tmp_path, monkeypatch):
|
||||
"""After `hermes model` -> xai-oauth, the singleton holds tokens. load_pool
|
||||
must surface that as a pool entry so `hermes auth list` reflects truth and
|
||||
refreshes route through the pool consistently with codex.
|
||||
|
||||
Device code is the only supported xAI OAuth flow, so the singleton is
|
||||
always surfaced as ``device_code``."""
|
||||
from agent.credential_pool import load_pool
|
||||
|
||||
hermes_home = tmp_path / "hermes"
|
||||
fresh = _jwt_with_exp(int(time.time()) + 2 * 60 * 60)
|
||||
_setup_hermes_auth(hermes_home, access_token=fresh, refresh_token="rt-1")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
pool = load_pool("xai-oauth")
|
||||
assert pool.has_credentials()
|
||||
entries = pool.entries()
|
||||
assert len(entries) == 1
|
||||
entry = entries[0]
|
||||
assert entry.access_token == fresh
|
||||
assert entry.refresh_token == "rt-1"
|
||||
assert entry.source == "device_code"
|
||||
assert entry.base_url == DEFAULT_XAI_OAUTH_BASE_URL
|
||||
|
||||
|
||||
def test_credential_pool_device_code_seed_respects_suppression(tmp_path, monkeypatch):
|
||||
from agent.credential_pool import load_pool
|
||||
from hermes_cli.auth import suppress_credential_source
|
||||
|
||||
hermes_home = tmp_path / "hermes"
|
||||
fresh = _jwt_with_exp(int(time.time()) + 2 * 60 * 60)
|
||||
_setup_hermes_auth(
|
||||
hermes_home,
|
||||
access_token=fresh,
|
||||
auth_mode="oauth_device_code",
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
suppress_credential_source("xai-oauth", "device_code")
|
||||
|
||||
pool = load_pool("xai-oauth")
|
||||
assert not pool.has_credentials()
|
||||
|
||||
|
||||
def test_auth_remove_xai_oauth_clears_singleton_and_sticks(tmp_path, monkeypatch):
|
||||
"""End-to-end regression: ``hermes auth remove xai-oauth 1`` for a
|
||||
singleton-seeded entry must clear auth.json providers.xai-oauth AND
|
||||
suppress further re-seeding — otherwise the next ``load_pool`` call
|
||||
silently resurrects the entry from the still-present singleton, making
|
||||
the user-facing removal a no-op (the entry reappears on the next
|
||||
invocation with no warning).
|
||||
|
||||
The bug pre-fix: there was no RemovalStep registered for the
|
||||
xai-oauth singleton source, so ``find_removal_step`` returned None
|
||||
and ``auth_remove_command`` fell through to the "unregistered source —
|
||||
nothing to clean up" branch. That branch is correct for ``manual``
|
||||
entries (pool-only) but wrong for singleton-seeded ``device_code``
|
||||
entries (auth.json singleton survives the in-memory removal)."""
|
||||
from agent.credential_pool import load_pool
|
||||
from hermes_cli.auth_commands import auth_remove_command
|
||||
from types import SimpleNamespace
|
||||
|
||||
hermes_home = tmp_path / "hermes"
|
||||
fresh = _jwt_with_exp(int(time.time()) + 2 * 60 * 60)
|
||||
_setup_hermes_auth(hermes_home, access_token=fresh, refresh_token="rt-1")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
# Confirm pre-state: pool sees the seeded entry, auth.json has the singleton.
|
||||
pool = load_pool("xai-oauth")
|
||||
assert pool.has_credentials()
|
||||
raw = json.loads((hermes_home / "auth.json").read_text())
|
||||
assert "xai-oauth" in raw.get("providers", {})
|
||||
|
||||
# Act: the user runs `hermes auth remove xai-oauth 1`.
|
||||
auth_remove_command(SimpleNamespace(provider="xai-oauth", target="1"))
|
||||
|
||||
# Post-state: auth.json singleton must be cleared so a re-seed has
|
||||
# nothing to import.
|
||||
raw_after = json.loads((hermes_home / "auth.json").read_text())
|
||||
assert "xai-oauth" not in raw_after.get("providers", {}), (
|
||||
"auth.json providers.xai-oauth must be cleared — otherwise the "
|
||||
"next load_pool() reseeds the removed entry from the surviving "
|
||||
"singleton, silently undoing the user's removal."
|
||||
)
|
||||
|
||||
# And the next load must not reseed the entry from anywhere.
|
||||
pool_after = load_pool("xai-oauth")
|
||||
assert not pool_after.has_credentials(), (
|
||||
"Removal must stick across load_pool() calls — without the "
|
||||
"device_code RemovalStep, the seed function reads the singleton "
|
||||
"and rebuilds the entry on every Hermes invocation."
|
||||
)
|
||||
|
||||
|
||||
def test_login_xai_oauth_relogin_clears_suppression_and_reseeds(tmp_path, monkeypatch):
|
||||
"""remove -> ``hermes model`` re-login (``_login_xai_oauth``) must clear the
|
||||
``device_code`` suppression marker so the singleton seed re-creates the
|
||||
pool entry.
|
||||
|
||||
Pre-fix: ``auth_remove_command`` set ``["device_code"]`` suppression but
|
||||
only ``auth_add_command`` cleared it — the ``hermes model`` re-login path did
|
||||
not. So after remove -> re-login the seed kept skipping and ``hermes auth
|
||||
list`` showed no xAI entry even though the agent still worked via the
|
||||
singleton fallback. The fix calls ``unsuppress_credential_source`` on
|
||||
explicit interactive login success.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from agent.credential_pool import load_pool
|
||||
from hermes_cli.auth import (
|
||||
_login_xai_oauth,
|
||||
is_source_suppressed,
|
||||
suppress_credential_source,
|
||||
)
|
||||
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
(hermes_home / "auth.json").write_text(json.dumps({"version": 1, "providers": {}}))
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.delenv("HERMES_XAI_BASE_URL", raising=False)
|
||||
monkeypatch.delenv("XAI_BASE_URL", raising=False)
|
||||
|
||||
# Post-remove state: singleton gone + device_code suppressed, so the
|
||||
# seed is gated off and the pool is empty.
|
||||
suppress_credential_source("xai-oauth", "device_code")
|
||||
assert is_source_suppressed("xai-oauth", "device_code") is True
|
||||
assert not load_pool("xai-oauth").has_credentials()
|
||||
|
||||
new_access = _jwt_with_exp(int(time.time()) + 2 * 60 * 60)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth._xai_oauth_device_code_login",
|
||||
lambda **kwargs: {
|
||||
"tokens": {
|
||||
"access_token": new_access,
|
||||
"refresh_token": "rt-relogin",
|
||||
"id_token": "",
|
||||
"token_type": "Bearer",
|
||||
},
|
||||
"discovery": {"token_endpoint": "https://auth.x.ai/token"},
|
||||
"redirect_uri": "",
|
||||
"base_url": DEFAULT_XAI_OAUTH_BASE_URL,
|
||||
"last_refresh": "2026-06-30T10:00:00Z",
|
||||
},
|
||||
)
|
||||
# Don't mutate a real config file during the test.
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth._update_config_for_provider",
|
||||
lambda *args, **kwargs: "config.toml",
|
||||
)
|
||||
|
||||
_login_xai_oauth(
|
||||
SimpleNamespace(no_browser=True, timeout=3),
|
||||
None, # pconfig is `del`-eted inside the function
|
||||
force_new_login=True,
|
||||
)
|
||||
|
||||
# The explicit interactive login cleared the suppression marker...
|
||||
assert is_source_suppressed("xai-oauth", "device_code") is False
|
||||
# ...so the singleton seed re-creates the canonical pool entry.
|
||||
pool = load_pool("xai-oauth")
|
||||
assert pool.has_credentials()
|
||||
entry = next(e for e in pool.entries() if e.source == "device_code")
|
||||
assert entry.access_token == new_access
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pool sync-back to singleton after refresh
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_pool_sync_back_writes_to_singleton(tmp_path, monkeypatch):
|
||||
"""When the pool refreshes a singleton-seeded xAI entry, the new tokens
|
||||
must be written back to providers["xai-oauth"] so that
|
||||
resolve_xai_oauth_runtime_credentials() (which reads the singleton)
|
||||
doesn't keep using the consumed refresh token."""
|
||||
from agent.credential_pool import load_pool
|
||||
|
||||
hermes_home = tmp_path / "hermes"
|
||||
expired = _jwt_with_exp(int(time.time()) - 10)
|
||||
_setup_hermes_auth(hermes_home, access_token=expired, refresh_token="rt-old")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
new_access = _jwt_with_exp(int(time.time()) + 2 * 60 * 60)
|
||||
|
||||
def _fake_refresh(access_token, refresh_token, **kwargs):
|
||||
assert refresh_token == "rt-old"
|
||||
return {
|
||||
"access_token": new_access,
|
||||
"refresh_token": "rt-new",
|
||||
"id_token": "",
|
||||
"expires_in": 3600,
|
||||
"token_type": "Bearer",
|
||||
"last_refresh": "2026-05-15T01:00:00Z",
|
||||
}
|
||||
|
||||
monkeypatch.setattr("hermes_cli.auth.refresh_xai_oauth_pure", _fake_refresh)
|
||||
|
||||
pool = load_pool("xai-oauth")
|
||||
selected = pool.select()
|
||||
assert selected is not None
|
||||
assert selected.access_token == new_access
|
||||
assert selected.refresh_token == "rt-new"
|
||||
|
||||
# Singleton must reflect refreshed tokens — otherwise the next process
|
||||
# to load credentials would re-seed the consumed refresh token.
|
||||
auth_path = hermes_home / "auth.json"
|
||||
raw = json.loads(auth_path.read_text())
|
||||
state = raw["providers"]["xai-oauth"]
|
||||
assert state["tokens"]["access_token"] == new_access
|
||||
assert state["tokens"]["refresh_token"] == "rt-new"
|
||||
assert state["last_refresh"] == "2026-05-15T01:00:00Z"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runtime provider routing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_runtime_provider_uses_pool_entry_for_xai_oauth(tmp_path, monkeypatch):
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
|
||||
hermes_home = tmp_path / "hermes"
|
||||
fresh = _jwt_with_exp(int(time.time()) + 2 * 60 * 60)
|
||||
_setup_hermes_auth(hermes_home, access_token=fresh)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.delenv("HERMES_XAI_BASE_URL", raising=False)
|
||||
monkeypatch.delenv("XAI_BASE_URL", raising=False)
|
||||
|
||||
runtime = resolve_runtime_provider(requested="xai-oauth")
|
||||
assert runtime["provider"] == "xai-oauth"
|
||||
assert runtime["api_mode"] == "codex_responses"
|
||||
assert runtime["api_key"] == fresh
|
||||
assert runtime["base_url"] == DEFAULT_XAI_OAUTH_BASE_URL
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token-expiry behavior on the pool path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_pool_refresh_recovers_when_other_process_already_refreshed(tmp_path, monkeypatch):
|
||||
"""Variant of the multi-process race where the other process refreshes
|
||||
BETWEEN our proactive sync and the HTTP POST. Our refresh fails with a
|
||||
consumed-token error; we must re-check auth.json, find the fresh pair
|
||||
(written by the racing process), and adopt it instead of marking the
|
||||
entry exhausted."""
|
||||
from agent.credential_pool import load_pool
|
||||
|
||||
hermes_home = tmp_path / "hermes"
|
||||
in_memory_at = _jwt_with_exp(int(time.time()) + 30)
|
||||
_setup_hermes_auth(hermes_home, access_token=in_memory_at, refresh_token="rt-shared")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
pool = load_pool("xai-oauth")
|
||||
|
||||
other_process_at = _jwt_with_exp(int(time.time()) + 2 * 60 * 60)
|
||||
|
||||
def _fake_refresh(access_token, refresh_token, **kwargs):
|
||||
# Simulate the racing process winning at the auth server right
|
||||
# before our POST: by the time we reach this call, auth.json
|
||||
# already holds the fresher pair, but we POSTed with rt-shared.
|
||||
raw = json.loads((hermes_home / "auth.json").read_text())
|
||||
raw["providers"]["xai-oauth"]["tokens"] = {
|
||||
"access_token": other_process_at,
|
||||
"refresh_token": "rt-rotated",
|
||||
"id_token": "",
|
||||
"expires_in": 3600,
|
||||
"token_type": "Bearer",
|
||||
}
|
||||
(hermes_home / "auth.json").write_text(json.dumps(raw))
|
||||
raise AuthError(
|
||||
"refresh_token_reused",
|
||||
provider="xai-oauth",
|
||||
code="xai_refresh_failed",
|
||||
relogin_required=True,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("hermes_cli.auth.refresh_xai_oauth_pure", _fake_refresh)
|
||||
|
||||
selected = pool.select()
|
||||
# Even though refresh_xai_oauth_pure raised, the post-failure
|
||||
# recovery path should adopt the fresher singleton tokens.
|
||||
assert selected is not None
|
||||
assert selected.access_token == other_process_at
|
||||
assert selected.refresh_token == "rt-rotated"
|
||||
|
||||
|
||||
def test_pool_manual_entry_does_not_sync_back_to_singleton(tmp_path, monkeypatch):
|
||||
"""`hermes auth add xai-oauth` entries (source='manual:xai_pkce') are
|
||||
independent credentials and must NOT write to the singleton. Sync-back
|
||||
is restricted to entries seeded from the singleton. Otherwise adding a
|
||||
second pool credential would silently overwrite the user's main login."""
|
||||
from agent.credential_pool import load_pool, AUTH_TYPE_OAUTH, PooledCredential
|
||||
import uuid
|
||||
|
||||
hermes_home = tmp_path / "hermes"
|
||||
# Singleton has its own tokens (separate login).
|
||||
singleton_at = _jwt_with_exp(int(time.time()) + 2 * 60 * 60)
|
||||
_setup_hermes_auth(hermes_home, access_token=singleton_at, refresh_token="rt-singleton")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
manual_at_old = _jwt_with_exp(int(time.time()) + 30)
|
||||
manual_at_new = _jwt_with_exp(int(time.time()) + 7200)
|
||||
|
||||
def _fake_refresh(access_token, refresh_token, **kwargs):
|
||||
assert refresh_token == "rt-manual"
|
||||
return {
|
||||
"access_token": manual_at_new,
|
||||
"refresh_token": "rt-manual-new",
|
||||
"id_token": "",
|
||||
"expires_in": 3600,
|
||||
"token_type": "Bearer",
|
||||
"last_refresh": "2026-05-15T04:00:00Z",
|
||||
}
|
||||
|
||||
monkeypatch.setattr("hermes_cli.auth.refresh_xai_oauth_pure", _fake_refresh)
|
||||
|
||||
pool = load_pool("xai-oauth")
|
||||
pool.add_entry(
|
||||
PooledCredential(
|
||||
provider="xai-oauth",
|
||||
id=uuid.uuid4().hex[:6],
|
||||
label="manual",
|
||||
auth_type=AUTH_TYPE_OAUTH,
|
||||
priority=0,
|
||||
source="manual:xai_pkce",
|
||||
access_token=manual_at_old,
|
||||
refresh_token="rt-manual",
|
||||
base_url=DEFAULT_XAI_OAUTH_BASE_URL,
|
||||
)
|
||||
)
|
||||
# Refresh the manual entry — singleton must be left alone.
|
||||
manual_entries = [e for e in pool.entries() if e.source == "manual:xai_pkce"]
|
||||
assert len(manual_entries) == 1
|
||||
pool._refresh_entry(manual_entries[0], force=True)
|
||||
|
||||
raw = json.loads((hermes_home / "auth.json").read_text())
|
||||
tokens = raw["providers"]["xai-oauth"]["tokens"]
|
||||
# Singleton must be untouched — manual refresh shouldn't leak across.
|
||||
assert tokens["access_token"] == singleton_at
|
||||
assert tokens["refresh_token"] == "rt-singleton"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auxiliary client routing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_auxiliary_client_routes_xai_oauth_through_responses_api(tmp_path, monkeypatch):
|
||||
"""Without explicit xai-oauth handling in ``resolve_provider_client``, an
|
||||
xai-oauth main provider falls through to the generic ``oauth_external``
|
||||
arm and returns ``(None, None)`` — silently re-routing every auxiliary
|
||||
task (compression, curator, web extract, session search, ...) to
|
||||
whatever Step-2 fallback chain the user has configured (OpenRouter,
|
||||
Nous, etc.). Users on xAI Grok OAuth would then see surprise charges
|
||||
on those side providers for side tasks they thought were running on
|
||||
their xAI subscription.
|
||||
|
||||
Pin the routing contract: ``resolve_provider_client("xai-oauth", model)``
|
||||
must return a non-None client wrapping the xAI Responses API."""
|
||||
from agent.auxiliary_client import (
|
||||
CodexAuxiliaryClient,
|
||||
resolve_provider_client,
|
||||
)
|
||||
|
||||
hermes_home = tmp_path / "hermes"
|
||||
fresh = _jwt_with_exp(int(time.time()) + 2 * 60 * 60)
|
||||
_setup_hermes_auth(hermes_home, access_token=fresh)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.delenv("HERMES_XAI_BASE_URL", raising=False)
|
||||
monkeypatch.delenv("XAI_BASE_URL", raising=False)
|
||||
|
||||
client, model = resolve_provider_client("xai-oauth", model="grok-4")
|
||||
assert client is not None, (
|
||||
"xai-oauth must route to a Responses-API client; falling through to "
|
||||
"the generic oauth_external branch silently swaps providers for "
|
||||
"every auxiliary task."
|
||||
)
|
||||
assert isinstance(client, CodexAuxiliaryClient)
|
||||
assert model == "grok-4"
|
||||
# The wrapper preserves base_url + api_key so async wrappers and cache
|
||||
# eviction can introspect them. Pin both to the live xAI runtime.
|
||||
assert str(client.base_url).rstrip("/") == DEFAULT_XAI_OAUTH_BASE_URL
|
||||
assert client.api_key == fresh
|
||||
|
||||
|
||||
def test_auxiliary_client_xai_oauth_requires_explicit_model(tmp_path, monkeypatch):
|
||||
"""xAI's Responses API has no safe "cheap aux model" default —
|
||||
pinning one would silently rot the same way Codex's did. Callers
|
||||
must pass an explicit model (auxiliary.<task>.model in config.yaml)."""
|
||||
from agent.auxiliary_client import resolve_provider_client
|
||||
|
||||
hermes_home = tmp_path / "hermes"
|
||||
fresh = _jwt_with_exp(int(time.time()) + 2 * 60 * 60)
|
||||
_setup_hermes_auth(hermes_home, access_token=fresh)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
client, model = resolve_provider_client("xai-oauth", model=None)
|
||||
assert client is None
|
||||
assert model is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# active_provider preservation on pool sync-back
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_pool_sync_back_preserves_active_provider(tmp_path, monkeypatch):
|
||||
"""A token-rotation sync-back is a side effect of refresh, not the user
|
||||
picking a provider. ``_save_provider_state`` flips ``active_provider``;
|
||||
using it on the sync-back path means every xAI/Codex/Nous refresh in a
|
||||
multi-provider setup silently overrides the user's chosen active
|
||||
provider (visible to ``hermes auth status``, ``hermes setup``, and the
|
||||
``hermes`` no-arg dispatcher). Pin the ``set_active=False`` contract so
|
||||
no future refactor regresses to the legacy semantic."""
|
||||
from agent.credential_pool import load_pool
|
||||
|
||||
hermes_home = tmp_path / "hermes"
|
||||
near_expiry = _jwt_with_exp(int(time.time()) + 30)
|
||||
_setup_hermes_auth(hermes_home, access_token=near_expiry, refresh_token="rt-xai")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
# Simulate a multi-provider user whose actual chosen provider is
|
||||
# OpenRouter — xai-oauth tokens exist in the singleton but are NOT
|
||||
# the active provider.
|
||||
raw = json.loads((hermes_home / "auth.json").read_text())
|
||||
raw["active_provider"] = "openrouter"
|
||||
(hermes_home / "auth.json").write_text(json.dumps(raw))
|
||||
|
||||
new_access = _jwt_with_exp(int(time.time()) + 2 * 60 * 60)
|
||||
|
||||
def _fake_refresh(access_token, refresh_token, **kwargs):
|
||||
return {
|
||||
"access_token": new_access,
|
||||
"refresh_token": "rt-rotated",
|
||||
"id_token": "",
|
||||
"expires_in": 3600,
|
||||
"token_type": "Bearer",
|
||||
"last_refresh": "2026-05-15T10:00:00Z",
|
||||
}
|
||||
|
||||
monkeypatch.setattr("hermes_cli.auth.refresh_xai_oauth_pure", _fake_refresh)
|
||||
|
||||
pool = load_pool("xai-oauth")
|
||||
selected = pool.select()
|
||||
assert selected is not None
|
||||
assert selected.access_token == new_access
|
||||
|
||||
# The refresh wrote new tokens back into the singleton — the user's
|
||||
# prior ``active_provider`` choice (openrouter) MUST survive.
|
||||
raw_after = json.loads((hermes_home / "auth.json").read_text())
|
||||
assert raw_after["active_provider"] == "openrouter", (
|
||||
"pool sync-back must not flip active_provider; otherwise xAI/Codex/"
|
||||
"Nous token rotations silently take over multi-provider users' "
|
||||
"auth.json `active_provider` flag."
|
||||
)
|
||||
# Tokens were actually written so the next process won't replay the
|
||||
# consumed refresh_token (preserves the original sync-back fix).
|
||||
state = raw_after["providers"]["xai-oauth"]["tokens"]
|
||||
assert state["access_token"] == new_access
|
||||
assert state["refresh_token"] == "rt-rotated"
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Regression test for #45759.
|
||||
|
||||
An all-exhausted credential pool holds entries but no *usable* credential.
|
||||
``list_authenticated_providers`` must not treat such a provider as
|
||||
authenticated -- otherwise an aggregator whose quota is spent gets matched
|
||||
during no-provider ``/model`` resolution, wins the model name, and sticks as
|
||||
the session provider (the "sticky provider fallback pollution" bug).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class _FakePool:
|
||||
def __init__(self, available: bool):
|
||||
self._available = available
|
||||
|
||||
def has_credentials(self) -> bool:
|
||||
# The pool still holds entries...
|
||||
return True
|
||||
|
||||
def has_available(self) -> bool:
|
||||
# ...but none of them are usable when exhausted/dead.
|
||||
return self._available
|
||||
|
||||
|
||||
def _patch_opencode_pool(monkeypatch, *, available: bool):
|
||||
"""Make the opencode-go aggregator look configured but with a pool whose
|
||||
only credential is (un)available, depending on ``available``."""
|
||||
import hermes_cli.auth as auth
|
||||
import agent.credential_pool as cp
|
||||
|
||||
monkeypatch.setattr(
|
||||
auth,
|
||||
"_load_auth_store",
|
||||
lambda: {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"active_provider": None,
|
||||
"credential_pool": {"opencode-go": {"entries": [{"id": "x"}]}},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cp,
|
||||
"load_pool",
|
||||
lambda provider: _FakePool(available if provider == "opencode-go" else True),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _strip_provider_env(monkeypatch):
|
||||
"""Don't let real provider keys in the environment authenticate providers
|
||||
through a different code path than the pool gate under test."""
|
||||
import os
|
||||
|
||||
for key in list(os.environ):
|
||||
if "OPENCODE" in key or key.endswith("_API_KEY"):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
|
||||
def test_exhausted_pool_provider_is_not_authenticated(monkeypatch):
|
||||
"""The fix: an exhausted pool is NOT authenticated. Fails on main, where
|
||||
the gate accepted any stored pool entry regardless of usability."""
|
||||
from hermes_cli.model_switch import get_authenticated_provider_slugs
|
||||
|
||||
_patch_opencode_pool(monkeypatch, available=False)
|
||||
slugs = get_authenticated_provider_slugs(current_provider="alibaba")
|
||||
assert "opencode-go" not in slugs
|
||||
|
||||
|
||||
def test_opaque_legacy_pool_value_stays_visible(monkeypatch):
|
||||
"""Legacy token-style auth-store values have no parsed pool entries."""
|
||||
from hermes_cli.model_switch import _credential_pool_is_usable
|
||||
|
||||
monkeypatch.setattr(
|
||||
"agent.credential_pool.load_pool",
|
||||
lambda _provider: type(
|
||||
"EmptyPool",
|
||||
(),
|
||||
{
|
||||
"has_credentials": lambda self: False,
|
||||
"has_available": lambda self: False,
|
||||
},
|
||||
)(),
|
||||
)
|
||||
|
||||
assert _credential_pool_is_usable("opencode-go", raw_pool_present=True)
|
||||
|
||||
|
||||
def test_picker_shows_exhausted_pool_provider(monkeypatch):
|
||||
"""The interactive picker must include providers whose credential pool
|
||||
entries are all exhausted, so the user can still switch to a different
|
||||
model under the same provider."""
|
||||
from hermes_cli.model_switch import list_picker_providers
|
||||
|
||||
_patch_opencode_pool(monkeypatch, available=False)
|
||||
providers = list_picker_providers(
|
||||
current_provider="alibaba",
|
||||
user_providers={},
|
||||
custom_providers=[],
|
||||
)
|
||||
slugs = [p["slug"] for p in providers]
|
||||
assert "opencode-go" in slugs, (
|
||||
"Picker must show exhausted-pool providers so the user can select "
|
||||
"a different model under the same provider"
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
class _StopPicker(BaseException):
|
||||
"""Aborts a picker right after it requests its provider list, before any
|
||||
interactive prompt. Subclasses BaseException so the picker's own
|
||||
``except Exception`` guards don't swallow it."""
|
||||
|
||||
|
||||
def _spy_list_authenticated(recorded: dict):
|
||||
def _spy(*_args, **kwargs):
|
||||
recorded.update(kwargs)
|
||||
raise _StopPicker
|
||||
return _spy
|
||||
|
||||
|
||||
def test_aux_task_picker_requests_exhausted_pool_visibility(monkeypatch):
|
||||
"""The ``hermes model`` auxiliary-task picker (``_aux_select_for_task``)
|
||||
must request exhausted-pool visibility (``for_picker=True``) like the
|
||||
``/model`` picker (#66584).
|
||||
|
||||
The aux picker writes a *persistent* per-task provider/model config that
|
||||
the user runs later — long after a momentary rate-limit cooldown clears —
|
||||
so silently hiding a provider whose keys are all exhausted is exactly the
|
||||
bug the #66584 picker fix addressed, one interactive picker over.
|
||||
"""
|
||||
import hermes_cli.main as main
|
||||
|
||||
recorded: dict = {}
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.model_switch.list_authenticated_providers",
|
||||
_spy_list_authenticated(recorded),
|
||||
)
|
||||
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {})
|
||||
|
||||
with pytest.raises(_StopPicker):
|
||||
main._aux_select_for_task("compression")
|
||||
|
||||
assert recorded.get("for_picker") is True, (
|
||||
"aux-task picker must pass for_picker=True so exhausted-pool providers "
|
||||
"stay selectable (before the fix it omitted the flag → hidden)"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Tests for the auxiliary-model configuration UI in ``hermes model``.
|
||||
|
||||
Covers the helper functions:
|
||||
- ``_save_aux_choice`` writes to config.yaml without touching main model config
|
||||
- ``_reset_aux_to_auto`` clears routing fields but preserves timeouts
|
||||
- ``_format_aux_current`` renders current task config for the menu
|
||||
- ``_AUX_TASKS`` stays in sync with ``DEFAULT_CONFIG["auxiliary"]``
|
||||
|
||||
These are pure-function tests — the interactive menu loops are not covered
|
||||
here (they're stdin-driven curses prompts).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.config import DEFAULT_CONFIG, load_config
|
||||
from hermes_cli.main import (
|
||||
_AUX_TASKS,
|
||||
_DELEGATION_TASK_KEY,
|
||||
_delegation_cfg_as_task,
|
||||
_format_aux_current,
|
||||
_reset_aux_to_auto,
|
||||
_save_aux_choice,
|
||||
)
|
||||
|
||||
|
||||
# ── Default config ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_title_generation_present_in_default_config():
|
||||
"""`title_generation` task must be defined in DEFAULT_CONFIG.
|
||||
|
||||
Regression for an existing gap: title_generator.py calls
|
||||
``call_llm(task="title_generation", ...)`` but the task was missing
|
||||
from DEFAULT_CONFIG["auxiliary"], so the config-backed timeout/provider
|
||||
overrides never worked for that task.
|
||||
"""
|
||||
assert "title_generation" in DEFAULT_CONFIG["auxiliary"]
|
||||
tg = DEFAULT_CONFIG["auxiliary"]["title_generation"]
|
||||
assert tg["enabled"] is True
|
||||
assert tg["provider"] == "auto"
|
||||
assert tg["model"] == ""
|
||||
assert tg["prefer_fast_model"] is False
|
||||
assert tg["timeout"] > 0
|
||||
assert tg["extra_body"] == {}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ── _format_aux_current ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
|
||||
# ── _save_aux_choice ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_save_aux_choice_persists_to_config_yaml(tmp_path, monkeypatch):
|
||||
"""Saving a task writes provider/model/base_url/api_key to auxiliary.<task>."""
|
||||
from pathlib import Path
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
(tmp_path / ".hermes").mkdir(exist_ok=True)
|
||||
|
||||
_save_aux_choice(
|
||||
"vision", provider="openrouter", model="google/gemini-2.5-flash",
|
||||
)
|
||||
cfg = load_config()
|
||||
v = cfg["auxiliary"]["vision"]
|
||||
assert v["provider"] == "openrouter"
|
||||
assert v["model"] == "google/gemini-2.5-flash"
|
||||
assert v["base_url"] == ""
|
||||
assert v["api_key"] == ""
|
||||
|
||||
|
||||
|
||||
|
||||
# ── _reset_aux_to_auto ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ── Menu dispatch ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
|
||||
# ── Delegation entry (top-level `delegation.*`, not `auxiliary.*`) ──────────
|
||||
|
||||
|
||||
def _isolate_home(tmp_path, monkeypatch):
|
||||
from pathlib import Path
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
(tmp_path / ".hermes").mkdir(exist_ok=True)
|
||||
|
||||
|
||||
def test_save_delegation_writes_top_level_section(tmp_path, monkeypatch):
|
||||
"""Delegation picks write to delegation.*, never auxiliary.delegation."""
|
||||
_isolate_home(tmp_path, monkeypatch)
|
||||
|
||||
_save_aux_choice(
|
||||
_DELEGATION_TASK_KEY, provider="openrouter", model="google/gemini-3-flash",
|
||||
)
|
||||
cfg = load_config()
|
||||
d = cfg["delegation"]
|
||||
assert d["provider"] == "openrouter"
|
||||
assert d["model"] == "google/gemini-3-flash"
|
||||
assert d["base_url"] == ""
|
||||
assert d["api_key"] == ""
|
||||
aux = cfg.get("auxiliary", {})
|
||||
entry = aux.get("delegation", {}) if isinstance(aux, dict) else {}
|
||||
assert not (isinstance(entry, dict) and entry.get("provider")), (
|
||||
"delegation routing leaked into auxiliary.delegation"
|
||||
)
|
||||
|
||||
|
||||
def test_save_delegation_auto_stores_empty_provider(tmp_path, monkeypatch):
|
||||
"""'auto' (inherit parent) persists as empty strings — never the literal
|
||||
'auto', which delegate_tool would resolve as a provider name."""
|
||||
_isolate_home(tmp_path, monkeypatch)
|
||||
|
||||
_save_aux_choice(_DELEGATION_TASK_KEY, provider="openrouter", model="m")
|
||||
_save_aux_choice(_DELEGATION_TASK_KEY, provider="auto", model="", base_url="", api_key="")
|
||||
cfg = load_config()
|
||||
d = cfg["delegation"]
|
||||
assert d["provider"] == ""
|
||||
assert d["model"] == ""
|
||||
assert d["base_url"] == ""
|
||||
assert d["api_key"] == ""
|
||||
|
||||
|
||||
def test_reset_aux_clears_delegation_routing_preserves_settings(tmp_path, monkeypatch):
|
||||
"""Reset-all clears delegation provider/model/base_url/api_key but leaves
|
||||
non-routing delegation settings (max_concurrent_children, etc.) alone."""
|
||||
from hermes_cli.config import load_config as _lc, save_config
|
||||
|
||||
_isolate_home(tmp_path, monkeypatch)
|
||||
|
||||
cfg = _lc()
|
||||
cfg.setdefault("delegation", {})
|
||||
cfg["delegation"].update(
|
||||
{"provider": "openrouter", "model": "x", "max_concurrent_children": 7}
|
||||
)
|
||||
save_config(cfg)
|
||||
|
||||
n = _reset_aux_to_auto()
|
||||
assert n >= 1
|
||||
|
||||
cfg = _lc()
|
||||
d = cfg["delegation"]
|
||||
assert d["provider"] == ""
|
||||
assert d["model"] == ""
|
||||
assert d["max_concurrent_children"] == 7
|
||||
|
||||
|
||||
def test_delegation_cfg_as_task_projection():
|
||||
"""Projection renders empty provider as auto via _format_aux_current."""
|
||||
assert _format_aux_current(_delegation_cfg_as_task({})) == "auto"
|
||||
shaped = _delegation_cfg_as_task(
|
||||
{"delegation": {"provider": "nous", "model": "Hermes-4.5"}}
|
||||
)
|
||||
assert _format_aux_current(shaped) == "nous · Hermes-4.5"
|
||||
# Non-dict delegation section must not crash
|
||||
assert _format_aux_current(_delegation_cfg_as_task({"delegation": "bogus"})) == "auto"
|
||||
|
||||
|
||||
def test_leave_unchanged_replaces_cancel_label(tmp_path, monkeypatch):
|
||||
"""The bottom cancel entry now reads 'Leave unchanged' (UX polish)."""
|
||||
from pathlib import Path
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
(tmp_path / ".hermes").mkdir(exist_ok=True)
|
||||
|
||||
from hermes_cli import main as main_mod
|
||||
|
||||
captured: list[list[str]] = []
|
||||
|
||||
def fake_prompt(choices, *, default=0):
|
||||
captured.append(list(choices))
|
||||
# Pick 'Leave unchanged' (last item) to exit cleanly
|
||||
for i, label in enumerate(choices):
|
||||
if label == "Leave unchanged":
|
||||
return i
|
||||
raise AssertionError("Leave unchanged not in provider list")
|
||||
|
||||
monkeypatch.setattr(main_mod, "_prompt_provider_choice", fake_prompt)
|
||||
|
||||
main_mod.select_provider_and_model()
|
||||
|
||||
assert captured, "provider menu never rendered"
|
||||
labels = captured[0]
|
||||
assert "Leave unchanged" in labels
|
||||
assert "Cancel" not in labels, "Cancel label should be replaced"
|
||||
assert any("Configure auxiliary models" in label for label in labels)
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Auxiliary-task pickers share one provider-inventory substrate.
|
||||
|
||||
Every aux picker (``hermes model`` → Configure auxiliary models, the
|
||||
``hermes tools`` vision picker, and any future one) must route through
|
||||
``hermes_cli.inventory.build_aux_picker_rows()`` so it shows the same
|
||||
provider universe as ``/model``.
|
||||
|
||||
Two independent contributor PRs fixed the same two call sites for exactly
|
||||
this reason:
|
||||
|
||||
- #52642 (@deepjia) — user ``providers:`` / ``custom_providers:`` entries
|
||||
were invisible because the aux picker never forwarded them.
|
||||
- #66624 (@Drexuxux) — providers with a fully rate-limited credential pool
|
||||
were hidden because the aux picker never forwarded ``for_picker``.
|
||||
|
||||
Both were per-call-site kwarg patches, so the next aux picker would have
|
||||
reintroduced the gap. These tests pin the *shared substrate* behaviour and
|
||||
guard the seam itself, not the kwargs at any one site.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
|
||||
CONFIG = {
|
||||
"model": {"provider": "openrouter", "default": "anthropic/claude-opus-4.6"},
|
||||
"model_catalog": {"excluded_providers": ["copilot"]},
|
||||
"providers": {
|
||||
"my-llm": {
|
||||
"name": "My LLM",
|
||||
"base_url": "https://myllm.example.com/v1",
|
||||
"key_env": "MYLLM_KEY",
|
||||
"discover_models": False,
|
||||
"models": {"big-model": {}, "small-model": {}},
|
||||
}
|
||||
},
|
||||
"custom_providers": [
|
||||
{
|
||||
"name": "Legacy Box",
|
||||
"base_url": "https://legacy.example.com/v1",
|
||||
"key_env": "LEGACY_KEY",
|
||||
"model": "legacy-1",
|
||||
"discover_models": False,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def configured_home(tmp_path, monkeypatch):
|
||||
"""A HERMES_HOME with one ``providers:`` entry and one legacy
|
||||
``custom_providers:`` entry, both credentialled via env."""
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
(home / "config.yaml").write_text(yaml.safe_dump(CONFIG))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test")
|
||||
monkeypatch.setenv("MYLLM_KEY", "sk-mine")
|
||||
monkeypatch.setenv("LEGACY_KEY", "sk-legacy")
|
||||
return home
|
||||
|
||||
|
||||
# ─── The substrate contract ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_aux_picker_surfaces_user_defined_providers(configured_home):
|
||||
"""Both config schemas for a user's own endpoint reach an aux picker.
|
||||
|
||||
This is #52642's bug: the aux picker built its own kwargs and passed
|
||||
neither ``user_providers`` nor ``custom_providers``, so a user who had
|
||||
configured their own endpoint could not route any auxiliary task to it.
|
||||
"""
|
||||
from hermes_cli.inventory import build_aux_picker_rows
|
||||
|
||||
slugs = {r["slug"] for r in build_aux_picker_rows()}
|
||||
|
||||
assert "my-llm" in slugs, (
|
||||
"a keyed providers: entry must be selectable for auxiliary tasks"
|
||||
)
|
||||
assert "custom:legacy-box" in slugs, (
|
||||
"a legacy custom_providers: entry must be selectable for auxiliary tasks"
|
||||
)
|
||||
|
||||
|
||||
def test_aux_picker_requests_exhausted_pool_visibility(configured_home):
|
||||
"""#66624: a provider whose credential pool is entirely rate-limited
|
||||
must stay visible. Rate limits are per-model and the aux picker writes a
|
||||
config the user runs later, once the cooldown has cleared."""
|
||||
from hermes_cli import inventory
|
||||
|
||||
seen = {}
|
||||
|
||||
def _capture(**kwargs):
|
||||
seen.update(kwargs)
|
||||
return []
|
||||
|
||||
with patch("hermes_cli.model_switch.list_authenticated_providers", _capture):
|
||||
inventory.build_aux_picker_rows()
|
||||
|
||||
assert seen.get("for_picker") is True
|
||||
|
||||
|
||||
# ─── Shared rendering ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ─── Seam guard ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_aux_pickers_route_through_the_shared_substrate(configured_home):
|
||||
"""Neither aux picker may call ``list_authenticated_providers`` directly.
|
||||
|
||||
This is the regression that produced #52642 and #66624 as two separate
|
||||
contributor PRs against the same two call sites: a picker calling the
|
||||
low-level function re-derives its own kwargs and drops whichever slice
|
||||
of user config the author didn't think about. Both pickers must reach
|
||||
the provider list only through ``build_aux_picker_rows``.
|
||||
"""
|
||||
import hermes_cli.main as main
|
||||
import hermes_cli.tools_config as tools_config
|
||||
|
||||
direct_calls = []
|
||||
substrate_calls = []
|
||||
|
||||
def _direct(**kwargs):
|
||||
direct_calls.append(kwargs)
|
||||
return []
|
||||
|
||||
def _substrate(**kwargs):
|
||||
substrate_calls.append(kwargs)
|
||||
return []
|
||||
|
||||
# Abort each picker at its first prompt, right after it has asked for its
|
||||
# provider list. The vision picker returns early on empty rows; the
|
||||
# aux-task picker still renders auto/custom/back, so cancel its menu.
|
||||
with (
|
||||
patch("hermes_cli.model_switch.list_authenticated_providers", _direct),
|
||||
patch("hermes_cli.inventory.build_aux_picker_rows", _substrate),
|
||||
patch("hermes_cli.main._prompt_provider_choice", return_value=None),
|
||||
):
|
||||
main._aux_select_for_task("compression")
|
||||
tools_config._configure_vision_provider_model({}, {})
|
||||
|
||||
assert len(substrate_calls) == 2, (
|
||||
"both the aux-task picker and the vision picker must source providers "
|
||||
f"from build_aux_picker_rows (got {len(substrate_calls)} calls)"
|
||||
)
|
||||
assert direct_calls == [], (
|
||||
"aux pickers must not call list_authenticated_providers directly — "
|
||||
"route through hermes_cli.inventory.build_aux_picker_rows so custom "
|
||||
"providers, exclusions, and picker visibility stay consistent"
|
||||
)
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Tests for hermes_cli.azure_detect — transport & model auto-detection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import azure_detect
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
class _FakeHTTPResponse:
|
||||
"""Minimal stand-in for urllib.request.urlopen's context manager."""
|
||||
|
||||
def __init__(self, status: int, body: bytes):
|
||||
self.status = status
|
||||
self._body = body
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def read(self) -> bytes:
|
||||
return self._body
|
||||
|
||||
|
||||
def _openai_models_body(*ids: str) -> bytes:
|
||||
return json.dumps({
|
||||
"object": "list",
|
||||
"data": [{"id": i, "object": "model"} for i in ids],
|
||||
}).encode()
|
||||
|
||||
|
||||
def _anthropic_error_body(msg: str = "model not found") -> bytes:
|
||||
return json.dumps({
|
||||
"type": "error",
|
||||
"error": {"type": "invalid_request_error", "message": msg},
|
||||
}).encode()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# _looks_like_anthropic_path
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# _extract_model_ids
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# detect() integration
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
def test_detect_openai_models_probe_success():
|
||||
"""/models probe returning a model list → chat_completions."""
|
||||
def _fake_get(url, api_key, timeout=6.0, **kwargs):
|
||||
assert "key-abc" == api_key
|
||||
return 200, json.loads(_openai_models_body("gpt-5.4", "claude-opus-4-6"))
|
||||
|
||||
with patch.object(azure_detect, "_http_get_json", side_effect=_fake_get):
|
||||
result = azure_detect.detect(
|
||||
"https://my.openai.azure.com/openai/v1", "key-abc",
|
||||
)
|
||||
assert result.api_mode == "chat_completions"
|
||||
assert result.models_probe_ok is True
|
||||
assert result.models == ["gpt-5.4", "claude-opus-4-6"]
|
||||
assert "/models" in result.reason
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# _probe_openai_models URL list (Azure vs v1 api-version)
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def test_probe_openai_models_tries_multiple_api_versions():
|
||||
"""First call (no api-version) fails, api-version fallback succeeds."""
|
||||
calls = []
|
||||
|
||||
def _fake_get(url, api_key, timeout=6.0, **kwargs):
|
||||
calls.append(url)
|
||||
if "api-version" not in url:
|
||||
return 404, None
|
||||
return 200, json.loads(_openai_models_body("gpt-4.1"))
|
||||
|
||||
with patch.object(azure_detect, "_http_get_json", side_effect=_fake_get):
|
||||
ok, models = azure_detect._probe_openai_models(
|
||||
"https://my.openai.azure.com/openai/v1", "k",
|
||||
)
|
||||
assert ok is True
|
||||
assert models == ["gpt-4.1"]
|
||||
# Should have tried without api-version first, then with at least one
|
||||
assert any("api-version" not in u for u in calls)
|
||||
assert any("api-version" in u for u in calls)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# _http_get_json error handling
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# lookup_context_length
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Tests for Azure Foundry Entra ID runtime resolution.
|
||||
|
||||
Covers the contract introduced in PR for Microsoft Entra ID auth on
|
||||
``azure-foundry``:
|
||||
|
||||
* ``_resolve_azure_foundry_runtime`` returns a callable ``api_key`` for
|
||||
``model.auth_mode = entra_id`` (OpenAI-style only).
|
||||
* Anthropic-style endpoints with ``auth_mode = entra_id`` return the same
|
||||
callable runtime credential as OpenAI-style endpoints.
|
||||
* The legacy ``api_key`` path is unchanged when ``auth_mode`` is absent
|
||||
or set to ``api_key``.
|
||||
* Explicit ``--api-key`` overrides at runtime still work in entra mode
|
||||
(escape hatch for one-off testing).
|
||||
* ``model.entra.scope`` propagates to the token-provider config; Azure
|
||||
identity selection stays in standard AZURE_* env vars.
|
||||
* ``_get_azure_foundry_auth_status`` is structural — never mints a
|
||||
token (verified by checking the credential cache untouched).
|
||||
* ``has_usable_secret`` for ``AZURE_FOUNDRY_API_KEY`` is irrelevant
|
||||
when ``auth_mode == entra_id``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_credential_cache():
|
||||
from agent.azure_identity_adapter import reset_credential_cache
|
||||
reset_credential_cache()
|
||||
yield
|
||||
reset_credential_cache()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_azure_identity(monkeypatch):
|
||||
"""Identical fake to test_azure_identity_adapter — keeps Azure SDK
|
||||
out of these tests so they run in CI without the package installed."""
|
||||
from agent import azure_identity_adapter as _adapter
|
||||
|
||||
last = {"scope": None, "kwargs": None, "credential_count": 0}
|
||||
|
||||
def _provider(scope):
|
||||
return lambda: f"jwt-for-{scope}"
|
||||
|
||||
fake_module = SimpleNamespace(
|
||||
DefaultAzureCredential=lambda **kw: SimpleNamespace(
|
||||
kwargs=kw,
|
||||
get_token=lambda scope: SimpleNamespace(token="fake", expires_on=9999999999),
|
||||
),
|
||||
get_bearer_token_provider=lambda credential, scope: (
|
||||
last.__setitem__("scope", scope),
|
||||
last.__setitem__("kwargs", credential.kwargs),
|
||||
last.__setitem__("credential_count", cast(int, last["credential_count"]) + 1),
|
||||
_provider(scope),
|
||||
)[-1],
|
||||
)
|
||||
monkeypatch.setattr(_adapter, "_require_azure_identity", lambda: fake_module)
|
||||
monkeypatch.setitem(sys.modules, "azure.identity", fake_module)
|
||||
return last
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _resolve_azure_foundry_runtime: entra_id branch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolveAzureFoundryRuntimeEntra:
|
||||
def test_returns_callable_api_key_for_entra(self, fake_azure_identity):
|
||||
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
|
||||
runtime = _resolve_azure_foundry_runtime(
|
||||
requested_provider="azure-foundry",
|
||||
model_cfg={
|
||||
"provider": "azure-foundry",
|
||||
"base_url": "https://my-resource.openai.azure.com/openai/v1",
|
||||
"api_mode": "chat_completions",
|
||||
"auth_mode": "entra_id",
|
||||
"default": "gpt-4o", # stays on chat_completions (no codex auto-upgrade)
|
||||
},
|
||||
)
|
||||
assert runtime["provider"] == "azure-foundry"
|
||||
assert runtime["auth_mode"] == "entra_id"
|
||||
assert runtime["api_mode"] == "chat_completions"
|
||||
assert callable(runtime["api_key"])
|
||||
assert runtime["source"] == "entra_id"
|
||||
|
||||
|
||||
def test_entra_propagates_scope_only(self, fake_azure_identity):
|
||||
"""``model.entra.scope`` is the only Hermes-managed Azure SDK
|
||||
setting. Identity selection (client ID, tenant, authority,
|
||||
service principal secret, federated token file) flows through
|
||||
standard ``AZURE_*`` env vars read by azure-identity directly.
|
||||
Legacy ``model.entra.client_id`` / ``tenant_id`` / ``authority``
|
||||
keys in config.yaml are silently ignored."""
|
||||
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
|
||||
_resolve_azure_foundry_runtime(
|
||||
requested_provider="azure-foundry",
|
||||
model_cfg={
|
||||
"provider": "azure-foundry",
|
||||
"base_url": "https://my-resource.services.ai.azure.com/v1",
|
||||
"api_mode": "chat_completions",
|
||||
"auth_mode": "entra_id",
|
||||
"entra": {
|
||||
"scope": "https://custom.example/.default",
|
||||
"client_id": "client-uuid",
|
||||
# Legacy keys must not crash — they are accepted in
|
||||
# from_dict but never propagated to the SDK.
|
||||
"tenant_id": "legacy-tenant",
|
||||
"authority": "https://login.microsoftonline.us",
|
||||
},
|
||||
},
|
||||
)
|
||||
assert fake_azure_identity["scope"] == "https://custom.example/.default"
|
||||
kw = fake_azure_identity["kwargs"]
|
||||
assert "managed_identity_client_id" not in kw
|
||||
assert "workload_identity_client_id" not in kw
|
||||
assert "interactive_browser_tenant_id" not in kw
|
||||
assert "authority" not in kw
|
||||
|
||||
|
||||
|
||||
|
||||
def test_entra_with_explicit_api_key_uses_string_escape_hatch(self, fake_azure_identity):
|
||||
"""Passing --api-key on the CLI overrides the entra path so a
|
||||
user can debug a single request with a static key without
|
||||
editing config.yaml."""
|
||||
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
|
||||
runtime = _resolve_azure_foundry_runtime(
|
||||
requested_provider="azure-foundry",
|
||||
model_cfg={
|
||||
"provider": "azure-foundry",
|
||||
"base_url": "https://r.openai.azure.com/openai/v1",
|
||||
"api_mode": "chat_completions",
|
||||
"auth_mode": "entra_id",
|
||||
},
|
||||
explicit_api_key="explicit-string-key",
|
||||
)
|
||||
assert runtime["api_key"] == "explicit-string-key"
|
||||
assert runtime["auth_mode"] == "api_key"
|
||||
assert runtime["source"] == "explicit"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _resolve_azure_foundry_runtime: legacy api_key branch (regression)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolveAzureFoundryRuntimeApiKey:
|
||||
def test_default_auth_mode_uses_static_key(self, monkeypatch):
|
||||
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
|
||||
monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "sk-azure-static-key")
|
||||
runtime = _resolve_azure_foundry_runtime(
|
||||
requested_provider="azure-foundry",
|
||||
model_cfg={
|
||||
"provider": "azure-foundry",
|
||||
"base_url": "https://r.openai.azure.com/openai/v1",
|
||||
"api_mode": "chat_completions",
|
||||
},
|
||||
)
|
||||
assert runtime["api_key"] == "sk-azure-static-key"
|
||||
assert runtime["auth_mode"] == "api_key"
|
||||
assert "entra" not in runtime # only present in entra mode
|
||||
|
||||
def test_explicit_auth_mode_api_key(self, monkeypatch):
|
||||
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
|
||||
monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "sk-static")
|
||||
runtime = _resolve_azure_foundry_runtime(
|
||||
requested_provider="azure-foundry",
|
||||
model_cfg={
|
||||
"provider": "azure-foundry",
|
||||
"base_url": "https://r.openai.azure.com/openai/v1",
|
||||
"api_mode": "chat_completions",
|
||||
"auth_mode": "api_key",
|
||||
},
|
||||
)
|
||||
assert runtime["api_key"] == "sk-static"
|
||||
assert runtime["auth_mode"] == "api_key"
|
||||
|
||||
def test_anthropic_messages_strips_v1_suffix(self, monkeypatch):
|
||||
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
|
||||
monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "k")
|
||||
runtime = _resolve_azure_foundry_runtime(
|
||||
requested_provider="azure-foundry",
|
||||
model_cfg={
|
||||
"provider": "azure-foundry",
|
||||
"base_url": "https://r.services.ai.azure.com/anthropic/v1",
|
||||
"api_mode": "anthropic_messages",
|
||||
},
|
||||
)
|
||||
assert runtime["base_url"] == "https://r.services.ai.azure.com/anthropic"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _get_azure_foundry_auth_status (auth.py) — never mints a token
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAzureFoundryAuthStatus:
|
||||
def test_entra_status_does_not_mint_token(self, monkeypatch, tmp_path):
|
||||
"""Structural check — must return logged_in=True based on
|
||||
importable + config, never call get_bearer_token_provider."""
|
||||
from hermes_cli import auth as _auth
|
||||
# Force load_config to return our entra config.
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: {
|
||||
"model": {
|
||||
"provider": "azure-foundry",
|
||||
"auth_mode": "entra_id",
|
||||
"base_url": "https://r.openai.azure.com/openai/v1",
|
||||
},
|
||||
},
|
||||
)
|
||||
# Patch has_azure_identity_installed to True; do NOT patch the
|
||||
# token provider — if the code path tried to mint, the SDK
|
||||
# missing would raise.
|
||||
monkeypatch.setattr(
|
||||
"agent.azure_identity_adapter.has_azure_identity_installed",
|
||||
lambda: True,
|
||||
)
|
||||
info = _auth._get_azure_foundry_auth_status()
|
||||
assert info["logged_in"] is True
|
||||
assert info["auth_mode"] == "entra_id"
|
||||
assert info["azure_identity_installed"] is True
|
||||
assert info["scope"].endswith("/.default")
|
||||
|
||||
|
||||
def test_api_key_status_false_when_missing(self, monkeypatch):
|
||||
from hermes_cli import auth as _auth
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: {
|
||||
"model": {
|
||||
"provider": "azure-foundry",
|
||||
"auth_mode": "api_key",
|
||||
},
|
||||
},
|
||||
)
|
||||
monkeypatch.delenv("AZURE_FOUNDRY_API_KEY", raising=False)
|
||||
info = _auth._get_azure_foundry_auth_status()
|
||||
assert info["logged_in"] is False
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,129 @@
|
||||
"""Tests for the #66140 fix: pre-update snapshots cover every profile."""
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_cli.backup as backup
|
||||
|
||||
|
||||
def _mk_profile(home: Path, jobs: int = 0) -> Path:
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
(home / "config.yaml").write_text("model: {}\n", encoding="utf-8")
|
||||
if jobs:
|
||||
cron = home / "cron"
|
||||
cron.mkdir(exist_ok=True)
|
||||
payload = {"jobs": [{"id": f"j{i}"} for i in range(jobs)]}
|
||||
(cron / "jobs.json").write_text(json.dumps(payload), encoding="utf-8")
|
||||
return home
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def profiles(monkeypatch, tmp_path):
|
||||
"""default (invoking) + work + sparks profile homes."""
|
||||
default_home = _mk_profile(tmp_path / "home", jobs=3)
|
||||
work = _mk_profile(tmp_path / "home" / "profiles" / "work", jobs=5)
|
||||
sparks = _mk_profile(tmp_path / "home" / "profiles" / "sparks", jobs=0)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.profiles._get_default_hermes_home", lambda: default_home
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.profiles._get_profiles_root", lambda: tmp_path / "home" / "profiles"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.profiles._PROFILE_ID_RE",
|
||||
re.compile(r"^[a-z0-9][a-z0-9_-]*$"),
|
||||
raising=False,
|
||||
)
|
||||
return {"default": default_home, "work": work, "sparks": sparks}
|
||||
|
||||
|
||||
class TestSiblingEnumeration:
|
||||
def test_excludes_invoking_profile(self, profiles):
|
||||
names = [n for n, _ in backup._sibling_profile_homes(profiles["default"])]
|
||||
assert names == ["sparks", "work"]
|
||||
|
||||
def test_invoked_from_named_profile_includes_default(self, profiles):
|
||||
names = [n for n, _ in backup._sibling_profile_homes(profiles["work"])]
|
||||
assert names == ["default", "sparks"]
|
||||
|
||||
def test_never_raises(self, monkeypatch, tmp_path):
|
||||
def _boom():
|
||||
raise RuntimeError("no profiles module")
|
||||
|
||||
monkeypatch.setattr("hermes_cli.profiles._get_default_hermes_home", _boom)
|
||||
assert backup._sibling_profile_homes(tmp_path) == []
|
||||
|
||||
|
||||
class TestAllProfileSnapshots:
|
||||
def test_each_sibling_snapshotted_into_own_home(self, profiles):
|
||||
result = backup.create_pre_update_snapshots_all_profiles(
|
||||
invoking_home=profiles["default"], keep=1
|
||||
)
|
||||
assert set(result) == {"work", "sparks"}
|
||||
for name, snap_id in result.items():
|
||||
snap_dir = profiles[name] / "state-snapshots" / snap_id
|
||||
assert snap_dir.is_dir()
|
||||
assert (snap_dir / "config.yaml").is_file()
|
||||
assert "pre-update" in snap_id
|
||||
# invoking profile untouched by THIS call
|
||||
assert not (profiles["default"] / "state-snapshots").exists()
|
||||
|
||||
def test_size_cap_forwarded(self, profiles):
|
||||
big = profiles["work"] / "state.db"
|
||||
big.write_bytes(b"\x00" * 4096)
|
||||
result = backup.create_pre_update_snapshots_all_profiles(
|
||||
invoking_home=profiles["default"], keep=1, max_file_size=1024
|
||||
)
|
||||
snap_dir = profiles["work"] / "state-snapshots" / result["work"]
|
||||
assert not (snap_dir / "state.db").exists() # capped out
|
||||
assert (snap_dir / "config.yaml").is_file() # small files captured
|
||||
|
||||
def test_one_failing_sibling_does_not_block_others(self, profiles, monkeypatch):
|
||||
real = backup.create_quick_snapshot
|
||||
|
||||
def _flaky(label=None, hermes_home=None, keep=None, max_file_size=None):
|
||||
if hermes_home == profiles["work"]:
|
||||
raise OSError("disk full")
|
||||
return real(
|
||||
label=label, hermes_home=hermes_home, keep=keep,
|
||||
max_file_size=max_file_size,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(backup, "create_quick_snapshot", _flaky)
|
||||
result = backup.create_pre_update_snapshots_all_profiles(
|
||||
invoking_home=profiles["default"]
|
||||
)
|
||||
assert "sparks" in result and "work" not in result
|
||||
|
||||
|
||||
class TestPerProfileCronRestore:
|
||||
def test_lost_jobs_restored_from_own_snapshot(self, profiles):
|
||||
snaps = backup.create_pre_update_snapshots_all_profiles(
|
||||
invoking_home=profiles["default"], keep=1
|
||||
)
|
||||
# simulate the migration emptying work's jobs.json
|
||||
jobs_path = profiles["work"] / "cron" / "jobs.json"
|
||||
jobs_path.write_text(json.dumps({"jobs": []}), encoding="utf-8")
|
||||
|
||||
restored = backup.restore_cron_jobs_all_profiles(
|
||||
snaps, invoking_home=profiles["default"]
|
||||
)
|
||||
assert len(restored) == 1
|
||||
assert restored[0]["profile"] == "work"
|
||||
assert restored[0]["job_count"] == 5
|
||||
live = json.loads(jobs_path.read_text(encoding="utf-8"))
|
||||
assert len(live["jobs"]) == 5
|
||||
|
||||
def test_healthy_profiles_untouched(self, profiles):
|
||||
snaps = backup.create_pre_update_snapshots_all_profiles(
|
||||
invoking_home=profiles["default"], keep=1
|
||||
)
|
||||
assert backup.restore_cron_jobs_all_profiles(
|
||||
snaps, invoking_home=profiles["default"]
|
||||
) == []
|
||||
|
||||
def test_empty_map_is_noop(self, profiles):
|
||||
assert backup.restore_cron_jobs_all_profiles({}) == []
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Regression test: `hermes backup -o <bad path>` errors cleanly (round-3 SUB-01).
|
||||
|
||||
Before, an unwritable/nonexistent-parent output path raised a raw
|
||||
PermissionError traceback from the unguarded is_dir()/mkdir() calls. It must
|
||||
print a one-line error and exit 1 instead.
|
||||
"""
|
||||
|
||||
from argparse import Namespace
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_home(tmp_path, monkeypatch):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
(home / "config.yaml").write_text("model: {}\n")
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
return home
|
||||
|
||||
|
||||
def test_backup_unwritable_parent_errors_cleanly(tmp_path, monkeypatch, capsys):
|
||||
_make_home(tmp_path, monkeypatch)
|
||||
import hermes_cli.backup as backup_mod
|
||||
|
||||
# A parent directory that cannot be created (a file stands where the dir
|
||||
# would go) reliably triggers an OSError on mkdir without needing root.
|
||||
blocker = tmp_path / "blocker"
|
||||
blocker.write_text("i am a file, not a dir")
|
||||
bad_out = blocker / "sub" / "backup.zip"
|
||||
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
backup_mod.run_backup(Namespace(output=str(bad_out)))
|
||||
|
||||
assert exc.value.code == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "cannot write backup" in out.lower()
|
||||
assert "Traceback" not in out
|
||||
@@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.backup import (
|
||||
BackupInProgressError,
|
||||
_atomic_output_path,
|
||||
_backup_operation_lock,
|
||||
_write_full_zip_backup,
|
||||
create_quick_snapshot,
|
||||
list_quick_snapshots,
|
||||
)
|
||||
|
||||
|
||||
def test_backup_lock_rejects_a_second_operation(tmp_path) -> None:
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
|
||||
with _backup_operation_lock(home):
|
||||
with pytest.raises(BackupInProgressError):
|
||||
with _backup_operation_lock(home, timeout_seconds=0):
|
||||
raise AssertionError("second backup unexpectedly acquired the lock")
|
||||
|
||||
|
||||
def test_atomic_output_publishes_only_after_clean_close(tmp_path) -> None:
|
||||
final = tmp_path / "backup.zip"
|
||||
final.write_bytes(b"previous")
|
||||
|
||||
with _atomic_output_path(final) as partial:
|
||||
partial.write_bytes(b"complete")
|
||||
assert final.read_bytes() == b"previous"
|
||||
|
||||
assert final.read_bytes() == b"complete"
|
||||
assert not partial.exists()
|
||||
|
||||
|
||||
def test_atomic_output_keeps_previous_file_after_failure(tmp_path) -> None:
|
||||
final = tmp_path / "backup.zip"
|
||||
final.write_bytes(b"previous")
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
with _atomic_output_path(final) as partial:
|
||||
partial.write_bytes(b"incomplete")
|
||||
raise RuntimeError("compression failed")
|
||||
|
||||
assert final.read_bytes() == b"previous"
|
||||
assert not partial.exists()
|
||||
|
||||
|
||||
def test_quick_snapshot_is_published_with_manifest(tmp_path, monkeypatch) -> None:
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
(home / "config.yaml").write_text("model: {}\n", encoding="utf-8")
|
||||
published: list[tuple[Path, Path]] = []
|
||||
|
||||
from hermes_cli import backup
|
||||
|
||||
real_replace = backup.os.replace
|
||||
|
||||
def replace(source, destination) -> None:
|
||||
source_path = Path(source)
|
||||
destination_path = Path(destination)
|
||||
if destination_path.parent == home / "state-snapshots":
|
||||
assert source_path.name.endswith(".partial")
|
||||
assert (source_path / "manifest.json").is_file()
|
||||
assert not destination_path.exists()
|
||||
published.append((source_path, destination_path))
|
||||
real_replace(source, destination)
|
||||
|
||||
monkeypatch.setattr(backup.os, "replace", replace)
|
||||
snapshot_id = create_quick_snapshot(hermes_home=home)
|
||||
|
||||
assert snapshot_id is not None
|
||||
assert len(published) == 1
|
||||
manifest = json.loads(
|
||||
(home / "state-snapshots" / snapshot_id / "manifest.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert manifest["id"] == snapshot_id
|
||||
assert manifest["files"] == {"config.yaml": 10}
|
||||
|
||||
|
||||
def test_quick_snapshot_listing_ignores_partial_directories(tmp_path) -> None:
|
||||
home = tmp_path / ".hermes"
|
||||
partial = home / "state-snapshots" / ".unfinished.1.partial"
|
||||
partial.mkdir(parents=True)
|
||||
(partial / "manifest.json").write_text('{"id":"unfinished"}', encoding="utf-8")
|
||||
|
||||
assert list_quick_snapshots(hermes_home=home) == []
|
||||
|
||||
|
||||
def test_failed_automatic_backup_preserves_previous_archive(tmp_path, monkeypatch) -> None:
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
(home / "state.db").write_bytes(b"not-a-database")
|
||||
archive = tmp_path / "automatic.zip"
|
||||
archive.write_bytes(b"previous-valid-backup")
|
||||
|
||||
monkeypatch.setattr("hermes_cli.backup._safe_copy_db", lambda _src, _dst: False)
|
||||
|
||||
assert _write_full_zip_backup(archive, home) is None
|
||||
assert archive.read_bytes() == b"previous-valid-backup"
|
||||
assert list(tmp_path.glob(".*.partial")) == []
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Tests for banner toolset name normalization and skin color usage."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
import hermes_cli.banner as banner
|
||||
import model_tools
|
||||
import tools.mcp_tool
|
||||
|
||||
|
||||
def test_cprint_falls_back_to_plain_print_when_prompt_toolkit_has_no_console(capsys):
|
||||
with patch(
|
||||
"prompt_toolkit.print_formatted_text",
|
||||
side_effect=RuntimeError("no console screen buffer"),
|
||||
):
|
||||
banner.cprint("fallback text")
|
||||
|
||||
assert capsys.readouterr().out == "fallback text\n"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_build_welcome_banner_title_falls_back_when_no_tag():
|
||||
"""Without a resolvable tag, the panel title renders as plain text (no hyperlink escape)."""
|
||||
import io
|
||||
from unittest.mock import patch as _patch
|
||||
import hermes_cli.banner as _banner
|
||||
import model_tools as _mt
|
||||
import tools.mcp_tool as _mcp
|
||||
|
||||
_banner._latest_release_cache = None
|
||||
buf = io.StringIO()
|
||||
with (
|
||||
_patch.object(_mt, "check_tool_availability", return_value=(["web"], [])),
|
||||
_patch.object(_banner, "get_available_skills", return_value={}),
|
||||
_patch.object(_banner, "get_update_result", return_value=None),
|
||||
_patch.object(_mcp, "get_mcp_status", return_value=[]),
|
||||
_patch.object(_banner, "get_latest_release_tag", return_value=None),
|
||||
):
|
||||
console = Console(file=buf, force_terminal=True, color_system="truecolor", width=160)
|
||||
_banner.build_welcome_banner(
|
||||
console=console, model="x", cwd="/tmp",
|
||||
session_id="abc123",
|
||||
tools=[{"function": {"name": "read_file"}}],
|
||||
get_toolset_for_tool=lambda n: "file",
|
||||
)
|
||||
|
||||
raw = buf.getvalue()
|
||||
assert "Hermes Agent v" in raw, "Version label missing from title"
|
||||
assert "\x1b]8;" not in raw, "OSC-8 hyperlink should not be emitted without a tag"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_build_welcome_banner_non_moa_unchanged(tmp_path, monkeypatch):
|
||||
"""A normal provider still renders the bare model slug, no MoA prefix."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
(tmp_path / ".hermes").mkdir()
|
||||
|
||||
with (
|
||||
patch.object(model_tools, "check_tool_availability", return_value=([], [])),
|
||||
patch.object(banner, "get_available_skills", return_value={}),
|
||||
patch.object(banner, "get_update_result", return_value=None),
|
||||
patch.object(tools.mcp_tool, "get_mcp_status", return_value=[]),
|
||||
):
|
||||
console = Console(record=True, force_terminal=False, color_system=None, width=160)
|
||||
banner.build_welcome_banner(
|
||||
console=console,
|
||||
model="anthropic/claude-opus-4.8",
|
||||
cwd="/tmp/project",
|
||||
tools=[],
|
||||
enabled_toolsets=[],
|
||||
provider="openrouter",
|
||||
)
|
||||
|
||||
out = console.export_text()
|
||||
assert "claude-opus-4.8" in out
|
||||
assert "MoA:" not in out
|
||||
@@ -0,0 +1,128 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
|
||||
|
||||
def test_format_banner_version_label_on_upstream_main():
|
||||
from hermes_cli import banner
|
||||
|
||||
with patch.object(
|
||||
banner,
|
||||
"get_git_banner_state",
|
||||
return_value={"upstream": "b2f477a3", "local": "b2f477a3", "ahead": 0},
|
||||
):
|
||||
value = banner.format_banner_version_label()
|
||||
|
||||
assert value.endswith("· upstream b2f477a3")
|
||||
assert "local" not in value
|
||||
|
||||
|
||||
def test_get_git_banner_state_reads_origin_and_head(tmp_path):
|
||||
from hermes_cli import banner
|
||||
|
||||
repo_dir = tmp_path / "repo"
|
||||
(repo_dir / ".git").mkdir(parents=True)
|
||||
|
||||
results = {
|
||||
("git", "rev-parse", "--short=8", "origin/main"): MagicMock(returncode=0, stdout="b2f477a3\n"),
|
||||
("git", "rev-parse", "--short=8", "HEAD"): MagicMock(returncode=0, stdout="af8aad31\n"),
|
||||
("git", "rev-list", "--count", "origin/main..HEAD"): MagicMock(returncode=0, stdout="3\n"),
|
||||
}
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
key = tuple(cmd)
|
||||
if key not in results:
|
||||
raise AssertionError(f"unexpected command: {cmd}")
|
||||
return results[key]
|
||||
|
||||
with patch("hermes_cli.banner.subprocess.run", side_effect=fake_run):
|
||||
state = banner.get_git_banner_state(repo_dir)
|
||||
|
||||
assert state == {"upstream": "b2f477a3", "local": "af8aad31", "ahead": 3}
|
||||
|
||||
|
||||
def test_check_via_local_git_ssh_fastpath_ahead_not_behind(tmp_path):
|
||||
"""SSH fast path must not report an ahead (carried) HEAD as behind.
|
||||
|
||||
A carried local commit means tip SHAs differ, but the fresh upstream tip
|
||||
is an ancestor of HEAD — that is "ahead", and reporting it as behind
|
||||
nudges the user into `hermes update`, which can wipe the carried work.
|
||||
"""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from hermes_cli import banner
|
||||
|
||||
repo_dir = tmp_path / "repo"
|
||||
(repo_dir / ".git").mkdir(parents=True)
|
||||
|
||||
def fake_git_stdout(args, *, cwd, timeout=5):
|
||||
if args == ["remote", "get-url", "origin"]:
|
||||
return "git@github.com:NousResearch/hermes-agent.git"
|
||||
if args == ["rev-parse", "HEAD"]:
|
||||
return "b" * 40 # carried commit, differs from upstream tip
|
||||
raise AssertionError(f"unexpected git call: {args}")
|
||||
|
||||
with (
|
||||
patch.object(banner, "_git_stdout", side_effect=fake_git_stdout),
|
||||
patch.object(banner, "_upstream_main_sha", return_value="a" * 40),
|
||||
# merge-base --is-ancestor exits 0: upstream tip IS an ancestor of HEAD
|
||||
patch.object(banner.subprocess, "run", return_value=MagicMock(returncode=0)),
|
||||
):
|
||||
behind = banner._check_via_local_git(repo_dir)
|
||||
|
||||
assert behind == 0
|
||||
|
||||
|
||||
def test_check_via_local_git_ssh_fastpath_genuinely_behind(tmp_path):
|
||||
"""SSH fast path reports the exact count (compare API) when behind."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from hermes_cli import banner
|
||||
|
||||
repo_dir = tmp_path / "repo"
|
||||
(repo_dir / ".git").mkdir(parents=True)
|
||||
|
||||
def fake_git_stdout(args, *, cwd, timeout=5):
|
||||
if args == ["remote", "get-url", "origin"]:
|
||||
return "git@github.com:NousResearch/hermes-agent.git"
|
||||
if args == ["rev-parse", "HEAD"]:
|
||||
return "b" * 40
|
||||
raise AssertionError(f"unexpected git call: {args}")
|
||||
|
||||
with (
|
||||
patch.object(banner, "_git_stdout", side_effect=fake_git_stdout),
|
||||
patch.object(banner, "_upstream_main_sha", return_value="a" * 40),
|
||||
# merge-base --is-ancestor exits 1: not an ancestor -> genuinely behind
|
||||
patch.object(banner.subprocess, "run", return_value=MagicMock(returncode=1)),
|
||||
patch.object(banner, "_github_compare_behind", return_value=3),
|
||||
):
|
||||
behind = banner._check_via_local_git(repo_dir)
|
||||
|
||||
assert behind == 3
|
||||
|
||||
|
||||
def test_check_via_local_git_ssh_fastpath_offline_keeps_sentinel(tmp_path):
|
||||
"""Behind + compare API unreachable = honest no-count sentinel, never 1."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from hermes_cli import banner
|
||||
|
||||
repo_dir = tmp_path / "repo"
|
||||
(repo_dir / ".git").mkdir(parents=True)
|
||||
|
||||
def fake_git_stdout(args, *, cwd, timeout=5):
|
||||
if args == ["remote", "get-url", "origin"]:
|
||||
return "git@github.com:NousResearch/hermes-agent.git"
|
||||
if args == ["rev-parse", "HEAD"]:
|
||||
return "b" * 40
|
||||
raise AssertionError(f"unexpected git call: {args}")
|
||||
|
||||
with (
|
||||
patch.object(banner, "_git_stdout", side_effect=fake_git_stdout),
|
||||
patch.object(banner, "_upstream_main_sha", return_value="a" * 40),
|
||||
patch.object(banner.subprocess, "run", return_value=MagicMock(returncode=1)),
|
||||
patch.object(banner, "_github_compare_behind", return_value=None),
|
||||
):
|
||||
behind = banner._check_via_local_git(repo_dir)
|
||||
|
||||
assert behind == banner.UPDATE_AVAILABLE_NO_COUNT
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Tests for banner get_available_skills() — disabled and platform filtering."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
_MOCK_SKILLS = [
|
||||
{"name": "skill-a", "description": "A skill", "category": "tools"},
|
||||
{"name": "skill-b", "description": "B skill", "category": "tools"},
|
||||
{"name": "skill-c", "description": "C skill", "category": "creative"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_skills_cache():
|
||||
"""get_available_skills is memoized per-process (startup perf) — reset
|
||||
the cache around each test so patched _find_all_skills results are
|
||||
actually observed."""
|
||||
import hermes_cli.banner as banner
|
||||
banner._available_skills_cache = None
|
||||
yield
|
||||
banner._available_skills_cache = None
|
||||
|
||||
|
||||
def test_get_available_skills_delegates_to_find_all_skills():
|
||||
"""get_available_skills should call _find_all_skills (which handles filtering)."""
|
||||
with patch("tools.skills_tool._find_all_skills", return_value=list(_MOCK_SKILLS)):
|
||||
from hermes_cli.banner import get_available_skills
|
||||
result = get_available_skills()
|
||||
|
||||
assert "tools" in result
|
||||
assert "creative" in result
|
||||
assert sorted(result["tools"]) == ["skill-a", "skill-b"]
|
||||
assert result["creative"] == ["skill-c"]
|
||||
|
||||
|
||||
def test_get_available_skills_null_category_becomes_general():
|
||||
"""Skills with None category should be grouped under 'general'."""
|
||||
skills = [{"name": "orphan-skill", "description": "No cat", "category": None}]
|
||||
with patch("tools.skills_tool._find_all_skills", return_value=skills):
|
||||
from hermes_cli.banner import get_available_skills
|
||||
result = get_available_skills()
|
||||
|
||||
assert "general" in result
|
||||
assert result["general"] == ["orphan-skill"]
|
||||
|
||||
|
||||
def test_get_available_skills_is_memoized():
|
||||
"""Second call must not re-walk the skills tree (startup perf contract)."""
|
||||
import hermes_cli.banner as banner
|
||||
calls = []
|
||||
|
||||
def fake_find(**kwargs):
|
||||
calls.append(1)
|
||||
return list(_MOCK_SKILLS)
|
||||
|
||||
with patch("tools.skills_tool._find_all_skills", side_effect=fake_find):
|
||||
first = banner.get_available_skills()
|
||||
second = banner.get_available_skills()
|
||||
|
||||
assert first == second
|
||||
assert len(calls) == 1
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Tests for banner skills display — terminal-width-aware truncation."""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
import hermes_cli.banner as banner
|
||||
import model_tools
|
||||
import tools.mcp_tool
|
||||
|
||||
|
||||
def _build_banner_with_skills(skills_by_category, term_width=160):
|
||||
"""Helper: build banner with given skills and return captured output."""
|
||||
with (
|
||||
patch.object(
|
||||
model_tools,
|
||||
"check_tool_availability",
|
||||
return_value=([], []),
|
||||
),
|
||||
patch.object(banner, "get_available_skills", return_value=skills_by_category),
|
||||
patch.object(banner, "get_update_result", return_value=None),
|
||||
patch.object(tools.mcp_tool, "get_mcp_status", return_value=[]),
|
||||
patch("shutil.get_terminal_size", return_value=os.terminal_size((term_width, 50))),
|
||||
):
|
||||
console = Console(
|
||||
record=True, force_terminal=False, color_system=None, width=term_width
|
||||
)
|
||||
banner.build_welcome_banner(
|
||||
console=console,
|
||||
model="anthropic/test-model",
|
||||
cwd="/tmp/project",
|
||||
tools=[],
|
||||
)
|
||||
return console.export_text()
|
||||
|
||||
|
||||
def test_wide_terminal_shows_more_than_8_skills():
|
||||
"""A wide terminal should display more than 8 skills per category."""
|
||||
# 15 skills in one category
|
||||
skills = {"research": [f"skill-{i:02d}" for i in range(15)]}
|
||||
text = _build_banner_with_skills(skills, term_width=200)
|
||||
|
||||
# With a 200-char terminal, more than 8 should be visible.
|
||||
# The old code always truncated at 8; we should see at least 9 now.
|
||||
assert "skill-08" in text, f"Expected skill-08 in output for wide terminal: {text}"
|
||||
|
||||
|
||||
def test_small_category_shows_all_skills():
|
||||
"""Categories with few skills should show all of them regardless of width."""
|
||||
skills = {"security": ["auth", "vault"]}
|
||||
text = _build_banner_with_skills(skills, term_width=80)
|
||||
|
||||
assert "auth" in text
|
||||
assert "vault" in text
|
||||
# No "+N more" indicator for small categories
|
||||
assert "+2 more" not in text
|
||||
|
||||
|
||||
def test_skills_respect_category_label_width():
|
||||
"""Skills display should account for the category label prefix width."""
|
||||
# A category with a long name should have less room for skills
|
||||
skills = {"very-long-category-name": [f"skill-{i:02d}" for i in range(10)]}
|
||||
text = _build_banner_with_skills(skills, term_width=120)
|
||||
|
||||
# Should still show at least some skills
|
||||
assert "skill-00" in text
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Regression tests: provider-identity checks must compare URL *hostnames*,
|
||||
not raw substrings.
|
||||
|
||||
Port of earendil-works/pi#7933's bug class (DeepSeek base-URL detection used a
|
||||
substring check, missing case variants and matching lookalike URLs). Hermes
|
||||
had the same class at several sites: keyless-endpoint detection, /model
|
||||
catalog routing, local-endpoint detection, and Nous Portal cache-layout
|
||||
detection all used ``"host" in base_url``. A proxy URL that merely *contains*
|
||||
a provider host in its path (``https://proxy.internal/openrouter.ai/v1``) or
|
||||
a lookalike domain (``https://openrouter.ai.evil.com``) must not be treated
|
||||
as that provider, and casing must not matter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from hermes_cli.cli_agent_setup_mixin import CLIAgentSetupMixin
|
||||
|
||||
|
||||
class _Host(CLIAgentSetupMixin):
|
||||
def __init__(self):
|
||||
self.requested_provider = "auto"
|
||||
self._explicit_api_key = None
|
||||
self._explicit_base_url = None
|
||||
|
||||
|
||||
def _ready_with(runtime: dict) -> bool:
|
||||
host = _Host()
|
||||
with patch(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value=runtime,
|
||||
):
|
||||
return host._runtime_credentials_ready()
|
||||
|
||||
|
||||
def test_keyless_real_openrouter_not_ready():
|
||||
# OpenRouter itself requires a key: keyless => not ready.
|
||||
assert _ready_with({"api_key": None, "base_url": "https://openrouter.ai/api/v1"}) is False
|
||||
|
||||
|
||||
def test_keyless_uppercase_openrouter_not_ready():
|
||||
# Case variants of the real host are still the real host (pi#7933 class).
|
||||
assert _ready_with({"api_key": None, "base_url": "https://OpenRouter.AI/api/v1"}) is False
|
||||
|
||||
|
||||
def test_keyless_proxy_with_openrouter_in_path_is_ready():
|
||||
# A custom proxy whose *path* contains the substring is NOT OpenRouter —
|
||||
# it's a keyless custom endpoint and must count as ready.
|
||||
assert _ready_with({"api_key": None, "base_url": "https://proxy.internal/openrouter.ai/v1"}) is True
|
||||
|
||||
|
||||
def test_keyless_lookalike_domain_is_ready():
|
||||
assert _ready_with({"api_key": None, "base_url": "https://openrouter.ai.evil.com/v1"}) is True
|
||||
|
||||
|
||||
def test_keyless_local_endpoint_is_ready():
|
||||
assert _ready_with({"api_key": None, "base_url": "http://localhost:11434/v1"}) is True
|
||||
|
||||
|
||||
def test_validate_requested_model_proxy_url_routes_to_custom():
|
||||
"""/model validation: an 'openrouter' provider pointed at a non-OpenRouter
|
||||
host is a custom endpoint, even when the URL contains the substring."""
|
||||
from utils import base_url_host_matches
|
||||
|
||||
assert base_url_host_matches("https://openrouter.ai/api/v1", "openrouter.ai")
|
||||
assert base_url_host_matches("https://OPENROUTER.AI/api/v1", "openrouter.ai")
|
||||
assert not base_url_host_matches("https://proxy.internal/openrouter.ai/v1", "openrouter.ai")
|
||||
assert not base_url_host_matches("https://openrouter.ai.evil.com/v1", "openrouter.ai")
|
||||
|
||||
|
||||
def test_local_endpoint_hostname_detection():
|
||||
from utils import base_url_hostname
|
||||
|
||||
assert base_url_hostname("http://localhost:11434/v1") == "localhost"
|
||||
assert base_url_hostname("http://127.0.0.1:1234") == "127.0.0.1"
|
||||
# A remote host with "localhost" embedded in its name is not local.
|
||||
assert base_url_hostname("https://my-localhost-mirror.com/v1") not in (
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
"0.0.0.0",
|
||||
)
|
||||
|
||||
|
||||
def test_nous_portal_host_detection():
|
||||
from utils import base_url_host_matches
|
||||
|
||||
assert base_url_host_matches("https://inference-api.nousresearch.com/v1", "nousresearch.com")
|
||||
assert base_url_host_matches("https://portal.nousresearch.com", "nousresearch.com")
|
||||
assert not base_url_host_matches("https://nousresearch.com.evil.io/v1", "nousresearch.com")
|
||||
assert not base_url_host_matches("https://proxy.example/nousresearch.com/v1", "nousresearch.com")
|
||||
|
||||
|
||||
# ── Widened class coverage (follow-up to #85737) ─────────────────────────────
|
||||
|
||||
|
||||
def test_azure_endpoint_detection_host_anchored():
|
||||
"""Azure detection (runtime_provider + run_agent) must be host-anchored:
|
||||
a path or lookalike containing 'azure.com'/'openai.azure.com' is not Azure."""
|
||||
from utils import base_url_host_matches
|
||||
|
||||
assert base_url_host_matches("https://myres.openai.azure.com/openai/v1", "azure.com")
|
||||
assert base_url_host_matches("https://myres.openai.azure.com/openai/v1", "openai.azure.com")
|
||||
assert not base_url_host_matches("https://proxy.corp/openai.azure.com/v1", "azure.com")
|
||||
assert not base_url_host_matches("https://azure.com.evil.net/v1", "azure.com")
|
||||
assert not base_url_host_matches("https://notazure.com/v1", "azure.com")
|
||||
|
||||
|
||||
def test_run_agent_azure_url_predicate():
|
||||
from run_agent import AIAgent
|
||||
|
||||
probe = object.__new__(AIAgent)
|
||||
assert probe._is_azure_openai_url("https://myres.openai.azure.com/openai/v1") is True
|
||||
assert probe._is_azure_openai_url("https://proxy.internal/openai.azure.com/v1") is False
|
||||
assert probe._is_azure_openai_url("https://openai.azure.com.evil.io/v1") is False
|
||||
|
||||
|
||||
def test_run_agent_copilot_url_predicate():
|
||||
from run_agent import AIAgent
|
||||
|
||||
probe = object.__new__(AIAgent)
|
||||
probe._base_url_lower = "https://api.githubcopilot.com/v1"
|
||||
assert probe._is_copilot_url() is True
|
||||
probe._base_url_lower = "https://proxy.test/api.githubcopilot.com/v1"
|
||||
assert probe._is_copilot_url() is False
|
||||
probe._base_url_lower = "https://models.github.ai/inference"
|
||||
assert probe._is_copilot_url() is True
|
||||
probe._base_url_lower = "https://models.github.ai.evil.com/v1"
|
||||
assert probe._is_copilot_url() is False
|
||||
|
||||
|
||||
def test_dotted_model_name_provider_allowlist_host_anchored():
|
||||
from run_agent import AIAgent
|
||||
|
||||
probe = object.__new__(AIAgent)
|
||||
probe.provider = ""
|
||||
probe.base_url = "https://open.bigmodel.cn/api/paas/v4"
|
||||
assert probe._anthropic_preserve_dots() is True
|
||||
probe.base_url = "https://gateway.example.com/bigmodel.cn/v4"
|
||||
assert probe._anthropic_preserve_dots() is False
|
||||
probe.base_url = "https://aiplatform.googleapis.com/v1"
|
||||
assert probe._anthropic_preserve_dots() is True
|
||||
probe.base_url = "https://evil.io/aiplatform.googleapis.com/v1"
|
||||
assert probe._anthropic_preserve_dots() is False
|
||||
|
||||
|
||||
def test_figma_remote_mcp_host_anchored():
|
||||
from tools.mcp_oauth import _is_figma_remote_mcp
|
||||
|
||||
assert _is_figma_remote_mcp(server_url="https://mcp.figma.com/mcp") is True
|
||||
assert _is_figma_remote_mcp(server_url="https://www.figma.com/mcp") is True
|
||||
assert _is_figma_remote_mcp(server_url="https://evil.example/mcp.figma.com/mcp") is False
|
||||
assert _is_figma_remote_mcp(server_url="https://figma.com.evil.io/mcp") is False
|
||||
# Name fallback still host-checks the URL when one is present.
|
||||
assert _is_figma_remote_mcp(server_name="figma", server_url="https://phish.example/figma") is False
|
||||
assert _is_figma_remote_mcp(server_name="figma") is True
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Bedrock API-key setup must produce a config that actually authenticates.
|
||||
|
||||
The wizard used to stash the Bedrock bearer token in ``OPENAI_API_KEY`` and set
|
||||
a bare ``provider: custom``. Since the cross-provider credential gate landed
|
||||
(#28660) that variable is only honoured for ``openai.com`` hosts, so the token
|
||||
was silently dropped and every request went out as ``no-key-required``.
|
||||
|
||||
These tests lock the seam the bug lived in: what the wizard writes, and what the
|
||||
runtime resolver then makes of it.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import yaml
|
||||
|
||||
import hermes_cli.runtime_provider as rp
|
||||
from hermes_cli.model_setup_flows import _model_flow_bedrock_api_key
|
||||
|
||||
|
||||
REGION = "us-east-1"
|
||||
TOKEN = "test-bedrock-bearer-token"
|
||||
|
||||
|
||||
def _run_wizard(monkeypatch, selected="openai.gpt-5.6-terra"):
|
||||
"""Drive the real setup flow non-interactively and return the saved config."""
|
||||
import hermes_cli.auth as auth_mod
|
||||
|
||||
monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", TOKEN)
|
||||
monkeypatch.setattr(
|
||||
auth_mod, "_prompt_model_selection", lambda *a, **k: selected
|
||||
)
|
||||
monkeypatch.setattr(auth_mod, "_save_model_choice", lambda *a, **k: None)
|
||||
monkeypatch.setattr(auth_mod, "deactivate_provider", lambda *a, **k: None)
|
||||
|
||||
_model_flow_bedrock_api_key({}, REGION)
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
return get_hermes_home(), yaml.safe_load(
|
||||
(get_hermes_home() / "config.yaml").read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
|
||||
def test_wizard_writes_named_provider_carrying_the_key_env(monkeypatch):
|
||||
home, cfg = _run_wizard(monkeypatch)
|
||||
|
||||
# The credential must travel via a named provider entry: that is the only
|
||||
# resolution branch that reads key_env.
|
||||
entry = cfg["providers"]["bedrock-mantle"]
|
||||
assert entry["key_env"] == "AWS_BEARER_TOKEN_BEDROCK"
|
||||
assert entry["base_url"].startswith(f"https://bedrock-mantle.{REGION}.api.aws")
|
||||
assert cfg["model"]["provider"] == "custom:bedrock-mantle"
|
||||
|
||||
# A bare ``custom`` provider plus model.base_url is the shape that could not
|
||||
# carry the token; make sure we did not leave it behind.
|
||||
assert "base_url" not in cfg["model"]
|
||||
|
||||
|
||||
def test_wizard_does_not_park_the_bedrock_token_in_openai_api_key(monkeypatch):
|
||||
home, _cfg = _run_wizard(monkeypatch)
|
||||
|
||||
env_file = home / ".env"
|
||||
written = env_file.read_text(encoding="utf-8") if env_file.exists() else ""
|
||||
names = {
|
||||
line.split("=", 1)[0].strip()
|
||||
for line in written.splitlines()
|
||||
if "=" in line and not line.lstrip().startswith("#")
|
||||
}
|
||||
|
||||
# Writing a Bedrock credential into another vendor's variable is what the
|
||||
# #28660 gate exists to prevent. (The token itself already came from the
|
||||
# environment here, so the flow has no reason to re-write it.)
|
||||
assert "OPENAI_API_KEY" not in names
|
||||
assert "OPENAI_BASE_URL" not in names
|
||||
|
||||
|
||||
def test_saved_config_resolves_the_bearer_token_not_a_placeholder(monkeypatch):
|
||||
"""The regression contract: the token must reach the resolved runtime."""
|
||||
_home, cfg = _run_wizard(monkeypatch)
|
||||
|
||||
# Nothing else may supply a credential for this host.
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||
monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", TOKEN)
|
||||
monkeypatch.setattr(rp, "load_config", lambda: cfg)
|
||||
|
||||
resolved = rp.resolve_runtime_provider(
|
||||
requested=cfg["model"]["provider"],
|
||||
)
|
||||
|
||||
assert resolved["api_key"] == TOKEN
|
||||
assert resolved["api_key"] != "no-key-required"
|
||||
assert "bedrock-mantle" in resolved["base_url"]
|
||||
|
||||
|
||||
def test_resolution_fails_closed_when_the_token_is_absent(monkeypatch):
|
||||
"""No token in the environment must not silently resolve to a placeholder key."""
|
||||
_home, cfg = _run_wizard(monkeypatch)
|
||||
|
||||
monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||
monkeypatch.setattr(rp, "load_config", lambda: cfg)
|
||||
|
||||
resolved = rp.resolve_runtime_provider(requested=cfg["model"]["provider"])
|
||||
|
||||
assert resolved["api_key"] != TOKEN
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Tests for AWS Bedrock integration in the model picker and provider catalog.
|
||||
|
||||
Covers the three paths changed by fix/bedrock-provider-model-ids-live-discovery:
|
||||
|
||||
1. provider_model_ids("bedrock") — uses live discover_bedrock_models() instead
|
||||
of the static _PROVIDER_MODELS table, with curated fallback.
|
||||
|
||||
2. list_authenticated_providers() Section 2 (HERMES_OVERLAYS) — bedrock
|
||||
appears when AWS credentials are present; model list comes from live
|
||||
discovery keyed by the resolved region, NOT the static us.* table.
|
||||
|
||||
3. Region resolution — resolve_bedrock_region() reads from botocore profile
|
||||
when no AWS_REGION / AWS_DEFAULT_REGION env vars are set, so EU/AP users
|
||||
in eu-central-1 get eu.* profile IDs, not us.* ones.
|
||||
|
||||
All Bedrock API calls are mocked — no real AWS credentials needed.
|
||||
"""
|
||||
|
||||
from contextlib import contextmanager
|
||||
from types import ModuleType
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
_BOTO_PREFIXES = ("botocore", "boto3")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _boto_sys_modules_hygiene():
|
||||
"""Snapshot/restore boto* sys.modules around every test.
|
||||
|
||||
Tests here plant fake botocore/boto3 modules; a fake that leaks (or a
|
||||
real submodule first-imported inside a stub window) poisons later
|
||||
imports of the real ``botocore.exceptions`` with
|
||||
``No module named 'botocore.vendored'`` (PR #92617 CI flake). This
|
||||
fixture makes stub windows airtight regardless of test ordering.
|
||||
"""
|
||||
import sys as _sys
|
||||
|
||||
saved = {
|
||||
name: mod
|
||||
for name, mod in _sys.modules.items()
|
||||
if name.split(".", 1)[0] in _BOTO_PREFIXES
|
||||
}
|
||||
yield
|
||||
for name in [n for n in _sys.modules if n.split(".", 1)[0] in _BOTO_PREFIXES]:
|
||||
_sys.modules.pop(name, None)
|
||||
_sys.modules.update(saved)
|
||||
|
||||
|
||||
from agent.bedrock_adapter import BEDROCK_OPENAI_RESPONSES_MODEL_IDS
|
||||
|
||||
_MANTLE_MODELS = list(BEDROCK_OPENAI_RESPONSES_MODEL_IDS)
|
||||
_MANTLE_SET = {m.lower() for m in _MANTLE_MODELS}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared helpers / fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _mock_botocore_session(*, return_value=None):
|
||||
"""Patch botocore.session even when botocore is not installed."""
|
||||
botocore_mod = ModuleType("botocore")
|
||||
session_mod = ModuleType("botocore.session")
|
||||
session_mod.get_session = MagicMock(return_value=return_value)
|
||||
botocore_mod.session = session_mod
|
||||
with patch.dict("sys.modules", {"botocore": botocore_mod, "botocore.session": session_mod}):
|
||||
yield session_mod.get_session
|
||||
|
||||
|
||||
_EU_MODELS = [
|
||||
{"id": "eu.anthropic.claude-sonnet-4-6-20250514-v1:0", "name": "Claude Sonnet 4.6 (EU)", "provider": "inference-profile"},
|
||||
{"id": "eu.anthropic.claude-haiku-4-5-20251015-v1:0", "name": "Claude Haiku 4.5 (EU)", "provider": "inference-profile"},
|
||||
{"id": "eu.amazon.nova-pro-v1:0", "name": "Nova Pro (EU)", "provider": "inference-profile"},
|
||||
]
|
||||
|
||||
_US_MODELS = [
|
||||
{"id": "us.anthropic.claude-sonnet-4-6-20250514-v1:0", "name": "Claude Sonnet 4.6 (US)", "provider": "inference-profile"},
|
||||
{"id": "us.amazon.nova-pro-v1:0", "name": "Nova Pro (US)", "provider": "inference-profile"},
|
||||
]
|
||||
|
||||
|
||||
def _mock_discover(region: str):
|
||||
"""Return EU models for eu-* regions, US models otherwise."""
|
||||
return _EU_MODELS if region.startswith("eu-") else _US_MODELS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. provider_model_ids("bedrock")
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestProviderModelIdsBedrock:
|
||||
"""provider_model_ids("bedrock") should use live Bedrock discovery."""
|
||||
|
||||
def test_returns_live_discovered_model_ids(self, monkeypatch):
|
||||
"""Live discovery result is returned as a flat list of model ID strings."""
|
||||
from hermes_cli.models import provider_model_ids
|
||||
|
||||
monkeypatch.setenv("AWS_REGION", "eu-central-1")
|
||||
|
||||
with patch("agent.bedrock_adapter.discover_bedrock_models", side_effect=_mock_discover), \
|
||||
patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
|
||||
result = provider_model_ids("bedrock")
|
||||
|
||||
assert "eu.anthropic.claude-sonnet-4-6-20250514-v1:0" in result
|
||||
assert "eu.anthropic.claude-haiku-4-5-20251015-v1:0" in result
|
||||
for _m in _MANTLE_MODELS:
|
||||
assert _m in result
|
||||
assert len(result) == len(_EU_MODELS) + len(_MANTLE_MODELS)
|
||||
|
||||
def test_region_determines_model_ids(self, monkeypatch):
|
||||
"""Different regions produce different model ID prefixes (eu.* vs us.*)."""
|
||||
from hermes_cli.models import provider_model_ids
|
||||
|
||||
with patch("agent.bedrock_adapter.discover_bedrock_models", side_effect=_mock_discover):
|
||||
with patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
|
||||
eu_result = provider_model_ids("bedrock")
|
||||
with patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="us-east-1"):
|
||||
us_result = provider_model_ids("bedrock")
|
||||
|
||||
assert all(m.startswith("eu.") or m.lower() in _MANTLE_SET for m in eu_result)
|
||||
assert all(m.startswith("us.") or m.lower() in _MANTLE_SET for m in us_result)
|
||||
assert eu_result != us_result
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. list_authenticated_providers() — bedrock via HERMES_OVERLAYS (Section 2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestListAuthenticatedProvidersBedrock:
|
||||
"""Bedrock should appear in the /model picker when AWS creds are present."""
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_bedrock_not_shown_without_credentials(self, monkeypatch):
|
||||
"""Bedrock must not appear when no AWS credentials are present."""
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
monkeypatch.delenv("AWS_PROFILE", raising=False)
|
||||
monkeypatch.delenv("AWS_ACCESS_KEY_ID", raising=False)
|
||||
monkeypatch.delenv("AWS_SECRET_ACCESS_KEY", raising=False)
|
||||
monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False)
|
||||
monkeypatch.delenv("AWS_WEB_IDENTITY_TOKEN_FILE", raising=False)
|
||||
monkeypatch.delenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", raising=False)
|
||||
|
||||
with patch("agent.bedrock_adapter.has_aws_credentials", return_value=False):
|
||||
providers = list_authenticated_providers(current_provider="openai")
|
||||
|
||||
bedrock = next((p for p in providers if p["slug"] == "bedrock"), None)
|
||||
assert bedrock is None, "bedrock should NOT appear when AWS credentials are absent"
|
||||
|
||||
def test_non_bedrock_picker_does_not_probe_full_aws_chain(self, monkeypatch):
|
||||
"""Non-Bedrock provider discovery must not touch boto3's full credential chain."""
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
monkeypatch.delenv("AWS_PROFILE", raising=False)
|
||||
monkeypatch.delenv("AWS_ACCESS_KEY_ID", raising=False)
|
||||
monkeypatch.delenv("AWS_SECRET_ACCESS_KEY", raising=False)
|
||||
monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False)
|
||||
monkeypatch.delenv("AWS_WEB_IDENTITY_TOKEN_FILE", raising=False)
|
||||
monkeypatch.delenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", raising=False)
|
||||
monkeypatch.delenv("AWS_CONTAINER_CREDENTIALS_FULL_URI", raising=False)
|
||||
|
||||
calls = {"has_aws_credentials": 0}
|
||||
|
||||
def _has_aws_credentials():
|
||||
calls["has_aws_credentials"] += 1
|
||||
return False
|
||||
|
||||
with patch("agent.bedrock_adapter.has_aws_credentials", side_effect=_has_aws_credentials):
|
||||
providers = list_authenticated_providers(current_provider="openrouter", max_models=0)
|
||||
|
||||
assert calls["has_aws_credentials"] == 0
|
||||
assert all(p["slug"] != "bedrock" for p in providers)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Region routing: EU/AP users see regional model IDs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBedrockRegionRouting:
|
||||
"""End-to-end: region from botocore profile is used for discovery, so EU/AP
|
||||
users get eu.*/ap.* model IDs rather than the hardcoded us-east-1 list."""
|
||||
|
||||
def test_eu_region_from_botocore_profile_yields_eu_models(self):
|
||||
"""When botocore resolves eu-central-1, picker shows eu.* model IDs."""
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.get_config_variable.return_value = "eu-central-1"
|
||||
|
||||
with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \
|
||||
patch("agent.bedrock_adapter.discover_bedrock_models", side_effect=_mock_discover), \
|
||||
_mock_botocore_session(return_value=mock_session):
|
||||
providers = list_authenticated_providers(current_provider="bedrock")
|
||||
|
||||
bedrock = next((p for p in providers if p["slug"] == "bedrock"), None)
|
||||
assert bedrock is not None
|
||||
for _m in _MANTLE_MODELS:
|
||||
assert _m in bedrock["models"]
|
||||
for model_id in bedrock["models"]:
|
||||
assert model_id.startswith("eu.") or model_id.lower() in _MANTLE_SET, \
|
||||
f"Expected eu.* or Bedrock OpenAI model ID from eu-central-1 profile, got {model_id!r}"
|
||||
|
||||
|
||||
def test_env_var_takes_priority_over_botocore_profile(self, monkeypatch):
|
||||
"""AWS_REGION env var wins over botocore profile region."""
|
||||
from agent.bedrock_adapter import resolve_bedrock_region
|
||||
|
||||
monkeypatch.setenv("AWS_REGION", "us-west-2")
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.get_config_variable.return_value = "eu-central-1"
|
||||
|
||||
with _mock_botocore_session(return_value=mock_session):
|
||||
region = resolve_bedrock_region()
|
||||
|
||||
assert region == "us-west-2", "env var should override botocore profile"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. providers.py overlay registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBedrockOverlayRegistration:
|
||||
"""bedrock entry in HERMES_OVERLAYS is correctly configured."""
|
||||
|
||||
def test_bedrock_overlay_exists(self):
|
||||
from hermes_cli.providers import HERMES_OVERLAYS
|
||||
assert "bedrock" in HERMES_OVERLAYS
|
||||
|
||||
|
||||
def test_bedrock_label(self):
|
||||
from hermes_cli.providers import get_label
|
||||
label = get_label("bedrock")
|
||||
assert label # non-empty
|
||||
assert "bedrock" in label.lower() or "aws" in label.lower()
|
||||
|
||||
def test_bedrock_aliases_resolve(self):
|
||||
from hermes_cli.providers import normalize_provider
|
||||
for alias in ("aws", "aws-bedrock", "amazon-bedrock", "amazon"):
|
||||
assert normalize_provider(alias) == "bedrock", \
|
||||
f"alias {alias!r} should normalize to 'bedrock'"
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Regression tests for #28156 — Bedrock picker must be region-scoped.
|
||||
|
||||
Geo-prefixed cross-region inference profiles (us.*, eu.*, apac.*, ...) only
|
||||
route from endpoints in their own geography. Offering us.* profiles to an
|
||||
eu-central-2 picker produces configs AWS rejects regardless of credentials.
|
||||
"""
|
||||
|
||||
from hermes_cli.model_setup_flows import (
|
||||
BEDROCK_GEO_PREFIXES,
|
||||
bedrock_model_routable_from_region,
|
||||
bedrock_region_geo_prefix,
|
||||
)
|
||||
|
||||
|
||||
class TestRegionGeoPrefix:
|
||||
def test_known_geographies(self):
|
||||
assert bedrock_region_geo_prefix("us-east-1") == "us."
|
||||
assert bedrock_region_geo_prefix("eu-central-2") == "eu."
|
||||
assert bedrock_region_geo_prefix("ap-southeast-1") == "ap."
|
||||
assert bedrock_region_geo_prefix("ca-central-1") == "ca."
|
||||
assert bedrock_region_geo_prefix("sa-east-1") == "sa."
|
||||
assert bedrock_region_geo_prefix("me-south-1") == "me."
|
||||
assert bedrock_region_geo_prefix("af-south-1") == "af."
|
||||
|
||||
def test_unknown_region_is_empty(self):
|
||||
assert bedrock_region_geo_prefix("") == ""
|
||||
assert bedrock_region_geo_prefix("moon-base-1") == ""
|
||||
|
||||
|
||||
class TestRoutableFromRegion:
|
||||
def test_us_profile_not_offered_in_eu(self):
|
||||
assert not bedrock_model_routable_from_region(
|
||||
"us.anthropic.claude-sonnet-4-6", "eu-central-2"
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
class TestGeoPrefixContract:
|
||||
def test_every_geo_prefix_maps_to_a_routable_region_or_is_alias(self):
|
||||
"""Invariant: each geo prefix is either reachable from some region's
|
||||
geo mapping or an ap-family alias — no dead entries."""
|
||||
mapped = {
|
||||
bedrock_region_geo_prefix(r)
|
||||
for r in (
|
||||
"us-east-1", "eu-central-2", "ap-southeast-1",
|
||||
"ca-central-1", "sa-east-1", "me-south-1", "af-south-1",
|
||||
)
|
||||
}
|
||||
ap_aliases = {"apac.", "jp."}
|
||||
for prefix in BEDROCK_GEO_PREFIXES:
|
||||
assert prefix in mapped or prefix in ap_aliases
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Tests for the /billing CLI handler (cli.py::_show_billing).
|
||||
|
||||
Focus on the non-interactive (no live prompt_toolkit app) path — the same
|
||||
discipline as the /credits non-interactive test: it must render text, never
|
||||
invoke the modal (which would read the slash-worker's JSON-RPC stdin and hang).
|
||||
Plus role/kill-switch gating and logged-out handling.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
import agent.billing_view as bv
|
||||
from agent.billing_view import BillingState, CardInfo, MonthlyCap
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cli():
|
||||
obj = HermesCLI.__new__(HermesCLI) # bypass __init__ (no full app needed)
|
||||
obj._app = None # non-interactive: forces the text path
|
||||
return obj
|
||||
|
||||
|
||||
def _boom_modal(*a, **kw):
|
||||
raise AssertionError("modal must NOT be called in non-interactive mode")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ── Card visibility + the add-card path (inline w/ NAS card-resolver) ──
|
||||
|
||||
|
||||
def _scripted(*responses):
|
||||
it = iter(responses)
|
||||
|
||||
def _modal(self, **kw):
|
||||
return next(it)
|
||||
|
||||
return _modal
|
||||
|
||||
|
||||
def test_topup_overview_splits_onetime_from_automatic_copy(cli, monkeypatch, capsys):
|
||||
# (c): the interactive /topup overview states the one-time-vs-automatic
|
||||
# distinction up front in each first sentence, and keeps "credits" out of the
|
||||
# dollars-only surface. Auto-reload OFF → the automatic line omits amounts.
|
||||
cli._app = object() # interactive → reaches the split-copy explainer
|
||||
state = BillingState(
|
||||
logged_in=True, role="OWNER", balance_usd=Decimal("50"),
|
||||
cli_billing_enabled=True, charge_presets=(Decimal("25"),),
|
||||
card=CardInfo(brand="Visa", last4="4242"),
|
||||
portal_url="https://portal/billing",
|
||||
)
|
||||
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state)
|
||||
# Overview prints the explainer, then the action modal → back out with "cancel".
|
||||
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted("cancel"), raising=False)
|
||||
cli._show_billing("/topup")
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert "Add funds now — a single charge, added to your balance today." in out
|
||||
assert "Refill when low — charges your card automatically when your balance falls below" in out
|
||||
# Dollars-only surface: no "credits" word leaks into /topup.
|
||||
assert "credits" not in out.lower()
|
||||
|
||||
|
||||
def test_topup_automatic_copy_generic_when_amounts_missing(cli, monkeypatch, capsys):
|
||||
# (5): auto-reload "enabled" but amounts absent (partial response) → generic
|
||||
# copy, never "charges — automatically … below —.".
|
||||
from agent.billing_view import AutoReload
|
||||
|
||||
cli._app = object()
|
||||
state = BillingState(
|
||||
logged_in=True, role="OWNER", balance_usd=Decimal("50"),
|
||||
cli_billing_enabled=True, charge_presets=(Decimal("25"),),
|
||||
card=CardInfo(brand="Visa", last4="4242"),
|
||||
auto_reload=AutoReload(enabled=True, threshold_usd=None, reload_to_usd=None),
|
||||
portal_url="https://portal/billing",
|
||||
)
|
||||
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state)
|
||||
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted("cancel"), raising=False)
|
||||
cli._show_billing("/topup")
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert (
|
||||
"Refill when low — charges your card automatically when your balance "
|
||||
"falls below the amount you set."
|
||||
) in out
|
||||
assert "charges — automatically" not in out
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_buy_flow_no_card_back_abandons(cli, monkeypatch, capsys):
|
||||
cli._app = object()
|
||||
nocard = BillingState(
|
||||
logged_in=True, role="OWNER", cli_billing_enabled=True,
|
||||
charge_presets=(Decimal("25"),), card=None,
|
||||
portal_url="https://portal/billing",
|
||||
)
|
||||
calls = {"n": 0}
|
||||
|
||||
def _no_fetch(*a, **kw):
|
||||
calls["n"] += 1
|
||||
return nocard
|
||||
|
||||
monkeypatch.setattr(bv, "build_billing_state", _no_fetch)
|
||||
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted("cancel"), raising=False)
|
||||
|
||||
cli._billing_buy_flow(nocard)
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert "Add a card first" in out
|
||||
assert "Cancelled. No funds added." in out
|
||||
assert calls["n"] == 0 # backed out before any re-check
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Portal-URL resolution for Phase 2b billing errors (nous_billing).
|
||||
|
||||
The server emits ``portalUrl`` relative by design (``/billing?topup=open``); the
|
||||
client must resolve it against the active portal base so deep-links are clickable
|
||||
on whatever deployment (preview / staging / prod) the user is pointed at.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.nous_billing import (
|
||||
BillingError,
|
||||
_absolutize_portal_url,
|
||||
_raise_for_error,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _preview(monkeypatch):
|
||||
monkeypatch.setenv("HERMES_PORTAL_BASE_URL", "https://nas-pr-412.nousresearch.wtf")
|
||||
|
||||
|
||||
def test_absolutize_resolves_relative(_preview):
|
||||
assert (
|
||||
_absolutize_portal_url("/billing?topup=open")
|
||||
== "https://nas-pr-412.nousresearch.wtf/billing?topup=open"
|
||||
)
|
||||
|
||||
|
||||
def test_raise_for_error_attaches_absolute_portal_url(_preview):
|
||||
# The 403 no_payment_method envelope carries a RELATIVE portalUrl; the raised
|
||||
# BillingError must expose it as ABSOLUTE so CLI + TUI render a clickable link.
|
||||
with pytest.raises(BillingError) as exc_info:
|
||||
_raise_for_error(
|
||||
403,
|
||||
{"error": "no_payment_method", "portalUrl": "/billing?topup=open"},
|
||||
)
|
||||
assert (
|
||||
exc_info.value.portal_url
|
||||
== "https://nas-pr-412.nousresearch.wtf/billing?topup=open"
|
||||
)
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Tests for the Phase 2b billing:manage scope step-up (auth.py)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_cli.auth as auth
|
||||
from hermes_cli.auth import (
|
||||
NOUS_BILLING_MANAGE_SCOPE,
|
||||
nous_token_has_billing_scope,
|
||||
step_up_nous_billing_scope,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# nous_token_has_billing_scope
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# step_up_nous_billing_scope
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _stub_persist(monkeypatch):
|
||||
"""Neutralize the persistence side-effects so step-up tests are pure."""
|
||||
monkeypatch.setattr(auth, "_auth_store_lock", lambda: _NullCtx())
|
||||
monkeypatch.setattr(auth, "_load_auth_store", lambda: {})
|
||||
monkeypatch.setattr(auth, "_save_provider_state", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(auth, "_save_auth_store", lambda *a, **kw: "auth.json")
|
||||
monkeypatch.setattr(auth, "_write_shared_nous_state", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(auth, "_sync_nous_pool_from_auth_store", lambda: None)
|
||||
|
||||
|
||||
class _NullCtx:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
|
||||
def test_step_up_requests_billing_scope_and_reuses_prior_urls(monkeypatch, _stub_persist):
|
||||
monkeypatch.setattr(
|
||||
auth,
|
||||
"get_provider_auth_state",
|
||||
lambda p: {
|
||||
"scope": "inference:invoke tool:invoke",
|
||||
"portal_base_url": "https://preview.example.com",
|
||||
"inference_base_url": "https://inf.example.com",
|
||||
"client_id": "hermes-cli",
|
||||
},
|
||||
)
|
||||
captured = {}
|
||||
|
||||
def _fake_login(**kw):
|
||||
captured.update(kw)
|
||||
# Simulate the admin ticking the box → token comes back WITH the scope.
|
||||
return {"scope": "inference:invoke tool:invoke billing:manage", "access_token": "t"}
|
||||
|
||||
monkeypatch.setattr(auth, "_nous_device_code_login", _fake_login)
|
||||
|
||||
granted = step_up_nous_billing_scope()
|
||||
assert granted is True
|
||||
# Requested scope must include billing:manage, preserving prior scopes.
|
||||
assert NOUS_BILLING_MANAGE_SCOPE in captured["scope"].split()
|
||||
assert "inference:invoke" in captured["scope"].split()
|
||||
# Reuses the prior credential's deployment URLs (so a preview stays a preview).
|
||||
assert captured["portal_base_url"] == "https://preview.example.com"
|
||||
assert captured["client_id"] == "hermes-cli"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# on_verification callback plumbing (TUI surfaces the device-flow URL via this)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
def test_device_login_fires_on_verification_before_polling(monkeypatch):
|
||||
"""on_verification(url, code) must fire BEFORE _poll_for_token (so the TUI
|
||||
can render the link while the flow blocks waiting for approval)."""
|
||||
order: list[str] = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
auth,
|
||||
"_request_device_code",
|
||||
lambda **kw: {
|
||||
"verification_uri_complete": "https://portal.example/device?code=ABCD",
|
||||
"user_code": "ABCD-1234",
|
||||
"device_code": "dev",
|
||||
"expires_in": 600,
|
||||
"interval": 5,
|
||||
},
|
||||
)
|
||||
|
||||
def _fake_poll(**kw):
|
||||
order.append("poll")
|
||||
return {"access_token": "t", "scope": "inference:invoke", "expires_in": 3600}
|
||||
|
||||
monkeypatch.setattr(auth, "_poll_for_token", _fake_poll)
|
||||
|
||||
seen = {}
|
||||
|
||||
def _cb(url, code):
|
||||
order.append("verify")
|
||||
seen["url"] = url
|
||||
seen["code"] = code
|
||||
|
||||
# We only assert the callback fires before polling. Post-poll token
|
||||
# validation (JWT usability checks) is out of scope and may raise on the
|
||||
# synthetic token — swallow it; the ordering assertion is what matters.
|
||||
try:
|
||||
auth._nous_device_code_login(open_browser=False, on_verification=_cb)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
assert order[:2] == ["verify", "poll"], "callback must fire before polling"
|
||||
assert seen["url"] == "https://portal.example/device?code=ABCD"
|
||||
assert seen["code"] == "ABCD-1234"
|
||||
@@ -0,0 +1,110 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from argparse import Namespace
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _bitwarden_config(*, enabled: bool = True, server_url: str = "") -> dict:
|
||||
return {
|
||||
"secrets": {
|
||||
"bitwarden": {
|
||||
"enabled": enabled,
|
||||
"access_token_env": "BWS_ACCESS_TOKEN",
|
||||
"project_id": "proj-123",
|
||||
"server_url": server_url,
|
||||
"cache_ttl_seconds": 300,
|
||||
"override_existing": True,
|
||||
"auto_install": True,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_status_surfaces_failed_token_validation(monkeypatch, capsys):
|
||||
from hermes_cli import secrets_cli
|
||||
|
||||
seen = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
secrets_cli,
|
||||
"load_config",
|
||||
lambda: _bitwarden_config(server_url="https://vault.bitwarden.eu"),
|
||||
)
|
||||
monkeypatch.setenv("BWS_ACCESS_TOKEN", "0.invalid-token")
|
||||
monkeypatch.setattr(
|
||||
secrets_cli.bw,
|
||||
"find_bws",
|
||||
lambda install_if_missing=False: Path("/tmp/bws"),
|
||||
)
|
||||
monkeypatch.setattr(secrets_cli, "_bws_version", lambda _binary: "bws v2.1.0")
|
||||
|
||||
def _fake_list_projects(binary, token, console, *, server_url=""):
|
||||
seen["binary"] = binary
|
||||
seen["token"] = token
|
||||
seen["server_url"] = server_url
|
||||
console.print(" bws project list failed: Doesn't contain a decryption key")
|
||||
console.print(
|
||||
" This usually means the access token is wrong or revoked. "
|
||||
"Double-check it in the Bitwarden web app."
|
||||
)
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(secrets_cli, "_list_projects", _fake_list_projects)
|
||||
|
||||
assert secrets_cli.cmd_status(Namespace()) == 0
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Token in env" in out
|
||||
assert "Token validation" in out
|
||||
assert "failed" in out
|
||||
assert "Doesn't contain a decryption key" in out
|
||||
assert "wrong or revoked" in out
|
||||
assert seen == {
|
||||
"binary": Path("/tmp/bws"),
|
||||
"token": "0.invalid-token",
|
||||
"server_url": "https://vault.bitwarden.eu",
|
||||
}
|
||||
|
||||
|
||||
def test_status_warns_when_token_does_not_look_like_bsm_token(monkeypatch, capsys):
|
||||
from hermes_cli import secrets_cli
|
||||
|
||||
monkeypatch.setattr(secrets_cli, "load_config", lambda: _bitwarden_config())
|
||||
monkeypatch.setenv("BWS_ACCESS_TOKEN", "not-a-bitwarden-token")
|
||||
monkeypatch.setattr(
|
||||
secrets_cli.bw,
|
||||
"find_bws",
|
||||
lambda install_if_missing=False: Path("/tmp/bws"),
|
||||
)
|
||||
monkeypatch.setattr(secrets_cli, "_bws_version", lambda _binary: "bws v2.1.0")
|
||||
monkeypatch.setattr(
|
||||
secrets_cli,
|
||||
"_list_projects",
|
||||
lambda binary, token, console, *, server_url="": [],
|
||||
)
|
||||
|
||||
assert secrets_cli.cmd_status(Namespace()) == 0
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Token validation" in out
|
||||
assert "passed" in out
|
||||
assert "doesn't start with '0.'" in out
|
||||
|
||||
|
||||
def test_status_marks_validation_as_not_checked_without_bws_binary(monkeypatch, capsys):
|
||||
from hermes_cli import secrets_cli
|
||||
|
||||
monkeypatch.setattr(secrets_cli, "load_config", lambda: _bitwarden_config())
|
||||
monkeypatch.setenv("BWS_ACCESS_TOKEN", "0.token-present")
|
||||
monkeypatch.setattr(
|
||||
secrets_cli.bw,
|
||||
"find_bws",
|
||||
lambda install_if_missing=False: None,
|
||||
)
|
||||
|
||||
assert secrets_cli.cmd_status(Namespace()) == 0
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Token validation" in out
|
||||
assert "not checked" in out
|
||||
assert "bws not installed" in out
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Every staged model must launch with a policy decision, never stock fit.
|
||||
|
||||
The managed server autoloads any GGUF in its models dir; a model missing
|
||||
from the preset INI loads with llama-server defaults (f16 KV at max
|
||||
context, no placement) — on Windows/WDDM that silently demotes VRAM and
|
||||
decodes at a crawl. Boot must therefore refuse to adopt a running server
|
||||
whose presets predate the staged set."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hermes_home(tmp_path, monkeypatch):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
return home
|
||||
|
||||
|
||||
def _stage(home, name):
|
||||
mdir = home / "models"
|
||||
mdir.mkdir(parents=True, exist_ok=True)
|
||||
(mdir / f"{name}.gguf").write_bytes(b"GGUF" + b"\x00" * 32)
|
||||
|
||||
|
||||
def _write_presets(home, *model_ids):
|
||||
pdir = home / "runtimes" / "llamacpp"
|
||||
pdir.mkdir(parents=True, exist_ok=True)
|
||||
body = "\n".join(f"[{m}]\nctx-size = 65536\n" for m in model_ids)
|
||||
(pdir / "presets.ini").write_text(body, encoding="utf-8")
|
||||
|
||||
|
||||
def test_presets_stale_when_a_staged_model_has_no_section(hermes_home):
|
||||
from hermes_cli.local_runtime.bootstrap import _presets_stale
|
||||
|
||||
_stage(hermes_home, "model-a")
|
||||
_stage(hermes_home, "model-b")
|
||||
_write_presets(hermes_home, "model-a")
|
||||
assert _presets_stale() is True
|
||||
|
||||
|
||||
def test_presets_current_when_every_staged_model_is_covered(hermes_home):
|
||||
from hermes_cli.local_runtime.bootstrap import _presets_stale
|
||||
|
||||
_stage(hermes_home, "model-a")
|
||||
_write_presets(hermes_home, "model-a")
|
||||
assert _presets_stale() is False
|
||||
|
||||
|
||||
def test_no_models_is_never_stale(hermes_home):
|
||||
from hermes_cli.local_runtime.bootstrap import _presets_stale
|
||||
|
||||
_write_presets(hermes_home, "model-a")
|
||||
assert _presets_stale() is False
|
||||
|
||||
|
||||
def test_boot_replaces_incumbent_with_stale_presets(hermes_home, monkeypatch):
|
||||
"""ensure_local_runtime must not adopt a running server whose presets
|
||||
miss a staged model — it stops it and boots fresh (boot itself is
|
||||
stubbed; the contract under test is the adopt/replace decision)."""
|
||||
import hermes_cli.local_runtime.bootstrap as boot
|
||||
|
||||
_stage(hermes_home, "model-a")
|
||||
_stage(hermes_home, "model-b")
|
||||
_write_presets(hermes_home, "model-a")
|
||||
|
||||
stopped = {}
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.local_runtime.endpoint._state_endpoint",
|
||||
lambda: {"base_url": "http://127.0.0.1:18434/v1", "pid": 12345})
|
||||
monkeypatch.setattr(boot, "_stop_state_server",
|
||||
lambda state: stopped.setdefault("pid", state["pid"]))
|
||||
|
||||
sentinel = object()
|
||||
|
||||
def fake_boot(*a, **k):
|
||||
raise _BootReached()
|
||||
|
||||
class _BootReached(Exception):
|
||||
pass
|
||||
|
||||
# Fail fast once boot proper begins — reaching it IS the assertion.
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.local_runtime.binaries.ensure_runtime_installed", fake_boot)
|
||||
|
||||
result = boot.ensure_local_runtime({"local_runtime": {"enabled": True}})
|
||||
assert stopped.get("pid") == 12345, "stale incumbent was not stopped"
|
||||
# Boot proceeded past adoption (our fake raised inside the try block,
|
||||
# which ensure_local_runtime swallows into a None return).
|
||||
assert result is None or result is sentinel
|
||||
|
||||
|
||||
def test_refresh_bounces_an_adopted_server(hermes_home, monkeypatch):
|
||||
"""refresh_local_runtime with no in-process supervisor but a running
|
||||
state-file server (the post-restart shape) must stop that server and
|
||||
boot fresh — NOT silently no-op. Regression: the no-op meant every
|
||||
download/delete after a backend restart left the router serving a
|
||||
stale model catalog, and picking the new model failed with
|
||||
'not found in this provider's model listing'."""
|
||||
import hermes_cli.local_runtime.bootstrap as boot
|
||||
|
||||
stopped = {}
|
||||
monkeypatch.setattr(boot, "_SUPERVISOR", None)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.local_runtime.endpoint._state_endpoint",
|
||||
lambda: {"base_url": "http://127.0.0.1:18434/v1", "pid": 4242})
|
||||
monkeypatch.setattr(boot, "_stop_state_server",
|
||||
lambda state: stopped.setdefault("pid", state["pid"]))
|
||||
booted = {}
|
||||
monkeypatch.setattr(boot, "ensure_local_runtime",
|
||||
lambda cfg, force=False: booted.setdefault("force", force) or object())
|
||||
|
||||
assert boot.refresh_local_runtime() is True
|
||||
assert stopped.get("pid") == 4242, "adopted server was not stopped"
|
||||
assert booted.get("force") is True, "fresh boot did not follow the stop"
|
||||
|
||||
|
||||
def test_refresh_no_server_anywhere_is_a_noop(hermes_home, monkeypatch):
|
||||
import hermes_cli.local_runtime.bootstrap as boot
|
||||
|
||||
monkeypatch.setattr(boot, "_SUPERVISOR", None)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.local_runtime.endpoint._state_endpoint", lambda: None)
|
||||
assert boot.refresh_local_runtime() is False
|
||||
@@ -0,0 +1,112 @@
|
||||
"""``bounded_probe_run`` — deadlock-safe capture for fail-open probes (#87134).
|
||||
|
||||
On Windows, ``subprocess.run(..., capture_output=True, timeout=N)`` can hang
|
||||
FOREVER after its timeout fires: run()'s cleanup kills the direct child and
|
||||
then joins the pipe reader threads with an unbounded ``communicate()``. A
|
||||
descendant (``conhost.exe`` under wmic/powershell, ``git.exe`` under a
|
||||
launcher shim) holding duplicated pipe handles keeps the pipes from EOF and
|
||||
the join never returns. ``hermes update`` wedged exactly there inside
|
||||
``_scan_gateway_pids`` on machines where the full ``Win32_Process`` scan
|
||||
exceeds its budget.
|
||||
|
||||
``bounded_probe_run`` is the shared, generalized form of the fix that
|
||||
``bounded_git_probe`` already proved for git probes (#68609 / #66037):
|
||||
explicit ``communicate(timeout)``, tree-kill on failure, bounded 1s drain,
|
||||
then abandon.
|
||||
|
||||
These tests use REAL subprocesses (no mocks) for the semantics: a mock cannot
|
||||
reproduce pipe-handle inheritance or timeout behavior. The POSIX-only
|
||||
descendant-survival cases live in ``test_git_probe_tree_kill.py`` and now
|
||||
exercise the same code path through the ``bounded_git_probe`` delegation.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli._subprocess_compat import bounded_git_probe, bounded_probe_run
|
||||
|
||||
_PY = sys.executable
|
||||
|
||||
|
||||
def test_success_returns_completed_process():
|
||||
result = bounded_probe_run([_PY, "-c", "print('ok'); import sys; sys.exit(0)"], timeout=30)
|
||||
assert result is not None
|
||||
assert result.returncode == 0
|
||||
assert result.stdout.strip() == "ok"
|
||||
|
||||
|
||||
def test_nonzero_exit_is_returned_not_swallowed():
|
||||
"""Unlike bounded_git_probe, callers see the real returncode — the gateway
|
||||
scan branches on ``returncode != 0`` to trip the wmic→powershell fallback."""
|
||||
result = bounded_probe_run([_PY, "-c", "print('partial'); import sys; sys.exit(3)"], timeout=30)
|
||||
assert result is not None
|
||||
assert result.returncode == 3
|
||||
assert result.stdout.strip() == "partial"
|
||||
|
||||
|
||||
def test_spawn_failure_returns_none():
|
||||
result = bounded_probe_run(["definitely-not-a-real-binary-87134"], timeout=5)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_timeout_returns_none_within_bounded_time():
|
||||
"""A child that sleeps past the timeout must produce ``None`` promptly —
|
||||
timeout + tree-kill + 1s bounded drain, not an unbounded join."""
|
||||
start = time.monotonic()
|
||||
result = bounded_probe_run(
|
||||
[_PY, "-c", "import time; time.sleep(300)"],
|
||||
timeout=1.0,
|
||||
)
|
||||
elapsed = time.monotonic() - start
|
||||
assert result is None
|
||||
# 1s timeout + tree-kill + 1s drain + slack. The pre-fix failure mode is
|
||||
# an indefinite hang, so any bound proves the property; keep it loose for
|
||||
# slow CI runners.
|
||||
assert elapsed < 30
|
||||
|
||||
|
||||
def test_decode_errors_configurable():
|
||||
"""The process scans pass errors='ignore' (wmic emits system code page);
|
||||
undecodable bytes must not raise or None out stdout (#17049 class)."""
|
||||
result = bounded_probe_run(
|
||||
[_PY, "-c", "import sys; sys.stdout.buffer.write(b'ok\\xff\\xfe')"],
|
||||
timeout=30,
|
||||
errors="ignore",
|
||||
)
|
||||
assert result is not None
|
||||
assert result.returncode == 0
|
||||
assert "ok" in result.stdout
|
||||
|
||||
|
||||
def test_stdin_is_devnull_not_inherited():
|
||||
"""A probe must never block reading the caller's stdin."""
|
||||
result = bounded_probe_run(
|
||||
[_PY, "-c", "import sys; print(repr(sys.stdin.read()))"],
|
||||
timeout=30,
|
||||
)
|
||||
assert result is not None
|
||||
assert result.returncode == 0
|
||||
assert result.stdout.strip() == "''"
|
||||
|
||||
|
||||
def test_bounded_git_probe_delegates_same_contract():
|
||||
"""The historical git-probe wrapper keeps its exact contract on top of
|
||||
bounded_probe_run: stripped stdout on rc==0, '' on any failure."""
|
||||
assert bounded_git_probe([_PY, "-c", "print(' x ')"], timeout=30) == "x"
|
||||
assert bounded_git_probe([_PY, "-c", "import sys; sys.exit(1)"], timeout=30) == ""
|
||||
assert bounded_git_probe(["definitely-not-a-real-binary-87134"], timeout=5) == ""
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX process-group check")
|
||||
def test_posix_child_gets_own_process_group():
|
||||
"""POSIX spawns use process_group=0 so timeout cleanup can killpg the
|
||||
whole tree (same contract bounded_git_probe had)."""
|
||||
result = bounded_probe_run(
|
||||
[_PY, "-c", "import os; print(os.getpgid(0) == os.getpid())"],
|
||||
timeout=30,
|
||||
)
|
||||
assert result is not None
|
||||
assert result.stdout.strip() == "True"
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Default-Chromium detection and profile-dir resolution (hermes_cli.browser_connect).
|
||||
|
||||
These exercise the parsers with real command output shapes instead of
|
||||
patching the detectors themselves, so a change in what macOS / xdg report is
|
||||
caught here rather than in a user's browser session.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_cli.browser_connect as bc
|
||||
|
||||
|
||||
def _ls_dump(*entries: str) -> str:
|
||||
return "(\n" + ",\n".join(entries) + "\n)\n"
|
||||
|
||||
|
||||
def _handler(scheme: str, bundle: str) -> str:
|
||||
return (
|
||||
" {\n"
|
||||
" LSHandlerPreferredVersions = {\n"
|
||||
' LSHandlerRoleAll = "-";\n'
|
||||
" };\n"
|
||||
f' LSHandlerRoleAll = "{bundle}";\n'
|
||||
f" LSHandlerURLScheme = {scheme};\n"
|
||||
" }"
|
||||
)
|
||||
|
||||
|
||||
def _content_type_handler(uti: str, bundle: str) -> str:
|
||||
return (
|
||||
" {\n"
|
||||
f' LSHandlerContentType = "{uti}";\n'
|
||||
f' LSHandlerRoleViewer = "{bundle}";\n'
|
||||
" }"
|
||||
)
|
||||
|
||||
|
||||
class TestLaunchServicesHttpsHandler:
|
||||
def test_https_entry_wins_over_other_schemes(self):
|
||||
dump = _ls_dump(
|
||||
_handler("ftp", "com.google.chrome"),
|
||||
_handler("https", "com.apple.safari"),
|
||||
)
|
||||
assert bc._launchservices_https_handler(dump) == "com.apple.safari"
|
||||
|
||||
def test_content_type_registration_is_not_an_https_handler(self):
|
||||
dump = _ls_dump(_content_type_handler("public.html", "com.google.chrome"))
|
||||
assert bc._launchservices_https_handler(dump) is None
|
||||
|
||||
def test_no_entries_means_no_recorded_handler(self):
|
||||
assert bc._launchservices_https_handler("(\n)\n") is None
|
||||
assert bc._launchservices_https_handler("") is None
|
||||
|
||||
def test_nested_dictionary_does_not_split_the_entry(self):
|
||||
dump = _ls_dump(_handler("https", "com.microsoft.edgemac"))
|
||||
assert bc._launchservices_https_handler(dump) == "com.microsoft.edgemac"
|
||||
|
||||
|
||||
class TestDetectDefaultDarwin:
|
||||
def _run_with(self, dump: str):
|
||||
class _Proc:
|
||||
stdout = dump
|
||||
|
||||
return patch.object(bc.subprocess, "run", return_value=_Proc())
|
||||
|
||||
def test_chrome_as_https_handler(self):
|
||||
with self._run_with(_ls_dump(_handler("https", "com.google.chrome"))):
|
||||
assert bc._detect_default_darwin() == "chrome"
|
||||
|
||||
def test_safari_default_with_chrome_installed_fails_closed(self):
|
||||
"""The old fallback returned the first installed Chromium app; a
|
||||
non-Chromium default must resolve to None even when Chrome exists."""
|
||||
dump = _ls_dump(
|
||||
_handler("https", "com.apple.safari"),
|
||||
_handler("ftp", "com.google.chrome"),
|
||||
)
|
||||
with self._run_with(dump), \
|
||||
patch.object(bc, "chromium_executable", return_value="/Applications/Google Chrome.app/x"):
|
||||
assert bc._detect_default_darwin() is None
|
||||
|
||||
def test_no_handler_recorded_fails_closed(self):
|
||||
with self._run_with("(\n)\n"), \
|
||||
patch.object(bc, "chromium_executable", return_value="/Applications/Google Chrome.app/x"):
|
||||
assert bc._detect_default_darwin() is None
|
||||
|
||||
def test_firefox_default_fails_closed(self):
|
||||
with self._run_with(_ls_dump(_handler("https", "org.mozilla.firefox"))):
|
||||
assert bc._detect_default_darwin() is None
|
||||
|
||||
def test_reader_failure_fails_closed(self):
|
||||
with patch.object(bc.subprocess, "run", side_effect=OSError("no defaults")):
|
||||
assert bc._detect_default_darwin() is None
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bundle,expected",
|
||||
[
|
||||
("com.google.Chrome", "chrome"),
|
||||
("com.brave.Browser", "brave"),
|
||||
("com.brave.Browser.origin", "brave-origin"),
|
||||
("com.microsoft.edgemac", "edge"),
|
||||
("org.chromium.Chromium", "chromium"),
|
||||
("com.brave.Browser.origin.beta", bc.UNSUPPORTED_CHANNEL),
|
||||
("com.brave.Browser.origin.nightly", bc.UNSUPPORTED_CHANNEL),
|
||||
],
|
||||
)
|
||||
def test_bundle_map(self, bundle, expected):
|
||||
with self._run_with(_ls_dump(_handler("https", bundle))):
|
||||
assert bc._detect_default_darwin() == expected
|
||||
|
||||
|
||||
class TestDetectDefaultLinux:
|
||||
def _run_with(self, output: str):
|
||||
class _Proc:
|
||||
stdout = output
|
||||
|
||||
return patch.object(bc.subprocess, "run", return_value=_Proc())
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"desktop,expected",
|
||||
[
|
||||
("google-chrome.desktop", "chrome"),
|
||||
("com.google.Chrome.desktop", "chrome"),
|
||||
("chromium_chromium.desktop", "chromium"),
|
||||
("org.chromium.Chromium.desktop", "chromium"),
|
||||
("brave-browser.desktop", "brave"),
|
||||
("com.brave.Browser.desktop", "brave"),
|
||||
("brave-origin.desktop", "brave-origin"),
|
||||
("brave-origin-beta.desktop", bc.UNSUPPORTED_CHANNEL),
|
||||
("brave-origin-nightly.desktop", bc.UNSUPPORTED_CHANNEL),
|
||||
("microsoft-edge.desktop", "edge"),
|
||||
("com.microsoft.Edge.desktop", "edge"),
|
||||
("firefox.desktop", None),
|
||||
("org.mozilla.firefox.desktop", None),
|
||||
("", None),
|
||||
],
|
||||
)
|
||||
def test_xdg_desktop_names(self, desktop, expected):
|
||||
with self._run_with(desktop + "\n"):
|
||||
assert bc._detect_default_linux() == expected
|
||||
|
||||
def test_missing_xdg_settings_fails_closed(self):
|
||||
with patch.object(bc.subprocess, "run", side_effect=FileNotFoundError("xdg-settings")):
|
||||
assert bc._detect_default_linux() is None
|
||||
|
||||
|
||||
class TestLinuxProfileDir:
|
||||
def _env(self, monkeypatch, home):
|
||||
monkeypatch.setenv("HOME", str(home))
|
||||
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
|
||||
|
||||
def test_native_path_when_nothing_exists(self, tmp_path, monkeypatch):
|
||||
self._env(monkeypatch, tmp_path)
|
||||
assert bc.real_profile_data_dir("chromium", "Linux") == str(tmp_path / ".config" / "chromium")
|
||||
|
||||
def test_snap_chromium_profile_is_found(self, tmp_path, monkeypatch):
|
||||
self._env(monkeypatch, tmp_path)
|
||||
snap = tmp_path / "snap" / "chromium" / "common" / "chromium"
|
||||
snap.mkdir(parents=True)
|
||||
assert bc.real_profile_data_dir("chromium", "Linux") == str(snap)
|
||||
|
||||
def test_flatpak_chrome_profile_is_found(self, tmp_path, monkeypatch):
|
||||
self._env(monkeypatch, tmp_path)
|
||||
flatpak = tmp_path / ".var" / "app" / "com.google.Chrome" / "config" / "google-chrome"
|
||||
flatpak.mkdir(parents=True)
|
||||
assert bc.real_profile_data_dir("chrome", "Linux") == str(flatpak)
|
||||
|
||||
def test_native_profile_wins_when_present(self, tmp_path, monkeypatch):
|
||||
self._env(monkeypatch, tmp_path)
|
||||
native = tmp_path / ".config" / "BraveSoftware" / "Brave-Browser"
|
||||
native.mkdir(parents=True)
|
||||
(tmp_path / ".var" / "app" / "com.brave.Browser" / "config" / "BraveSoftware" / "Brave-Browser").mkdir(parents=True)
|
||||
assert bc.real_profile_data_dir("brave", "Linux") == str(native)
|
||||
|
||||
def test_xdg_config_home_is_honoured(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", "/home/t/.config")
|
||||
assert bc.real_profile_data_dir("edge", "Linux") == "/home/t/.config/microsoft-edge"
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Dual-stack loopback discovery + port-squatter handling for /browser connect.
|
||||
|
||||
Regression context: on Windows, an IDE debugger (VS Code js-debug) holding
|
||||
127.0.0.1:9222 pushes a Chrome launched with --remote-debugging-port=9222
|
||||
onto [::1]:9222 only. The old IPv4-only probe missed the live browser AND
|
||||
hung against the squatter (accepts TCP, never answers HTTP), driving the
|
||||
whole connect past the desktop GUI's RPC timeout
|
||||
("error: request timed out: browser.manage").
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.browser_connect import (
|
||||
DEFAULT_BROWSER_CDP_PORT,
|
||||
discover_local_cdp_url,
|
||||
find_free_debug_port,
|
||||
local_port_in_use,
|
||||
)
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return sock.getsockname()[1]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ipv4_squatter():
|
||||
"""A listener on the IPv4 loopback that accepts TCP but never speaks HTTP."""
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
server.bind(("127.0.0.1", 0))
|
||||
server.listen(5)
|
||||
port = server.getsockname()[1]
|
||||
|
||||
conns: list[socket.socket] = []
|
||||
stop = threading.Event()
|
||||
|
||||
def _accept_loop() -> None:
|
||||
server.settimeout(0.2)
|
||||
while not stop.is_set():
|
||||
try:
|
||||
conn, _ = server.accept()
|
||||
conns.append(conn) # hold open, say nothing — like a debug adapter
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
thread = threading.Thread(target=_accept_loop, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield port
|
||||
finally:
|
||||
stop.set()
|
||||
thread.join(timeout=2)
|
||||
for conn in conns:
|
||||
try:
|
||||
conn.close()
|
||||
except OSError:
|
||||
pass
|
||||
server.close()
|
||||
|
||||
|
||||
class TestDiscoverLocalCdpUrl:
|
||||
def test_returns_none_when_nothing_listens(self):
|
||||
port = _free_port()
|
||||
assert discover_local_cdp_url(port, timeout=0.3) is None
|
||||
|
||||
|
||||
def test_finds_ipv6_only_endpoint(self, monkeypatch):
|
||||
"""When only [::1] speaks CDP (IPv4 side squatted), discovery
|
||||
returns the IPv6 URL instead of giving up."""
|
||||
import hermes_cli.browser_connect as bc
|
||||
|
||||
def _ready(url: str, timeout: float = 1.0) -> bool:
|
||||
return "[::1]" in url
|
||||
|
||||
monkeypatch.setattr(bc, "is_browser_debug_ready", _ready)
|
||||
assert bc.discover_local_cdp_url(9222) == "http://[::1]:9222"
|
||||
|
||||
|
||||
class TestLocalPortInUse:
|
||||
def test_free_port_reports_unused(self):
|
||||
assert local_port_in_use(_free_port(), timeout=0.3) is False
|
||||
|
||||
|
||||
class TestFindFreeDebugPort:
|
||||
def test_returns_port_above_preferred(self):
|
||||
port = find_free_debug_port(DEFAULT_BROWSER_CDP_PORT)
|
||||
assert port > DEFAULT_BROWSER_CDP_PORT
|
||||
|
||||
def test_skips_occupied_successor(self):
|
||||
"""When preferred+1 is held on IPv4, the next candidate is chosen."""
|
||||
preferred = _free_port()
|
||||
blocker = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
blocker.bind(("127.0.0.1", preferred + 1))
|
||||
blocker.listen(1)
|
||||
port = find_free_debug_port(preferred)
|
||||
assert port != preferred + 1
|
||||
assert port > preferred
|
||||
except OSError:
|
||||
pytest.skip("successor port unavailable to bind in this environment")
|
||||
finally:
|
||||
blocker.close()
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Launch and growth decisions must price against CAPACITY, not live-free
|
||||
VRAM. Both execute through a server bounce — the outgoing instance's memory
|
||||
is freed before the new one loads — so a probe that reads the predecessor's
|
||||
(or the grown model's own) residency as 'gone' vetoes configurations that
|
||||
genuinely fit. Symptom when this regresses: a model the pane promised
|
||||
'144K on GPU' launches with its weights pinned to CPU and single-digit
|
||||
tokens/s while the card sits 60% empty."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import inspect
|
||||
|
||||
|
||||
def _planning_probe_calls(source: str) -> list[bool]:
|
||||
"""Every probe_budget(...) call's planning= value in the source."""
|
||||
tree = ast.parse(source)
|
||||
out = []
|
||||
for node in ast.walk(tree):
|
||||
if (isinstance(node, ast.Call)
|
||||
and getattr(node.func, "id", getattr(node.func, "attr", ""))
|
||||
== "probe_budget"):
|
||||
planning = any(
|
||||
kw.arg == "planning"
|
||||
and isinstance(kw.value, ast.Constant)
|
||||
and kw.value.value is True
|
||||
for kw in node.keywords)
|
||||
out.append(planning)
|
||||
return out
|
||||
|
||||
|
||||
def test_bootstrap_presets_price_against_capacity():
|
||||
import hermes_cli.local_runtime.bootstrap as bootstrap
|
||||
|
||||
calls = _planning_probe_calls(inspect.getsource(bootstrap))
|
||||
assert calls, "bootstrap no longer probes a budget? update this test"
|
||||
assert all(calls), (
|
||||
"bootstrap prices launch decisions against live-free VRAM; a "
|
||||
"restart/refresh probes while the outgoing server still holds the "
|
||||
"card, pinning fitting models to CPU")
|
||||
|
||||
|
||||
def test_growth_refit_prices_against_capacity():
|
||||
import hermes_cli.local_runtime.growth as growth
|
||||
|
||||
calls = _planning_probe_calls(inspect.getsource(growth))
|
||||
assert calls, "growth no longer probes a budget? update this test"
|
||||
assert all(calls), (
|
||||
"growth re-fits against live-free VRAM; the grown model's own "
|
||||
"residency reads as unavailable and vetoes rungs that fit the "
|
||||
"post-bounce card")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""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_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
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Tests for hermes_cli/bundles.py — the `hermes bundles` CLI subcommand."""
|
||||
|
||||
import argparse
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.bundles import (
|
||||
bundles_command,
|
||||
register_cli,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bundles_env(tmp_path, monkeypatch):
|
||||
bundles_dir = tmp_path / "skill-bundles"
|
||||
monkeypatch.setenv("HERMES_BUNDLES_DIR", str(bundles_dir))
|
||||
# Reset module-level cache between tests.
|
||||
import agent.skill_bundles as mod
|
||||
mod._bundles_cache = {}
|
||||
mod._bundles_cache_mtime = None
|
||||
return bundles_dir
|
||||
|
||||
|
||||
def _parse(argv):
|
||||
parser = argparse.ArgumentParser()
|
||||
register_cli(parser)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
class TestBundlesCli:
|
||||
|
||||
|
||||
|
||||
def test_create_refuses_overwrite(self, bundles_env, capsys):
|
||||
bundles_command(_parse(["create", "dup", "--skill", "s1"]))
|
||||
capsys.readouterr()
|
||||
with pytest.raises(SystemExit) as ei:
|
||||
bundles_command(_parse(["create", "dup", "--skill", "s2"]))
|
||||
assert ei.value.code == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "already exists" in out.lower() or "--force" in out.lower()
|
||||
|
||||
|
||||
def test_create_requires_skills(self, bundles_env, capsys, monkeypatch):
|
||||
# Simulate user pressing Ctrl-D immediately at the interactive prompt.
|
||||
monkeypatch.setattr("builtins.input", lambda *_a, **_kw: (_ for _ in ()).throw(EOFError()))
|
||||
with pytest.raises(SystemExit):
|
||||
bundles_command(_parse(["create", "empty"]))
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Invariant tests for the declarative busy_policy on CommandDef.
|
||||
|
||||
Guards the contract introduced by the Guard-2 refactor (gateway/run.py):
|
||||
every command's mid-run behavior is declared on its CommandDef via
|
||||
``busy_policy`` / ``busy_handler`` and the historical
|
||||
``ACTIVE_SESSION_BYPASS_COMMANDS`` frozenset is DERIVED from the registry
|
||||
rather than hand-maintained.
|
||||
"""
|
||||
|
||||
from hermes_cli.commands import (
|
||||
ACTIVE_SESSION_BYPASS_COMMANDS,
|
||||
COMMAND_REGISTRY,
|
||||
VALID_BUSY_POLICIES,
|
||||
is_interrupt_then_dispatch,
|
||||
should_bypass_active_session,
|
||||
)
|
||||
|
||||
# The hand-written frozenset as it existed before the busy_policy refactor.
|
||||
# This is a behavior contract, not a snapshot: the derived set must remain a
|
||||
# SUPERSET of these names (each had an explicit mid-run handler in the old
|
||||
# Guard-2 if-chain and must keep bypassing the busy-reject catch-all).
|
||||
_HISTORICAL_BYPASS_NAMES = frozenset(
|
||||
{
|
||||
"agents",
|
||||
"approve",
|
||||
"bg",
|
||||
"btw",
|
||||
"commands",
|
||||
"deny",
|
||||
"help",
|
||||
"new",
|
||||
"profile",
|
||||
"queue",
|
||||
"restart",
|
||||
"status",
|
||||
"steer",
|
||||
"stop",
|
||||
"update",
|
||||
"version",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_bypass_set_is_derived_from_registry():
|
||||
expected = frozenset(
|
||||
cmd.name for cmd in COMMAND_REGISTRY if cmd.busy_policy != "reject"
|
||||
)
|
||||
assert ACTIVE_SESSION_BYPASS_COMMANDS == expected
|
||||
|
||||
|
||||
def test_interrupt_then_dispatch_class():
|
||||
# The cancel-handoff class (Guard 1, gateway/platforms/base.py) must
|
||||
# contain exactly the /stop and /new (alias /reset) commands today.
|
||||
assert is_interrupt_then_dispatch("stop")
|
||||
assert is_interrupt_then_dispatch("new")
|
||||
assert is_interrupt_then_dispatch("reset") # alias of /new
|
||||
assert not is_interrupt_then_dispatch("model")
|
||||
assert not is_interrupt_then_dispatch("status")
|
||||
assert not is_interrupt_then_dispatch(None)
|
||||
assert not is_interrupt_then_dispatch("not-a-command")
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Tests for the launch-time stale-bytecode sweep (checkout fingerprint guard).
|
||||
|
||||
Bug class: the checkout's ``.py`` files change (``hermes update``, manual
|
||||
``git pull``, ZIP update) while ``__pycache__`` retains bytecode compiled
|
||||
from the previous revision; the next process to import trusts the stale
|
||||
``.pyc`` and dies with ``cannot import name ...`` (#6207, #60242).
|
||||
|
||||
The launch-time guard compares the current checkout fingerprint against the
|
||||
last-validated stamp and sweeps ``__pycache__`` once when they diverge —
|
||||
covering paths no update-time clear can reach (manual pulls, pre-hardening
|
||||
updaters).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_cli import main as hermes_main
|
||||
|
||||
|
||||
def _make_repo(tmp_path: Path, sha: str = "a" * 40) -> Path:
|
||||
"""Minimal git checkout layout that _read_git_revision_fingerprint groks."""
|
||||
repo = tmp_path / "repo"
|
||||
git_dir = repo / ".git"
|
||||
(git_dir / "refs" / "heads").mkdir(parents=True)
|
||||
(git_dir / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
|
||||
(git_dir / "refs" / "heads" / "main").write_text(sha + "\n", encoding="utf-8")
|
||||
return repo
|
||||
|
||||
|
||||
def _make_pycache(repo: Path, subdir: str = "hermes_cli") -> Path:
|
||||
cache = repo / subdir / "__pycache__"
|
||||
cache.mkdir(parents=True)
|
||||
(cache / "main.cpython-311.pyc").write_bytes(b"stale")
|
||||
return cache
|
||||
|
||||
|
||||
def test_sweep_clears_pycache_when_checkout_changed(monkeypatch, tmp_path):
|
||||
repo = _make_repo(tmp_path, sha="b" * 40)
|
||||
cache = _make_pycache(repo)
|
||||
monkeypatch.setattr(hermes_main, "PROJECT_ROOT", repo)
|
||||
# Stamp records a different (older) fingerprint.
|
||||
(repo / hermes_main._BYTECODE_FINGERPRINT_FILE).write_text(
|
||||
"git:refs/heads/main:" + "a" * 40, encoding="utf-8"
|
||||
)
|
||||
|
||||
hermes_main._sweep_stale_bytecode_if_checkout_changed()
|
||||
|
||||
assert not cache.exists()
|
||||
# Stamp updated to the current fingerprint.
|
||||
recorded = (repo / hermes_main._BYTECODE_FINGERPRINT_FILE).read_text(encoding="utf-8")
|
||||
assert recorded.strip().endswith("b" * 40)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin-update sibling site: __pycache__ under ~/.hermes/plugins/<name>
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_clear_plugin_bytecode_removes_nested_caches(tmp_path):
|
||||
from hermes_cli import plugins_cmd
|
||||
|
||||
plugin = tmp_path / "myplugin"
|
||||
top = plugin / "__pycache__"
|
||||
nested = plugin / "sub" / "__pycache__"
|
||||
top.mkdir(parents=True)
|
||||
nested.mkdir(parents=True)
|
||||
(top / "a.pyc").write_bytes(b"stale")
|
||||
(nested / "b.pyc").write_bytes(b"stale")
|
||||
|
||||
removed = plugins_cmd._clear_plugin_bytecode(plugin)
|
||||
|
||||
assert removed == 2
|
||||
assert not top.exists()
|
||||
assert not nested.exists()
|
||||
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
"""Cache-contract tests for ``cached_fetch_api_models()``.
|
||||
|
||||
Custom OpenAI-compatible endpoints (named ``custom_providers`` rows, bare
|
||||
``provider: custom``, and per-endpoint-map entries) previously called
|
||||
``fetch_api_models()`` directly with no disk cache, so the current custom
|
||||
endpoint's ``/v1/models`` got a live HTTP round-trip on literally every
|
||||
``/model`` open (#72762). ``cached_fetch_api_models()`` gives custom
|
||||
endpoints the same ``provider_models_cache.json`` TTL cache first-class
|
||||
providers already get via ``cached_provider_model_ids()``.
|
||||
|
||||
These pin the cache contract directly (hit / stale / rotation / refresh /
|
||||
fallback), separate from ``test_model_switch_custom_providers.py``'s
|
||||
higher-level picker-shape tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestCachedFetchApiModels:
|
||||
def _entry(self, models, age_seconds, fp="fp"):
|
||||
return {"fp": fp, "at": time.time() - age_seconds, "models": list(models)}
|
||||
|
||||
def test_fresh_entry_served_without_live_fetch(self):
|
||||
import hermes_cli.models as mod
|
||||
|
||||
cache = {"custom:https://gw.example.com/v1": self._entry(["m1", "m2"], age_seconds=10)}
|
||||
with patch.object(mod, "_load_provider_models_cache", return_value=cache), \
|
||||
patch.object(mod, "_custom_endpoint_fingerprint", return_value="fp"), \
|
||||
patch.object(mod, "_save_provider_models_cache") as save, \
|
||||
patch.object(mod, "fetch_api_models") as live:
|
||||
out = mod.cached_fetch_api_models("sk-key", "https://gw.example.com/v1")
|
||||
assert out == ["m1", "m2"]
|
||||
live.assert_not_called()
|
||||
save.assert_not_called()
|
||||
|
||||
def test_cache_key_normalizes_trailing_slash_and_case(self):
|
||||
"""A saved entry for the lowercased/rstripped URL must be hit even
|
||||
when the caller passes a differently-cased URL with a trailing
|
||||
slash — config.yaml entries are not guaranteed to be normalized."""
|
||||
import hermes_cli.models as mod
|
||||
|
||||
cache = {"custom:https://gw.example.com/v1": self._entry(["m1"], age_seconds=10)}
|
||||
with patch.object(mod, "_load_provider_models_cache", return_value=cache), \
|
||||
patch.object(mod, "_custom_endpoint_fingerprint", return_value="fp"), \
|
||||
patch.object(mod, "fetch_api_models") as live:
|
||||
out = mod.cached_fetch_api_models("sk-key", "HTTPS://GW.example.com/v1/")
|
||||
assert out == ["m1"]
|
||||
live.assert_not_called()
|
||||
|
||||
def test_expired_entry_triggers_live_fetch_and_is_persisted(self):
|
||||
import hermes_cli.models as mod
|
||||
|
||||
# Beyond the stale-serve window, so the wrapper must block on a
|
||||
# live fetch (within the window it stale-serves + refreshes off
|
||||
# thread — covered in TestSalvageFollowups).
|
||||
too_old = mod._PROVIDER_MODELS_STALE_SERVE_MAX + 60
|
||||
cache = {"custom:https://gw.example.com/v1": self._entry(["old"], age_seconds=too_old)}
|
||||
saved = {}
|
||||
with patch.object(mod, "_load_provider_models_cache", return_value=cache), \
|
||||
patch.object(mod, "_custom_endpoint_fingerprint", return_value="fp"), \
|
||||
patch.object(mod, "_save_provider_models_cache", side_effect=saved.update), \
|
||||
patch.object(mod, "fetch_api_models", return_value=["fresh-a", "fresh-b"]) as live:
|
||||
out = mod.cached_fetch_api_models(
|
||||
"sk-key", "https://gw.example.com/v1", ttl_seconds=3600
|
||||
)
|
||||
assert out == ["fresh-a", "fresh-b"]
|
||||
live.assert_called_once()
|
||||
assert saved["custom:https://gw.example.com/v1"]["models"] == ["fresh-a", "fresh-b"]
|
||||
assert saved["custom:https://gw.example.com/v1"]["fp"] == "fp"
|
||||
|
||||
def test_rotated_api_key_busts_cache_even_when_fresh(self):
|
||||
"""A same-age entry with a DIFFERENT fingerprint (key rotated, or
|
||||
extra_headers edited) must not be served — it reflects the old
|
||||
credentials' catalog."""
|
||||
import hermes_cli.models as mod
|
||||
|
||||
cache = {"custom:https://gw.example.com/v1": self._entry(["old-key-models"], 10, fp="old-fp")}
|
||||
with patch.object(mod, "_load_provider_models_cache", return_value=cache), \
|
||||
patch.object(mod, "_custom_endpoint_fingerprint", return_value="new-fp"), \
|
||||
patch.object(mod, "_save_provider_models_cache"), \
|
||||
patch.object(mod, "fetch_api_models", return_value=["new-key-models"]) as live:
|
||||
out = mod.cached_fetch_api_models("sk-new-key", "https://gw.example.com/v1")
|
||||
assert out == ["new-key-models"]
|
||||
live.assert_called_once()
|
||||
|
||||
def test_force_refresh_bypasses_fresh_cache(self):
|
||||
import hermes_cli.models as mod
|
||||
|
||||
cache = {"custom:https://gw.example.com/v1": self._entry(["stale-but-fresh"], age_seconds=5)}
|
||||
with patch.object(mod, "_load_provider_models_cache", return_value=cache), \
|
||||
patch.object(mod, "_custom_endpoint_fingerprint", return_value="fp"), \
|
||||
patch.object(mod, "_save_provider_models_cache"), \
|
||||
patch.object(mod, "fetch_api_models", return_value=["forced-live"]) as live:
|
||||
out = mod.cached_fetch_api_models(
|
||||
"sk-key", "https://gw.example.com/v1", force_refresh=True
|
||||
)
|
||||
assert out == ["forced-live"]
|
||||
live.assert_called_once()
|
||||
|
||||
def test_live_failure_falls_back_to_stale_same_fingerprint_entry(self):
|
||||
"""Stale data beats no data when the endpoint is flaky (#72762
|
||||
proposed-fix: 'same stale-beats-nothing fallback as
|
||||
cached_provider_model_ids')."""
|
||||
import hermes_cli.models as mod
|
||||
|
||||
cache = {"custom:https://gw.example.com/v1": self._entry(["last-known-good"], age_seconds=99999, fp="fp")}
|
||||
with patch.object(mod, "_load_provider_models_cache", return_value=cache), \
|
||||
patch.object(mod, "_custom_endpoint_fingerprint", return_value="fp"), \
|
||||
patch.object(mod, "_save_provider_models_cache") as save, \
|
||||
patch.object(mod, "fetch_api_models", return_value=None):
|
||||
out = mod.cached_fetch_api_models("sk-key", "https://gw.example.com/v1")
|
||||
assert out == ["last-known-good"]
|
||||
save.assert_not_called() # nothing new to persist
|
||||
|
||||
def test_live_failure_with_no_matching_entry_returns_live_value(self):
|
||||
import hermes_cli.models as mod
|
||||
|
||||
with patch.object(mod, "_load_provider_models_cache", return_value={}), \
|
||||
patch.object(mod, "_custom_endpoint_fingerprint", return_value="fp"), \
|
||||
patch.object(mod, "_save_provider_models_cache") as save, \
|
||||
patch.object(mod, "fetch_api_models", return_value=None):
|
||||
out = mod.cached_fetch_api_models("sk-key", "https://gw.example.com/v1")
|
||||
assert out is None
|
||||
save.assert_not_called()
|
||||
|
||||
|
||||
class TestCacheOnly:
|
||||
"""``cache_only=True`` is the no-network read used by picker opens that
|
||||
deliberately skip live probing. It must answer from disk or not at all —
|
||||
never a live fetch, never a background revalidation."""
|
||||
|
||||
def _entry(self, models, age_seconds, fp="fp"):
|
||||
return {"fp": fp, "at": time.time() - age_seconds, "models": list(models)}
|
||||
|
||||
def _call(self, cache, *, fp="fp", **kwargs):
|
||||
import hermes_cli.models as mod
|
||||
|
||||
with patch.object(mod, "_load_provider_models_cache", return_value=cache), \
|
||||
patch.object(mod, "_custom_endpoint_fingerprint", return_value=fp), \
|
||||
patch.object(mod, "_save_provider_models_cache") as save, \
|
||||
patch.object(mod, "_spawn_swr_refresh") as swr, \
|
||||
patch.object(mod, "fetch_api_models") as live:
|
||||
out = mod.cached_fetch_api_models(
|
||||
"sk-key", "https://gw.example.com/v1", cache_only=True, **kwargs
|
||||
)
|
||||
live.assert_not_called()
|
||||
swr.assert_not_called()
|
||||
save.assert_not_called()
|
||||
return out
|
||||
|
||||
def test_fresh_entry_is_served(self):
|
||||
cache = {"custom:https://gw.example.com/v1": self._entry(["m1", "m2"], 10)}
|
||||
assert self._call(cache) == ["m1", "m2"]
|
||||
|
||||
def test_entry_past_ttl_is_still_served_within_the_stale_window(self):
|
||||
"""The TTL governs when to *revalidate*, and cache_only cannot. Inside
|
||||
the stale-serve bound the entry is still the best answer available —
|
||||
collapsing to the config subset an hour in would reintroduce the bug."""
|
||||
import hermes_cli.models as mod
|
||||
|
||||
age = mod._PROVIDER_MODELS_CACHE_TTL + 60
|
||||
cache = {"custom:https://gw.example.com/v1": self._entry(["m1", "m2"], age)}
|
||||
assert self._call(cache) == ["m1", "m2"]
|
||||
|
||||
def test_entry_beyond_the_stale_window_is_a_miss(self):
|
||||
import hermes_cli.models as mod
|
||||
|
||||
age = mod._PROVIDER_MODELS_STALE_SERVE_MAX + 60
|
||||
cache = {"custom:https://gw.example.com/v1": self._entry(["ancient"], age)}
|
||||
assert self._call(cache) is None
|
||||
|
||||
def test_empty_cache_is_a_miss(self):
|
||||
assert self._call({}) is None
|
||||
|
||||
def test_rotated_credentials_are_a_miss(self):
|
||||
cache = {"custom:https://gw.example.com/v1": self._entry(["old"], 10, fp="old-fp")}
|
||||
assert self._call(cache, fp="new-fp") is None
|
||||
|
||||
def test_force_refresh_is_a_miss_rather_than_a_live_fetch(self):
|
||||
"""cache_only outranks force_refresh: the caller has said no network,
|
||||
so an un-revalidatable entry is withheld instead of fetched."""
|
||||
cache = {"custom:https://gw.example.com/v1": self._entry(["m1"], 10)}
|
||||
assert self._call(cache, force_refresh=True) is None
|
||||
|
||||
def test_missing_base_url_is_a_miss_rather_than_a_live_fetch(self):
|
||||
import hermes_cli.models as mod
|
||||
|
||||
with patch.object(mod, "fetch_api_models") as live:
|
||||
out = mod.cached_fetch_api_models("sk-key", "", cache_only=True)
|
||||
assert out is None
|
||||
live.assert_not_called()
|
||||
|
||||
def test_empty_live_result_is_not_persisted(self):
|
||||
"""An empty list from a transient error must never pin an empty
|
||||
cache entry over real data on the next open."""
|
||||
import hermes_cli.models as mod
|
||||
|
||||
with patch.object(mod, "_load_provider_models_cache", return_value={}), \
|
||||
patch.object(mod, "_custom_endpoint_fingerprint", return_value="fp"), \
|
||||
patch.object(mod, "_save_provider_models_cache") as save, \
|
||||
patch.object(mod, "fetch_api_models", return_value=[]):
|
||||
out = mod.cached_fetch_api_models("sk-key", "https://gw.example.com/v1")
|
||||
assert out == []
|
||||
save.assert_not_called()
|
||||
|
||||
def test_blank_base_url_skips_cache_entirely(self):
|
||||
"""No base_url means nothing to key the cache on — call straight
|
||||
through to fetch_api_models rather than caching under an empty key."""
|
||||
import hermes_cli.models as mod
|
||||
|
||||
with patch.object(mod, "_load_provider_models_cache") as load, \
|
||||
patch.object(mod, "fetch_api_models", return_value=["x"]) as live:
|
||||
out = mod.cached_fetch_api_models("sk-key", "")
|
||||
assert out == ["x"]
|
||||
live.assert_called_once()
|
||||
load.assert_not_called()
|
||||
|
||||
def test_fingerprint_ignores_timeout_but_reacts_to_headers(self):
|
||||
"""Sanity check on the real (non-mocked) fingerprint helper: it must
|
||||
not vary with call-only params like timeout, but must vary with the
|
||||
actual credential/header inputs."""
|
||||
import hermes_cli.models as mod
|
||||
|
||||
fp_a = mod._custom_endpoint_fingerprint("sk-key", None, {"X-Tenant": "a"})
|
||||
fp_b = mod._custom_endpoint_fingerprint("sk-key", None, {"X-Tenant": "b"})
|
||||
fp_a_again = mod._custom_endpoint_fingerprint("sk-key", None, {"X-Tenant": "a"})
|
||||
assert fp_a != fp_b
|
||||
assert fp_a == fp_a_again
|
||||
|
||||
|
||||
class TestCachedFetchApiModelsDiskRoundTrip:
|
||||
"""End-to-end through the real (per-test-isolated) provider_models_cache.json
|
||||
disk file rather than mocked load/save, so a regression in the on-disk
|
||||
schema (e.g. a key collision with provider-slug entries) would show up
|
||||
here even if the mocked unit tests above stayed green."""
|
||||
|
||||
def test_second_call_within_ttl_hits_disk_cache_no_live_fetch(self, monkeypatch):
|
||||
import hermes_cli.models as mod
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_fetch(api_key, base_url, **kwargs):
|
||||
calls.append((api_key, base_url))
|
||||
return ["disk-cached-model"]
|
||||
|
||||
monkeypatch.setattr(mod, "fetch_api_models", fake_fetch)
|
||||
|
||||
first = mod.cached_fetch_api_models("sk-key", "https://gw.example.com/v1")
|
||||
second = mod.cached_fetch_api_models("sk-key", "https://gw.example.com/v1")
|
||||
|
||||
assert first == ["disk-cached-model"]
|
||||
assert second == ["disk-cached-model"]
|
||||
assert len(calls) == 1, "second open must be served from disk, not a fresh live fetch"
|
||||
|
||||
def test_custom_key_does_not_collide_with_provider_slug_cache(self, monkeypatch):
|
||||
"""A custom endpoint literally named e.g. 'openrouter' in its
|
||||
base_url must not read/write the same cache slot as the first-class
|
||||
'openrouter' provider slug used by cached_provider_model_ids()."""
|
||||
import hermes_cli.models as mod
|
||||
|
||||
monkeypatch.setattr(
|
||||
mod, "fetch_api_models", lambda *a, **k: ["custom-endpoint-model"]
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mod, "provider_model_ids", lambda *a, **k: ["openrouter-curated-model"]
|
||||
)
|
||||
|
||||
mod.cached_fetch_api_models("sk-key", "https://openrouter.ai/v1")
|
||||
mod.cached_provider_model_ids("openrouter")
|
||||
|
||||
cache = mod._load_provider_models_cache()
|
||||
assert cache["custom:https://openrouter.ai/v1"]["models"] == ["custom-endpoint-model"]
|
||||
assert cache["openrouter"]["models"] == ["openrouter-curated-model"]
|
||||
|
||||
|
||||
class TestSalvageFollowups:
|
||||
"""Follow-up behaviors added while salvaging PR #80740: SWR stale-serve
|
||||
parity with cached_provider_model_ids, and corrupt-cache degradation."""
|
||||
|
||||
def _entry(self, models, age_seconds, fp="fp"):
|
||||
return {"fp": fp, "at": time.time() - age_seconds, "models": list(models)}
|
||||
|
||||
def test_expired_entry_within_stale_window_is_served_and_refreshed_off_thread(self):
|
||||
"""TTL-expired (but < stale-serve max) entries must be served
|
||||
immediately — the picker never blocks on a live round-trip — while a
|
||||
background refresh is spawned for the next open."""
|
||||
import hermes_cli.models as mod
|
||||
|
||||
cache = {"custom:https://gw.example.com/v1": self._entry(["stale-ok"], age_seconds=7200)}
|
||||
with patch.object(mod, "_load_provider_models_cache", return_value=cache), \
|
||||
patch.object(mod, "_custom_endpoint_fingerprint", return_value="fp"), \
|
||||
patch.object(mod, "_spawn_swr_refresh") as spawn, \
|
||||
patch.object(mod, "fetch_api_models") as live:
|
||||
out = mod.cached_fetch_api_models(
|
||||
"sk-key", "https://gw.example.com/v1", ttl_seconds=3600
|
||||
)
|
||||
assert out == ["stale-ok"]
|
||||
live.assert_not_called()
|
||||
spawn.assert_called_once()
|
||||
assert spawn.call_args[0][0] == "custom:https://gw.example.com/v1"
|
||||
|
||||
def test_entry_beyond_stale_window_blocks_on_live_fetch(self):
|
||||
import hermes_cli.models as mod
|
||||
|
||||
too_old = mod._PROVIDER_MODELS_STALE_SERVE_MAX + 60
|
||||
cache = {"custom:https://gw.example.com/v1": self._entry(["ancient"], age_seconds=too_old)}
|
||||
with patch.object(mod, "_load_provider_models_cache", return_value=cache), \
|
||||
patch.object(mod, "_custom_endpoint_fingerprint", return_value="fp"), \
|
||||
patch.object(mod, "_save_provider_models_cache"), \
|
||||
patch.object(mod, "_spawn_swr_refresh") as spawn, \
|
||||
patch.object(mod, "fetch_api_models", return_value=["fresh"]) as live:
|
||||
out = mod.cached_fetch_api_models("sk-key", "https://gw.example.com/v1")
|
||||
assert out == ["fresh"]
|
||||
live.assert_called_once()
|
||||
spawn.assert_not_called()
|
||||
|
||||
def test_swr_refresh_fn_writes_custom_entry_through_shared_scaffolding(self):
|
||||
"""The generalized _spawn_swr_refresh(key, refresh_fn) must persist a
|
||||
custom-endpoint entry via the shared inflight-dedupe machinery."""
|
||||
import threading as _threading
|
||||
|
||||
import hermes_cli.models as mod
|
||||
|
||||
done = _threading.Event()
|
||||
saved = {}
|
||||
|
||||
def fake_save(data):
|
||||
saved.update(data)
|
||||
done.set()
|
||||
|
||||
with patch.object(mod, "_load_provider_models_cache", return_value={}), \
|
||||
patch.object(mod, "_save_provider_models_cache", side_effect=fake_save):
|
||||
mod._spawn_swr_refresh(
|
||||
"custom:https://gw.example.com/v1",
|
||||
lambda: {"fp": "fp", "at": time.time(), "models": ["refreshed"]},
|
||||
)
|
||||
assert done.wait(timeout=5), "background refresh did not complete"
|
||||
assert saved["custom:https://gw.example.com/v1"]["models"] == ["refreshed"]
|
||||
assert "custom:https://gw.example.com/v1" not in mod._swr_refresh_inflight
|
||||
|
||||
def test_corrupt_at_field_degrades_to_live_fetch_instead_of_raising(self):
|
||||
"""provider_models_cache.json is user-editable; a corrupted 'at' must
|
||||
be a cache miss (live fetch), never an exception out of the wrapper."""
|
||||
import hermes_cli.models as mod
|
||||
|
||||
cache = {
|
||||
"custom:https://gw.example.com/v1": {
|
||||
"fp": "fp", "at": "yesterday", "models": ["corrupt-row"],
|
||||
}
|
||||
}
|
||||
with patch.object(mod, "_load_provider_models_cache", return_value=cache), \
|
||||
patch.object(mod, "_custom_endpoint_fingerprint", return_value="fp"), \
|
||||
patch.object(mod, "_save_provider_models_cache"), \
|
||||
patch.object(mod, "fetch_api_models", return_value=["live-models"]) as live:
|
||||
out = mod.cached_fetch_api_models("sk-key", "https://gw.example.com/v1")
|
||||
assert out == ["live-models"]
|
||||
live.assert_called_once()
|
||||
@@ -0,0 +1,148 @@
|
||||
"""``canonical_custom_identity`` must return the durable config-key identity.
|
||||
|
||||
A keyed ``providers:`` entry's identity is its config key, not its display
|
||||
name — ``custom_provider_slug`` encodes that, and the endpoint- and
|
||||
model-based recovery sources both honour it. The configured-provider fallback
|
||||
built its slug from whatever string the caller had, so a display name that
|
||||
differs from its key healed to ``custom:<display-name>``: a second identity
|
||||
for the same endpoint that no longer matches what persistence and routing
|
||||
store.
|
||||
|
||||
Both spellings match the entry (``_get_named_custom_provider`` accepts
|
||||
either), so the test asserts they converge on one identity rather than
|
||||
asserting any particular spelling is rejected.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import runtime_provider as rp
|
||||
|
||||
PROVIDER_KEY = "my-endpoint"
|
||||
DISPLAY_NAME = "My Endpoint Display"
|
||||
BASE_URL = "https://example.invalid/v1"
|
||||
MODEL = "cool-model-1"
|
||||
|
||||
CANONICAL = f"custom:{PROVIDER_KEY}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def keyed_provider_config(monkeypatch):
|
||||
"""A ``providers:`` entry whose display name differs from its config key."""
|
||||
config = {
|
||||
"providers": {
|
||||
PROVIDER_KEY: {
|
||||
"name": DISPLAY_NAME,
|
||||
"api": BASE_URL,
|
||||
"api_key": "sk-test",
|
||||
"default_model": MODEL,
|
||||
"models": [MODEL],
|
||||
}
|
||||
}
|
||||
}
|
||||
monkeypatch.setattr(rp, "load_config", lambda *a, **k: config)
|
||||
monkeypatch.setattr("hermes_cli.config.load_config", lambda *a, **k: config)
|
||||
monkeypatch.setattr(rp, "_get_model_config", lambda: {})
|
||||
return config
|
||||
|
||||
|
||||
def test_display_name_heals_to_the_config_key_identity(keyed_provider_config):
|
||||
"""The regression: the display-name spelling must not mint a second identity."""
|
||||
assert rp.canonical_custom_identity(config_provider=DISPLAY_NAME) == CANONICAL
|
||||
|
||||
|
||||
def test_config_model_provider_display_name_heals_too(keyed_provider_config, monkeypatch):
|
||||
"""Same path reached through ``config.model.provider`` rather than an argument."""
|
||||
monkeypatch.setattr(rp, "_get_model_config", lambda: {"provider": DISPLAY_NAME})
|
||||
assert rp.canonical_custom_identity() == CANONICAL
|
||||
|
||||
|
||||
def test_config_key_spelling_still_resolves(keyed_provider_config):
|
||||
"""The spelling that already worked keeps working."""
|
||||
assert rp.canonical_custom_identity(config_provider=PROVIDER_KEY) == CANONICAL
|
||||
|
||||
|
||||
def test_all_recovery_sources_agree_on_one_identity(keyed_provider_config):
|
||||
"""Endpoint, model and configured-provider recovery must not disagree.
|
||||
|
||||
Three sources feeding the same session-identity slot is only safe while
|
||||
they agree; a divergent one silently splits an endpoint in two.
|
||||
"""
|
||||
by_url = rp.canonical_custom_identity(base_url=BASE_URL)
|
||||
by_model = rp.canonical_custom_identity(model=MODEL)
|
||||
by_config = rp.canonical_custom_identity(config_provider=DISPLAY_NAME)
|
||||
|
||||
assert {by_url, by_model, by_config} == {CANONICAL}
|
||||
|
||||
|
||||
def test_unconfigured_candidate_still_returns_none(keyed_provider_config):
|
||||
"""Fail-closed contract: never invent an identity resolution can't honour."""
|
||||
assert rp.canonical_custom_identity(config_provider="not-a-configured-entry") is None
|
||||
|
||||
|
||||
def test_legacy_unkeyed_entry_keeps_its_name_identity(monkeypatch):
|
||||
"""``custom_providers:`` entries have no key, so the name stays the identity."""
|
||||
config = {
|
||||
"custom_providers": [
|
||||
{
|
||||
"name": "Legacy Endpoint",
|
||||
"base_url": "https://legacy.invalid/v1",
|
||||
"api_key": "sk-legacy",
|
||||
"models": ["legacy-model"],
|
||||
}
|
||||
]
|
||||
}
|
||||
monkeypatch.setattr(rp, "load_config", lambda *a, **k: config)
|
||||
monkeypatch.setattr("hermes_cli.config.load_config", lambda *a, **k: config)
|
||||
monkeypatch.setattr(rp, "_get_model_config", lambda: {})
|
||||
|
||||
assert rp.canonical_custom_identity(config_provider="Legacy Endpoint") == "custom:legacy-endpoint"
|
||||
|
||||
|
||||
class TestIsRoutableProvider:
|
||||
"""``is_routable_provider`` gates session-resume fallback: a persisted
|
||||
provider name that no longer resolves (renamed/removed) must be detected
|
||||
so recovery falls back instead of failing agent init with
|
||||
"Unknown provider '<name>'".
|
||||
"""
|
||||
|
||||
def test_empty_auto_and_builtin_are_routable(self, keyed_provider_config):
|
||||
assert rp.is_routable_provider(None) is True
|
||||
assert rp.is_routable_provider("") is True
|
||||
assert rp.is_routable_provider("auto") is True
|
||||
assert rp.is_routable_provider("openrouter") is True
|
||||
|
||||
def test_bare_custom_is_not_routable(self, keyed_provider_config):
|
||||
# The resolved billing class, not a routable identity — restore
|
||||
# paths must heal it (canonical_custom_identity) or fall back.
|
||||
assert rp.is_routable_provider("custom") is False
|
||||
|
||||
def test_registered_names_are_routable(self, keyed_provider_config):
|
||||
assert rp.is_routable_provider(PROVIDER_KEY) is True
|
||||
assert rp.is_routable_provider(CANONICAL) is True
|
||||
|
||||
def test_stale_name_is_not_routable(self, keyed_provider_config):
|
||||
# Same endpoint family, but the OLD slug no longer matches any
|
||||
# configured entry — the regression this gate exists for.
|
||||
assert rp.is_routable_provider("stale-endpoint") is False
|
||||
assert rp.is_routable_provider("custom:stale-endpoint") is False
|
||||
|
||||
def test_legacy_unkeyed_name_is_routable(self, monkeypatch):
|
||||
config = {
|
||||
"custom_providers": [
|
||||
{
|
||||
"name": "Legacy Endpoint",
|
||||
"base_url": "https://legacy.invalid/v1",
|
||||
"api_key": "sk-legacy",
|
||||
"models": ["legacy-model"],
|
||||
}
|
||||
]
|
||||
}
|
||||
monkeypatch.setattr(rp, "load_config", lambda *a, **k: config)
|
||||
monkeypatch.setattr("hermes_cli.config.load_config", lambda *a, **k: config)
|
||||
monkeypatch.setattr(rp, "_get_model_config", lambda: {})
|
||||
|
||||
assert rp.is_routable_provider("legacy-endpoint") is True
|
||||
assert rp.is_routable_provider("custom:legacy-endpoint") is True
|
||||
assert rp.is_routable_provider("Legacy Endpoint") is True
|
||||
@@ -0,0 +1,118 @@
|
||||
"""The pulled catalog: packaged JSON is the offline truth, a GitHub fetch
|
||||
swaps entries in memory only, and min_engine gates day-0 models.
|
||||
|
||||
Nothing here touches disk beyond the packaged file — the design constraint
|
||||
is that a git checkout must never see a dirty tracked catalog.json."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import io
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_cli.local_runtime.catalog as cat
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_refresh_state(monkeypatch):
|
||||
"""Each test starts outside the TTL window with the packaged catalog."""
|
||||
monkeypatch.setattr(cat, "_last_refresh_attempt", 0.0)
|
||||
packaged = cat._packaged_catalog()
|
||||
monkeypatch.setattr(cat, "CATALOG", packaged)
|
||||
yield
|
||||
|
||||
|
||||
def _doc_from(entries):
|
||||
"""A fetchable catalog document built by mutating the packaged JSON."""
|
||||
from importlib.resources import files
|
||||
|
||||
doc = json.loads(files("hermes_cli.local_runtime")
|
||||
.joinpath("catalog.json").read_text(encoding="utf-8"))
|
||||
doc["models"] = entries(doc["models"])
|
||||
return doc
|
||||
|
||||
|
||||
def _fetch_returns(monkeypatch, doc):
|
||||
body = json.dumps(doc).encode()
|
||||
|
||||
class R(io.BytesIO):
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen",
|
||||
lambda *a, **k: R(body))
|
||||
|
||||
|
||||
def test_packaged_json_round_trips_the_catalog():
|
||||
"""The packaged JSON must produce a complete, selection-ready catalog:
|
||||
every entry carries estimator inputs and at least one variant, and the
|
||||
known invariants (best-first ordering, Q4 floor) hold — the same
|
||||
contract the literals obeyed."""
|
||||
assert len(cat.CATALOG) >= 4
|
||||
for e in cat.CATALOG:
|
||||
assert e.variants and e.n_ctx_train > 0 and e.per_layer_f16 >= 0
|
||||
sizes = [v.size_bytes for v in e.variants]
|
||||
assert sizes == sorted(sizes, reverse=True), f"{e.id} not best-first"
|
||||
|
||||
|
||||
def test_refresh_swaps_in_memory_only(monkeypatch, tmp_path):
|
||||
"""A fetched catalog replaces CATALOG in memory; the packaged file on
|
||||
disk is untouched (checkout stays clean)."""
|
||||
from importlib.resources import files
|
||||
|
||||
packaged_path = files("hermes_cli.local_runtime").joinpath("catalog.json")
|
||||
before = packaged_path.read_text(encoding="utf-8")
|
||||
|
||||
def add_day0(models):
|
||||
day0 = dict(models[0])
|
||||
day0.update(id="day0-model", display_name="Day 0",
|
||||
description="new", min_engine="b99999")
|
||||
return models + [day0]
|
||||
|
||||
_fetch_returns(monkeypatch, _doc_from(add_day0))
|
||||
assert cat.refresh_catalog(force=True) is True
|
||||
assert "day0-model" in {e.id for e in cat.CATALOG}
|
||||
assert cat.catalog_by_id()["day0-model"].min_engine == "b99999"
|
||||
assert packaged_path.read_text(encoding="utf-8") == before
|
||||
|
||||
|
||||
def test_refresh_failure_keeps_current_catalog(monkeypatch):
|
||||
def boom(*a, **k):
|
||||
raise OSError("offline")
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", boom)
|
||||
ids_before = [e.id for e in cat.CATALOG]
|
||||
assert cat.refresh_catalog(force=True) is False
|
||||
assert [e.id for e in cat.CATALOG] == ids_before
|
||||
|
||||
|
||||
def test_refresh_rejects_wrong_schema(monkeypatch):
|
||||
doc = _doc_from(lambda m: m)
|
||||
doc["schema_version"] = 2
|
||||
_fetch_returns(monkeypatch, doc)
|
||||
ids_before = [e.id for e in cat.CATALOG]
|
||||
assert cat.refresh_catalog(force=True) is False
|
||||
assert [e.id for e in cat.CATALOG] == ids_before
|
||||
|
||||
|
||||
def test_loader_ignores_unknown_fields():
|
||||
doc = _doc_from(lambda m: m)
|
||||
doc["models"][0]["future_field"] = {"anything": True}
|
||||
entries = cat._load_catalog(doc)
|
||||
assert entries[0].id == doc["models"][0]["id"]
|
||||
|
||||
|
||||
def test_min_engine_gate(monkeypatch):
|
||||
from hermes_cli.web_routers.local_models import _engine_too_old
|
||||
|
||||
monkeypatch.setattr("hermes_cli.local_runtime.binaries.installed_tags",
|
||||
lambda: ["b10362"])
|
||||
assert _engine_too_old("") is False, "no requirement, no gate"
|
||||
assert _engine_too_old("b10000") is False, "installed engine suffices"
|
||||
assert _engine_too_old("b10363") is True, "newer requirement gates"
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Catalog reachability: every entry's repo and files must exist upstream.
|
||||
|
||||
Existence is the contract; SIZES are advisory (they feed the estimator and
|
||||
progress bars, and downloads deliberately tolerate a stale size when
|
||||
upstream re-uploads — completeness is judged against the server's own
|
||||
declared length, never the catalog). Size drift prints as a warning so a
|
||||
catalog refresh can be batched deliberately; only a MISSING file or repo
|
||||
fails.
|
||||
|
||||
Network-marked (skipped in hermetic CI unless explicitly enabled) — this is
|
||||
the test that catches wrong repo names (the Nemotron 401) and moved files.
|
||||
Run before any catalog commit:
|
||||
|
||||
HERMES_TEST_NETWORK=1 scripts/run_tests.sh tests/hermes_cli/test_catalog_reachability.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not os.environ.get("HERMES_TEST_NETWORK"),
|
||||
reason="network test; set HERMES_TEST_NETWORK=1 to run",
|
||||
)
|
||||
|
||||
|
||||
def test_every_catalog_file_resolves():
|
||||
from hermes_cli.local_runtime.catalog import CATALOG
|
||||
|
||||
problems = []
|
||||
drift = []
|
||||
for entry in CATALOG:
|
||||
url = f"https://huggingface.co/api/models/{entry.repo}/tree/main?recursive=true"
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=30) as r:
|
||||
files = {f["path"]: f.get("size") for f in json.load(r)}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
problems.append(f"{entry.id}: repo {entry.repo} unreachable ({exc})")
|
||||
continue
|
||||
for variant in entry.variants:
|
||||
for asset in entry.download_files(variant):
|
||||
if asset.path not in files:
|
||||
problems.append(
|
||||
f"{entry.id}/{variant.quant}: {asset.path} not in {entry.repo}")
|
||||
continue
|
||||
live_size = files[asset.path]
|
||||
if live_size and live_size != asset.size_bytes:
|
||||
drift.append(
|
||||
f"{entry.id}/{variant.quant}: size drift on {asset.path} — "
|
||||
f"catalog {asset.size_bytes} vs live {live_size}")
|
||||
if drift:
|
||||
print("\nADVISORY size drift (downloads tolerate this; refresh when convenient):")
|
||||
print("\n".join(drift))
|
||||
assert not problems, "\n".join(problems)
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Variant-selection contracts: fit the catalog's single Q4-class build
|
||||
to a machine and price it honestly. Pure decision-table tests over
|
||||
synthetic budgets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.local_runtime.catalog import (
|
||||
CATALOG,
|
||||
catalog_by_id,
|
||||
find_entry_for_model,
|
||||
select_variant,
|
||||
)
|
||||
from hermes_cli.local_runtime.estimator import HardwareBudget
|
||||
|
||||
GIB = 1 << 30
|
||||
|
||||
|
||||
def budget(vram_gib: float, ram_gib: float = 64) -> HardwareBudget:
|
||||
return HardwareBudget(usable_vram_bytes=int(vram_gib * GIB),
|
||||
total_device_bytes=int(vram_gib * GIB),
|
||||
ram_available_bytes=int(ram_gib * GIB))
|
||||
|
||||
|
||||
def test_every_entry_ships_exactly_one_q4_build():
|
||||
"""No quant ladder: one Q4-class build per entry (K_M where the repo
|
||||
ships it, XL elsewhere) — the quant class current engines optimize
|
||||
for. Nothing below Q4 ever ships. Validation status is explicit per
|
||||
variant in catalog.json; unvalidated builds are permitted (day-0
|
||||
entries) and surface as unbadged rows in the pane."""
|
||||
for entry in CATALOG:
|
||||
assert len(entry.variants) == 1, (
|
||||
f"{entry.id}: {len(entry.variants)} variants — expected exactly one")
|
||||
build = entry.variants[0]
|
||||
assert build.quant.startswith(("UD-Q4", "Q4")), (
|
||||
f"{entry.id}: ships {build.quant}, not a Q4-class build")
|
||||
for asset in entry.download_files(build):
|
||||
assert asset.size_bytes > 0, f"{entry.id}: no size on {asset.path}"
|
||||
|
||||
|
||||
def test_split_variants_have_coherent_parts():
|
||||
"""Multi-file variants: same model_id from every part, exact sizes,
|
||||
first file is the load target."""
|
||||
entry = catalog_by_id()["deepseek-v4-flash"]
|
||||
for v in entry.variants:
|
||||
assert len(v.files) >= 2, "deepseek ships split GGUFs"
|
||||
assert "00001-of" in v.files[0].path, "first part must be the load target"
|
||||
assert v.size_bytes == sum(f.size_bytes for f in v.files)
|
||||
assert entry.draft is not None, "DSpark draft rides along"
|
||||
|
||||
|
||||
def test_selection_is_the_q4_build_even_with_headroom():
|
||||
"""The selector picks the Q4 build even when bigger quants would fit
|
||||
with room to spare — headroom buys window, not quant. Larger builds
|
||||
stay one tile click away in the pane."""
|
||||
entry = catalog_by_id()["qwen3.8-27b"]
|
||||
choice = select_variant(entry, budget(60))
|
||||
assert choice is not None
|
||||
assert choice.zero_spill
|
||||
assert choice.variant.quant == entry.variants[-1].quant # the Q4 rung
|
||||
assert choice.reason_key == "best-large-window"
|
||||
|
||||
|
||||
def test_selected_build_constant_and_fit_shape_monotone_in_vram():
|
||||
"""More VRAM never changes the selected build (always the Q4 rung);
|
||||
what improves is the fit shape: spilled -> floor -> target window."""
|
||||
entry = catalog_by_id()["qwen3.8-27b"]
|
||||
quants = set()
|
||||
shapes = []
|
||||
rank = {"smallest-fits-spilled": 0, "best-fits": 1, "best-large-window": 2}
|
||||
for vram in (8, 12, 16, 24, 32, 48):
|
||||
choice = select_variant(entry, budget(vram))
|
||||
assert choice is not None
|
||||
quants.add(choice.variant.quant)
|
||||
shapes.append(rank[choice.reason_key])
|
||||
assert quants == {entry.variants[-1].quant}, f"selection not constant: {quants}"
|
||||
assert shapes == sorted(shapes), f"fit shape not monotone in VRAM: {shapes}"
|
||||
|
||||
|
||||
def test_small_card_gets_q4_spilled_never_below():
|
||||
"""8 GiB card + 27B: nothing zero-spills. The floor holds — the
|
||||
selector offers Q4 spilled (priced honestly), never a sub-Q4 build."""
|
||||
entry = catalog_by_id()["qwen3.8-27b"]
|
||||
choice = select_variant(entry, budget(8))
|
||||
assert choice is not None
|
||||
assert not choice.zero_spill
|
||||
assert choice.reason_key == "smallest-fits-spilled"
|
||||
assert choice.variant.quant == "UD-Q4_K_M"
|
||||
|
||||
|
||||
def test_frontier_model_refused_on_consumer_card_offered_on_big_ram():
|
||||
"""DeepSeek V4 Flash (161 GB at Q4): refused outright on a 32 GiB-RAM
|
||||
desktop; offered spilled on a 192 GiB-RAM workstation. The catalog
|
||||
carries frontier hardware honestly instead of hiding the model."""
|
||||
entry = catalog_by_id()["deepseek-v4-flash"]
|
||||
assert select_variant(entry, budget(32, ram_gib=32)) is None
|
||||
big = select_variant(entry, budget(32, ram_gib=192))
|
||||
assert big is not None and not big.zero_spill
|
||||
|
||||
|
||||
def test_selection_accounts_for_kv_not_just_weights():
|
||||
"""The zero-spill check prices weights + KV, not weights alone: give a
|
||||
machine exactly enough VRAM for the build's weights and the fit must
|
||||
come back spilled, not zero-spill."""
|
||||
entry = catalog_by_id()["qwen3.8-27b"]
|
||||
build = entry.variants[0]
|
||||
exactly_weights = HardwareBudget(
|
||||
usable_vram_bytes=build.size_bytes + (100 << 20),
|
||||
total_device_bytes=build.size_bytes + (100 << 20),
|
||||
ram_available_bytes=64 * GIB)
|
||||
choice = select_variant(entry, exactly_weights)
|
||||
assert choice is not None
|
||||
assert not choice.zero_spill, "KV cost ignored — weights alone can't zero-spill"
|
||||
|
||||
|
||||
def test_floor_fallback_when_target_window_does_not_fit():
|
||||
"""Cards where nothing clears the target keep the old rule: highest
|
||||
quality that zero-spills at the 64K floor (reason 'best-fits'), never
|
||||
a needless step down."""
|
||||
entry = catalog_by_id()["qwen3.8-27b"]
|
||||
# ~23.5 GiB usable: Q4 weights (16.7 GiB in-memory) + floor KV (2.2)
|
||||
# + overhead (1.5 + 0.9 mmproj + ~1.0 MTP-posture logits) fits, but
|
||||
# the 144K-target KV (+2.7 more) does not.
|
||||
choice = select_variant(entry, budget(23.5))
|
||||
assert choice is not None and choice.zero_spill
|
||||
assert choice.reason_key == "best-fits"
|
||||
assert choice.variant.quant == "UD-Q4_K_M"
|
||||
|
||||
|
||||
def test_target_never_degrades_below_floor_choice():
|
||||
"""The target preference may only IMPROVE the window, never the
|
||||
floor guarantees: whenever the old floor rule found a zero-spill pick,
|
||||
the new rule also finds one (possibly a smaller quant, never spill)."""
|
||||
for entry in CATALOG:
|
||||
for vram in (8, 12, 16, 24, 32, 48, 96):
|
||||
choice = select_variant(entry, budget(vram, ram_gib=256))
|
||||
if choice is None:
|
||||
continue
|
||||
# Rule 2: whatever was chosen zero-spill must genuinely clear
|
||||
# the floor (the selector's own invariant, re-checked).
|
||||
if choice.zero_spill:
|
||||
assert choice.reason_key in ("best-large-window", "best-fits")
|
||||
|
||||
|
||||
def test_find_entry_for_model_resolves_split_ids():
|
||||
hit = find_entry_for_model("DeepSeek-V4-Flash-0731-UD-Q4_K_XL")
|
||||
assert hit is not None
|
||||
entry, variant = hit
|
||||
assert entry.id == "deepseek-v4-flash"
|
||||
assert variant.quant == "UD-Q4_K_XL"
|
||||
|
||||
|
||||
def test_hybrid_long_context_stays_cheap():
|
||||
"""The reason Nemotron/Qwen3.6 headline the catalog: their priced
|
||||
64K-floor KV must be a small fraction of a dense model's."""
|
||||
from hermes_cli.local_runtime.catalog import FLOOR
|
||||
from hermes_cli.local_runtime.estimator import ctx_bytes
|
||||
|
||||
from hermes_cli.local_runtime.estimator import LayerKind, ModelProfile
|
||||
|
||||
hybrid = catalog_by_id()["qwen3.6-35b-a3b"]
|
||||
hybrid_profile = hybrid.profile(hybrid.variants[-1])
|
||||
# A fully-dense profile of the same layer count and per-layer cost:
|
||||
# the contract is about LAYER ECONOMICS (recurrent layers pay no
|
||||
# per-token KV), not about any particular catalog entry.
|
||||
n_layers = len(hybrid_profile.layers)
|
||||
dense_profile = ModelProfile(
|
||||
name="synthetic-dense", weights_bytes=hybrid_profile.weights_bytes,
|
||||
embd_table_bytes=0, n_ctx_train=hybrid.n_ctx_train,
|
||||
layers=[(LayerKind.FULL, hybrid.per_layer_f16)] * n_layers)
|
||||
dense_kv = ctx_bytes(dense_profile, FLOOR)
|
||||
hybrid_kv = ctx_bytes(hybrid_profile, FLOOR)
|
||||
# The contract is structural: recurrent layers pay no per-token KV,
|
||||
# so the hybrid's KV must track its full-attention share (x kv_scale
|
||||
# for MTP's draft context), not its total layer count.
|
||||
full = sum(1 for kind, _ in hybrid_profile.layers if kind == LayerKind.FULL)
|
||||
expected = dense_kv * full / n_layers * hybrid_profile.kv_scale
|
||||
assert hybrid_kv < dense_kv, "hybrid must be cheaper than dense"
|
||||
assert abs(hybrid_kv - expected) / expected < 0.25, (
|
||||
f"hybrid KV ({hybrid_kv:,}) should track its full-attention share "
|
||||
f"(expected ~{expected:,.0f})")
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Regression tests for issue #29866.
|
||||
|
||||
A brew Python upgrade (or an interrupted venv rebuild — see also the v0.19.0
|
||||
report in the same issue) can leave ``certifi`` importable while its bundled
|
||||
``cacert.pem`` is missing or a dangling symlink. Every TLS connection then
|
||||
fails with an opaque ``Could not find a suitable TLS CA certificate bundle``
|
||||
and the gateway is down on all platforms.
|
||||
|
||||
Behavior contracts pinned here:
|
||||
|
||||
1. The venv-repair import probes (early recovery + `hermes update`) must
|
||||
classify certifi as BROKEN when the module imports but ``cacert.pem`` is
|
||||
missing or corrupt — an attribute probe alone passes in that state.
|
||||
2. ``hermes doctor`` must fail the certificate check in that state, and
|
||||
``hermes doctor --fix`` must repair by force-reinstalling certifi and
|
||||
re-verifying.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_cli._early_recovery as er
|
||||
|
||||
|
||||
def _fake_certifi(monkeypatch, bundle_path: Path):
|
||||
"""Install a fake certifi module whose where() points at bundle_path."""
|
||||
fake = types.ModuleType("certifi")
|
||||
fake.contents = lambda: "" # satisfies the ('certifi', 'contents') probe
|
||||
fake.where = lambda: str(bundle_path)
|
||||
monkeypatch.setitem(sys.modules, "certifi", fake)
|
||||
return fake
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 1. Import probes detect a missing/corrupt cacert.pem
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestEarlyRecoveryCertifiBundleProbe:
|
||||
def test_missing_bundle_flags_certifi_broken(self, monkeypatch, tmp_path):
|
||||
_fake_certifi(monkeypatch, tmp_path / "nonexistent" / "cacert.pem")
|
||||
broken = er._probe_broken_packages()
|
||||
assert "certifi" in broken, (
|
||||
"certifi imports but cacert.pem is missing — the probe must flag "
|
||||
"it broken (#29866); the attribute check alone passes here"
|
||||
)
|
||||
|
||||
def test_tiny_bundle_flags_certifi_broken(self, monkeypatch, tmp_path):
|
||||
bundle = tmp_path / "cacert.pem"
|
||||
bundle.write_text("truncated", encoding="utf-8")
|
||||
_fake_certifi(monkeypatch, bundle)
|
||||
broken = er._probe_broken_packages()
|
||||
assert "certifi" in broken
|
||||
|
||||
|
||||
def test_where_raising_flags_certifi_broken(self, monkeypatch):
|
||||
fake = types.ModuleType("certifi")
|
||||
fake.contents = lambda: ""
|
||||
|
||||
def _boom():
|
||||
raise OSError("simulated broken installation")
|
||||
|
||||
fake.where = _boom
|
||||
monkeypatch.setitem(sys.modules, "certifi", fake)
|
||||
broken = er._probe_broken_packages()
|
||||
assert "certifi" in broken
|
||||
|
||||
|
||||
class TestUpdateProbeScriptChecksBundle:
|
||||
"""The subprocess probe used by `hermes update`'s venv repair must apply
|
||||
the same bundle-file check inside the target venv's interpreter."""
|
||||
|
||||
def _run_probe_script(self, monkeypatch, tmp_path, bundle_path):
|
||||
"""Extract the generated probe script and run it in-process against a
|
||||
fake certifi that points at bundle_path."""
|
||||
from hermes_cli import main as main_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
captured["script"] = cmd[-1]
|
||||
|
||||
class _R:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
|
||||
return _R()
|
||||
|
||||
monkeypatch.setattr(main_mod.subprocess, "run", fake_run)
|
||||
monkeypatch.setattr(
|
||||
main_mod, "_resolve_install_target_python", lambda *a, **k: sys.executable
|
||||
)
|
||||
main_mod._detect_broken_lazy_refresh_imports(["pip"])
|
||||
script = captured["script"]
|
||||
|
||||
# Execute the probe script with a fake certifi installed.
|
||||
_fake_certifi(monkeypatch, bundle_path)
|
||||
printed = []
|
||||
namespace = {"__builtins__": __builtins__}
|
||||
import builtins as _b
|
||||
|
||||
real_print = _b.print
|
||||
monkeypatch.setattr(
|
||||
_b, "print", lambda *a, **k: printed.append(" ".join(map(str, a)))
|
||||
)
|
||||
try:
|
||||
exec(script, namespace)
|
||||
finally:
|
||||
monkeypatch.setattr(_b, "print", real_print)
|
||||
return "\n".join(printed)
|
||||
|
||||
|
||||
def test_probe_script_quiet_when_bundle_healthy(self, monkeypatch, tmp_path):
|
||||
import certifi as real_certifi
|
||||
|
||||
out = self._run_probe_script(
|
||||
monkeypatch, tmp_path, Path(real_certifi.where())
|
||||
)
|
||||
assert "certifi" not in out.splitlines()
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 2. hermes doctor: detection and --fix repair
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestDoctorCertificates:
|
||||
def test_broken_bundle_fails_without_fix(self, monkeypatch, capsys, tmp_path):
|
||||
from hermes_cli import doctor as doctor_mod
|
||||
|
||||
monkeypatch.setenv("SSL_CERT_FILE", str(tmp_path / "missing.pem"))
|
||||
issues = []
|
||||
doctor_mod.check_certificates(should_fix=False, issues=issues)
|
||||
out = capsys.readouterr().out
|
||||
assert "broken" in out.lower()
|
||||
assert issues, "a broken bundle must be funneled into the action list"
|
||||
assert any("doctor --fix" in i for i in issues)
|
||||
|
||||
def test_fix_reinstalls_certifi_and_reverifies(self, monkeypatch, capsys, tmp_path):
|
||||
from hermes_cli import doctor as doctor_mod
|
||||
|
||||
# First verification fails, post-reinstall verification succeeds.
|
||||
calls = {"verify": 0, "pip": []}
|
||||
|
||||
def fake_verify():
|
||||
calls["verify"] += 1
|
||||
if calls["verify"] == 1:
|
||||
from agent.errors import SSLConfigurationError
|
||||
|
||||
raise SSLConfigurationError("certifi points to a missing CA bundle")
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
calls["pip"].append(cmd)
|
||||
|
||||
class _R:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
|
||||
return _R()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"agent.ssl_guard.verify_ca_bundle_with_fallback", fake_verify
|
||||
)
|
||||
monkeypatch.setattr(doctor_mod.subprocess, "run", fake_run)
|
||||
|
||||
issues = []
|
||||
doctor_mod.check_certificates(should_fix=True, issues=issues)
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert calls["pip"], "--fix must run a pip force-reinstall of certifi"
|
||||
pip_cmd = calls["pip"][0]
|
||||
assert "--force-reinstall" in pip_cmd and "certifi" in pip_cmd
|
||||
assert calls["verify"] == 2, "must re-verify after the reinstall"
|
||||
assert "repaired" in out.lower()
|
||||
assert not issues
|
||||
|
||||
|
||||
def test_healthy_bundle_never_touches_pip(self, monkeypatch, capsys):
|
||||
from hermes_cli import doctor as doctor_mod
|
||||
|
||||
def _fail_run(*a, **k):
|
||||
raise AssertionError("healthy bundle must not trigger a reinstall")
|
||||
|
||||
monkeypatch.setattr(doctor_mod.subprocess, "run", _fail_run)
|
||||
doctor_mod.check_certificates(should_fix=True, issues=[])
|
||||
out = capsys.readouterr().out
|
||||
assert "valid" in out.lower()
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 3. Startup error message stays actionable
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestSslGuardRepairHint:
|
||||
def test_missing_bundle_error_mentions_doctor_fix(self, monkeypatch, tmp_path):
|
||||
import certifi
|
||||
|
||||
from agent.errors import SSLConfigurationError
|
||||
from agent.ssl_guard import verify_ca_bundle
|
||||
|
||||
monkeypatch.setattr(certifi, "where", lambda: str(tmp_path / "gone.pem"))
|
||||
with pytest.raises(SSLConfigurationError) as excinfo:
|
||||
verify_ca_bundle()
|
||||
message = str(excinfo.value)
|
||||
assert "hermes doctor --fix" in message
|
||||
assert "certifi" in message
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Tests for `chat -c <title>` failing loudly (stderr) and `--create-if-missing`.
|
||||
|
||||
Regression for #86794: a background/quiet `hermes chat -c "<title>" -q "..."`
|
||||
against a not-yet-existing titled session silently no-oped — the error message
|
||||
was written to stdout (which quiet/programmatic callers treat as the "final
|
||||
response" channel) instead of stderr, and there was no way to create the
|
||||
titled session from the same invocation.
|
||||
|
||||
Two behaviors are fixed here:
|
||||
|
||||
1. **fail loudly on stderr** — when no session matches `-c <title>`, the error
|
||||
goes to stderr (exit 1), so programmatic callers always see it.
|
||||
2. **--create-if-missing** — same invocation with the flag creates a fresh
|
||||
session carrying the title and proceeds, giving plugins/scripts a
|
||||
deterministic "send to this named thread, making it if needed" primitive.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_home(tmp_path, monkeypatch):
|
||||
"""Point HERMES_HOME at a temp dir so session creation stays isolated."""
|
||||
home = tmp_path / "hermes_home"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
return home
|
||||
|
||||
|
||||
def _create_titled_session(title):
|
||||
from hermes_cli.main import _create_titled_session as fn
|
||||
|
||||
return fn(title)
|
||||
|
||||
|
||||
class TestCreateIfMissingFlagParsing:
|
||||
def test_flag_parses_on_chat_subparser(self):
|
||||
"""--create-if-missing is accepted by the real chat parser."""
|
||||
from hermes_cli._parser import build_top_level_parser
|
||||
|
||||
parser, _subparsers, _chat_parser = build_top_level_parser()
|
||||
args = parser.parse_args(
|
||||
["chat", "-c", "Bot Chat", "--create-if-missing", "-q", "hi"]
|
||||
)
|
||||
assert args.continue_last == "Bot Chat"
|
||||
assert getattr(args, "create_if_missing", False) is True
|
||||
|
||||
def test_flag_absent_means_false(self):
|
||||
"""Without the flag, create_if_missing stays unset (SUPPRESS default)."""
|
||||
from hermes_cli._parser import build_top_level_parser
|
||||
|
||||
parser, _subparsers, _chat_parser = build_top_level_parser()
|
||||
args = parser.parse_args(["chat", "-c", "Bot Chat", "-q", "hi"])
|
||||
# SUPPRESS default: attribute absent, and cmd_chat's getattr(..., False)
|
||||
# must treat it as False.
|
||||
assert getattr(args, "create_if_missing", False) is False
|
||||
|
||||
|
||||
class TestCreateTitledSession:
|
||||
def test_creates_session_with_title(self, isolated_home):
|
||||
sid = _create_titled_session("Bot Chat")
|
||||
assert sid, "should return a session id"
|
||||
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
session = db.get_session(sid)
|
||||
assert session is not None
|
||||
assert db.get_session_title(sid) == "Bot Chat"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def test_created_session_resolvable_by_title(self, isolated_home):
|
||||
"""After creation, resolve_session_by_title finds it (idempotent sends)."""
|
||||
sid = _create_titled_session("Bot Chat")
|
||||
assert sid
|
||||
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
resolved = db.resolve_session_by_title("Bot Chat")
|
||||
assert resolved == sid
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
class TestChatCFailLoudlyOnStderr:
|
||||
"""Behavior-level: run the real cmd_chat path and inspect channels."""
|
||||
|
||||
def test_missing_session_fails_on_stderr(self, isolated_home, monkeypatch):
|
||||
"""-c <missing title> → exit 1, message on stderr, stdout untouched."""
|
||||
import sys
|
||||
|
||||
import hermes_cli.main as main_mod
|
||||
|
||||
stderr_lines = []
|
||||
|
||||
class _Exit(Exception):
|
||||
pass
|
||||
|
||||
def _fake_exit(code):
|
||||
raise _Exit(code)
|
||||
|
||||
class _Stderr:
|
||||
def write(self, text):
|
||||
stderr_lines.append(text)
|
||||
return len(text)
|
||||
|
||||
class _Stdout:
|
||||
def write(self, text):
|
||||
return len(text)
|
||||
|
||||
monkeypatch.setattr(sys, "exit", _fake_exit)
|
||||
monkeypatch.setattr(sys, "stderr", _Stderr())
|
||||
monkeypatch.setattr(sys, "stdout", _Stdout())
|
||||
|
||||
args = type(
|
||||
"Args",
|
||||
(),
|
||||
{
|
||||
"continue_last": "Bot Chat",
|
||||
"resume": None,
|
||||
"create_if_missing": False,
|
||||
},
|
||||
)()
|
||||
|
||||
with pytest.raises(_Exit) as ei:
|
||||
main_mod._resolve_continue_arg(args, use_tui=False)
|
||||
|
||||
assert ei.value.args[0] == 1
|
||||
assert any("No session found matching 'Bot Chat'" in l for l in stderr_lines)
|
||||
assert not stderr_lines[0].startswith("Use 'hermes sessions list'")
|
||||
|
||||
def test_create_if_missing_sets_resume(self, isolated_home, monkeypatch):
|
||||
"""--create-if-missing resolves to a new session id on args.resume."""
|
||||
import hermes_cli.main as main_mod
|
||||
|
||||
args = type(
|
||||
"Args",
|
||||
(),
|
||||
{
|
||||
"continue_last": "Bot Chat",
|
||||
"resume": None,
|
||||
"create_if_missing": True,
|
||||
},
|
||||
)()
|
||||
|
||||
main_mod._resolve_continue_arg(args, use_tui=False)
|
||||
|
||||
assert args.resume, "resume should be set to the new session id"
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
assert db.get_session_title(args.resume) == "Bot Chat"
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,86 @@
|
||||
"""--query-file: single-query text arrives verbatim, never shell-interpreted.
|
||||
|
||||
Regression tests for the Bot Mode DM injection fix: the DM protocol used to
|
||||
tell agents to interpolate message bodies into a double-quoted shell command,
|
||||
so quotes truncated the message and $(...) executed on the sender's machine.
|
||||
The transport is now a file (--query-file) / stdin, and the protocol text
|
||||
must never regress to inlining the body into -q.
|
||||
"""
|
||||
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
HOSTILE = 'hi "there" $(touch /tmp/pwned_by_dm_test) `id` \\ and a\nsecond line'
|
||||
|
||||
|
||||
def _parse(argv):
|
||||
sys.path.insert(0, str(REPO))
|
||||
try:
|
||||
from hermes_cli._parser import build_top_level_parser
|
||||
|
||||
built = build_top_level_parser()
|
||||
parser = built[0] if isinstance(built, tuple) else built
|
||||
return parser.parse_args(argv)
|
||||
finally:
|
||||
sys.path.remove(str(REPO))
|
||||
|
||||
|
||||
def test_chat_parser_accepts_query_file():
|
||||
args = _parse(["chat", "--query-file", "/tmp/x.txt"])
|
||||
assert args.query_file == "/tmp/x.txt"
|
||||
assert args.query is None
|
||||
|
||||
|
||||
def test_query_file_reads_hostile_text_verbatim(tmp_path, monkeypatch):
|
||||
"""The file body must reach args.query byte-identical — no shell pass."""
|
||||
f = tmp_path / "dm.txt"
|
||||
f.write_text(HOSTILE, encoding="utf-8")
|
||||
|
||||
# Exercise the exact resolution block in hermes_cli.main by simulating it:
|
||||
# the block reads the file into args.query before dispatch.
|
||||
args = _parse(["chat", "--query-file", str(f)])
|
||||
assert args.query_file is not None
|
||||
body = Path(args.query_file).read_text(encoding="utf-8")
|
||||
assert body == HOSTILE
|
||||
assert "$(touch" in body # preserved, not executed
|
||||
assert not Path("/tmp/pwned_by_dm_test").exists()
|
||||
|
||||
|
||||
def test_query_and_query_file_mutually_exclusive(tmp_path):
|
||||
"""argparse rejects -q + --query-file at parse time (exit 2), no env needed."""
|
||||
import pytest
|
||||
|
||||
f = tmp_path / "dm.txt"
|
||||
f.write_text("hello", encoding="utf-8")
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
_parse(["chat", "-q", "x", "--query-file", str(f)])
|
||||
assert exc.value.code == 2
|
||||
|
||||
|
||||
def test_bot_mode_protocol_never_inlines_message_into_shell():
|
||||
"""The DM transport must use --query-file / stdin, not -q "…" inlining.
|
||||
|
||||
The transport moved from prompt-injected instructions (bot_mode_probe)
|
||||
to the message_agent tool (bot_mode_dm) in Aug 2026 — the invariant now
|
||||
holds on the tool's command builder, and the probe must no longer teach
|
||||
any shellout at all.
|
||||
"""
|
||||
sys.path.insert(0, str(REPO))
|
||||
try:
|
||||
import importlib
|
||||
|
||||
dm = importlib.import_module("tools.bot_mode_dm")
|
||||
src = Path(dm.__file__).read_text(encoding="utf-8")
|
||||
probe = importlib.import_module("tools.bot_mode_probe")
|
||||
probe_src = Path(probe.__file__).read_text(encoding="utf-8")
|
||||
finally:
|
||||
sys.path.remove(str(REPO))
|
||||
assert "--query-file" in src
|
||||
assert '-q "Message from' not in src
|
||||
assert 'dm <peer>/<agent-name> "Message from' not in src
|
||||
# The protocol section teaches the tool, never a hand-rolled shellout.
|
||||
assert "message_agent" in probe_src
|
||||
assert "--query-file /tmp/dm.txt" not in probe_src
|
||||
@@ -0,0 +1,53 @@
|
||||
import sys
|
||||
|
||||
|
||||
def test_top_level_skills_flag_defaults_to_chat(monkeypatch):
|
||||
import hermes_cli.main as main_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_cmd_chat(args):
|
||||
captured["skills"] = args.skills
|
||||
captured["command"] = args.command
|
||||
|
||||
monkeypatch.setattr(main_mod, "cmd_chat", fake_cmd_chat)
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
["hermes", "-s", "hermes-agent-dev,github-auth"],
|
||||
)
|
||||
|
||||
main_mod.main()
|
||||
|
||||
assert captured == {
|
||||
"skills": ["hermes-agent-dev,github-auth"],
|
||||
"command": None,
|
||||
}
|
||||
|
||||
|
||||
def test_continue_worktree_and_skills_flags_work_together(monkeypatch):
|
||||
import hermes_cli.main as main_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_cmd_chat(args):
|
||||
captured["continue_last"] = args.continue_last
|
||||
captured["worktree"] = args.worktree
|
||||
captured["skills"] = args.skills
|
||||
captured["command"] = args.command
|
||||
|
||||
monkeypatch.setattr(main_mod, "cmd_chat", fake_cmd_chat)
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
["hermes", "-c", "-w", "-s", "hermes-agent-dev"],
|
||||
)
|
||||
|
||||
main_mod.main()
|
||||
|
||||
assert captured == {
|
||||
"continue_last": True,
|
||||
"worktree": True,
|
||||
"skills": ["hermes-agent-dev"],
|
||||
"command": "chat",
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"""The test suite must never mutate the LIVE checkout or its venv.
|
||||
|
||||
Regression tests for the pytest-guards on the checkout-root mutation paths.
|
||||
Before the guards, tests that drove ``cmd_update``/recovery without
|
||||
sandboxing ``PROJECT_ROOT`` left ``.lazy-refresh-incomplete`` at the real
|
||||
repo root (observed after full-suite runs), and — on a venv with genuinely
|
||||
broken packages — test-spawned subprocesses importing ``hermes_cli.main``
|
||||
ran a REAL ``ensurepip`` + ``pip install --force-reinstall`` against the
|
||||
developer's executing environment mid-suite.
|
||||
|
||||
The guard predicate requires BOTH conditions (under pytest AND the target is
|
||||
this checkout itself), so every tmp_path-sandboxed test keeps exercising the
|
||||
real code paths unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import hermes_cli.main as main_mod
|
||||
from hermes_cli import _early_recovery as er
|
||||
|
||||
CHECKOUT_ROOT = Path(er.__file__).resolve().parent.parent
|
||||
|
||||
|
||||
class TestPredicate:
|
||||
def test_true_for_live_checkout_under_pytest(self):
|
||||
# PYTEST_CURRENT_TEST is set by pytest itself right now.
|
||||
assert er._pytest_owns_live_checkout(CHECKOUT_ROOT) is True
|
||||
assert main_mod._pytest_owns_live_checkout(CHECKOUT_ROOT) is True
|
||||
|
||||
def test_false_for_sandboxed_root(self, tmp_path):
|
||||
assert er._pytest_owns_live_checkout(tmp_path) is False
|
||||
assert main_mod._pytest_owns_live_checkout(tmp_path) is False
|
||||
|
||||
def test_false_outside_pytest(self, monkeypatch):
|
||||
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
|
||||
assert er._pytest_owns_live_checkout(CHECKOUT_ROOT) is False
|
||||
assert main_mod._pytest_owns_live_checkout(CHECKOUT_ROOT) is False
|
||||
|
||||
|
||||
class TestMarkerWrites:
|
||||
def test_refuses_breadcrumb_at_live_repo_root(self):
|
||||
target = CHECKOUT_ROOT / ".lazy-refresh-incomplete"
|
||||
# The marker may legitimately pre-exist: upstream currently TRACKS a
|
||||
# littered copy in git (the exact pollution this guard prevents), so
|
||||
# the contract is content-unchanged, not never-exists.
|
||||
before = target.read_text(encoding="utf-8") if target.exists() else None
|
||||
try:
|
||||
main_mod._write_marker_file(target, label="lazy-refresh-incomplete")
|
||||
after = (
|
||||
target.read_text(encoding="utf-8") if target.exists() else None
|
||||
)
|
||||
assert after == before, (
|
||||
"marker breadcrumb written into the LIVE checkout from a test"
|
||||
)
|
||||
finally:
|
||||
# If the guard is broken (RED state), restore the pre-test state —
|
||||
# leaving pollution behind is exactly the bug being pinned.
|
||||
if before is None:
|
||||
target.unlink(missing_ok=True)
|
||||
else:
|
||||
target.write_text(before, encoding="utf-8")
|
||||
|
||||
def test_still_writes_sandboxed(self, tmp_path):
|
||||
target = tmp_path / ".lazy-refresh-incomplete"
|
||||
main_mod._write_marker_file(target, label="lazy-refresh-incomplete")
|
||||
assert target.exists()
|
||||
assert "pid=" in target.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class TestEarlyRecovery:
|
||||
def test_skips_live_checkout_before_any_probe_or_lock(self, monkeypatch):
|
||||
# A probe call would mean recovery is proceeding against the live
|
||||
# checkout; the guard must return before ANY side-effectful step.
|
||||
def _boom():
|
||||
raise AssertionError("probe ran against the live checkout")
|
||||
|
||||
monkeypatch.setattr(er, "_probe_broken_packages", _boom)
|
||||
monkeypatch.setattr(er, "_run_repair_install", lambda *a, **k: _boom())
|
||||
er.recover_if_needed(project_root=CHECKOUT_ROOT, argv=[])
|
||||
|
||||
def test_sandboxed_root_still_recovers(self, tmp_path, monkeypatch):
|
||||
# The guard must not disable recovery for sandboxed roots: with a
|
||||
# marker present and a broken probe, the repair path still runs.
|
||||
(tmp_path / ".lazy-refresh-incomplete").write_text("started=1\npid=1\n")
|
||||
(tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n")
|
||||
monkeypatch.setattr(er, "_probe_broken_packages", lambda: ["PyYAML"])
|
||||
monkeypatch.setattr(er, "_pinned_specs", lambda broken, root: broken)
|
||||
installs = []
|
||||
monkeypatch.setattr(
|
||||
er, "_run_repair_install", lambda specs, root: installs.append(specs) or True
|
||||
)
|
||||
er.recover_if_needed(project_root=tmp_path, argv=[])
|
||||
assert installs, "sandboxed recovery was wrongly disabled by the guard"
|
||||
|
||||
|
||||
class TestLaunchRecovery:
|
||||
def test_recover_from_interrupted_install_noops_on_live_checkout(
|
||||
self, monkeypatch
|
||||
):
|
||||
# PROJECT_ROOT is the live checkout in-suite; the launch-time
|
||||
# recovery must return before touching markers or spawning installs.
|
||||
def _boom(*a, **k):
|
||||
raise AssertionError("launch recovery ran against the live checkout")
|
||||
|
||||
monkeypatch.setattr(main_mod, "_update_marker_path", _boom)
|
||||
main_mod._recover_from_interrupted_install()
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Tests for `hermes checkpoints prune`'s orphan confirmation flow.
|
||||
|
||||
Covers the P1 raised on PR #69141: the confirmation preview must cover
|
||||
BOTH v2 projects (`store_status()["projects"]`) and pre-v2 shadow repos
|
||||
(`store_status()["pre_v2_projects"]`), since `prune_checkpoints()` deletes
|
||||
orphans from both layouts. Exercises decline / accept / --force across
|
||||
pre-v2-only and mixed (v2 + pre-v2) stores.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _ns(**kwargs) -> argparse.Namespace:
|
||||
defaults = {"retention_days": 7, "max_size_mb": 500, "keep_orphans": False, "force": False}
|
||||
defaults.update(kwargs)
|
||||
return argparse.Namespace(**defaults)
|
||||
|
||||
|
||||
def _prune_result(**kwargs) -> dict:
|
||||
result = {"scanned": 0, "deleted_orphan": 0, "deleted_stale": 0, "errors": 0, "bytes_freed": 0}
|
||||
result.update(kwargs)
|
||||
return result
|
||||
|
||||
|
||||
_V2_ORPHAN_ONLY_STATUS = {
|
||||
"projects": [],
|
||||
"pre_v2_projects": [],
|
||||
}
|
||||
|
||||
_PRE_V2_ONLY_STATUS = {
|
||||
"projects": [],
|
||||
"pre_v2_projects": [
|
||||
{"path": "/home/user/.hermes/checkpoints/deadbeefcafebabe", "workdir": None, "exists": False},
|
||||
],
|
||||
}
|
||||
|
||||
_MIXED_STATUS = {
|
||||
"projects": [
|
||||
{"hash": "abc123", "workdir": "/gone/v2-project", "exists": False, "commits": 4},
|
||||
],
|
||||
"pre_v2_projects": [
|
||||
{"path": "/home/user/.hermes/checkpoints/deadbeefcafebabe", "workdir": "/gone/pre-v2-project", "exists": False},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _patch_checkpoint_manager(monkeypatch, status: dict, prune_calls: list):
|
||||
import tools.checkpoint_manager as ckpt_mgr
|
||||
|
||||
monkeypatch.setattr(ckpt_mgr, "store_status", lambda *a, **k: status)
|
||||
|
||||
def _fake_prune(**kwargs):
|
||||
prune_calls.append(kwargs)
|
||||
return _prune_result(
|
||||
deleted_orphan=len(status["projects"]) + len(status["pre_v2_projects"]),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(ckpt_mgr, "prune_checkpoints", _fake_prune)
|
||||
|
||||
|
||||
# ─── pre-v2-only store ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
|
||||
# ─── mixed store (v2 + pre-v2) ──────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
|
||||
# ─── --keep-orphans skips the prompt entirely, on either layout ───────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [_PRE_V2_ONLY_STATUS, _MIXED_STATUS], ids=["pre_v2_only", "mixed"])
|
||||
def test_keep_orphans_skips_prompt(monkeypatch, capsys, status):
|
||||
import hermes_cli.checkpoints as checkpoints_cli
|
||||
|
||||
prune_calls: list = []
|
||||
_patch_checkpoint_manager(monkeypatch, status, prune_calls)
|
||||
|
||||
def _unexpected_input(_prompt):
|
||||
raise AssertionError("input() must not be called when --keep-orphans is passed")
|
||||
|
||||
monkeypatch.setattr("builtins.input", _unexpected_input)
|
||||
|
||||
rc = checkpoints_cli.cmd_prune(_ns(keep_orphans=True))
|
||||
|
||||
assert rc == 0
|
||||
assert len(prune_calls) == 1
|
||||
assert prune_calls[0]["delete_orphans"] is False
|
||||
|
||||
|
||||
# ─── no orphans present: never prompts even without --force ───────────────
|
||||
|
||||
|
||||
# ─── allowlist binding: preview set == deletion set, even when empty ───────
|
||||
|
||||
|
||||
def test_empty_preview_binds_empty_allowlist(monkeypatch, capsys):
|
||||
"""Zero-orphan-preview timing regression (PR #69141 review).
|
||||
|
||||
When the non-force preview shows zero orphans, no prompt runs — but the
|
||||
later rescan inside prune_checkpoints() may discover a project that
|
||||
became orphaned *after* the preview. That undisplayed, unconfirmed orphan
|
||||
must not be deletable: the allowlist passed down must be the exact
|
||||
(empty) displayed set, never the unrestricted None sentinel.
|
||||
"""
|
||||
import hermes_cli.checkpoints as checkpoints_cli
|
||||
|
||||
prune_calls: list = []
|
||||
_patch_checkpoint_manager(monkeypatch, _V2_ORPHAN_ONLY_STATUS, prune_calls)
|
||||
|
||||
rc = checkpoints_cli.cmd_prune(_ns())
|
||||
|
||||
assert rc == 0
|
||||
assert len(prune_calls) == 1
|
||||
assert prune_calls[0]["orphan_allowlist"] == set()
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
"""Tests for hermes claw commands."""
|
||||
|
||||
from argparse import Namespace
|
||||
import subprocess
|
||||
from types import ModuleType
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import claw as claw_mod
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _find_migration_script
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFindMigrationScript:
|
||||
"""Test script discovery in known locations."""
|
||||
|
||||
def test_finds_project_root_script(self, tmp_path):
|
||||
script = tmp_path / "openclaw_to_hermes.py"
|
||||
script.write_text("# placeholder")
|
||||
with patch.object(claw_mod, "_OPENCLAW_SCRIPT", script):
|
||||
assert claw_mod._find_migration_script() == script
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _find_openclaw_dirs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFindOpenclawDirs:
|
||||
"""Test discovery of OpenClaw directories."""
|
||||
|
||||
def test_finds_openclaw_dir(self, tmp_path):
|
||||
openclaw = tmp_path / ".openclaw"
|
||||
openclaw.mkdir()
|
||||
with patch("pathlib.Path.home", return_value=tmp_path):
|
||||
found = claw_mod._find_openclaw_dirs()
|
||||
assert openclaw in found
|
||||
|
||||
def test_finds_legacy_dirs(self, tmp_path):
|
||||
clawdbot = tmp_path / ".clawdbot"
|
||||
clawdbot.mkdir()
|
||||
moltbot = tmp_path / ".moltbot"
|
||||
moltbot.mkdir()
|
||||
with patch("pathlib.Path.home", return_value=tmp_path):
|
||||
found = claw_mod._find_openclaw_dirs()
|
||||
assert len(found) == 2
|
||||
assert clawdbot in found
|
||||
assert moltbot in found
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _scan_workspace_state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestScanWorkspaceState:
|
||||
"""Test scanning for workspace state files."""
|
||||
|
||||
def test_finds_root_state_files(self, tmp_path):
|
||||
(tmp_path / "todo.json").write_text("{}")
|
||||
(tmp_path / "sessions").mkdir()
|
||||
findings = claw_mod._scan_workspace_state(tmp_path)
|
||||
descs = [desc for _, desc in findings]
|
||||
assert any("todo.json" in d for d in descs)
|
||||
assert any("sessions" in d for d in descs)
|
||||
|
||||
|
||||
def test_ignores_hidden_dirs(self, tmp_path):
|
||||
scan_dir = tmp_path / "scan_target"
|
||||
scan_dir.mkdir()
|
||||
hidden = scan_dir / ".git"
|
||||
hidden.mkdir()
|
||||
(hidden / "todo.json").write_text("{}")
|
||||
findings = claw_mod._scan_workspace_state(scan_dir)
|
||||
assert len(findings) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _archive_directory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestArchiveDirectory:
|
||||
"""Test directory archival (rename)."""
|
||||
|
||||
def test_renames_to_pre_migration(self, tmp_path):
|
||||
source = tmp_path / ".openclaw"
|
||||
source.mkdir()
|
||||
(source / "test.txt").write_text("data")
|
||||
|
||||
archive_path = claw_mod._archive_directory(source)
|
||||
assert archive_path == tmp_path / ".openclaw.pre-migration"
|
||||
assert archive_path.is_dir()
|
||||
assert not source.exists()
|
||||
assert (archive_path / "test.txt").read_text() == "data"
|
||||
|
||||
def test_adds_timestamp_when_archive_exists(self, tmp_path):
|
||||
source = tmp_path / ".openclaw"
|
||||
source.mkdir()
|
||||
# Pre-existing archive
|
||||
(tmp_path / ".openclaw.pre-migration").mkdir()
|
||||
|
||||
archive_path = claw_mod._archive_directory(source)
|
||||
assert ".pre-migration-" in archive_path.name
|
||||
assert archive_path.is_dir()
|
||||
assert not source.exists()
|
||||
|
||||
def test_dry_run_does_not_rename(self, tmp_path):
|
||||
source = tmp_path / ".openclaw"
|
||||
source.mkdir()
|
||||
|
||||
archive_path = claw_mod._archive_directory(source, dry_run=True)
|
||||
assert archive_path == tmp_path / ".openclaw.pre-migration"
|
||||
assert source.is_dir() # Still exists
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# claw_command routing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestClawCommand:
|
||||
"""Test the claw_command router."""
|
||||
|
||||
def test_routes_to_migrate(self):
|
||||
args = Namespace(claw_action="migrate", source=None, dry_run=True,
|
||||
preset="full", overwrite=False, migrate_secrets=False,
|
||||
workspace_target=None, skill_conflict="skip", yes=False)
|
||||
with patch.object(claw_mod, "_cmd_migrate") as mock:
|
||||
claw_mod.claw_command(args)
|
||||
mock.assert_called_once_with(args)
|
||||
|
||||
|
||||
def test_shows_help_for_no_action(self, capsys):
|
||||
args = Namespace(claw_action=None)
|
||||
claw_mod.claw_command(args)
|
||||
captured = capsys.readouterr()
|
||||
assert "migrate" in captured.out
|
||||
assert "cleanup" in captured.out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _cmd_migrate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCmdMigrate:
|
||||
"""Test the migrate command handler."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_openclaw_running(self):
|
||||
with patch.object(claw_mod, "_detect_openclaw_processes", return_value=[]):
|
||||
yield
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_handles_migration_error(self, tmp_path, capsys):
|
||||
openclaw_dir = tmp_path / ".openclaw"
|
||||
openclaw_dir.mkdir()
|
||||
config_path = tmp_path / "config.yaml"
|
||||
config_path.write_text("")
|
||||
|
||||
args = Namespace(
|
||||
source=str(openclaw_dir),
|
||||
dry_run=True, preset="full", overwrite=False,
|
||||
migrate_secrets=False, workspace_target=None,
|
||||
skill_conflict="skip", yes=False,
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
|
||||
patch.object(claw_mod, "_load_migration_module", side_effect=RuntimeError("boom")),
|
||||
patch.object(claw_mod, "get_config_path", return_value=config_path),
|
||||
patch.object(claw_mod, "save_config"),
|
||||
patch.object(claw_mod, "load_config", return_value={}),
|
||||
):
|
||||
claw_mod._cmd_migrate(args)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Could not load migration script" in captured.out
|
||||
|
||||
def test_full_preset_does_not_enable_secrets_silently(self, tmp_path, capsys):
|
||||
"""The 'full' preset must NOT auto-enable migrate_secrets.
|
||||
|
||||
Users have to opt in to secret import explicitly via --migrate-secrets,
|
||||
even under the 'full' preset. This mirrors OpenClaw's migrate-hermes
|
||||
posture (two-phase import) and prevents a 'full' run from silently
|
||||
copying API keys.
|
||||
"""
|
||||
openclaw_dir = tmp_path / ".openclaw"
|
||||
openclaw_dir.mkdir()
|
||||
|
||||
fake_mod = ModuleType("openclaw_to_hermes")
|
||||
fake_mod.resolve_selected_options = MagicMock(return_value=set())
|
||||
fake_migrator = MagicMock()
|
||||
fake_migrator.migrate.return_value = {
|
||||
"summary": {"migrated": 0, "skipped": 0, "conflict": 0, "error": 0},
|
||||
"items": [],
|
||||
}
|
||||
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
|
||||
|
||||
args = Namespace(
|
||||
source=str(openclaw_dir),
|
||||
dry_run=True, preset="full", overwrite=False,
|
||||
migrate_secrets=False, # Not explicitly set by user
|
||||
workspace_target=None,
|
||||
skill_conflict="skip", yes=False,
|
||||
no_backup=False,
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
|
||||
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
|
||||
patch.object(claw_mod, "get_config_path", return_value=tmp_path / "config.yaml"),
|
||||
patch.object(claw_mod, "save_config"),
|
||||
patch.object(claw_mod, "load_config", return_value={}),
|
||||
):
|
||||
claw_mod._cmd_migrate(args)
|
||||
|
||||
# Migrator should have been called with migrate_secrets=False — the
|
||||
# 'full' preset on its own no longer opts the user into secret import.
|
||||
call_kwargs = fake_mod.Migrator.call_args[1]
|
||||
assert call_kwargs["migrate_secrets"] is False
|
||||
|
||||
def test_full_preset_with_explicit_migrate_secrets_passes_through(self, tmp_path, capsys):
|
||||
"""Explicit --migrate-secrets still works under --preset full."""
|
||||
openclaw_dir = tmp_path / ".openclaw"
|
||||
openclaw_dir.mkdir()
|
||||
|
||||
fake_mod = ModuleType("openclaw_to_hermes")
|
||||
fake_mod.resolve_selected_options = MagicMock(return_value=set())
|
||||
fake_migrator = MagicMock()
|
||||
fake_migrator.migrate.return_value = {
|
||||
"summary": {"migrated": 0, "skipped": 0, "conflict": 0, "error": 0},
|
||||
"items": [],
|
||||
}
|
||||
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
|
||||
|
||||
args = Namespace(
|
||||
source=str(openclaw_dir),
|
||||
dry_run=True, preset="full", overwrite=False,
|
||||
migrate_secrets=True, # Explicitly requested
|
||||
workspace_target=None,
|
||||
skill_conflict="skip", yes=False,
|
||||
no_backup=False,
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
|
||||
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
|
||||
patch.object(claw_mod, "get_config_path", return_value=tmp_path / "config.yaml"),
|
||||
patch.object(claw_mod, "save_config"),
|
||||
patch.object(claw_mod, "load_config", return_value={}),
|
||||
):
|
||||
claw_mod._cmd_migrate(args)
|
||||
|
||||
call_kwargs = fake_mod.Migrator.call_args[1]
|
||||
assert call_kwargs["migrate_secrets"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _cmd_cleanup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCmdCleanup:
|
||||
"""Test the cleanup command handler."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_openclaw_running(self):
|
||||
with patch.object(claw_mod, "_detect_openclaw_processes", return_value=[]):
|
||||
yield
|
||||
|
||||
|
||||
def test_dry_run_lists_dirs(self, tmp_path, capsys):
|
||||
openclaw = tmp_path / ".openclaw"
|
||||
openclaw.mkdir()
|
||||
ws = openclaw / "workspace"
|
||||
ws.mkdir()
|
||||
(ws / "todo.json").write_text("{}")
|
||||
|
||||
args = Namespace(source=None, dry_run=True, yes=False)
|
||||
with patch.object(claw_mod, "_find_openclaw_dirs", return_value=[openclaw]):
|
||||
claw_mod._cmd_cleanup(args)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Would archive" in captured.out
|
||||
assert openclaw.is_dir() # Not actually archived
|
||||
|
||||
|
||||
def test_explicit_source(self, tmp_path, capsys):
|
||||
custom_dir = tmp_path / "my-openclaw"
|
||||
custom_dir.mkdir()
|
||||
(custom_dir / "todo.json").write_text("{}")
|
||||
|
||||
args = Namespace(source=str(custom_dir), dry_run=False, yes=True)
|
||||
claw_mod._cmd_cleanup(args)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Archived" in captured.out
|
||||
assert not custom_dir.exists()
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _print_migration_report
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPrintMigrationReport:
|
||||
"""Test the report formatting function."""
|
||||
|
||||
def test_dry_run_report(self, capsys):
|
||||
report = {
|
||||
"summary": {"migrated": 2, "skipped": 1, "conflict": 1, "error": 0},
|
||||
"items": [
|
||||
{"kind": "soul", "status": "migrated", "destination": "/home/user/.hermes/SOUL.md"},
|
||||
{"kind": "memory", "status": "migrated", "destination": "/home/user/.hermes/memories/MEMORY.md"},
|
||||
{"kind": "skills", "status": "conflict", "reason": "already exists"},
|
||||
{"kind": "tts-assets", "status": "skipped", "reason": "not found"},
|
||||
],
|
||||
"preset": "full",
|
||||
}
|
||||
claw_mod._print_migration_report(report, dry_run=True)
|
||||
captured = capsys.readouterr()
|
||||
assert "Dry Run Results" in captured.out
|
||||
assert "Would migrate" in captured.out
|
||||
assert "2 would migrate" in captured.out
|
||||
assert "--dry-run" in captured.out
|
||||
|
||||
|
||||
def test_empty_report(self, capsys):
|
||||
report = {
|
||||
"summary": {"migrated": 0, "skipped": 0, "conflict": 0, "error": 0},
|
||||
"items": [],
|
||||
}
|
||||
claw_mod._print_migration_report(report, dry_run=False)
|
||||
captured = capsys.readouterr()
|
||||
assert "Nothing to migrate" in captured.out
|
||||
|
||||
|
||||
class TestDetectOpenclawProcesses:
|
||||
def test_returns_match_when_pgrep_finds_openclaw(self):
|
||||
with patch.object(claw_mod, "subprocess") as mock_subprocess:
|
||||
# systemd check misses, pgrep finds openclaw
|
||||
mock_subprocess.run.side_effect = [
|
||||
MagicMock(returncode=1, stdout=""), # systemctl
|
||||
MagicMock(returncode=0, stdout="1234\n"), # pgrep
|
||||
]
|
||||
mock_subprocess.TimeoutExpired = subprocess.TimeoutExpired
|
||||
result = claw_mod._detect_openclaw_processes()
|
||||
assert len(result) == 1
|
||||
assert "1234" in result[0]
|
||||
|
||||
|
||||
@pytest.mark.windows_only
|
||||
def test_returns_empty_on_windows_when_nothing_found(self):
|
||||
"""Faking win32 picked the tasklist/powershell branch on a host that has
|
||||
neither; only a real Windows host resolves those executables.
|
||||
|
||||
``return_value`` rather than a ``side_effect`` list: the branch's call
|
||||
count is not the assertion, and pinning it breaks whenever the host
|
||||
shells out once more than the dev box did.
|
||||
"""
|
||||
with patch.object(claw_mod, "subprocess") as mock_subprocess:
|
||||
mock_subprocess.run.return_value = MagicMock(returncode=0, stdout="")
|
||||
result = claw_mod._detect_openclaw_processes()
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestWarnIfOpenclawRunning:
|
||||
def test_noop_when_not_running(self, capsys):
|
||||
with patch.object(claw_mod, "_detect_openclaw_processes", return_value=[]):
|
||||
claw_mod._warn_if_openclaw_running(auto_yes=False)
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == ""
|
||||
|
||||
def test_warns_and_exits_when_running_and_user_declines(self, capsys):
|
||||
with patch.object(claw_mod, "_detect_openclaw_processes", return_value=["openclaw process(es) (PIDs: 1234)"]):
|
||||
with patch.object(claw_mod, "prompt_yes_no", return_value=False):
|
||||
with patch.object(claw_mod.sys.stdin, "isatty", return_value=True):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
claw_mod._warn_if_openclaw_running(auto_yes=False)
|
||||
assert exc_info.value.code == 0
|
||||
captured = capsys.readouterr()
|
||||
assert "OpenClaw appears to be running" in captured.out
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Tests for _clear_stale_openai_base_url() cleanup after provider switch (#5161)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from hermes_cli.config import load_config, save_config, save_env_value, get_env_value
|
||||
|
||||
|
||||
def _write_provider(provider: str, model: str = "test-model"):
|
||||
"""Helper: write a provider + model to config.yaml."""
|
||||
cfg = load_config()
|
||||
model_cfg = cfg.get("model", {})
|
||||
if not isinstance(model_cfg, dict):
|
||||
model_cfg = {}
|
||||
model_cfg["provider"] = provider
|
||||
model_cfg["default"] = model
|
||||
cfg["model"] = model_cfg
|
||||
save_config(cfg)
|
||||
|
||||
|
||||
class TestClearStaleOpenaiBaseUrl:
|
||||
"""_clear_stale_openai_base_url() removes OPENAI_BASE_URL when provider is not custom."""
|
||||
|
||||
def test_clears_when_provider_is_named(self, monkeypatch):
|
||||
"""OPENAI_BASE_URL is cleared when config provider is a named provider."""
|
||||
from hermes_cli.main import _clear_stale_openai_base_url
|
||||
|
||||
_write_provider("openrouter")
|
||||
save_env_value("OPENAI_BASE_URL", "http://localhost:11434/v1")
|
||||
|
||||
_clear_stale_openai_base_url()
|
||||
|
||||
result = get_env_value("OPENAI_BASE_URL")
|
||||
assert not result, f"Expected OPENAI_BASE_URL to be cleared, got: {result!r}"
|
||||
|
||||
def test_preserves_when_provider_is_custom(self, monkeypatch):
|
||||
"""OPENAI_BASE_URL is NOT cleared when config provider is 'custom'."""
|
||||
from hermes_cli.main import _clear_stale_openai_base_url
|
||||
|
||||
_write_provider("custom")
|
||||
save_env_value("OPENAI_BASE_URL", "http://localhost:11434/v1")
|
||||
|
||||
_clear_stale_openai_base_url()
|
||||
|
||||
result = get_env_value("OPENAI_BASE_URL")
|
||||
assert result == "http://localhost:11434/v1", \
|
||||
f"Expected OPENAI_BASE_URL to be preserved, got: {result!r}"
|
||||
|
||||
|
||||
@@ -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 len(printed) == 1
|
||||
assert "active session limit (1/1)" in printed[0]
|
||||
# Names the holding surface ("tui"), not the blocked one.
|
||||
assert "Held by: tui" in printed[0]
|
||||
|
||||
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,125 @@
|
||||
"""End-to-end CLI coverage for named custom-provider vision routing.
|
||||
|
||||
Adapted from the independently reproduced CLI regression in #69896. Unlike
|
||||
that PR, these tests keep the live transport canonical (``provider=custom``)
|
||||
and assert that the separate requested identity reaches each capability gate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from hermes_cli.cli_agent_setup_mixin import CLIAgentSetupMixin
|
||||
|
||||
|
||||
MODEL = "qwen3.8-max-preview"
|
||||
REQUESTED_PROVIDER = "custom:qwen-token-plan"
|
||||
|
||||
|
||||
class _RuntimeCLI(CLIAgentSetupMixin):
|
||||
def __init__(self, *, model: str, provider: str):
|
||||
self.model = model
|
||||
self.requested_provider = provider
|
||||
self.provider = provider
|
||||
self.api_key = None
|
||||
self.base_url = None
|
||||
self.api_mode = "chat_completions"
|
||||
self.acp_command = None
|
||||
self.acp_args = []
|
||||
self.agent = None
|
||||
self._fallback_model = []
|
||||
self._explicit_api_key = None
|
||||
self._explicit_base_url = None
|
||||
self._credential_pool = None
|
||||
self.service_tier = None
|
||||
|
||||
def _normalize_model_for_provider(self, _provider: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _write_profile_config(hermes_home) -> None:
|
||||
(hermes_home / "config.yaml").write_text(
|
||||
"""
|
||||
model:
|
||||
default: ollama-cloud/glm-5.2
|
||||
provider: ollama-cloud
|
||||
providers:
|
||||
qwen-token-plan:
|
||||
base_url: https://qwen-token-plan.example/v1
|
||||
api_key: test-key
|
||||
models:
|
||||
qwen3.8-max-preview:
|
||||
supports_vision: true
|
||||
agent:
|
||||
image_input_mode: auto
|
||||
""".strip()
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _resolve_cli_route():
|
||||
from hermes_cli._parser import build_top_level_parser
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
_write_profile_config(get_hermes_home())
|
||||
parser, _subparsers, _chat = build_top_level_parser()
|
||||
args, _unknown = parser.parse_known_args(
|
||||
["-m", MODEL, "--provider", REQUESTED_PROVIDER, "chat"]
|
||||
)
|
||||
cli = _RuntimeCLI(model=args.model, provider=args.provider)
|
||||
assert cli._ensure_runtime_credentials() is True
|
||||
return cli, cli._resolve_turn_agent_config("inspect the image")
|
||||
|
||||
|
||||
def test_real_cli_args_keep_transport_and_capability_identities_separate():
|
||||
from agent.image_routing import decide_image_input_mode
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
cli, route = _resolve_cli_route()
|
||||
runtime = route["runtime"]
|
||||
|
||||
assert cli.provider == "custom"
|
||||
assert cli.requested_provider == REQUESTED_PROVIDER
|
||||
assert runtime["provider"] == "custom"
|
||||
assert runtime["requested_provider"] == REQUESTED_PROVIDER
|
||||
assert decide_image_input_mode(
|
||||
runtime["provider"],
|
||||
route["model"],
|
||||
load_config(),
|
||||
requested_provider=runtime["requested_provider"],
|
||||
) == "native"
|
||||
|
||||
# A credential refresh with the same two identities must retain the live
|
||||
# agent instead of treating canonicalization as a provider switch.
|
||||
sentinel_agent = SimpleNamespace()
|
||||
cli.agent = sentinel_agent
|
||||
assert cli._ensure_runtime_credentials() is True
|
||||
assert cli.agent is sentinel_agent
|
||||
|
||||
|
||||
def test_named_identity_reaches_agent_and_vision_tool_native_gates():
|
||||
from agent.auxiliary_client import reset_runtime_main, set_runtime_main
|
||||
from run_agent import AIAgent
|
||||
from tools.vision_tools import _should_use_native_vision_fast_path
|
||||
|
||||
_cli, route = _resolve_cli_route()
|
||||
runtime = route["runtime"]
|
||||
token = set_runtime_main(
|
||||
runtime["provider"],
|
||||
route["model"],
|
||||
requested_provider=runtime["requested_provider"],
|
||||
base_url=runtime["base_url"],
|
||||
api_key=runtime["api_key"],
|
||||
api_mode=runtime["api_mode"],
|
||||
)
|
||||
try:
|
||||
agent = AIAgent.__new__(AIAgent)
|
||||
agent.provider = runtime["provider"]
|
||||
agent.requested_provider = runtime["requested_provider"]
|
||||
agent.model = route["model"]
|
||||
|
||||
assert agent._model_supports_vision() is True
|
||||
assert _should_use_native_vision_fast_path() is True
|
||||
finally:
|
||||
reset_runtime_main(token)
|
||||
@@ -0,0 +1,151 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from hermes_cli.model_switch import ModelSwitchResult
|
||||
|
||||
|
||||
class _FakeAgent:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
self.model = "old/model"
|
||||
self.provider = "openrouter"
|
||||
|
||||
def switch_model(self, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
self.model = kwargs["new_model"]
|
||||
self.provider = kwargs["new_provider"]
|
||||
|
||||
|
||||
class _StubCLI:
|
||||
model = "old/model"
|
||||
provider = "openrouter"
|
||||
requested_provider = "openrouter"
|
||||
api_key = "sk-old"
|
||||
_explicit_api_key = "sk-old"
|
||||
base_url = "https://openrouter.ai/api/v1"
|
||||
_explicit_base_url = "https://openrouter.ai/api/v1"
|
||||
api_mode = "chat_completions"
|
||||
agent = None
|
||||
_pending_model_switch_note = None
|
||||
_pending_one_turn_model_restore = None
|
||||
|
||||
def _confirm_expensive_model_switch(self, result):
|
||||
return True
|
||||
|
||||
def _confirm_and_apply_cli_model_switch(
|
||||
self, result, persist_global, one_turn, custom_provs=None
|
||||
):
|
||||
import cli as cli_mod
|
||||
|
||||
return cli_mod.HermesCLI._confirm_and_apply_cli_model_switch(
|
||||
self, result, persist_global, one_turn, custom_provs
|
||||
)
|
||||
|
||||
|
||||
def test_cli_model_once_records_restore_and_does_not_persist(monkeypatch):
|
||||
import cli as cli_mod
|
||||
|
||||
stub = _StubCLI()
|
||||
stub.agent = _FakeAgent()
|
||||
stub._snapshot_model_runtime = cli_mod.HermesCLI._snapshot_model_runtime.__get__(stub)
|
||||
printed = []
|
||||
|
||||
monkeypatch.setattr(cli_mod, "_cprint", lambda s, *a, **k: printed.append(str(s)))
|
||||
monkeypatch.setattr(cli_mod, "save_config_value", lambda *a, **k: (_ for _ in ()).throw(AssertionError("should not persist")))
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.inventory.load_picker_context",
|
||||
lambda: SimpleNamespace(
|
||||
user_providers=None,
|
||||
custom_providers=None,
|
||||
with_overrides=lambda **_: SimpleNamespace(user_providers=None, custom_providers=None),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.model_switch.switch_model",
|
||||
lambda **_: ModelSwitchResult(
|
||||
success=True,
|
||||
new_model="claude-sonnet-4.6",
|
||||
target_provider="anthropic",
|
||||
api_key="sk-ant",
|
||||
base_url="https://api.anthropic.com",
|
||||
api_mode="anthropic_messages",
|
||||
provider_label="Anthropic",
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr("hermes_cli.model_switch.resolve_display_context_length", lambda *a, **k: None)
|
||||
|
||||
cli_mod.HermesCLI._handle_model_switch(
|
||||
stub,
|
||||
"/model claude-sonnet-4.6 --provider anthropic --once",
|
||||
)
|
||||
|
||||
assert stub.model == "claude-sonnet-4.6"
|
||||
assert stub.provider == "anthropic"
|
||||
assert stub.agent.calls[-1]["new_model"] == "claude-sonnet-4.6"
|
||||
assert stub._pending_one_turn_model_restore["model"] == "old/model"
|
||||
assert "next turn only" in printed[-1]
|
||||
|
||||
|
||||
def test_cli_restore_model_runtime_snapshot_restores_agent():
|
||||
import cli as cli_mod
|
||||
|
||||
stub = _StubCLI()
|
||||
stub.agent = _FakeAgent()
|
||||
snapshot = {
|
||||
"model": "old/model",
|
||||
"provider": "openrouter",
|
||||
"requested_provider": "openrouter",
|
||||
"api_key": "sk-old",
|
||||
"explicit_api_key": "sk-old",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"explicit_base_url": "https://openrouter.ai/api/v1",
|
||||
"api_mode": "chat_completions",
|
||||
}
|
||||
|
||||
cli_mod.HermesCLI._restore_model_runtime_snapshot(stub, snapshot)
|
||||
|
||||
assert stub.model == "old/model"
|
||||
assert stub.provider == "openrouter"
|
||||
assert stub.agent.calls[-1]["new_model"] == "old/model"
|
||||
|
||||
|
||||
def test_cli_restore_model_runtime_prefers_primary_runtime():
|
||||
import cli as cli_mod
|
||||
|
||||
class Agent(_FakeAgent):
|
||||
_primary_runtime = None
|
||||
_rate_limited_until = 123
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.model = "temp/model"
|
||||
self.provider = "anthropic"
|
||||
|
||||
def _restore_primary_runtime(self):
|
||||
self.model = self._primary_runtime["model"]
|
||||
self.provider = self._primary_runtime["provider"]
|
||||
return True
|
||||
|
||||
stub = _StubCLI()
|
||||
stub.agent = Agent()
|
||||
snapshot = {
|
||||
"model": "old/model",
|
||||
"provider": "openrouter",
|
||||
"requested_provider": "openrouter",
|
||||
"api_key": "sk-old",
|
||||
"explicit_api_key": "sk-old",
|
||||
"base_url": "",
|
||||
"explicit_base_url": "",
|
||||
"api_mode": "chat_completions",
|
||||
"agent_primary_runtime": {
|
||||
"model": "old/model",
|
||||
"provider": "openrouter",
|
||||
},
|
||||
}
|
||||
|
||||
cli_mod.HermesCLI._restore_model_runtime_snapshot(stub, snapshot)
|
||||
|
||||
assert stub.agent.model == "old/model"
|
||||
assert stub.agent.provider == "openrouter"
|
||||
assert stub.agent.calls == []
|
||||
@@ -0,0 +1,113 @@
|
||||
from hermes_cli import cli_output
|
||||
|
||||
|
||||
class _TTY:
|
||||
def isatty(self):
|
||||
return True
|
||||
|
||||
|
||||
class _NotTTY:
|
||||
def isatty(self):
|
||||
return False
|
||||
|
||||
|
||||
def test_line_input_supports_cursor_and_emacs_navigation(monkeypatch):
|
||||
from prompt_toolkit.application.current import create_app_session
|
||||
from prompt_toolkit.input.defaults import create_pipe_input
|
||||
from prompt_toolkit.output import DummyOutput
|
||||
|
||||
monkeypatch.setattr(cli_output.sys, "stdin", _TTY())
|
||||
monkeypatch.setattr(cli_output.sys, "stdout", _TTY())
|
||||
|
||||
with create_pipe_input() as pipe_input:
|
||||
# Type "ac", move left, insert "b", move right, append "d", then
|
||||
# exercise Ctrl+A/Ctrl+E before accepting the line.
|
||||
pipe_input.send_text("ac\x1b[Db\x1b[Cd\x01X\x05Y\r")
|
||||
with create_app_session(input=pipe_input, output=DummyOutput()):
|
||||
assert cli_output.line_input("Enter model name: ") == "XabcdY"
|
||||
|
||||
|
||||
def test_line_input_preserves_builtin_input_for_redirected_stdin(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def fake_input(prompt_text):
|
||||
seen["prompt"] = prompt_text
|
||||
return "piped-model"
|
||||
|
||||
monkeypatch.setattr(cli_output.sys, "stdin", _NotTTY())
|
||||
monkeypatch.setattr(cli_output.sys, "stdout", _TTY())
|
||||
monkeypatch.setattr("builtins.input", fake_input)
|
||||
|
||||
assert cli_output.line_input("Enter model name: ") == "piped-model"
|
||||
assert seen["prompt"] == "Enter model name: "
|
||||
|
||||
|
||||
def test_line_input_falls_back_to_input_when_prompt_toolkit_raises_oserror(monkeypatch):
|
||||
"""isatty() can report True while the asyncio selector still rejects stdin.
|
||||
|
||||
Under a `curl ... | bash` install the setup wizard reattaches stdin from
|
||||
/dev/tty, so isatty() is True and the guard above passes. prompt_toolkit
|
||||
then fails to register fd 0 with the event-loop selector (observed on
|
||||
macOS, where kqueue raises OSError EINVAL / "Invalid argument"). line_input
|
||||
must degrade to the built-in reader instead of letting the OSError abort
|
||||
the whole wizard.
|
||||
"""
|
||||
seen = {}
|
||||
|
||||
def raising_prompt(*_args, **_kwargs):
|
||||
raise OSError(22, "Invalid argument")
|
||||
|
||||
def fake_input(prompt_text):
|
||||
seen["prompt"] = prompt_text
|
||||
return "fallback-value"
|
||||
|
||||
monkeypatch.setattr(cli_output.sys, "stdin", _TTY())
|
||||
monkeypatch.setattr(cli_output.sys, "stdout", _TTY())
|
||||
monkeypatch.setattr("prompt_toolkit.prompt", raising_prompt)
|
||||
monkeypatch.setattr("builtins.input", fake_input)
|
||||
|
||||
assert cli_output.line_input("Choice [1/2]: ") == "fallback-value"
|
||||
assert seen["prompt"] == "Choice [1/2]: "
|
||||
|
||||
|
||||
def test_line_input_falls_back_to_input_on_any_prompt_toolkit_failure(monkeypatch):
|
||||
"""Any prompt_toolkit runtime failure — not just OSError — degrades to
|
||||
input() so the wizard keeps running. ValueError and RuntimeError can
|
||||
arise from exotic stream wrappers or event-loop issues that share the
|
||||
same root cause: prompt_toolkit cannot attach stdin on this terminal.
|
||||
"""
|
||||
seen = {}
|
||||
|
||||
def raising_prompt(*_args, **_kwargs):
|
||||
raise ValueError("Invalid file descriptor")
|
||||
|
||||
def fake_input(prompt_text):
|
||||
seen["prompt"] = prompt_text
|
||||
return "fallback-value"
|
||||
|
||||
monkeypatch.setattr(cli_output.sys, "stdin", _TTY())
|
||||
monkeypatch.setattr(cli_output.sys, "stdout", _TTY())
|
||||
monkeypatch.setattr("prompt_toolkit.prompt", raising_prompt)
|
||||
monkeypatch.setattr("builtins.input", fake_input)
|
||||
|
||||
assert cli_output.line_input("Enter token: ") == "fallback-value"
|
||||
assert seen["prompt"] == "Enter token: "
|
||||
|
||||
|
||||
def test_password_prompt_uses_masked_secret_prompt(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def fake_masked_secret_prompt(display):
|
||||
seen["display"] = display
|
||||
return " secret "
|
||||
|
||||
monkeypatch.setattr(cli_output, "masked_secret_prompt", fake_masked_secret_prompt)
|
||||
|
||||
assert cli_output.prompt("API key", default="old", password=True) == "secret"
|
||||
assert "API key [old]" in seen["display"]
|
||||
|
||||
|
||||
def test_empty_password_prompt_returns_default(monkeypatch):
|
||||
monkeypatch.setattr(cli_output, "masked_secret_prompt", lambda _display: "")
|
||||
|
||||
assert cli_output.prompt("API key", default="old", password=True) == "old"
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Chat startup must explain a mixed-version ImportError (#96900).
|
||||
|
||||
A half-updated install can ship a newer ``cli.py`` that imports
|
||||
``resolve_turn_limit`` / ``split_model_config_default`` from
|
||||
``hermes_cli.config`` while the older ``config.py`` does not export them.
|
||||
Construction of ``HermesCLI`` then dies before the agent-setup mixin can
|
||||
print ``partial_update_hint``. ``cmd_chat`` is the load-bearing catch:
|
||||
bare ``hermes`` and ``hermes chat`` (including the fast-chat launch path)
|
||||
all go through it.
|
||||
"""
|
||||
|
||||
from argparse import Namespace
|
||||
import io
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_constants import emit_partial_update_hint, partial_update_hint
|
||||
|
||||
|
||||
def _chat_args(**overrides):
|
||||
base = {
|
||||
"continue_last": None,
|
||||
"model": None,
|
||||
"provider": None,
|
||||
"resume": None,
|
||||
"no_restore_cwd": False,
|
||||
"toolsets": None,
|
||||
"skills": None,
|
||||
"tui": False,
|
||||
"tui_dev": False,
|
||||
"cli": True,
|
||||
"verbose": None,
|
||||
"quiet": True,
|
||||
"query": "hello",
|
||||
"image": None,
|
||||
"worktree": False,
|
||||
"checkpoints": False,
|
||||
"pass_session_id": False,
|
||||
"max_turns": None,
|
||||
"ignore_rules": False,
|
||||
"ignore_user_config": False,
|
||||
"safe_mode": False,
|
||||
"compact": False,
|
||||
"source": None,
|
||||
"yolo": False,
|
||||
"accept_hooks": False,
|
||||
}
|
||||
base.update(overrides)
|
||||
return Namespace(**base)
|
||||
|
||||
|
||||
def _missing_config_name_error(name: str = "resolve_turn_limit") -> ImportError:
|
||||
exc = ImportError(
|
||||
f"cannot import name '{name}' from 'hermes_cli.config'"
|
||||
)
|
||||
exc.name = "hermes_cli.config"
|
||||
return exc
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def main_mod(monkeypatch):
|
||||
import hermes_cli.main as mod
|
||||
|
||||
monkeypatch.setattr(mod, "_has_any_provider_configured", lambda: True)
|
||||
monkeypatch.setattr(mod, "_sync_bundled_skills_for_startup", lambda: None)
|
||||
monkeypatch.setattr(mod, "_termux_should_prefetch_update_check", lambda: False)
|
||||
monkeypatch.setattr(mod, "_pin_kanban_board_env", lambda: None)
|
||||
monkeypatch.setattr(mod, "_resolve_session_by_name_or_id", lambda val: val)
|
||||
return mod
|
||||
|
||||
|
||||
def test_emit_hint_for_missing_resolve_turn_limit():
|
||||
exc = _missing_config_name_error("resolve_turn_limit")
|
||||
buf = io.StringIO()
|
||||
|
||||
assert emit_partial_update_hint(exc, file=buf) is True
|
||||
text = buf.getvalue()
|
||||
assert "resolve_turn_limit" in text
|
||||
assert "hermes update" in text
|
||||
assert "partially-updated" in text
|
||||
|
||||
|
||||
def test_emit_hint_for_missing_split_model_config_default():
|
||||
exc = _missing_config_name_error("split_model_config_default")
|
||||
assert partial_update_hint(exc)
|
||||
buf = io.StringIO()
|
||||
assert emit_partial_update_hint(exc, file=buf) is True
|
||||
assert "hermes update" in buf.getvalue()
|
||||
|
||||
|
||||
def test_emit_hint_stays_silent_for_third_party_import_error():
|
||||
exc = ImportError("cannot import name 'dumps' from 'requests'")
|
||||
exc.name = "requests"
|
||||
buf = io.StringIO()
|
||||
assert emit_partial_update_hint(exc, file=buf) is False
|
||||
assert buf.getvalue() == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
["resolve_turn_limit", "split_model_config_default"],
|
||||
)
|
||||
def test_cmd_chat_prints_update_hint_when_config_helper_is_missing(
|
||||
main_mod, monkeypatch, capsys, name
|
||||
):
|
||||
def boom(**_kwargs):
|
||||
raise _missing_config_name_error(name)
|
||||
|
||||
monkeypatch.setitem(sys.modules, "cli", types.SimpleNamespace(main=boom))
|
||||
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main_mod.cmd_chat(_chat_args())
|
||||
|
||||
assert excinfo.value.code == 1
|
||||
err = capsys.readouterr().err
|
||||
assert name in err
|
||||
assert "hermes update" in err
|
||||
assert "partially-updated" in err
|
||||
|
||||
|
||||
def test_cmd_chat_still_reraises_unrelated_import_errors(main_mod, monkeypatch):
|
||||
exc = ImportError("cannot import name 'dumps' from 'requests'")
|
||||
exc.name = "requests"
|
||||
|
||||
def boom(**_kwargs):
|
||||
raise exc
|
||||
|
||||
monkeypatch.setitem(sys.modules, "cli", types.SimpleNamespace(main=boom))
|
||||
|
||||
with pytest.raises(ImportError) as excinfo:
|
||||
main_mod.cmd_chat(_chat_args())
|
||||
|
||||
assert excinfo.value is exc
|
||||
@@ -0,0 +1,309 @@
|
||||
from argparse import Namespace
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class _NonInteractiveStdin:
|
||||
def isatty(self):
|
||||
return False
|
||||
|
||||
|
||||
def _chat_args(**overrides):
|
||||
base = {
|
||||
"continue_last": None,
|
||||
"model": None,
|
||||
"provider": None,
|
||||
"resume": None,
|
||||
"no_restore_cwd": False,
|
||||
"toolsets": None,
|
||||
"skills": None,
|
||||
"tui": False,
|
||||
"tui_dev": False,
|
||||
"cli": True,
|
||||
"verbose": None,
|
||||
"quiet": True,
|
||||
"query": "hello",
|
||||
"image": None,
|
||||
"worktree": False,
|
||||
"checkpoints": False,
|
||||
"pass_session_id": False,
|
||||
"max_turns": None,
|
||||
"ignore_rules": False,
|
||||
"ignore_user_config": False,
|
||||
"safe_mode": False,
|
||||
"compact": False,
|
||||
"source": None,
|
||||
"yolo": False,
|
||||
"accept_hooks": False,
|
||||
}
|
||||
base.update(overrides)
|
||||
return Namespace(**base)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def main_mod(monkeypatch):
|
||||
import hermes_cli.main as mod
|
||||
|
||||
monkeypatch.setattr(mod, "_has_any_provider_configured", lambda: True)
|
||||
monkeypatch.setattr(mod, "_sync_bundled_skills_for_startup", lambda: None)
|
||||
monkeypatch.setattr(mod, "_termux_should_prefetch_update_check", lambda: False)
|
||||
monkeypatch.setattr(mod, "_pin_kanban_board_env", lambda: None)
|
||||
monkeypatch.setattr(mod, "_resolve_session_by_name_or_id", lambda val: val)
|
||||
monkeypatch.setattr(mod, "_oneshot_cleanup_done", False)
|
||||
return mod
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_cli(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_cli_main(**kwargs):
|
||||
captured.update(kwargs)
|
||||
|
||||
monkeypatch.setitem(sys.modules, "cli", types.SimpleNamespace(main=fake_cli_main))
|
||||
return captured
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def codex_config(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: {
|
||||
"model": {
|
||||
"provider": "openai-codex",
|
||||
"default": "gpt-5.5",
|
||||
"base_url": "https://chatgpt.com/backend-api/codex",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _set_startup_config(
|
||||
monkeypatch, *, provider="custom", default="safe-model", base_url="", ack=None
|
||||
):
|
||||
model = {"provider": provider, "default": default}
|
||||
if base_url:
|
||||
model["base_url"] = base_url
|
||||
config = {"model": model}
|
||||
if ack is not None:
|
||||
config["security"] = {
|
||||
"allow_data_training_tiers_noninteractive": ack,
|
||||
}
|
||||
monkeypatch.setattr("hermes_cli.config.load_config", lambda: config)
|
||||
|
||||
|
||||
def test_cmd_chat_rejects_noninteractive_gpt55_pro_startup_override(
|
||||
main_mod, fake_cli, codex_config, monkeypatch, capsys
|
||||
):
|
||||
monkeypatch.setattr(sys, "stdin", _NonInteractiveStdin())
|
||||
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main_mod.cmd_chat(_chat_args(model="openai/gpt-5.5-pro"))
|
||||
|
||||
assert excinfo.value.code == 1
|
||||
assert not fake_cli
|
||||
err = capsys.readouterr().err
|
||||
assert "EXPENSIVE MODEL WARNING" in err
|
||||
assert "did you mean to select openai/gpt-5.5?" in err
|
||||
assert "non-interactive" in err
|
||||
|
||||
|
||||
def test_cmd_chat_rejects_noninteractive_gpt55_pro_even_with_yolo(
|
||||
main_mod, fake_cli, codex_config, monkeypatch, capsys
|
||||
):
|
||||
monkeypatch.setattr(sys, "stdin", _NonInteractiveStdin())
|
||||
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main_mod.cmd_chat(_chat_args(model="openai/gpt-5.5-pro", yolo=True))
|
||||
|
||||
assert excinfo.value.code == 1
|
||||
assert not fake_cli
|
||||
assert "EXPENSIVE MODEL WARNING" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_cmd_chat_allows_acknowledged_data_training_tier_noninteractively(
|
||||
main_mod, fake_cli, monkeypatch, capsys
|
||||
):
|
||||
monkeypatch.setattr(sys, "stdin", _NonInteractiveStdin())
|
||||
_set_startup_config(monkeypatch, ack=True)
|
||||
|
||||
main_mod.cmd_chat(
|
||||
_chat_args(model="muse-spark-1.2-contributor", provider="custom")
|
||||
)
|
||||
|
||||
assert fake_cli["model"] == "muse-spark-1.2-contributor"
|
||||
err = capsys.readouterr().err
|
||||
assert "TRAINS ON YOUR DATA" in err
|
||||
assert "security.allow_data_training_tiers_noninteractive" in err
|
||||
|
||||
|
||||
def test_cmd_chat_rejects_unacknowledged_data_training_tier_with_opt_in_hint(
|
||||
main_mod, fake_cli, monkeypatch, capsys
|
||||
):
|
||||
monkeypatch.setattr(sys, "stdin", _NonInteractiveStdin())
|
||||
_set_startup_config(monkeypatch)
|
||||
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main_mod.cmd_chat(
|
||||
_chat_args(model="muse-spark-1.2-contributor", provider="custom")
|
||||
)
|
||||
|
||||
assert excinfo.value.code == 1
|
||||
assert not fake_cli
|
||||
err = capsys.readouterr().err
|
||||
assert "TRAINS ON YOUR DATA" in err
|
||||
assert "security.allow_data_training_tiers_noninteractive" in err
|
||||
|
||||
|
||||
def test_data_training_acknowledgement_does_not_bypass_cost_guard(
|
||||
main_mod, fake_cli, monkeypatch, capsys
|
||||
):
|
||||
monkeypatch.setattr(sys, "stdin", _NonInteractiveStdin())
|
||||
_set_startup_config(
|
||||
monkeypatch,
|
||||
provider="openai-codex",
|
||||
default="gpt-5.5",
|
||||
base_url="https://chatgpt.com/backend-api/codex",
|
||||
ack=True,
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main_mod.cmd_chat(_chat_args(model="openai/gpt-5.5-pro"))
|
||||
|
||||
assert excinfo.value.code == 1
|
||||
assert not fake_cli
|
||||
assert "EXPENSIVE MODEL WARNING" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_data_training_acknowledgement_requires_literal_true(
|
||||
main_mod, fake_cli, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(sys, "stdin", _NonInteractiveStdin())
|
||||
_set_startup_config(monkeypatch, ack="true")
|
||||
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main_mod.cmd_chat(
|
||||
_chat_args(model="muse-spark-1.2-contributor", provider="custom")
|
||||
)
|
||||
|
||||
assert excinfo.value.code == 1
|
||||
assert not fake_cli
|
||||
|
||||
|
||||
def test_data_training_acknowledgement_does_not_skip_interactive_confirmation(
|
||||
main_mod, fake_cli, monkeypatch, capsys
|
||||
):
|
||||
monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
|
||||
monkeypatch.setattr("builtins.input", lambda _prompt: "n")
|
||||
_set_startup_config(monkeypatch, ack=True)
|
||||
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main_mod.cmd_chat(
|
||||
_chat_args(model="muse-spark-1.2-contributor", provider="custom")
|
||||
)
|
||||
|
||||
assert excinfo.value.code == 1
|
||||
assert not fake_cli
|
||||
assert "Model override cancelled" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_cmd_chat_allows_interactive_gpt55_pro_when_confirmed(
|
||||
main_mod, fake_cli, codex_config, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
|
||||
monkeypatch.setattr("builtins.input", lambda _prompt: "yes")
|
||||
|
||||
main_mod.cmd_chat(_chat_args(model="openai/gpt-5.5-pro"))
|
||||
|
||||
assert fake_cli["model"] == "openai/gpt-5.5-pro"
|
||||
|
||||
|
||||
def test_cmd_chat_cancels_interactive_gpt55_pro_when_not_confirmed(
|
||||
main_mod, fake_cli, codex_config, monkeypatch, capsys
|
||||
):
|
||||
monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
|
||||
monkeypatch.setattr("builtins.input", lambda _prompt: "n")
|
||||
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main_mod.cmd_chat(_chat_args(model="openai/gpt-5.5-pro"))
|
||||
|
||||
assert excinfo.value.code == 1
|
||||
assert not fake_cli
|
||||
assert "Model override cancelled" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_cmd_chat_cancels_interactive_gpt55_pro_on_eof(
|
||||
main_mod, fake_cli, codex_config, monkeypatch, capsys
|
||||
):
|
||||
monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
|
||||
|
||||
def raise_eof(_prompt):
|
||||
raise EOFError
|
||||
|
||||
monkeypatch.setattr("builtins.input", raise_eof)
|
||||
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main_mod.cmd_chat(_chat_args(model="openai/gpt-5.5-pro"))
|
||||
|
||||
assert excinfo.value.code == 1
|
||||
assert not fake_cli
|
||||
assert "Model override cancelled" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_cmd_chat_rejects_noninteractive_provider_only_override_when_default_is_expensive(
|
||||
main_mod, fake_cli, monkeypatch, capsys
|
||||
):
|
||||
monkeypatch.setattr(sys, "stdin", _NonInteractiveStdin())
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: {"model": {"provider": "openai-codex", "default": "openai/gpt-5.5-pro"}},
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main_mod.cmd_chat(_chat_args(model=None, provider="nous"))
|
||||
|
||||
assert excinfo.value.code == 1
|
||||
assert not fake_cli
|
||||
assert "EXPENSIVE MODEL WARNING" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_cmd_chat_allows_noninteractive_safe_codex_startup_override(
|
||||
main_mod, fake_cli, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(sys, "stdin", _NonInteractiveStdin())
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: {"model": {"provider": "openai-codex", "default": "gpt-5.5"}},
|
||||
)
|
||||
|
||||
main_mod.cmd_chat(_chat_args(model="gpt-5.5", provider="openai-codex"))
|
||||
|
||||
assert fake_cli["model"] == "gpt-5.5"
|
||||
assert fake_cli["provider"] == "openai-codex"
|
||||
|
||||
|
||||
def test_top_level_oneshot_rejects_noninteractive_gpt55_pro_startup_override(
|
||||
main_mod, codex_config, monkeypatch, capsys
|
||||
):
|
||||
monkeypatch.setattr(sys, "stdin", _NonInteractiveStdin())
|
||||
monkeypatch.setattr(sys, "argv", ["hermes", "-z", "hello", "-m", "openai/gpt-5.5-pro"])
|
||||
monkeypatch.setattr(main_mod, "_prepare_agent_startup", lambda _args: None)
|
||||
|
||||
called = False
|
||||
|
||||
def fake_run_and_exit_oneshot(*_args, **_kwargs):
|
||||
nonlocal called
|
||||
called = True
|
||||
|
||||
monkeypatch.setattr(main_mod, "_run_and_exit_oneshot", fake_run_and_exit_oneshot)
|
||||
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
main_mod.main()
|
||||
|
||||
assert excinfo.value.code == 1
|
||||
assert called is False
|
||||
err = capsys.readouterr().err
|
||||
assert "EXPENSIVE MODEL WARNING" in err
|
||||
assert "non-interactive" in err
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user