Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
View File
+56
View File
@@ -0,0 +1,56 @@
"""Fixtures shared across hermes_cli kanban tests."""
from __future__ import annotations
import pytest
@pytest.fixture
def all_assignees_spawnable(monkeypatch):
"""Pretend every assignee maps to a real Hermes profile.
Most dispatcher tests use synthetic assignees ("alice", "bob") that
don't correspond to actual profile directories on disk. Without this
patch, the dispatcher's profile-exists guard (PR #20105) routes
those tasks into ``skipped_nonspawnable`` instead of spawning, which
would break tests that assert spawn behavior.
"""
from hermes_cli import profiles
monkeypatch.setattr(profiles, "profile_exists", lambda name: True)
@pytest.fixture(autouse=True)
def _suppress_concurrent_hermes_gate(request, monkeypatch):
"""Default ``_detect_concurrent_hermes_instances`` to ``[]`` for every test.
The Windows update path now refuses to proceed when another
``hermes.exe`` is detected (issue #26670). On a developer's Windows
machine running the test suite via ``hermes`` itself, this would
flag the running agent as a concurrent instance and abort every
``cmd_update`` test. Tests that want to exercise the gate explicitly
re-patch ``_detect_concurrent_hermes_instances`` with their own
return value — autouse here gives a clean default without touching
the rest of the suite.
Tests that need to call the REAL function (e.g. unit tests for the
helper itself) opt out with ``@pytest.mark.real_concurrent_gate``.
"""
if request.node.get_closest_marker("real_concurrent_gate"):
return
try:
from hermes_cli import main as _cli_main
except Exception:
return
# raising=False: under pytest's per-test spawn isolation, a concurrent
# xdist worker importing a module that transitively touches hermes_cli.main
# can briefly expose a partially-initialized module object here — one where
# _detect_concurrent_hermes_instances isn't defined yet. A bare setattr
# would raise AttributeError and error the (unrelated) test. The attribute
# always exists once main.py finishes importing, so a no-op when it's
# transiently absent is the correct, race-free default.
monkeypatch.setattr(
_cli_main,
"_detect_concurrent_hermes_instances",
lambda *_a, **_k: [],
raising=False,
)
+184
View File
@@ -0,0 +1,184 @@
"""Stub auth provider + shared fixtures for dashboard-auth tests.
NOT a pytest conftest.py — this is an importable helper module. Phase 2
of the dashboard-OAuth plan; used by Phase 3's end-to-end gate tests.
Import via::
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
The stub bounces straight back to the callback with a fake code so tests
can complete the OAuth round trip in-process without external network.
Tokens are HMAC-signed JSON blobs (not real JWTs) — just enough structure
for ``verify_session`` to detect tampering and expiry.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import secrets
import time
from hermes_cli.dashboard_auth.base import (
DashboardAuthProvider,
InvalidCodeError,
LoginStart,
RefreshExpiredError,
Session,
)
_STUB_SECRET = b"stub-test-secret-not-for-prod"
# Length of HMAC-SHA256 digest. We append this many trailing bytes of
# signature after ``raw`` in ``_sign``; ``_unsign`` slices them back off
# rather than splitting on a separator. (A separator byte chosen
# arbitrarily, e.g. ``b"."``, fails ~12% of the time when the HMAC
# digest happens to contain that byte — ``bytes.rsplit`` then splits at
# the wrong index and HMAC verification spuriously rejects the token.)
_SIG_LEN = hashlib.sha256().digest_size
def _sign(payload: dict) -> str:
"""Produce a tamper-evident opaque token.
Not a real JWT — just a base64(JSON || HMAC-SHA256) blob with enough
structure to round-trip through verify_session. The signature is
appended as a fixed-length suffix (no separator) so binary HMAC bytes
can't be confused with a delimiter.
"""
raw = json.dumps(payload, separators=(",", ":")).encode()
sig = hmac.new(_STUB_SECRET, raw, hashlib.sha256).digest()
return base64.urlsafe_b64encode(raw + sig).decode()
def _unsign(token: str) -> dict | None:
"""Inverse of ``_sign``; returns None on any tamper/decode failure."""
try:
blob = base64.urlsafe_b64decode(token.encode())
if len(blob) <= _SIG_LEN:
return None
raw, sig = blob[:-_SIG_LEN], blob[-_SIG_LEN:]
expected = hmac.new(_STUB_SECRET, raw, hashlib.sha256).digest()
if not hmac.compare_digest(sig, expected):
return None
return json.loads(raw)
except Exception:
return None
class StubAuthProvider(DashboardAuthProvider):
"""Local fake IDP for E2E tests.
``start_login`` returns a redirect to
``{redirect_uri}?code=stub_code&state={s}`` so the test harness can
walk the full round trip in-process without talking to anything
external. ``access_token`` is an HMAC-signed JSON blob;
``verify_session`` decodes and checks ``exp``.
"""
name = "stub"
display_name = "Stub IdP (test only)"
def __init__(self, default_ttl: int = 3600):
self._default_ttl = default_ttl
# state → verifier mapping, cleared on complete_login
self._state_to_verifier: dict[str, str] = {}
def start_login(self, *, redirect_uri: str) -> LoginStart:
state = secrets.token_urlsafe(16)
verifier = secrets.token_urlsafe(32)
self._state_to_verifier[state] = verifier
return LoginStart(
redirect_url=f"{redirect_uri}?code=stub_code&state={state}",
cookie_payload={
"hermes_session_pkce": f"state={state};verifier={verifier}",
},
)
def complete_login(
self, *, code: str, state: str, code_verifier: str, redirect_uri: str,
) -> Session:
if code != "stub_code":
raise InvalidCodeError(
f"stub expects code='stub_code', got {code!r}"
)
expected_verifier = self._state_to_verifier.get(state)
if expected_verifier is None or expected_verifier != code_verifier:
raise InvalidCodeError("stub state/verifier mismatch")
del self._state_to_verifier[state]
now = int(time.time())
exp = now + self._default_ttl
return Session(
user_id="stub-user-1",
email="stub@example.test",
display_name="Stub User",
org_id="stub-org-1",
provider=self.name,
expires_at=exp,
access_token=_sign({
"sub": "stub-user-1",
"email": "stub@example.test",
"name": "Stub User",
"org_id": "stub-org-1",
"exp": exp,
}),
refresh_token=_sign({
"sub": "stub-user-1",
"kind": "refresh",
"exp": now + 30 * 86400,
}),
)
def verify_session(self, *, access_token: str):
payload = _unsign(access_token)
# ``<=`` so default_ttl=0 produces a born-expired token. This
# matches what Phase 6's silent-refresh tests need ("set a 0-TTL
# access token; the next request should refresh transparently").
if payload is None or payload.get("exp", 0) <= int(time.time()):
return None
return Session(
user_id=payload["sub"],
email=payload["email"],
display_name=payload["name"],
org_id=payload["org_id"],
provider=self.name,
expires_at=payload["exp"],
access_token=access_token,
refresh_token="", # not surfaced on verify
)
def refresh_session(self, *, refresh_token: str) -> Session:
payload = _unsign(refresh_token)
# ``<=`` for symmetry with verify_session — a 0-TTL token is
# treated as expired.
if payload is None or payload.get("exp", 0) <= int(time.time()):
raise RefreshExpiredError("stub refresh token expired/invalid")
now = int(time.time())
exp = now + self._default_ttl
return Session(
user_id=payload["sub"],
email="stub@example.test",
display_name="Stub User",
org_id="stub-org-1",
provider=self.name,
expires_at=exp,
access_token=_sign({
"sub": payload["sub"],
"email": "stub@example.test",
"name": "Stub User",
"org_id": "stub-org-1",
"exp": exp,
}),
refresh_token=_sign({
"sub": payload["sub"],
"kind": "refresh",
"exp": now + 30 * 86400,
}),
)
def revoke_session(self, *, refresh_token: str) -> None:
# Stub is in-memory; nothing to revoke server-side.
return None
@@ -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
+648
View File
@@ -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) == []
+373
View File
@@ -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
+729
View File
@@ -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
+560
View File
@@ -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"]}}
)
+161
View File
@@ -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
+120
View File
@@ -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
+562
View File
@@ -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()
+296
View File
@@ -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
+221
View File
@@ -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
+160
View File
@@ -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
# =============================================================================
+77
View File
@@ -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
+281
View File
@@ -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)
+207
View File
@@ -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
+86
View File
@@ -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)"
)
+201
View File
@@ -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"
)
+120
View File
@@ -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
+105
View File
@@ -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")) == []
+85
View File
@@ -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
+128
View File
@@ -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
+62
View File
@@ -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
+124
View File
@@ -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"
+110
View File
@@ -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
+112
View File
@@ -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()
+51
View File
@@ -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")
+35
View File
@@ -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
+50
View File
@@ -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")
+79
View File
@@ -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
+118
View File
@@ -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)
+182
View File
@@ -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})")
+212
View File
@@ -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
+159
View File
@@ -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()
+86
View File
@@ -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
+53
View File
@@ -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()
+125
View File
@@ -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()
+398
View File
@@ -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)
+151
View File
@@ -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 == []
+113
View File
@@ -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