Initial import of NousResearch/hermes-agent
Deploy Site / deploy-vercel (push) Has been cancelled
Deploy Site / deploy-docs (push) Has been cancelled
Docker / shell lint / Lint Dockerfile (hadolint) (push) Has been cancelled
Docker / shell lint / Lint docker/ shell scripts (shellcheck) (push) Has been cancelled
Docker Build and Publish / build-amd64 (push) Has been cancelled
Docker Build and Publish / build-arm64 (push) Has been cancelled
Lint (ruff + ty) / ruff + ty diff (push) Has been cancelled
Lint (ruff + ty) / ruff enforcement (blocking) (push) Has been cancelled
Lint (ruff + ty) / Windows footguns (blocking) (push) Has been cancelled
Nix Lockfile Fix / auto-fix-main (push) Has been cancelled
Nix Lockfile Fix / fix (push) Has been cancelled
Nix / nix (macos-latest) (push) Has been cancelled
Nix / nix (ubuntu-latest) (push) Has been cancelled
OSV-Scanner / Scan lockfiles (push) Has been cancelled
Build Skills Index / build-index (push) Has been cancelled
Tests / test (1) (push) Has been cancelled
Tests / test (2) (push) Has been cancelled
Tests / test (3) (push) Has been cancelled
Tests / test (4) (push) Has been cancelled
Tests / test (5) (push) Has been cancelled
Tests / test (6) (push) Has been cancelled
Tests / e2e (push) Has been cancelled
uv.lock check / uv lock --check (push) Has been cancelled
Docker Build and Publish / merge (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
Tests / save-durations (push) Has been cancelled

This commit is contained in:
红尘
2026-05-31 09:36:58 +08:00
commit d73ff9b0fb
4188 changed files with 1614916 additions and 0 deletions
View File
+46
View File
@@ -0,0 +1,46 @@
"""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
monkeypatch.setattr(
_cli_main, "_detect_concurrent_hermes_instances", lambda *_a, **_k: []
)
+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,207 @@
"""Tests for Bug #12905 fix — stale OAuth token detection in hermes model flow.
Bug 3: `hermes model` with `provider=anthropic` skips OAuth re-authentication
when a stale ANTHROPIC_TOKEN exists in ~/.hermes/.env but no valid
Claude Code credentials are available. The fast-path silently proceeds to
model selection with a broken token instead of offering re-auth.
"""
from hermes_cli.config import save_env_value
class TestStaleOAuthTokenDetection:
"""Bug 3: stale OAuth token must trigger needs_auth=True in _model_flow_anthropic."""
def test_stale_oauth_token_triggers_reauth(self, tmp_path, monkeypatch, capsys):
"""
Scenario: ANTHROPIC_TOKEN is an expired OAuth token and there are no
valid Claude Code credentials anywhere. The flow MUST offer re-auth
instead of silently skipping to model selection.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
# Pre-load .env with an expired OAuth token (sk-ant- prefix = OAuth)
save_env_value("ANTHROPIC_TOKEN", "sk-ant-oat-ExpiredToken00000")
save_env_value("ANTHROPIC_API_KEY", "")
# No valid Claude Code credentials available (expired, no refresh token)
monkeypatch.setattr(
"agent.anthropic_adapter.read_claude_code_credentials",
lambda: {
"accessToken": "expired-cc-token",
"refreshToken": "", # No refresh — can't recover
"expiresAt": 0, # Already expired
"source": "claude_code_credentials_file",
},
)
monkeypatch.setattr(
"agent.anthropic_adapter.is_claude_code_token_valid",
lambda creds: False, # Explicitly expired
)
monkeypatch.setattr(
"agent.anthropic_adapter._is_oauth_token",
lambda key: key.startswith("sk-ant-"),
)
# _resolve_claude_code_token_from_credentials has no valid path
monkeypatch.setattr(
"agent.anthropic_adapter._resolve_claude_code_token_from_credentials",
lambda creds=None: None,
)
# Simulate user types "3" (Cancel) when prompted for re-auth
monkeypatch.setattr("builtins.input", lambda _: "3")
monkeypatch.setattr("hermes_cli.secret_prompt.masked_secret_prompt", lambda _: "")
from hermes_cli.main import _model_flow_anthropic
cfg = {}
_model_flow_anthropic(cfg)
output = capsys.readouterr().out
# Must show auth method choice since token is stale
assert "subscription" in output or "API key" in output, (
f"Expected auth method menu but got: {output!r}"
)
def test_valid_api_key_skips_stale_check(self, tmp_path, monkeypatch, capsys):
"""
A non-OAuth ANTHROPIC_API_KEY (regular pay-per-token key) must NOT be
flagged as stale even when cc_creds are invalid. Regular API keys don't
expire the same way OAuth tokens do.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
# Regular API key — NOT an OAuth token
save_env_value("ANTHROPIC_API_KEY", "sk-ant-api03-RegularPayPerTokenKey")
save_env_value("ANTHROPIC_TOKEN", "")
monkeypatch.setattr(
"agent.anthropic_adapter.read_claude_code_credentials",
lambda: None, # No CC creds
)
monkeypatch.setattr(
"agent.anthropic_adapter.is_claude_code_token_valid",
lambda creds: False,
)
monkeypatch.setattr(
"agent.anthropic_adapter._is_oauth_token",
lambda key: key.startswith("sk-ant-") and "oat" in key,
)
# Simulate user picks "1" (use existing)
monkeypatch.setattr("builtins.input", lambda _: "1")
from hermes_cli.main import _model_flow_anthropic
cfg = {}
_model_flow_anthropic(cfg)
output = capsys.readouterr().out
# Should show "Use existing credentials" menu, NOT auth method choice
assert "Use existing" in output or "credentials" in output.lower()
def test_valid_oauth_token_with_refresh_available_skips_reauth(self, tmp_path, monkeypatch, capsys):
"""
When ANTHROPIC_TOKEN is OAuth and valid cc_creds with refresh exist,
the flow should use existing credentials (no forced re-auth).
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
save_env_value("ANTHROPIC_TOKEN", "sk-ant-oat-GoodOAuthToken")
save_env_value("ANTHROPIC_API_KEY", "")
# Valid Claude Code credentials with refresh token
monkeypatch.setattr(
"agent.anthropic_adapter.read_claude_code_credentials",
lambda: {
"accessToken": "valid-cc-token",
"refreshToken": "valid-refresh",
"expiresAt": 9999999999999,
},
)
monkeypatch.setattr(
"agent.anthropic_adapter.is_claude_code_token_valid",
lambda creds: True,
)
monkeypatch.setattr(
"agent.anthropic_adapter._is_oauth_token",
lambda key: key.startswith("sk-ant-"),
)
monkeypatch.setattr(
"agent.anthropic_adapter._resolve_claude_code_token_from_credentials",
lambda creds=None: "valid-cc-token",
)
# Simulate user picks "1" (use existing)
monkeypatch.setattr("builtins.input", lambda _: "1")
from hermes_cli.main import _model_flow_anthropic
cfg = {}
_model_flow_anthropic(cfg)
output = capsys.readouterr().out
# Should show "Use existing" without forcing re-auth
assert "Use existing" in output or "credentials" in output.lower()
class TestStaleOAuthGuardLogic:
"""Unit-level test of the stale-OAuth detection guard logic."""
def test_stale_oauth_flag_logic_no_cc_creds(self):
"""
When existing_key is OAuth and cc_available is False,
existing_is_stale_oauth should be True → has_creds = False.
"""
existing_key = "sk-ant-oat-expiredtoken123"
_is_oauth_token = lambda k: k.startswith("sk-ant-")
cc_available = False
existing_is_stale_oauth = (
bool(existing_key) and
_is_oauth_token(existing_key) and
not cc_available
)
has_creds = (bool(existing_key) and not existing_is_stale_oauth) or cc_available
assert existing_is_stale_oauth is True
assert has_creds is False
def test_stale_oauth_flag_logic_with_valid_cc_creds(self):
"""
When existing_key is OAuth but cc_available is True (valid creds exist),
has_creds should be True — the cc_creds will be used instead.
"""
existing_key = "sk-ant-oat-sometoken"
_is_oauth_token = lambda k: k.startswith("sk-ant-")
cc_available = True
existing_is_stale_oauth = (
bool(existing_key) and
_is_oauth_token(existing_key) and
not cc_available
)
has_creds = (bool(existing_key) and not existing_is_stale_oauth) or cc_available
assert existing_is_stale_oauth is False
assert has_creds is True
def test_non_oauth_key_not_flagged_as_stale(self):
"""
Regular ANTHROPIC_API_KEY (non-OAuth) must not be flagged as stale
even when cc_available is False.
"""
existing_key = "sk-ant-api03-regular-key"
_is_oauth_token = lambda k: k.startswith("sk-ant-") and "oat" in k
cc_available = False
existing_is_stale_oauth = (
bool(existing_key) and
_is_oauth_token(existing_key) and
not cc_available
)
has_creds = (bool(existing_key) and not existing_is_stale_oauth) or cc_available
assert existing_is_stale_oauth is False
assert has_creds is True
@@ -0,0 +1,55 @@
"""Tests for Anthropic OAuth setup flow behavior."""
from hermes_cli.config import load_env, save_env_value
def test_run_anthropic_oauth_flow_prefers_claude_code_credentials(tmp_path, monkeypatch, capsys):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setattr(
"agent.anthropic_adapter.run_oauth_setup_token",
lambda: "sk-ant-oat01-from-claude-setup",
)
monkeypatch.setattr(
"agent.anthropic_adapter.read_claude_code_credentials",
lambda: {
"accessToken": "cc-access-token",
"refreshToken": "cc-refresh-token",
"expiresAt": 9999999999999,
},
)
monkeypatch.setattr(
"agent.anthropic_adapter.is_claude_code_token_valid",
lambda creds: True,
)
from hermes_cli.main import _run_anthropic_oauth_flow
save_env_value("ANTHROPIC_TOKEN", "stale-env-token")
assert _run_anthropic_oauth_flow(save_env_value) is True
env_vars = load_env()
assert env_vars["ANTHROPIC_TOKEN"] == ""
assert env_vars["ANTHROPIC_API_KEY"] == ""
output = capsys.readouterr().out
assert "Claude Code credentials linked" in output
def test_run_anthropic_oauth_flow_manual_token_still_persists(tmp_path, monkeypatch, capsys):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setattr("agent.anthropic_adapter.run_oauth_setup_token", lambda: None)
monkeypatch.setattr("agent.anthropic_adapter.read_claude_code_credentials", lambda: None)
monkeypatch.setattr("agent.anthropic_adapter.is_claude_code_token_valid", lambda creds: False)
monkeypatch.setattr("builtins.input", lambda _prompt="": "sk-ant-oat01-manual-token")
monkeypatch.setattr(
"hermes_cli.secret_prompt.masked_secret_prompt",
lambda _prompt="": "sk-ant-oat01-manual-token",
)
from hermes_cli.main import _run_anthropic_oauth_flow
assert _run_anthropic_oauth_flow(save_env_value) is True
env_vars = load_env()
assert env_vars["ANTHROPIC_TOKEN"] == "sk-ant-oat01-manual-token"
output = capsys.readouterr().out
assert "Setup-token saved" in output
@@ -0,0 +1,46 @@
"""Tests for Anthropic credential persistence helpers."""
from hermes_cli.config import load_env
def test_save_anthropic_oauth_token_uses_token_slot_and_clears_api_key(tmp_path, monkeypatch):
home = tmp_path / "hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
from hermes_cli.config import save_anthropic_oauth_token
save_anthropic_oauth_token("sk-ant-oat01-test-token")
env_vars = load_env()
assert env_vars["ANTHROPIC_TOKEN"] == "sk-ant-oat01-test-token"
assert env_vars["ANTHROPIC_API_KEY"] == ""
def test_use_anthropic_claude_code_credentials_clears_env_slots(tmp_path, monkeypatch):
home = tmp_path / "hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
from hermes_cli.config import save_anthropic_oauth_token, use_anthropic_claude_code_credentials
save_anthropic_oauth_token("sk-ant-oat01-token")
use_anthropic_claude_code_credentials()
env_vars = load_env()
assert env_vars["ANTHROPIC_TOKEN"] == ""
assert env_vars["ANTHROPIC_API_KEY"] == ""
def test_save_anthropic_api_key_uses_api_key_slot_and_clears_token(tmp_path, monkeypatch):
home = tmp_path / "hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
from hermes_cli.config import save_anthropic_api_key
save_anthropic_api_key("sk-ant-api03-key")
env_vars = load_env()
assert env_vars["ANTHROPIC_API_KEY"] == "sk-ant-api03-key"
assert env_vars["ANTHROPIC_TOKEN"] == ""
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,152 @@
"""Regression test for the `/model` picker confirmation display.
Bug (April 2026): after choosing a model from the interactive `/model` picker,
``HermesCLI._apply_model_switch_result()`` printed ``ModelInfo.context_window``
straight from models.dev, which always reports the vendor-wide value (e.g.
gpt-5.5 = 1,050,000 on ``openai``). That ignored provider-specific caps — in
particular, ChatGPT Codex OAuth enforces 272K on the same slug. The sibling
``_handle_model_switch()`` (typed ``/model <name>``) was already fixed to use
``resolve_display_context_length()``; the picker path was missed, causing
"sometimes 1M, sometimes 272K" for the same model across sibling UI paths.
Fix: both display paths now go through ``resolve_display_context_length()``.
"""
from __future__ import annotations
from unittest.mock import patch
from hermes_cli.model_switch import ModelSwitchResult
class _FakeModelInfo:
context_window = 1_050_000
max_output = 0
def has_cost_data(self):
return False
def format_capabilities(self):
return ""
class _StubCLI:
"""Minimum attrs ``_apply_model_switch_result`` reads on ``self``."""
agent = None
model = ""
provider = ""
requested_provider = ""
api_key = ""
_explicit_api_key = ""
base_url = ""
_explicit_base_url = ""
api_mode = ""
_pending_model_switch_note = ""
def _run_display(monkeypatch, result):
import cli as cli_mod
captured: list[str] = []
monkeypatch.setattr(cli_mod, "_cprint", lambda s, *a, **k: captured.append(str(s)))
# Avoid writing to ~/.hermes/config.yaml during the test.
monkeypatch.setattr(cli_mod, "save_config_value", lambda *a, **k: None)
cli_mod.HermesCLI._apply_model_switch_result(_StubCLI(), result, False)
return captured
def test_picker_path_uses_provider_aware_context_on_codex(monkeypatch):
"""``_apply_model_switch_result`` must prefer the provider-aware resolver
(272K on Codex) over the raw models.dev value (1.05M for gpt-5.5).
"""
result = ModelSwitchResult(
success=True,
new_model="gpt-5.5",
target_provider="openai-codex",
provider_changed=True,
api_key="",
base_url="https://chatgpt.com/backend-api/codex",
api_mode="codex_responses",
warning_message="",
provider_label="ChatGPT Codex",
resolved_via_alias=False,
capabilities=None,
model_info=_FakeModelInfo(), # models.dev says 1.05M
is_global=False,
)
with patch(
"agent.model_metadata.get_model_context_length",
return_value=272_000,
):
lines = _run_display(monkeypatch, result)
ctx_line = next((l for l in lines if "Context:" in l), "")
assert "272,000" in ctx_line, (
f"picker-path display must show Codex's 272K cap, got: {ctx_line!r}"
)
assert "1,050,000" not in ctx_line, (
f"picker-path display leaked models.dev's 1.05M for Codex: {ctx_line!r}"
)
def test_picker_path_shows_vendor_value_when_no_provider_cap(monkeypatch):
"""On providers with no enforced cap (e.g. OpenRouter), the picker path
should surface the real 1.05M context for gpt-5.5 — resolver and models.dev
agree here.
"""
result = ModelSwitchResult(
success=True,
new_model="openai/gpt-5.5",
target_provider="openrouter",
provider_changed=True,
api_key="",
base_url="https://openrouter.ai/api/v1",
api_mode="chat_completions",
warning_message="",
provider_label="OpenRouter",
resolved_via_alias=False,
capabilities=None,
model_info=_FakeModelInfo(),
is_global=False,
)
with patch(
"agent.model_metadata.get_model_context_length",
return_value=1_050_000,
):
lines = _run_display(monkeypatch, result)
ctx_line = next((l for l in lines if "Context:" in l), "")
assert "1,050,000" in ctx_line, (
f"OpenRouter gpt-5.5 should show 1.05M context, got: {ctx_line!r}"
)
def test_picker_path_falls_back_to_model_info_when_resolver_empty(monkeypatch):
"""If ``get_model_context_length`` returns nothing (rare — truly unknown
endpoint), the display still surfaces ``ModelInfo.context_window`` so the
user sees *something* rather than a silent blank.
"""
result = ModelSwitchResult(
success=True,
new_model="some-model",
target_provider="some-provider",
provider_changed=True,
api_key="",
base_url="",
api_mode="chat_completions",
warning_message="",
provider_label="Some Provider",
resolved_via_alias=False,
capabilities=None,
model_info=_FakeModelInfo(), # context_window = 1_050_000
is_global=False,
)
with patch(
"agent.model_metadata.get_model_context_length",
return_value=None,
):
lines = _run_display(monkeypatch, result)
ctx_line = next((l for l in lines if "Context:" in l), "")
assert "1,050,000" in ctx_line, (
f"resolver-empty path should fall back to ModelInfo, got: {ctx_line!r}"
)
@@ -0,0 +1,140 @@
"""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
def _run_apply_profile_override(
tmp_path, monkeypatch, *, hermes_home: str | None, active_profile: str | None,
argv: list[str] | None = None,
):
"""Run _apply_profile_override in isolation.
Returns the value of os.environ["HERMES_HOME"] after the call,
or None if unset.
"""
hermes_root = tmp_path / ".hermes"
hermes_root.mkdir(parents=True, exist_ok=True)
if active_profile is not None:
(hermes_root / "active_profile").write_text(active_profile)
if active_profile and active_profile != "default":
(hermes_root / "profiles" / active_profile).mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
if hermes_home is not None:
monkeypatch.setenv("HERMES_HOME", hermes_home)
else:
monkeypatch.delenv("HERMES_HOME", raising=False)
monkeypatch.setattr(sys, "argv", argv or ["hermes", "gateway", "start"])
from hermes_cli.main import _apply_profile_override
_apply_profile_override()
return os.environ.get("HERMES_HOME")
class TestApplyProfileOverrideHermesHomeGuard:
"""Regression guard for issue #22502.
Verifies that HERMES_HOME pointing to the hermes root does NOT suppress
the active_profile check, while HERMES_HOME already pointing to a
profile directory IS trusted as-is.
"""
def test_hermes_home_at_root_with_active_profile_is_redirected(
self, tmp_path, monkeypatch
):
"""HERMES_HOME=/root/.hermes + active_profile=coder must redirect
HERMES_HOME to .../profiles/coder.
Bug scenario from #22502: systemd sets HERMES_HOME to the hermes root
and the user switches to a profile via `hermes profile use`.
Before the fix, the guard returned early and active_profile was ignored.
"""
hermes_root = tmp_path / ".hermes"
hermes_root.mkdir(parents=True, exist_ok=True)
result = _run_apply_profile_override(
tmp_path,
monkeypatch,
hermes_home=str(hermes_root),
active_profile="coder",
)
assert result is not None, "HERMES_HOME must be set after profile redirect"
assert "profiles" in result, (
f"Expected HERMES_HOME to point into profiles/ dir, got: {result!r}"
)
assert result.endswith("coder"), (
f"Expected HERMES_HOME to end with 'coder', got: {result!r}"
)
def test_hermes_home_already_profile_dir_is_trusted(self, tmp_path, monkeypatch):
"""HERMES_HOME=.../profiles/coder must not be overridden even when
active_profile says something different.
Preserves the child-process inheritance contract: a subprocess spawned
with HERMES_HOME already set to a specific profile must stay in that
profile.
"""
hermes_root = tmp_path / ".hermes"
profile_dir = hermes_root / "profiles" / "coder"
profile_dir.mkdir(parents=True, exist_ok=True)
(hermes_root / "active_profile").write_text("other")
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(profile_dir))
monkeypatch.setattr(sys, "argv", ["hermes", "gateway", "start"])
from hermes_cli.main import _apply_profile_override
_apply_profile_override()
assert os.environ.get("HERMES_HOME") == str(profile_dir), (
"HERMES_HOME must remain unchanged when already pointing to a profile dir"
)
def test_hermes_home_unset_reads_active_profile(self, tmp_path, monkeypatch):
"""Classic case: HERMES_HOME unset + active_profile=coder must set
HERMES_HOME to the profile directory (existing behaviour must not regress).
"""
result = _run_apply_profile_override(
tmp_path,
monkeypatch,
hermes_home=None,
active_profile="coder",
)
assert result is not None
assert "coder" in result
def test_hermes_home_unset_default_profile_no_redirect(self, tmp_path, monkeypatch):
"""active_profile=default must not redirect HERMES_HOME."""
hermes_root = tmp_path / ".hermes"
hermes_root.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.delenv("HERMES_HOME", raising=False)
monkeypatch.setattr(sys, "argv", ["hermes", "gateway", "start"])
(hermes_root / "active_profile").write_text("default")
from hermes_cli.main import _apply_profile_override
_apply_profile_override()
assert os.environ.get("HERMES_HOME") is None
+201
View File
@@ -0,0 +1,201 @@
"""Tests for Arcee AI provider support — standard direct API provider."""
import types
import pytest
from hermes_cli.auth import (
PROVIDER_REGISTRY,
resolve_provider,
get_api_key_provider_status,
resolve_api_key_provider_credentials,
)
_OTHER_PROVIDER_KEYS = (
"OPENAI_API_KEY", "ANTHROPIC_API_KEY", "DEEPSEEK_API_KEY",
"GOOGLE_API_KEY", "GEMINI_API_KEY", "DASHSCOPE_API_KEY",
"XAI_API_KEY", "KIMI_API_KEY", "KIMI_CN_API_KEY",
"MINIMAX_API_KEY", "MINIMAX_CN_API_KEY",
"KILOCODE_API_KEY", "HF_TOKEN", "GLM_API_KEY", "ZAI_API_KEY",
"XIAOMI_API_KEY", "TOKENHUB_API_KEY", "COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN",
)
# =============================================================================
# Provider Registry
# =============================================================================
class TestArceeProviderRegistry:
def test_registered(self):
assert "arcee" in PROVIDER_REGISTRY
def test_name(self):
assert PROVIDER_REGISTRY["arcee"].name == "Arcee AI"
def test_auth_type(self):
assert PROVIDER_REGISTRY["arcee"].auth_type == "api_key"
def test_inference_base_url(self):
assert PROVIDER_REGISTRY["arcee"].inference_base_url == "https://api.arcee.ai/api/v1"
def test_api_key_env_vars(self):
assert PROVIDER_REGISTRY["arcee"].api_key_env_vars == ("ARCEEAI_API_KEY",)
def test_base_url_env_var(self):
assert PROVIDER_REGISTRY["arcee"].base_url_env_var == "ARCEE_BASE_URL"
# =============================================================================
# Aliases
# =============================================================================
class TestArceeAliases:
@pytest.mark.parametrize("alias", ["arcee", "arcee-ai", "arceeai"])
def test_alias_resolves(self, alias, monkeypatch):
for key in _OTHER_PROVIDER_KEYS + ("OPENROUTER_API_KEY",):
monkeypatch.delenv(key, raising=False)
monkeypatch.setenv("ARCEEAI_API_KEY", "arc-test-12345")
assert resolve_provider(alias) == "arcee"
def test_normalize_provider_models_py(self):
from hermes_cli.models import normalize_provider
assert normalize_provider("arcee-ai") == "arcee"
assert normalize_provider("arceeai") == "arcee"
def test_normalize_provider_providers_py(self):
from hermes_cli.providers import normalize_provider
assert normalize_provider("arcee-ai") == "arcee"
assert normalize_provider("arceeai") == "arcee"
# =============================================================================
# Credentials
# =============================================================================
class TestArceeCredentials:
def test_status_configured(self, monkeypatch):
monkeypatch.setenv("ARCEEAI_API_KEY", "arc-test")
status = get_api_key_provider_status("arcee")
assert status["configured"]
def test_status_not_configured(self, monkeypatch):
monkeypatch.delenv("ARCEEAI_API_KEY", raising=False)
status = get_api_key_provider_status("arcee")
assert not status["configured"]
def test_openrouter_key_does_not_make_arcee_configured(self, monkeypatch):
"""OpenRouter users should NOT see arcee as configured."""
monkeypatch.delenv("ARCEEAI_API_KEY", raising=False)
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test")
status = get_api_key_provider_status("arcee")
assert not status["configured"]
def test_resolve_credentials(self, monkeypatch):
monkeypatch.setenv("ARCEEAI_API_KEY", "arc-direct-key")
monkeypatch.delenv("ARCEE_BASE_URL", raising=False)
creds = resolve_api_key_provider_credentials("arcee")
assert creds["api_key"] == "arc-direct-key"
assert creds["base_url"] == "https://api.arcee.ai/api/v1"
def test_custom_base_url_override(self, monkeypatch):
monkeypatch.setenv("ARCEEAI_API_KEY", "arc-x")
monkeypatch.setenv("ARCEE_BASE_URL", "https://custom.arcee.example/v1")
creds = resolve_api_key_provider_credentials("arcee")
assert creds["base_url"] == "https://custom.arcee.example/v1"
# =============================================================================
# Model catalog
# =============================================================================
class TestArceeModelCatalog:
def test_static_model_list(self):
"""Arcee has a static _PROVIDER_MODELS catalog entry. Specific model
names change with releases and don't belong in tests.
"""
from hermes_cli.models import _PROVIDER_MODELS
assert "arcee" in _PROVIDER_MODELS
assert len(_PROVIDER_MODELS["arcee"]) >= 1
def test_canonical_provider_entry(self):
from hermes_cli.models import CANONICAL_PROVIDERS
slugs = [p.slug for p in CANONICAL_PROVIDERS]
assert "arcee" in slugs
# =============================================================================
# Model normalization
# =============================================================================
class TestArceeNormalization:
def test_in_matching_prefix_strip_set(self):
from hermes_cli.model_normalize import _MATCHING_PREFIX_STRIP_PROVIDERS
assert "arcee" in _MATCHING_PREFIX_STRIP_PROVIDERS
def test_strips_prefix(self):
from hermes_cli.model_normalize import normalize_model_for_provider
assert normalize_model_for_provider("arcee/trinity-mini", "arcee") == "trinity-mini"
def test_bare_name_unchanged(self):
from hermes_cli.model_normalize import normalize_model_for_provider
assert normalize_model_for_provider("trinity-mini", "arcee") == "trinity-mini"
# =============================================================================
# URL mapping
# =============================================================================
class TestArceeURLMapping:
def test_url_to_provider(self):
from agent.model_metadata import _URL_TO_PROVIDER
assert _URL_TO_PROVIDER.get("api.arcee.ai") == "arcee"
def test_provider_prefixes(self):
from agent.model_metadata import _PROVIDER_PREFIXES
assert "arcee" in _PROVIDER_PREFIXES
assert "arcee-ai" in _PROVIDER_PREFIXES
assert "arceeai" in _PROVIDER_PREFIXES
def test_trajectory_compressor_detects_arcee(self):
import trajectory_compressor as tc
comp = tc.TrajectoryCompressor.__new__(tc.TrajectoryCompressor)
comp.config = types.SimpleNamespace(base_url="https://api.arcee.ai/api/v1")
assert comp._detect_provider() == "arcee"
# =============================================================================
# providers.py overlay + aliases
# =============================================================================
class TestArceeProvidersModule:
def test_overlay_exists(self):
from hermes_cli.providers import HERMES_OVERLAYS
assert "arcee" in HERMES_OVERLAYS
overlay = HERMES_OVERLAYS["arcee"]
assert overlay.transport == "openai_chat"
assert overlay.base_url_env_var == "ARCEE_BASE_URL"
assert not overlay.is_aggregator
def test_label(self):
from hermes_cli.models import _PROVIDER_LABELS
assert _PROVIDER_LABELS["arcee"] == "Arcee AI"
# =============================================================================
# Auxiliary client — main-model-first design
# =============================================================================
class TestArceeAuxiliary:
def test_main_model_first_design(self):
"""Arcee uses main-model-first — no entry in _API_KEY_PROVIDER_AUX_MODELS."""
from agent.auxiliary_client import _API_KEY_PROVIDER_AUX_MODELS
assert "arcee" not in _API_KEY_PROVIDER_AUX_MODELS
@@ -0,0 +1,184 @@
"""Tests for parent→subparser flag propagation.
When flags like --yolo, -w, -s exist on both the parent parser and the 'chat'
subparser, placing the flag BEFORE the subcommand (e.g. 'hermes --yolo chat')
must not silently drop the flag value.
Regression test for: argparse subparser default=False overwriting parent's
parsed True when the same argument is defined on both parsers.
Fix: chat subparser uses default=argparse.SUPPRESS for all duplicated flags,
so the subparser only sets the attribute when the user explicitly provides it.
"""
import argparse
import os
import sys
import pytest
def _build_parser():
"""Build the hermes argument parser from the real code.
We import the real main() and extract the parser it builds.
Since main() is a large function that does much more than parse args,
we replicate just the parser structure here to avoid side effects.
"""
parser = argparse.ArgumentParser(prog="hermes")
parser.add_argument("--resume", "-r", metavar="SESSION", default=None)
parser.add_argument(
"--continue", "-c", dest="continue_last", nargs="?",
const=True, default=None, metavar="SESSION_NAME",
)
parser.add_argument("--worktree", "-w", action="store_true", default=False)
parser.add_argument("--skills", "-s", action="append", default=None)
parser.add_argument("--yolo", action="store_true", default=False)
parser.add_argument("--pass-session-id", action="store_true", default=False)
subparsers = parser.add_subparsers(dest="command")
chat = subparsers.add_parser("chat")
# These MUST use argparse.SUPPRESS to avoid overwriting parent values
chat.add_argument("--yolo", action="store_true",
default=argparse.SUPPRESS)
chat.add_argument("--worktree", "-w", action="store_true",
default=argparse.SUPPRESS)
chat.add_argument("--skills", "-s", action="append",
default=argparse.SUPPRESS)
chat.add_argument("--pass-session-id", action="store_true",
default=argparse.SUPPRESS)
chat.add_argument("--resume", "-r", metavar="SESSION_ID",
default=argparse.SUPPRESS)
chat.add_argument(
"--continue", "-c", dest="continue_last", nargs="?",
const=True, default=argparse.SUPPRESS, metavar="SESSION_NAME",
)
return parser
class TestChatVerboseArg:
"""Verify chat --verbose preserves config fallback when absent."""
def test_chat_without_verbose_leaves_attribute_unset(self):
from hermes_cli._parser import build_top_level_parser
parser, _subparsers, _chat_parser = build_top_level_parser()
args = parser.parse_args(["chat"])
assert not hasattr(args, "verbose")
def test_chat_verbose_sets_attribute_true(self):
from hermes_cli._parser import build_top_level_parser
parser, _subparsers, _chat_parser = build_top_level_parser()
args = parser.parse_args(["chat", "--verbose"])
assert args.verbose is True
def test_cmd_chat_forwards_none_when_verbose_is_absent(self, monkeypatch):
import types
import sys
import hermes_cli.main as main_mod
from hermes_cli._parser import build_top_level_parser
parser, _subparsers, chat_parser = build_top_level_parser()
chat_parser.set_defaults(func=main_mod.cmd_chat)
args = parser.parse_args(["chat"])
captured = {}
fake_cli = types.ModuleType("cli")
def fake_main(**kwargs):
captured.update(kwargs)
setattr(fake_cli, "main", fake_main)
fake_banner = types.ModuleType("hermes_cli.banner")
setattr(fake_banner, "prefetch_update_check", lambda: None)
fake_skills_sync = types.ModuleType("tools.skills_sync")
setattr(fake_skills_sync, "sync_skills", lambda quiet=True: None)
monkeypatch.setitem(sys.modules, "cli", fake_cli)
monkeypatch.setitem(sys.modules, "hermes_cli.banner", fake_banner)
monkeypatch.setitem(sys.modules, "tools.skills_sync", fake_skills_sync)
monkeypatch.setattr(main_mod, "_has_any_provider_configured", lambda: True)
monkeypatch.setattr(main_mod, "_pin_kanban_board_env", lambda: None)
main_mod.cmd_chat(args)
assert captured["quiet"] is False
assert "verbose" not in captured
class TestYoloEnvVar:
"""Verify --yolo sets HERMES_YOLO_MODE regardless of flag position.
This tests the actual cmd_chat logic pattern (getattr → os.environ).
"""
@pytest.fixture(autouse=True)
def _clean_env(self):
os.environ.pop("HERMES_YOLO_MODE", None)
yield
os.environ.pop("HERMES_YOLO_MODE", None)
def _simulate_cmd_chat_yolo_check(self, args):
"""Replicate the exact check from cmd_chat in main.py."""
if getattr(args, "yolo", False):
os.environ["HERMES_YOLO_MODE"] = "1"
def test_yolo_before_chat_sets_env(self):
parser = _build_parser()
args = parser.parse_args(["--yolo", "chat"])
self._simulate_cmd_chat_yolo_check(args)
assert os.environ.get("HERMES_YOLO_MODE") == "1"
def test_yolo_after_chat_sets_env(self):
parser = _build_parser()
args = parser.parse_args(["chat", "--yolo"])
self._simulate_cmd_chat_yolo_check(args)
assert os.environ.get("HERMES_YOLO_MODE") == "1"
def test_no_yolo_no_env(self):
parser = _build_parser()
args = parser.parse_args(["chat"])
self._simulate_cmd_chat_yolo_check(args)
assert os.environ.get("HERMES_YOLO_MODE") is None
class TestAcceptHooksOnAgentSubparsers:
"""Verify --accept-hooks is accepted at every agent-subcommand
position (before the subcommand, between group/subcommand, and
after the leaf subcommand) for gateway/cron/mcp/acp. Regression
against prior behaviour where the flag only worked on the root
parser and `chat`, so `hermes gateway run --accept-hooks` failed
with `unrecognized arguments`."""
@pytest.mark.parametrize("argv", [
["--accept-hooks", "gateway", "run", "--help"],
["gateway", "--accept-hooks", "run", "--help"],
["gateway", "run", "--accept-hooks", "--help"],
["--accept-hooks", "cron", "tick", "--help"],
["cron", "--accept-hooks", "tick", "--help"],
["cron", "tick", "--accept-hooks", "--help"],
["cron", "run", "--accept-hooks", "dummy-id", "--help"],
["--accept-hooks", "mcp", "serve", "--help"],
["mcp", "--accept-hooks", "serve", "--help"],
["mcp", "serve", "--accept-hooks", "--help"],
["acp", "--accept-hooks", "--help"],
])
def test_accepted_at_every_position(self, argv):
"""Invoking `hermes <argv>` must exit 0 (help) rather than
failing with `unrecognized arguments`."""
import subprocess
result = subprocess.run(
[sys.executable, "-m", "hermes_cli.main", *argv],
capture_output=True,
text=True,
timeout=15,
)
assert result.returncode == 0, (
f"argv={argv!r} returned {result.returncode}\n"
f"stdout: {result.stdout[:300]}\n"
f"stderr: {result.stderr[:300]}"
)
assert "unrecognized arguments" not in result.stderr
@@ -0,0 +1,90 @@
"""Regression test: `@folder:` completion must only surface directories and
`@file:` must only surface regular files.
Reported during TUI v2 blitz testing: typing `@folder:` showed .dockerignore,
.env, .gitignore, etc. alongside the actual directories because the path-
completion branch yielded every entry regardless of the explicit prefix, and
auto-switched the completion kind based on `is_dir`. That defeated the user's
explicit choice and rendered the `@folder:` / `@file:` prefixes useless for
filtering.
"""
from __future__ import annotations
from pathlib import Path
from typing import Iterable
from hermes_cli.commands import SlashCommandCompleter
def _run(tmp_path: Path, word: str) -> list[tuple[str, str]]:
(tmp_path / "readme.md").write_text("x")
(tmp_path / ".env").write_text("x")
(tmp_path / "src").mkdir()
(tmp_path / "docs").mkdir()
completer = SlashCommandCompleter.__new__(SlashCommandCompleter)
completions: Iterable = completer._context_completions(word)
return [(c.text, c.display_meta) for c in completions if c.text.startswith(("@file:", "@folder:"))]
def test_at_folder_only_yields_directories(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
texts = [t for t, _ in _run(tmp_path, "@folder:")]
assert all(t.startswith("@folder:") for t in texts), texts
assert any(t == "@folder:src/" for t in texts)
assert any(t == "@folder:docs/" for t in texts)
assert not any(t == "@folder:readme.md" for t in texts)
assert not any(t == "@folder:.env" for t in texts)
def test_at_file_only_yields_files(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
texts = [t for t, _ in _run(tmp_path, "@file:")]
assert all(t.startswith("@file:") for t in texts), texts
assert any(t == "@file:readme.md" for t in texts)
assert any(t == "@file:.env" for t in texts)
assert not any(t == "@file:src/" for t in texts)
assert not any(t == "@file:docs/" for t in texts)
def test_at_folder_preserves_prefix_on_empty_match(tmp_path, monkeypatch):
"""User typed `@folder:` (no partial) — completion text must keep the
`@folder:` prefix even though the previous implementation auto-rewrote
it to `@file:` for non-dir entries.
"""
monkeypatch.chdir(tmp_path)
texts = [t for t, _ in _run(tmp_path, "@folder:")]
assert texts, "expected at least one directory completion"
for t in texts:
assert t.startswith("@folder:"), f"prefix leaked: {t}"
def test_at_folder_bare_without_colon_lists_directories(tmp_path, monkeypatch):
"""Typing `@folder` alone (no colon yet) should surface directories so
users don't need to first accept the static `@folder:` hint before
seeing what they're picking from.
"""
monkeypatch.chdir(tmp_path)
texts = [t for t, _ in _run(tmp_path, "@folder")]
assert any(t == "@folder:src/" for t in texts), texts
assert any(t == "@folder:docs/" for t in texts), texts
assert not any(t == "@folder:readme.md" for t in texts)
def test_at_file_bare_without_colon_lists_files(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
texts = [t for t, _ in _run(tmp_path, "@file")]
assert any(t == "@file:readme.md" for t in texts), texts
assert not any(t == "@file:src/" for t in texts)
+158
View File
@@ -0,0 +1,158 @@
"""Tests for utils.atomic_json_write — crash-safe JSON file writes."""
import json
from pathlib import Path
from unittest.mock import patch
import pytest
from utils import atomic_json_write
class TestAtomicJsonWrite:
"""Core atomic write behavior."""
def test_writes_valid_json(self, tmp_path):
target = tmp_path / "data.json"
data = {"key": "value", "nested": {"a": 1}}
atomic_json_write(target, data)
result = json.loads(target.read_text(encoding="utf-8"))
assert result == data
def test_creates_parent_directories(self, tmp_path):
target = tmp_path / "deep" / "nested" / "dir" / "data.json"
atomic_json_write(target, {"ok": True})
assert target.exists()
assert json.loads(target.read_text())["ok"] is True
def test_overwrites_existing_file(self, tmp_path):
target = tmp_path / "data.json"
target.write_text('{"old": true}')
atomic_json_write(target, {"new": True})
result = json.loads(target.read_text())
assert result == {"new": True}
def test_preserves_original_on_serialization_error(self, tmp_path):
target = tmp_path / "data.json"
original = {"preserved": True}
target.write_text(json.dumps(original))
# Try to write non-serializable data — should fail
with pytest.raises(TypeError):
atomic_json_write(target, {"bad": object()})
# Original file should be untouched
result = json.loads(target.read_text())
assert result == original
def test_no_leftover_temp_files_on_success(self, tmp_path):
target = tmp_path / "data.json"
atomic_json_write(target, [1, 2, 3])
# No .tmp files should be left behind
tmp_files = [f for f in tmp_path.iterdir() if ".tmp" in f.name]
assert len(tmp_files) == 0
assert target.exists()
def test_no_leftover_temp_files_on_failure(self, tmp_path):
target = tmp_path / "data.json"
with pytest.raises(TypeError):
atomic_json_write(target, {"bad": object()})
# No temp files should be left behind
tmp_files = [f for f in tmp_path.iterdir() if ".tmp" in f.name]
assert len(tmp_files) == 0
def test_cleans_up_temp_file_on_baseexception(self, tmp_path):
class SimulatedAbort(BaseException):
pass
target = tmp_path / "data.json"
original = {"preserved": True}
target.write_text(json.dumps(original), encoding="utf-8")
with patch("utils.json.dump", side_effect=SimulatedAbort):
with pytest.raises(SimulatedAbort):
atomic_json_write(target, {"new": True})
tmp_files = [f for f in tmp_path.iterdir() if ".tmp" in f.name]
assert len(tmp_files) == 0
assert json.loads(target.read_text(encoding="utf-8")) == original
def test_accepts_string_path(self, tmp_path):
target = str(tmp_path / "string_path.json")
atomic_json_write(target, {"string": True})
result = json.loads(Path(target).read_text())
assert result == {"string": True}
def test_writes_list_data(self, tmp_path):
target = tmp_path / "list.json"
data = [1, "two", {"three": 3}]
atomic_json_write(target, data)
result = json.loads(target.read_text())
assert result == data
def test_empty_list(self, tmp_path):
target = tmp_path / "empty.json"
atomic_json_write(target, [])
result = json.loads(target.read_text())
assert result == []
def test_custom_indent(self, tmp_path):
target = tmp_path / "custom.json"
atomic_json_write(target, {"a": 1}, indent=4)
text = target.read_text()
assert ' "a"' in text # 4-space indent
def test_accepts_json_dump_default_hook(self, tmp_path):
class CustomValue:
def __str__(self):
return "custom-value"
target = tmp_path / "custom_default.json"
atomic_json_write(target, {"value": CustomValue()}, default=str)
result = json.loads(target.read_text(encoding="utf-8"))
assert result == {"value": "custom-value"}
def test_unicode_content(self, tmp_path):
target = tmp_path / "unicode.json"
data = {"emoji": "🎉", "japanese": "日本語"}
atomic_json_write(target, data)
result = json.loads(target.read_text(encoding="utf-8"))
assert result["emoji"] == "🎉"
assert result["japanese"] == "日本語"
def test_concurrent_writes_dont_corrupt(self, tmp_path):
"""Multiple rapid writes should each produce valid JSON."""
import threading
target = tmp_path / "concurrent.json"
errors = []
def writer(n):
try:
atomic_json_write(target, {"writer": n, "data": list(range(100))})
except Exception as e:
errors.append(e)
threads = [threading.Thread(target=writer, args=(i,)) for i in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
assert not errors
# File should contain valid JSON from one of the writers
result = json.loads(target.read_text())
assert "writer" in result
assert len(result["data"]) == 100
@@ -0,0 +1,43 @@
"""Tests for utils.atomic_yaml_write — crash-safe YAML file writes."""
from unittest.mock import patch
import pytest
import yaml
from utils import atomic_yaml_write
class TestAtomicYamlWrite:
def test_writes_valid_yaml(self, tmp_path):
target = tmp_path / "data.yaml"
data = {"key": "value", "nested": {"a": 1}}
atomic_yaml_write(target, data)
assert yaml.safe_load(target.read_text(encoding="utf-8")) == data
def test_cleans_up_temp_file_on_baseexception(self, tmp_path):
class SimulatedAbort(BaseException):
pass
target = tmp_path / "data.yaml"
original = {"preserved": True}
target.write_text(yaml.safe_dump(original), encoding="utf-8")
with patch("utils.yaml.dump", side_effect=SimulatedAbort):
with pytest.raises(SimulatedAbort):
atomic_yaml_write(target, {"new": True})
tmp_files = [f for f in tmp_path.iterdir() if ".tmp" in f.name]
assert len(tmp_files) == 0
assert yaml.safe_load(target.read_text(encoding="utf-8")) == original
def test_appends_extra_content(self, tmp_path):
target = tmp_path / "data.yaml"
atomic_yaml_write(target, {"key": "value"}, extra_content="\n# comment\n")
text = target.read_text(encoding="utf-8")
assert "key: value" in text
assert "# comment" in text
@@ -0,0 +1,660 @@
"""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_read_codex_tokens_success(tmp_path, monkeypatch):
hermes_home = tmp_path / "hermes"
_setup_hermes_auth(hermes_home)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
data = _read_codex_tokens()
assert data["tokens"]["access_token"] == "access"
assert data["tokens"]["refresh_token"] == "refresh"
def test_read_codex_tokens_missing(tmp_path, monkeypatch):
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
# Empty auth store
(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_codex_tokens()
assert exc.value.code == "codex_auth_missing"
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))
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_refreshes_expiring_token(tmp_path, monkeypatch):
hermes_home = tmp_path / "hermes"
expiring_token = _jwt_with_exp(int(time.time()) - 10)
_setup_hermes_auth(hermes_home, access_token=expiring_token, refresh_token="refresh-old")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
called = {"count": 0}
def _fake_refresh(tokens, timeout_seconds):
called["count"] += 1
return {"access_token": "access-new", "refresh_token": "refresh-new"}
monkeypatch.setattr("hermes_cli.auth._refresh_codex_auth_tokens", _fake_refresh)
resolved = resolve_codex_runtime_credentials()
assert called["count"] == 1
assert resolved["api_key"] == "access-new"
def test_resolve_codex_runtime_credentials_force_refresh(tmp_path, monkeypatch):
hermes_home = tmp_path / "hermes"
_setup_hermes_auth(hermes_home, access_token="access-current", refresh_token="refresh-old")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
called = {"count": 0}
def _fake_refresh(tokens, timeout_seconds):
called["count"] += 1
return {"access_token": "access-forced", "refresh_token": "refresh-new"}
monkeypatch.setattr("hermes_cli.auth._refresh_codex_auth_tokens", _fake_refresh)
resolved = resolve_codex_runtime_credentials(force_refresh=True, refresh_if_expiring=False)
assert called["count"] == 1
assert resolved["api_key"] == "access-forced"
def test_resolve_codex_runtime_credentials_falls_back_to_pool_when_singleton_empty(tmp_path, monkeypatch):
"""Regression for #32992 — chat path returns 401 when singleton is empty but pool has creds.
The chat path historically went through ``resolve_codex_runtime_credentials`` which
only consulted ``providers.openai-codex.tokens`` and raised ``AuthError`` when that
was empty. The auxiliary path went through ``_read_codex_access_token`` which
checks the pool first. Users with creds only in the pool (manual seed, partial
re-auth, restore from backup) hit a bare HTTP 401 on chat but worked fine on
auxiliary calls. The fallback closes that divergence.
"""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
# Singleton: empty tokens (would normally raise AuthError).
# Pool: valid access_token.
auth_store = {
"version": 1,
"providers": {}, # no openai-codex singleton at all
"credential_pool": {
"openai-codex": [
{
"source": "device_code",
"access_token": "pool-fallback-token",
"refresh_token": "pool-refresh",
"last_status": "ok",
"auth_type": "oauth",
},
],
},
}
(hermes_home / "auth.json").write_text(json.dumps(auth_store))
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
resolved = resolve_codex_runtime_credentials()
assert resolved["api_key"] == "pool-fallback-token"
assert resolved["source"] == "credential_pool"
assert resolved["base_url"] # default codex backend URL
def test_resolve_codex_runtime_credentials_pool_fallback_skips_exhausted(tmp_path, monkeypatch):
"""The pool fallback skips entries currently in an exhaustion cooldown window."""
import time as _time
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
future_reset = _time.time() + 3600 # 1h cooldown remaining
auth_store = {
"version": 1,
"providers": {},
"credential_pool": {
"openai-codex": [
{
"source": "device_code",
"access_token": "wedged-token",
"last_error_reset_at": future_reset, # in cooldown
},
{
"source": "device_code",
"access_token": "usable-token",
"last_status": "ok",
},
],
},
}
(hermes_home / "auth.json").write_text(json.dumps(auth_store))
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
resolved = resolve_codex_runtime_credentials()
assert resolved["api_key"] == "usable-token"
assert resolved["source"] == "credential_pool"
def test_resolve_codex_runtime_credentials_pool_fallback_no_usable_entry(tmp_path, monkeypatch):
"""When both singleton and pool are empty/unusable, the original AuthError propagates."""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
auth_store = {
"version": 1,
"providers": {},
"credential_pool": {
"openai-codex": [
{"source": "device_code", "access_token": ""}, # empty
],
},
}
(hermes_home / "auth.json").write_text(json.dumps(auth_store))
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
with pytest.raises(AuthError) as exc:
resolve_codex_runtime_credentials()
assert exc.value.code == "codex_auth_missing"
def test_resolve_provider_explicit_codex_does_not_fallback(monkeypatch):
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
assert resolve_provider("openai-codex") == "openai-codex"
def test_save_codex_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_codex_tokens({"access_token": "at123", "refresh_token": "rt456"})
data = _read_codex_tokens()
assert data["tokens"]["access_token"] == "at123"
assert data["tokens"]["refresh_token"] == "rt456"
def test_save_codex_tokens_syncs_credential_pool(tmp_path, monkeypatch):
"""Re-auth must update the credential_pool device_code entry, not just providers.
Regression for #33000: the runtime selects from credential_pool, so a
re-auth that only refreshed providers.openai-codex.tokens left the pool
holding a consumed refresh token and stale error markers, causing an
immediate 401 token_invalidated on the next request.
"""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
(hermes_home / "auth.json").write_text(json.dumps({
"version": 1,
"providers": {
"openai-codex": {
"tokens": {"access_token": "old-at", "refresh_token": "old-rt"},
"last_refresh": "2026-01-01T00:00:00Z",
"auth_mode": "chatgpt",
},
},
"credential_pool": {
"openai-codex": [
{
"id": "abc123",
"source": "device_code",
"auth_type": "oauth",
"access_token": "old-at",
"refresh_token": "old-rt",
"last_status": "exhausted",
"last_error_code": 401,
"last_error_reason": "token_invalidated",
"last_error_reset_at": 9999999999,
},
{
"id": "manual1",
"source": "manual:codex",
"auth_type": "oauth",
"access_token": "manual-at",
"refresh_token": "manual-rt",
},
],
},
}))
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
_save_codex_tokens({"access_token": "new-at", "refresh_token": "new-rt"},
last_refresh="2026-05-27T00:00:00Z")
auth = json.loads((hermes_home / "auth.json").read_text())
pool = auth["credential_pool"]["openai-codex"]
seeded = next(e for e in pool if e["source"] == "device_code")
assert seeded["access_token"] == "new-at"
assert seeded["refresh_token"] == "new-rt"
assert seeded["last_refresh"] == "2026-05-27T00:00:00Z"
assert seeded["last_status"] is None
assert seeded["last_error_code"] is None
assert seeded["last_error_reason"] is None
assert seeded["last_error_reset_at"] is None
# Manual entries are independent credentials and must not be overwritten.
manual = next(e for e in pool if e["source"] == "manual:codex")
assert manual["access_token"] == "manual-at"
assert manual["refresh_token"] == "manual-rt"
# Provider singleton is updated too.
assert auth["providers"]["openai-codex"]["tokens"]["access_token"] == "new-at"
def test_save_codex_tokens_syncs_manual_device_code_entries(tmp_path, monkeypatch):
"""Re-auth must also refresh ``manual:device_code`` pool entries.
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.
An interactive Codex device-code re-auth proves the user owns the ChatGPT
account, so it is safe to refresh every device-code-backed entry in the
pool — but NOT independent ``manual:api_key`` entries (separate accounts /
explicit API keys).
"""
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",
},
{
"id": "auth-add",
"source": "manual:device_code",
"auth_type": "oauth",
"access_token": "stale-manual-at",
"refresh_token": "stale-manual-rt",
"last_status": "exhausted",
"last_error_code": 401,
"last_error_reason": "token_invalidated",
},
{
"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["source"] == "device_code")
assert seeded["access_token"] == "fresh-at"
assert seeded["refresh_token"] == "fresh-rt"
# manual:device_code entry: ALSO refreshed (the new behavior).
manual_dc = next(e for e in pool if e["source"] == "manual:device_code")
assert manual_dc["access_token"] == "fresh-at"
assert manual_dc["refresh_token"] == "fresh-rt"
assert manual_dc["last_refresh"] == "2026-05-28T00:00:00Z"
assert manual_dc["last_status"] is None
assert manual_dc["last_error_code"] is None
assert manual_dc["last_error_reason"] is None
# 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_import_codex_cli_tokens(tmp_path, monkeypatch):
codex_home = tmp_path / "codex-cli"
codex_home.mkdir(parents=True, exist_ok=True)
(codex_home / "auth.json").write_text(json.dumps({
"tokens": {"access_token": "cli-at", "refresh_token": "cli-rt"},
}))
monkeypatch.setenv("CODEX_HOME", str(codex_home))
tokens = _import_codex_cli_tokens()
assert tokens is not None
assert tokens["access_token"] == "cli-at"
assert tokens["refresh_token"] == "cli-rt"
def test_import_codex_cli_tokens_missing(tmp_path, monkeypatch):
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "nonexistent"))
assert _import_codex_cli_tokens() is None
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_parses_openai_nested_error_shape_refresh_token_reused(monkeypatch):
"""OpenAI returns {"error": {"code": "refresh_token_reused", "message": "..."}}
— parser must surface relogin_required and the dedicated message.
"""
response = _StubHTTPResponse(
401,
{
"error": {
"message": "Your refresh token has already been used to generate a new access token. Please try signing in again.",
"type": "invalid_request_error",
"param": None,
"code": "refresh_token_reused",
}
},
)
_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 == "refresh_token_reused"
assert err.relogin_required is True
# The existing dedicated branch should override the message with actionable guidance.
assert "already consumed by another client" in str(err)
def test_refresh_parses_openai_nested_error_shape_generic_code(monkeypatch):
"""Nested error with arbitrary code still surfaces code + message."""
response = _StubHTTPResponse(
400,
{
"error": {
"message": "Invalid client credentials.",
"type": "invalid_request_error",
"code": "invalid_client",
}
},
)
_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 == "invalid_client"
assert "Invalid client credentials." in str(err)
def test_refresh_parses_oauth_spec_flat_error_shape_invalid_grant(monkeypatch):
"""Fallback path: OAuth spec-shape {"error": "invalid_grant", "error_description": "..."}
must still map to relogin_required=True via the existing code set.
"""
response = _StubHTTPResponse(
400,
{
"error": "invalid_grant",
"error_description": "Refresh token is expired or revoked.",
},
)
_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 == "invalid_grant"
assert err.relogin_required is True
assert "Refresh token is expired or revoked." in str(err)
def test_refresh_falls_back_to_generic_message_on_unparseable_body(monkeypatch):
"""No JSON body → generic 'with status 401' message; 401 always forces relogin."""
response = _StubHTTPResponse(401, ValueError("not json"))
_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_refresh_failed"
# 401/403 from the token endpoint always means the refresh token is
# invalid/expired — force relogin even without a parseable error body.
assert err.relogin_required is True
assert "status 401" in str(err)
def test_refresh_429_classified_as_quota_not_auth_failure(monkeypatch):
"""429 from the token endpoint is a usage-quota cap, not an auth failure.
Regression test for #32790: must NOT force relogin and must carry the
dedicated rate-limit code so callers surface a "retry later" notice rather
than a misleading "run hermes auth".
"""
from hermes_cli.auth import (
CODEX_RATE_LIMITED_CODE,
format_auth_error,
is_rate_limited_auth_error,
)
response = _StubHTTPResponse(
429,
{"error": {"message": "You hit your usage limit.", "code": "usage_limit_reached"}},
headers={"retry-after": "120"},
)
_patch_httpx(monkeypatch, response)
with pytest.raises(AuthError) as exc_info:
refresh_codex_oauth_pure("a-tok", "r-tok")
err = exc_info.value
assert err.code == CODEX_RATE_LIMITED_CODE
assert err.relogin_required is False
assert is_rate_limited_auth_error(err) is True
assert "retry after 120s" in str(err)
# User-facing copy must not tell the operator to re-authenticate.
rendered = format_auth_error(err)
assert "re-authenticate" not in rendered
assert "hermes auth" not in rendered
def test_refresh_429_without_retry_after_header(monkeypatch):
"""429 without a Retry-After header still classifies as quota, no relogin."""
from hermes_cli.auth import CODEX_RATE_LIMITED_CODE
response = _StubHTTPResponse(429, {"error": "rate_limited"})
_patch_httpx(monkeypatch, response)
with pytest.raises(AuthError) as exc_info:
refresh_codex_oauth_pure("a-tok", "r-tok")
err = exc_info.value
assert err.code == CODEX_RATE_LIMITED_CODE
assert err.relogin_required is False
assert "quota exhausted" in str(err).lower()
def test_is_rate_limited_auth_error_distinguishes_credential_errors():
"""Missing/expired credentials must NOT be treated as rate-limit errors."""
from hermes_cli.auth import CODEX_RATE_LIMITED_CODE, is_rate_limited_auth_error
rate_limited = AuthError(
"quota", provider="openai-codex", code=CODEX_RATE_LIMITED_CODE, relogin_required=False
)
missing_creds = AuthError(
"No Codex credentials stored.",
provider="openai-codex",
code="codex_auth_missing",
relogin_required=True,
)
assert is_rate_limited_auth_error(rate_limited) is True
assert is_rate_limited_auth_error(missing_creds) is False
assert is_rate_limited_auth_error(ValueError("nope")) is False
def test_login_openai_codex_force_new_login_skips_existing_reuse_prompt(monkeypatch):
called = {"device_login": 0}
monkeypatch.setattr(
"hermes_cli.auth.resolve_codex_runtime_credentials",
lambda: {"base_url": DEFAULT_CODEX_BASE_URL},
)
monkeypatch.setattr(
"hermes_cli.auth._import_codex_cli_tokens",
lambda: {"access_token": "cli-at", "refresh_token": "cli-rt"},
)
monkeypatch.setattr(
"hermes_cli.auth._codex_device_code_login",
lambda: {
"tokens": {"access_token": "fresh-at", "refresh_token": "fresh-rt"},
"last_refresh": "2026-04-01T00:00:00Z",
"base_url": DEFAULT_CODEX_BASE_URL,
},
)
def _fake_save(tokens, last_refresh=None):
called["device_login"] += 1
called["tokens"] = dict(tokens)
called["last_refresh"] = last_refresh
monkeypatch.setattr("hermes_cli.auth._save_codex_tokens", _fake_save)
monkeypatch.setattr("hermes_cli.auth._update_config_for_provider", lambda *args, **kwargs: "/tmp/config.yaml")
monkeypatch.setattr(
"builtins.input",
lambda prompt="": (_ for _ in ()).throw(AssertionError("force_new_login should not prompt for reuse/import")),
)
_login_openai_codex(SimpleNamespace(), PROVIDER_REGISTRY["openai-codex"], force_new_login=True)
assert called["device_login"] == 1
assert called["tokens"]["access_token"] == "fresh-at"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,148 @@
"""Unit tests for _print_loopback_ssh_hint() in hermes_cli/auth.py.
The helper exists to warn users that loopback OAuth flows (xAI Grok OAuth,
Spotify) don't work over SSH unless they set up an `ssh -L` port forward
between their laptop's browser and the remote host's loopback listener.
"""
from __future__ import annotations
import io
import contextlib
import socket
from hermes_cli import auth as auth_mod
def _cap(fn):
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
fn()
return buf.getvalue()
def test_loopback_ssh_hint_silent_when_not_remote(monkeypatch):
monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: False)
out = _cap(lambda: auth_mod._print_loopback_ssh_hint(
"http://127.0.0.1:56121/callback", docs_url=auth_mod.XAI_OAUTH_DOCS_URL
))
assert out == ""
def test_loopback_ssh_hint_prints_tunnel_command_on_ssh(monkeypatch):
monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True)
out = _cap(lambda: auth_mod._print_loopback_ssh_hint(
"http://127.0.0.1:56121/callback", docs_url=auth_mod.XAI_OAUTH_DOCS_URL
))
# Must include the exact ssh -L command with the port from the redirect URI
assert "ssh -N -L 56121:127.0.0.1:56121" in out
# Must include the provider-specific docs URL
assert auth_mod.XAI_OAUTH_DOCS_URL in out
# Must always include the cross-provider SSH guide
assert auth_mod.OAUTH_OVER_SSH_DOCS_URL in out
def test_loopback_ssh_hint_uses_actual_bound_port(monkeypatch):
"""When the preferred port is busy, _xai_start_callback_server falls back to
an OS-assigned port. The hint must echo whichever port actually got bound,
not the hardcoded constant."""
monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True)
out = _cap(lambda: auth_mod._print_loopback_ssh_hint(
"http://127.0.0.1:51234/callback", docs_url=auth_mod.XAI_OAUTH_DOCS_URL
))
assert "ssh -N -L 51234:127.0.0.1:51234" in out
assert "56121" not in out
def test_loopback_ssh_hint_silent_for_non_loopback_uri(monkeypatch):
"""Defense in depth: if a future caller passes a non-loopback redirect URI
by mistake, we don't tell the user to forward an external port."""
monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True)
out = _cap(lambda: auth_mod._print_loopback_ssh_hint(
"https://example.com/callback", docs_url=auth_mod.XAI_OAUTH_DOCS_URL
))
assert out == ""
def test_loopback_ssh_hint_silent_for_malformed_uri(monkeypatch):
monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True)
out = _cap(lambda: auth_mod._print_loopback_ssh_hint(
"not-a-uri", docs_url=auth_mod.XAI_OAUTH_DOCS_URL
))
assert out == ""
def test_loopback_ssh_hint_works_without_provider_docs_url(monkeypatch):
monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True)
out = _cap(lambda: auth_mod._print_loopback_ssh_hint(
"http://127.0.0.1:43827/spotify/callback"
))
assert "ssh -N -L 43827:127.0.0.1:43827" in out
# Generic SSH guide is always present even without a provider-specific URL
assert auth_mod.OAUTH_OVER_SSH_DOCS_URL in out
# Should not falsely show "Provider docs:" when no docs_url was passed
assert "Provider docs:" not in out
def test_loopback_ssh_hint_accepts_localhost_hostname(monkeypatch):
"""The constant is 127.0.0.1, but parsing tolerates `localhost` too in case
a future caller normalizes the URI differently."""
monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True)
out = _cap(lambda: auth_mod._print_loopback_ssh_hint(
"http://localhost:56121/callback"
))
assert "ssh -N -L 56121:127.0.0.1:56121" in out
def test_loopback_ssh_hint_includes_user_at_host(monkeypatch):
"""The SSH command should include a detected user@host so the user can
copy-paste it without manually substituting placeholders."""
monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True)
monkeypatch.setattr(auth_mod, "_ssh_user_at_host", lambda: "alice@myserver.lan")
out = _cap(lambda: auth_mod._print_loopback_ssh_hint(
"http://127.0.0.1:56121/callback"
))
assert "ssh -N -L 56121:127.0.0.1:56121 alice@myserver.lan" in out
def test_loopback_ssh_hint_has_visual_header(monkeypatch):
"""The hint should print a divider and header so it stands out in noisy output."""
monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True)
out = _cap(lambda: auth_mod._print_loopback_ssh_hint(
"http://127.0.0.1:56121/callback"
))
assert "Remote session detected" in out
assert "---" in out # divider is present
class TestSshUserAtHost:
def test_resolves_user_and_hostname(self, monkeypatch):
monkeypatch.setenv("USER", "alice")
monkeypatch.delenv("LOGNAME", raising=False)
monkeypatch.setattr(socket, "gethostname", lambda: "myserver")
assert auth_mod._ssh_user_at_host() == "alice@myserver"
def test_falls_back_to_logname(self, monkeypatch):
monkeypatch.delenv("USER", raising=False)
monkeypatch.setenv("LOGNAME", "bob")
monkeypatch.setattr(socket, "gethostname", lambda: "host1")
assert auth_mod._ssh_user_at_host() == "bob@host1"
def test_placeholder_when_no_env_vars(self, monkeypatch):
monkeypatch.delenv("USER", raising=False)
monkeypatch.delenv("LOGNAME", raising=False)
monkeypatch.setattr(socket, "gethostname", lambda: "host1")
assert auth_mod._ssh_user_at_host() == "<user>@host1"
def test_placeholder_when_socket_raises(self, monkeypatch):
monkeypatch.setenv("USER", "charlie")
def _raise():
raise OSError("no network")
monkeypatch.setattr(socket, "gethostname", _raise)
assert auth_mod._ssh_user_at_host() == "charlie@<this-host>"
def test_placeholder_when_empty_hostname(self, monkeypatch):
monkeypatch.setenv("USER", "dave")
monkeypatch.setattr(socket, "gethostname", lambda: "")
assert auth_mod._ssh_user_at_host() == "dave@<this-host>"
+642
View File
@@ -0,0 +1,642 @@
"""Tests for the OAuth manual-paste fallback for browser-only remotes.
Regression coverage for [#26923](https://github.com/NousResearch/hermes-agent/issues/26923):
GCP Cloud Shell, GitHub Codespaces, AWS EC2 Instance Connect and
other browser-only remote consoles can't reach the
``http://127.0.0.1:56121/callback`` loopback listener bound on the
remote VM. The previous SSH-tunnel hint was useless without a real
SSH client, leaving the user with no path forward. This test file
locks in four things:
* ``_is_remote_session`` recognises the cloud-shell / Codespaces
envvars (so the existing hint at least fires).
* ``_parse_pasted_callback`` accepts every form a user might paste
(full URL, ``?code=...&state=...`` fragment, bare ``code=...``,
bare opaque value) and returns the same shape the loopback HTTP
handler does.
* ``_prompt_manual_callback_paste`` reads stdin and produces that
same shape.
* ``_xai_oauth_loopback_login(manual_paste=True)`` skips the HTTP
server entirely, validates ``state``, and goes straight to the
token exchange — proving the paste path actually wires up.
"""
from __future__ import annotations
import builtins
import io
import contextlib
import pytest
from hermes_cli import auth as auth_mod
# ---------------------------------------------------------------------------
# _is_remote_session — broadened detection (#26923)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"envvar",
[
"SSH_CLIENT",
"SSH_TTY",
"CLOUD_SHELL",
"CODESPACES",
"CODESPACE_NAME",
"GITPOD_WORKSPACE_ID",
"REPL_ID",
"STACKBLITZ",
],
)
def test_is_remote_session_detects_known_remote_envvar(monkeypatch, envvar):
"""Each documented remote-console env var must trip the check.
The SSH ones preserve historical behaviour; the cloud-shell ones
are what closes #26923. Without these, the SSH hint never fires
and the user has no signal that ``--manual-paste`` exists.
"""
for name in (
"SSH_CLIENT",
"SSH_TTY",
"CLOUD_SHELL",
"CODESPACES",
"CODESPACE_NAME",
"GITPOD_WORKSPACE_ID",
"REPL_ID",
"STACKBLITZ",
):
monkeypatch.delenv(name, raising=False)
monkeypatch.setenv(envvar, "1")
assert auth_mod._is_remote_session() is True
def test_is_remote_session_false_when_no_remote_envvars(monkeypatch):
for name in (
"SSH_CLIENT",
"SSH_TTY",
"CLOUD_SHELL",
"CODESPACES",
"CODESPACE_NAME",
"GITPOD_WORKSPACE_ID",
"REPL_ID",
"STACKBLITZ",
):
monkeypatch.delenv(name, raising=False)
assert auth_mod._is_remote_session() is False
# ---------------------------------------------------------------------------
# _parse_pasted_callback — accept every plausible paste form
# ---------------------------------------------------------------------------
def test_parse_full_callback_url():
out = auth_mod._parse_pasted_callback(
"http://127.0.0.1:56121/callback?code=abc123&state=deadbeef"
)
assert out == {
"code": "abc123",
"state": "deadbeef",
"error": None,
"error_description": None,
}
def test_parse_callback_url_https_and_extra_params():
out = auth_mod._parse_pasted_callback(
"https://127.0.0.1:56121/callback?code=abc&state=xyz&scope=openid"
)
assert out["code"] == "abc"
assert out["state"] == "xyz"
def test_parse_bare_query_string_with_leading_question_mark():
out = auth_mod._parse_pasted_callback("?code=p1&state=s1")
assert out["code"] == "p1"
assert out["state"] == "s1"
def test_parse_bare_query_fragment_no_question_mark():
out = auth_mod._parse_pasted_callback("code=p2&state=s2")
assert out["code"] == "p2"
assert out["state"] == "s2"
def test_parse_bare_opaque_code_value():
"""Some users only copy the ``code`` value itself."""
out = auth_mod._parse_pasted_callback("ABCDEF-the-code-value")
assert out["code"] == "ABCDEF-the-code-value"
assert out["state"] is None
def test_parse_callback_with_error_field():
out = auth_mod._parse_pasted_callback(
"http://127.0.0.1:56121/callback?error=access_denied"
"&error_description=user+rejected"
)
assert out["code"] is None
assert out["error"] == "access_denied"
assert out["error_description"] == "user rejected"
def test_parse_empty_input_returns_all_none():
out = auth_mod._parse_pasted_callback("")
assert out == {
"code": None,
"state": None,
"error": None,
"error_description": None,
}
def test_parse_whitespace_only_returns_all_none():
out = auth_mod._parse_pasted_callback(" \n\t ")
assert out["code"] is None
def test_parse_malformed_url_does_not_crash():
out = auth_mod._parse_pasted_callback("http://[not a url")
# Malformed URLs return all-None rather than raising — the caller
# (state check) will reject the empty payload with a clear error.
assert out["code"] is None
# ---------------------------------------------------------------------------
# _prompt_manual_callback_paste — stdin handling
# ---------------------------------------------------------------------------
def test_prompt_reads_stdin_and_parses(monkeypatch):
monkeypatch.setattr(
builtins, "input",
lambda *_a, **_k: "http://127.0.0.1:56121/callback?code=abc&state=xyz",
)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
out = auth_mod._prompt_manual_callback_paste(
"http://127.0.0.1:56121/callback"
)
rendered = buf.getvalue()
assert "Manual callback paste" in rendered
assert "127.0.0.1:56121" in rendered
assert out["code"] == "abc"
assert out["state"] == "xyz"
def test_prompt_eof_returns_all_none(monkeypatch):
def _raise_eof(*_a, **_k):
raise EOFError()
monkeypatch.setattr(builtins, "input", _raise_eof)
with contextlib.redirect_stdout(io.StringIO()):
out = auth_mod._prompt_manual_callback_paste(
"http://127.0.0.1:56121/callback"
)
assert out["code"] is None
def test_prompt_keyboard_interrupt_returns_all_none(monkeypatch):
def _raise_kbi(*_a, **_k):
raise KeyboardInterrupt()
monkeypatch.setattr(builtins, "input", _raise_kbi)
with contextlib.redirect_stdout(io.StringIO()):
out = auth_mod._prompt_manual_callback_paste(
"http://127.0.0.1:56121/callback"
)
assert out["code"] is None
# ---------------------------------------------------------------------------
# _xai_oauth_loopback_login(manual_paste=True) — full integration
# ---------------------------------------------------------------------------
class _StubTokenResponse:
status_code = 200
def __init__(self, payload):
self._payload = payload
self.text = ""
def json(self):
return self._payload
def test_xai_loopback_login_manual_paste_skips_http_server(monkeypatch):
"""``manual_paste=True`` must NOT bind a loopback HTTP server.
Direct end-to-end regression for #26923: the whole point is that
the listener is unreachable on browser-only remotes, so the paste
path must avoid it entirely. We assert this by replacing
``_xai_start_callback_server`` with a function that fails if
invoked, then driving the full happy path with a stubbed prompt
+ stubbed token endpoint.
"""
monkeypatch.setattr(
auth_mod, "_xai_oauth_discovery",
lambda *_a, **_k: {
"authorization_endpoint": "https://auth.x.ai/oauth2/authorize",
"token_endpoint": "https://auth.x.ai/oauth2/token",
},
)
def _server_must_not_be_called(*_a, **_k):
raise AssertionError(
"manual_paste=True must skip the loopback HTTP server "
"(regression for #26923)"
)
monkeypatch.setattr(
auth_mod, "_xai_start_callback_server", _server_must_not_be_called
)
captured_state: dict = {}
def _fake_prompt(_redirect_uri):
# Hermes generates state internally; we won't know it ahead of
# time, so capture the state Hermes baked into the authorize
# URL via a sneak peek on ``_xai_oauth_build_authorize_url``.
return {
"code": "fake-auth-code",
"state": captured_state["value"],
"error": None,
"error_description": None,
}
monkeypatch.setattr(
auth_mod, "_prompt_manual_callback_paste", _fake_prompt
)
original_build = auth_mod._xai_oauth_build_authorize_url
def _capture_state(**kwargs):
captured_state["value"] = kwargs["state"]
return original_build(**kwargs)
monkeypatch.setattr(
auth_mod, "_xai_oauth_build_authorize_url", _capture_state
)
def _fake_token_post(*_a, **_k):
return _StubTokenResponse(
{
"access_token": "at",
"refresh_token": "rt",
"id_token": "",
"expires_in": 3600,
"token_type": "Bearer",
}
)
monkeypatch.setattr(auth_mod.httpx, "post", _fake_token_post)
with contextlib.redirect_stdout(io.StringIO()):
creds = auth_mod._xai_oauth_loopback_login(manual_paste=True)
assert creds["tokens"]["access_token"] == "at"
assert creds["tokens"]["refresh_token"] == "rt"
assert "127.0.0.1:56121" in creds["redirect_uri"]
def test_xai_loopback_login_manual_paste_state_mismatch_raises(monkeypatch):
"""A pasted callback with the wrong state must still be rejected.
The HTTP-server path uses the same state check; manual-paste
must not be a CSRF bypass.
"""
monkeypatch.setattr(
auth_mod, "_xai_oauth_discovery",
lambda *_a, **_k: {
"authorization_endpoint": "https://auth.x.ai/oauth2/authorize",
"token_endpoint": "https://auth.x.ai/oauth2/token",
},
)
monkeypatch.setattr(
auth_mod, "_prompt_manual_callback_paste",
lambda _ru: {
"code": "fake",
"state": "WRONG-STATE",
"error": None,
"error_description": None,
},
)
with contextlib.redirect_stdout(io.StringIO()):
with pytest.raises(auth_mod.AuthError) as exc:
auth_mod._xai_oauth_loopback_login(manual_paste=True)
assert exc.value.code == "xai_state_mismatch"
def test_xai_loopback_login_manual_paste_bare_code_succeeds(monkeypatch):
"""Bare-code paste (state=None) must complete login under manual_paste.
xAI's consent page renders the authorization code in-page rather than
redirecting through 127.0.0.1, so on remote/headless setups the only
value the user can obtain is the opaque code with no ``state=``
parameter. ``_parse_pasted_callback`` correctly returns
``state=None`` for that input. The login flow must accept this case
(PKCE still protects the exchange); historically it raised
``xai_state_mismatch``. Regression for the bare-code branch of #26923.
"""
monkeypatch.setattr(
auth_mod, "_xai_oauth_discovery",
lambda *_a, **_k: {
"authorization_endpoint": "https://auth.x.ai/oauth2/authorize",
"token_endpoint": "https://auth.x.ai/oauth2/token",
},
)
monkeypatch.setattr(
auth_mod, "_prompt_manual_callback_paste",
lambda _ru: {
"code": "bare-opaque-code",
"state": None,
"error": None,
"error_description": None,
},
)
def _fake_token_post(*_a, **_k):
return _StubTokenResponse(
{
"access_token": "at",
"refresh_token": "rt",
"id_token": "",
"expires_in": 3600,
"token_type": "Bearer",
}
)
monkeypatch.setattr(auth_mod.httpx, "post", _fake_token_post)
with contextlib.redirect_stdout(io.StringIO()):
creds = auth_mod._xai_oauth_loopback_login(manual_paste=True)
assert creds["tokens"]["access_token"] == "at"
assert creds["tokens"]["refresh_token"] == "rt"
def test_xai_loopback_login_loopback_path_rejects_missing_state(monkeypatch):
"""Loopback (manual_paste=False) must NOT accept ``state=None``.
The bare-code relaxation only applies to the manual-paste path,
where the user demonstrably has no way to supply ``state``. The
HTTP-server path always sees ``state`` populated from the real
callback query string, so missing state there means something is
wrong (a malformed callback, an attacker-supplied request) and
must still raise ``xai_state_mismatch``.
"""
monkeypatch.setattr(
auth_mod, "_xai_oauth_discovery",
lambda *_a, **_k: {
"authorization_endpoint": "https://auth.x.ai/oauth2/authorize",
"token_endpoint": "https://auth.x.ai/oauth2/token",
},
)
class _StubServer:
def shutdown(self):
return None
def server_close(self):
return None
monkeypatch.setattr(
auth_mod, "_xai_start_callback_server",
lambda *_a, **_k: (
_StubServer(),
None,
{"code": "fake", "state": None, "error": None,
"error_description": None},
"http://127.0.0.1:56121/callback",
),
)
monkeypatch.setattr(
auth_mod, "_xai_wait_for_callback",
lambda *_a, **_k: {
"code": "fake",
"state": None,
"error": None,
"error_description": None,
},
)
monkeypatch.setattr(auth_mod, "_xai_validate_loopback_redirect_uri", lambda _u: None)
monkeypatch.setattr(auth_mod, "_print_loopback_ssh_hint", lambda *_a, **_k: None)
with contextlib.redirect_stdout(io.StringIO()):
with pytest.raises(auth_mod.AuthError) as exc:
auth_mod._xai_oauth_loopback_login(manual_paste=False, open_browser=False)
assert exc.value.code == "xai_state_mismatch"
def test_xai_loopback_login_manual_paste_missing_code_raises(monkeypatch):
"""Empty paste must surface as ``xai_code_missing``, not crash."""
monkeypatch.setattr(
auth_mod, "_xai_oauth_discovery",
lambda *_a, **_k: {
"authorization_endpoint": "https://auth.x.ai/oauth2/authorize",
"token_endpoint": "https://auth.x.ai/oauth2/token",
},
)
captured: dict = {"state": None}
original_build = auth_mod._xai_oauth_build_authorize_url
def _capture(**kw):
captured["state"] = kw["state"]
return original_build(**kw)
monkeypatch.setattr(auth_mod, "_xai_oauth_build_authorize_url", _capture)
monkeypatch.setattr(
auth_mod, "_prompt_manual_callback_paste",
lambda _ru: {
"code": None,
"state": captured["state"],
"error": None,
"error_description": None,
},
)
with contextlib.redirect_stdout(io.StringIO()):
with pytest.raises(auth_mod.AuthError) as exc:
auth_mod._xai_oauth_loopback_login(manual_paste=True)
assert exc.value.code == "xai_code_missing"
def test_xai_loopback_login_timeout_falls_back_to_manual_paste(monkeypatch):
"""Loopback timeout should offer the existing manual-paste path."""
monkeypatch.setattr(
auth_mod, "_xai_oauth_discovery",
lambda *_a, **_k: {
"authorization_endpoint": "https://auth.x.ai/oauth2/authorize",
"token_endpoint": "https://auth.x.ai/oauth2/token",
},
)
class _StubServer:
def shutdown(self):
return None
def server_close(self):
return None
class _StubThread:
def join(self, timeout=None):
return None
monkeypatch.setattr(
auth_mod,
"_xai_start_callback_server",
lambda: (
_StubServer(),
_StubThread(),
{
"code": None,
"state": None,
"error": None,
"error_description": None,
},
"http://127.0.0.1:56121/callback",
),
)
captured: dict = {"state": None, "prompt_calls": 0}
original_build = auth_mod._xai_oauth_build_authorize_url
def _capture(**kwargs):
captured["state"] = kwargs["state"]
return original_build(**kwargs)
monkeypatch.setattr(auth_mod, "_xai_oauth_build_authorize_url", _capture)
def _raise_timeout(*_a, **_k):
raise auth_mod.AuthError(
"xAI authorization timed out waiting for the local callback.",
provider="xai-oauth",
code="xai_callback_timeout",
)
monkeypatch.setattr(auth_mod, "_xai_wait_for_callback", _raise_timeout)
def _fake_prompt(_redirect_uri):
captured["prompt_calls"] += 1
return {
"code": "manual-auth-code",
"state": captured["state"],
"error": None,
"error_description": None,
}
monkeypatch.setattr(auth_mod, "_prompt_manual_callback_paste", _fake_prompt)
monkeypatch.setattr(
auth_mod.sys, "stdin", type("StubStdin", (), {"isatty": lambda self: True})()
)
monkeypatch.setattr(
auth_mod.httpx,
"post",
lambda *_a, **_k: _StubTokenResponse(
{
"access_token": "at-timeout",
"refresh_token": "rt-timeout",
"id_token": "",
"expires_in": 3600,
"token_type": "Bearer",
}
),
)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
creds = auth_mod._xai_oauth_loopback_login(manual_paste=False)
rendered = buf.getvalue()
assert "xAI loopback callback timed out." in rendered
assert "--manual-paste" in rendered
assert captured["prompt_calls"] == 1
assert creds["tokens"]["access_token"] == "at-timeout"
assert creds["tokens"]["refresh_token"] == "rt-timeout"
def test_xai_loopback_login_timeout_noninteractive_reraises(monkeypatch):
"""Non-interactive stdin must keep the original timeout error."""
monkeypatch.setattr(
auth_mod, "_xai_oauth_discovery",
lambda *_a, **_k: {
"authorization_endpoint": "https://auth.x.ai/oauth2/authorize",
"token_endpoint": "https://auth.x.ai/oauth2/token",
},
)
class _StubServer:
def shutdown(self):
return None
def server_close(self):
return None
class _StubThread:
def join(self, timeout=None):
return None
monkeypatch.setattr(
auth_mod,
"_xai_start_callback_server",
lambda: (
_StubServer(),
_StubThread(),
{
"code": None,
"state": None,
"error": None,
"error_description": None,
},
"http://127.0.0.1:56121/callback",
),
)
monkeypatch.setattr(
auth_mod,
"_xai_wait_for_callback",
lambda *_a, **_k: (_ for _ in ()).throw(
auth_mod.AuthError(
"xAI authorization timed out waiting for the local callback.",
provider="xai-oauth",
code="xai_callback_timeout",
)
),
)
monkeypatch.setattr(
auth_mod.sys, "stdin", type("StubStdin", (), {"isatty": lambda self: False})()
)
monkeypatch.setattr(
auth_mod,
"_prompt_manual_callback_paste",
lambda *_a, **_k: pytest.fail("manual-paste fallback should not run"),
)
with contextlib.redirect_stdout(io.StringIO()):
with pytest.raises(auth_mod.AuthError) as exc:
auth_mod._xai_oauth_loopback_login(manual_paste=False)
assert exc.value.code == "xai_callback_timeout"
# ---------------------------------------------------------------------------
# _print_loopback_ssh_hint — now also mentions --manual-paste
# ---------------------------------------------------------------------------
def test_ssh_hint_mentions_manual_paste_for_non_ssh_remotes(monkeypatch):
"""Users on Cloud Shell / Codespaces have no real SSH client; the
hint must point them at the new ``--manual-paste`` flag instead
of leaving them stuck on the ``ssh -L`` recipe."""
monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
auth_mod._print_loopback_ssh_hint(
"http://127.0.0.1:56121/callback",
docs_url=auth_mod.XAI_OAUTH_DOCS_URL,
)
rendered = buf.getvalue()
assert "--manual-paste" in rendered
assert "Cloud Shell" in rendered or "Codespaces" in rendered
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,452 @@
"""Tests for cross-profile auth fallback.
When ``HERMES_HOME`` points to a named profile, ``read_credential_pool()``
and ``get_provider_auth_state()`` fall back to the global-root
``auth.json`` per-provider when the profile has no entries for that
provider. Writes still target the profile only.
See the #18594 follow-up report: profile workers couldn't see providers
authenticated only at the global root.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
def _make_auth_store(pool: dict | None = None, providers: dict | None = None) -> dict:
store: dict = {"version": 1}
if pool is not None:
store["credential_pool"] = pool
if providers is not None:
store["providers"] = providers
return store
@pytest.fixture()
def profile_env(tmp_path, monkeypatch):
"""Set up a global root + an active profile under Path.home()/.hermes/profiles/coder.
* Path.home() -> tmp_path
* Global root -> tmp_path/.hermes (has its own auth.json fixture)
* Profile -> tmp_path/.hermes/profiles/coder (active, HERMES_HOME points here)
This mirrors the real "named profile mounted under the default root"
layout that profile users actually have on disk.
"""
monkeypatch.setattr(Path, "home", lambda: tmp_path)
global_root = tmp_path / ".hermes"
global_root.mkdir()
profile_dir = global_root / "profiles" / "coder"
profile_dir.mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(profile_dir))
return {"global": global_root, "profile": profile_dir}
def _write(path: Path, payload: dict) -> None:
path.write_text(json.dumps(payload, indent=2))
# ---------------------------------------------------------------------------
# read_credential_pool — provider-slice reads
# ---------------------------------------------------------------------------
def test_profile_with_zero_entries_falls_back_to_global(profile_env):
"""Empty profile pool inherits the global-root entries for that provider."""
from hermes_cli.auth import read_credential_pool
_write(profile_env["global"] / "auth.json", _make_auth_store(pool={
"openrouter": [{
"id": "glob-1",
"label": "global-key",
"auth_type": "api_key",
"priority": 0,
"source": "manual",
"access_token": "sk-or-global",
}],
}))
# Profile auth.json: exists but has no openrouter entries.
_write(profile_env["profile"] / "auth.json", _make_auth_store(pool={}))
entries = read_credential_pool("openrouter")
assert len(entries) == 1
assert entries[0]["id"] == "glob-1"
assert entries[0]["access_token"] == "sk-or-global"
def test_profile_with_entries_fully_shadows_global(profile_env):
"""Once the profile has any entries for a provider, global is ignored."""
from hermes_cli.auth import read_credential_pool
_write(profile_env["global"] / "auth.json", _make_auth_store(pool={
"openrouter": [{
"id": "glob-1",
"label": "global-key",
"auth_type": "api_key",
"priority": 0,
"source": "manual",
"access_token": "sk-or-global",
}],
}))
_write(profile_env["profile"] / "auth.json", _make_auth_store(pool={
"openrouter": [{
"id": "prof-1",
"label": "profile-key",
"auth_type": "api_key",
"priority": 0,
"source": "manual",
"access_token": "sk-or-profile",
}],
}))
entries = read_credential_pool("openrouter")
assert len(entries) == 1
assert entries[0]["id"] == "prof-1"
assert entries[0]["access_token"] == "sk-or-profile"
def test_per_provider_shadowing_is_independent(profile_env):
"""Profile can override one provider while inheriting another from global."""
from hermes_cli.auth import read_credential_pool
_write(profile_env["global"] / "auth.json", _make_auth_store(pool={
"openrouter": [{
"id": "glob-or",
"label": "global-or",
"auth_type": "api_key",
"priority": 0,
"source": "manual",
"access_token": "sk-or-global",
}],
"anthropic": [{
"id": "glob-ant",
"label": "global-ant",
"auth_type": "api_key",
"priority": 0,
"source": "manual",
"access_token": "sk-ant-global",
}],
}))
_write(profile_env["profile"] / "auth.json", _make_auth_store(pool={
# Profile has openrouter only — anthropic should still fall back.
"openrouter": [{
"id": "prof-or",
"label": "profile-or",
"auth_type": "api_key",
"priority": 0,
"source": "manual",
"access_token": "sk-or-profile",
}],
}))
or_entries = read_credential_pool("openrouter")
ant_entries = read_credential_pool("anthropic")
assert [e["id"] for e in or_entries] == ["prof-or"]
assert [e["id"] for e in ant_entries] == ["glob-ant"]
def test_missing_global_auth_file_is_safe(profile_env):
"""Profile processes that never had a global auth.json still work."""
from hermes_cli.auth import read_credential_pool
# No global auth.json written at all.
_write(profile_env["profile"] / "auth.json", _make_auth_store(pool={
"openrouter": [{
"id": "prof-1",
"label": "profile",
"auth_type": "api_key",
"priority": 0,
"source": "manual",
"access_token": "sk-profile",
}],
}))
assert read_credential_pool("openrouter")[0]["id"] == "prof-1"
assert read_credential_pool("anthropic") == []
def test_malformed_global_auth_file_does_not_break_profile_read(profile_env):
(profile_env["global"] / "auth.json").write_text("{not valid json")
_write(profile_env["profile"] / "auth.json", _make_auth_store(pool={
"openrouter": [{
"id": "prof-1",
"label": "profile",
"auth_type": "api_key",
"priority": 0,
"source": "manual",
"access_token": "sk-profile",
}],
}))
from hermes_cli.auth import read_credential_pool
# Profile reads still work; malformed global is silently ignored.
assert read_credential_pool("openrouter")[0]["id"] == "prof-1"
# And no fallback for anthropic since global is unreadable.
assert read_credential_pool("anthropic") == []
# ---------------------------------------------------------------------------
# read_credential_pool — whole-pool reads (provider_id=None)
# ---------------------------------------------------------------------------
def test_whole_pool_merges_global_providers_when_missing_locally(profile_env):
from hermes_cli.auth import read_credential_pool
_write(profile_env["global"] / "auth.json", _make_auth_store(pool={
"openrouter": [{
"id": "glob-or",
"label": "global-or",
"auth_type": "api_key",
"priority": 0,
"source": "manual",
"access_token": "sk-or-global",
}],
"anthropic": [{
"id": "glob-ant",
"label": "global-ant",
"auth_type": "api_key",
"priority": 0,
"source": "manual",
"access_token": "sk-ant-global",
}],
}))
_write(profile_env["profile"] / "auth.json", _make_auth_store(pool={
"openrouter": [{
"id": "prof-or",
"label": "profile-or",
"auth_type": "api_key",
"priority": 0,
"source": "manual",
"access_token": "sk-or-profile",
}],
}))
pool = read_credential_pool(None)
# Profile wins for openrouter, global fills in anthropic.
assert [e["id"] for e in pool["openrouter"]] == ["prof-or"]
assert [e["id"] for e in pool["anthropic"]] == ["glob-ant"]
# ---------------------------------------------------------------------------
# get_provider_auth_state — singleton fallback
# ---------------------------------------------------------------------------
def test_provider_auth_state_falls_back_to_global_when_profile_has_none(profile_env):
from hermes_cli.auth import get_provider_auth_state
_write(profile_env["global"] / "auth.json", _make_auth_store(providers={
"nous": {"access_token": "nous-global", "refresh_token": "rt-global"},
}))
_write(profile_env["profile"] / "auth.json", _make_auth_store(providers={}))
state = get_provider_auth_state("nous")
assert state is not None
assert state["access_token"] == "nous-global"
def test_provider_auth_state_profile_wins_when_present(profile_env):
from hermes_cli.auth import get_provider_auth_state
_write(profile_env["global"] / "auth.json", _make_auth_store(providers={
"nous": {"access_token": "nous-global"},
}))
_write(profile_env["profile"] / "auth.json", _make_auth_store(providers={
"nous": {"access_token": "nous-profile"},
}))
state = get_provider_auth_state("nous")
assert state is not None
assert state["access_token"] == "nous-profile"
def test_provider_auth_state_returns_none_when_neither_has_it(profile_env):
from hermes_cli.auth import get_provider_auth_state
_write(profile_env["global"] / "auth.json", _make_auth_store(providers={}))
_write(profile_env["profile"] / "auth.json", _make_auth_store(providers={}))
assert get_provider_auth_state("nous") is None
# ---------------------------------------------------------------------------
# _load_provider_state — internal global fallback (issue #18594 follow-up)
#
# Several runtime helpers (notably ``resolve_nous_runtime_credentials`` and
# ``resolve_nous_access_token``) call ``_load_provider_state`` directly with
# a profile-loaded auth store rather than going through
# ``get_provider_auth_state``. Without the fallback wired into
# ``_load_provider_state`` itself, those helpers raise ``"Hermes is not
# logged into Nous Portal"`` even though the user has a valid global Nous
# login. These tests pin the per-provider shadowing into the helper.
# ---------------------------------------------------------------------------
def test_load_provider_state_falls_back_to_global(profile_env):
"""When the loaded profile store has no provider entry, fall back to global."""
from hermes_cli.auth import _load_auth_store, _load_provider_state
_write(profile_env["global"] / "auth.json", _make_auth_store(providers={
"nous": {"access_token": "global-nous-token", "refresh_token": "rt"},
}))
_write(profile_env["profile"] / "auth.json", _make_auth_store(providers={}))
auth_store = _load_auth_store()
state = _load_provider_state(auth_store, "nous")
assert state is not None
assert state["access_token"] == "global-nous-token"
def test_load_provider_state_profile_wins_over_global(profile_env):
from hermes_cli.auth import _load_auth_store, _load_provider_state
_write(profile_env["global"] / "auth.json", _make_auth_store(providers={
"nous": {"access_token": "global-token"},
}))
_write(profile_env["profile"] / "auth.json", _make_auth_store(providers={
"nous": {"access_token": "profile-token"},
}))
auth_store = _load_auth_store()
state = _load_provider_state(auth_store, "nous")
assert state is not None
assert state["access_token"] == "profile-token"
def test_load_provider_state_returns_none_when_neither_has_it(profile_env):
from hermes_cli.auth import _load_auth_store, _load_provider_state
_write(profile_env["global"] / "auth.json", _make_auth_store(providers={}))
_write(profile_env["profile"] / "auth.json", _make_auth_store(providers={}))
auth_store = _load_auth_store()
assert _load_provider_state(auth_store, "nous") is None
def test_load_provider_state_classic_mode_no_fallback(tmp_path, monkeypatch):
"""In classic mode there is no global to fall back to; behavior is unchanged."""
fake_home = tmp_path / "home"
fake_home.mkdir()
monkeypatch.setattr(Path, "home", lambda: fake_home)
hermes_home = tmp_path / "classic"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
_write(hermes_home / "auth.json", _make_auth_store(providers={
"nous": {"access_token": "classic-token"},
}))
from hermes_cli.auth import _load_auth_store, _load_provider_state
auth_store = _load_auth_store()
state = _load_provider_state(auth_store, "nous")
assert state is not None
assert state["access_token"] == "classic-token"
# Absent providers still return None.
assert _load_provider_state(auth_store, "anthropic") is None
def test_load_provider_state_malformed_global_does_not_break_profile(profile_env):
"""A corrupt global auth.json must not break profile reads."""
(profile_env["global"] / "auth.json").write_text("{not valid json")
_write(profile_env["profile"] / "auth.json", _make_auth_store(providers={
"nous": {"access_token": "profile-token"},
}))
from hermes_cli.auth import _load_auth_store, _load_provider_state
auth_store = _load_auth_store()
state = _load_provider_state(auth_store, "nous")
assert state is not None
assert state["access_token"] == "profile-token"
# ---------------------------------------------------------------------------
# Classic mode — no fallback path should ever trigger
# ---------------------------------------------------------------------------
def test_classic_mode_does_not_double_read_same_file(tmp_path, monkeypatch):
"""In classic mode (HERMES_HOME == global root), no fallback path runs.
This guards against the merge accidentally duplicating entries when the
profile and global resolve to the same directory.
"""
# Put Path.home() under a subdir so the seat belt in _auth_file_path()
# sees tmp_path/home/.hermes as the "real home" — which is NOT equal
# to the HERMES_HOME we set (tmp_path/classic), so the guard passes.
fake_home = tmp_path / "home"
fake_home.mkdir()
monkeypatch.setattr(Path, "home", lambda: fake_home)
hermes_home = tmp_path / "classic"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
_write(hermes_home / "auth.json", _make_auth_store(pool={
"openrouter": [{
"id": "only",
"label": "classic",
"auth_type": "api_key",
"priority": 0,
"source": "manual",
"access_token": "sk-classic",
}],
}))
from hermes_cli.auth import read_credential_pool, _global_auth_file_path
# Classic mode: HERMES_HOME is set to a custom path that is NOT under
# ~/.hermes/profiles/ — get_default_hermes_root() returns HERMES_HOME
# itself, so the profile root and global root are the same directory,
# and the helper correctly returns None (no fallback).
assert _global_auth_file_path() is None
# And the read should return exactly one entry (not two).
entries = read_credential_pool("openrouter")
assert len(entries) == 1
assert entries[0]["id"] == "only"
# ---------------------------------------------------------------------------
# Writes stay scoped to the profile
# ---------------------------------------------------------------------------
def test_write_credential_pool_targets_profile_not_global(profile_env):
from hermes_cli.auth import read_credential_pool, write_credential_pool
_write(profile_env["global"] / "auth.json", _make_auth_store(pool={
"openrouter": [{
"id": "glob-1",
"label": "global",
"auth_type": "api_key",
"priority": 0,
"source": "manual",
"access_token": "sk-global",
}],
}))
write_credential_pool("openrouter", [{
"id": "prof-new",
"label": "profile-new",
"auth_type": "api_key",
"priority": 0,
"source": "manual",
"access_token": "sk-profile-new",
}])
# Global auth.json unchanged.
global_data = json.loads((profile_env["global"] / "auth.json").read_text())
assert global_data["credential_pool"]["openrouter"][0]["id"] == "glob-1"
# Profile auth.json holds the new entry.
profile_data = json.loads((profile_env["profile"] / "auth.json").read_text())
assert profile_data["credential_pool"]["openrouter"][0]["id"] == "prof-new"
# Subsequent read returns profile (shadows global).
assert [e["id"] for e in read_credential_pool("openrouter")] == ["prof-new"]
@@ -0,0 +1,84 @@
"""Tests for is_provider_explicitly_configured()."""
import json
import pytest
def _write_config(tmp_path, config: dict) -> None:
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
import yaml
(hermes_home / "config.yaml").write_text(yaml.dump(config))
def _write_auth_store(tmp_path, payload: dict) -> None:
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
(hermes_home / "auth.json").write_text(json.dumps(payload, indent=2))
@pytest.fixture(autouse=True)
def _clean_anthropic_env(monkeypatch):
"""Strip Anthropic env vars so CI secrets don't leak into tests."""
for key in ("ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"):
monkeypatch.delenv(key, raising=False)
def test_returns_false_when_no_config(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
(tmp_path / "hermes").mkdir(parents=True, exist_ok=True)
from hermes_cli.auth import is_provider_explicitly_configured
assert is_provider_explicitly_configured("anthropic") is False
def test_returns_true_when_active_provider_matches(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_write_auth_store(tmp_path, {
"version": 1,
"providers": {},
"active_provider": "anthropic",
})
from hermes_cli.auth import is_provider_explicitly_configured
assert is_provider_explicitly_configured("anthropic") is True
def test_returns_true_when_config_provider_matches(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_write_config(tmp_path, {"model": {"provider": "anthropic", "default": "claude-sonnet-4-6"}})
from hermes_cli.auth import is_provider_explicitly_configured
assert is_provider_explicitly_configured("anthropic") is True
def test_returns_false_when_config_provider_is_different(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_write_config(tmp_path, {"model": {"provider": "kimi-coding", "default": "kimi-k2"}})
_write_auth_store(tmp_path, {
"version": 1,
"providers": {},
"active_provider": None,
})
from hermes_cli.auth import is_provider_explicitly_configured
assert is_provider_explicitly_configured("anthropic") is False
def test_returns_true_when_anthropic_env_var_set(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-realkey")
(tmp_path / "hermes").mkdir(parents=True, exist_ok=True)
from hermes_cli.auth import is_provider_explicitly_configured
assert is_provider_explicitly_configured("anthropic") is True
def test_claude_code_oauth_token_does_not_count_as_explicit(tmp_path, monkeypatch):
"""CLAUDE_CODE_OAUTH_TOKEN is set by Claude Code, not the user — must not gate."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "sk-ant-oat01-auto-token")
(tmp_path / "hermes").mkdir(parents=True, exist_ok=True)
from hermes_cli.auth import is_provider_explicitly_configured
assert is_provider_explicitly_configured("anthropic") is False
+474
View File
@@ -0,0 +1,474 @@
"""Tests for Qwen OAuth provider authentication (hermes_cli/auth.py).
Covers: _qwen_cli_auth_path, _read_qwen_cli_tokens, _save_qwen_cli_tokens,
_qwen_access_token_is_expiring, _refresh_qwen_cli_tokens,
resolve_qwen_runtime_credentials, get_qwen_auth_status.
"""
import json
import stat
import time
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from hermes_cli.auth import (
AuthError,
DEFAULT_QWEN_BASE_URL,
QWEN_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
_qwen_cli_auth_path,
_read_qwen_cli_tokens,
_save_qwen_cli_tokens,
_qwen_access_token_is_expiring,
_refresh_qwen_cli_tokens,
resolve_qwen_runtime_credentials,
get_qwen_auth_status,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_qwen_tokens(
access_token="test-access-token",
refresh_token="test-refresh-token",
expiry_date=None,
**extra,
):
"""Create a minimal Qwen CLI OAuth credential dict."""
if expiry_date is None:
# 1 hour from now in milliseconds
expiry_date = int((time.time() + 3600) * 1000)
data = {
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": "Bearer",
"expiry_date": expiry_date,
"resource_url": "portal.qwen.ai",
}
data.update(extra)
return data
def _write_qwen_creds(tmp_path, tokens=None):
"""Write tokens to the Qwen CLI credentials file and return the path."""
qwen_dir = tmp_path / ".qwen"
qwen_dir.mkdir(parents=True, exist_ok=True)
creds_path = qwen_dir / "oauth_creds.json"
if tokens is None:
tokens = _make_qwen_tokens()
creds_path.write_text(json.dumps(tokens), encoding="utf-8")
return creds_path
@pytest.fixture()
def qwen_env(tmp_path, monkeypatch):
"""Redirect _qwen_cli_auth_path to tmp_path/.qwen/oauth_creds.json."""
creds_path = tmp_path / ".qwen" / "oauth_creds.json"
monkeypatch.setattr(
"hermes_cli.auth._qwen_cli_auth_path", lambda: creds_path
)
return tmp_path
# ---------------------------------------------------------------------------
# _qwen_cli_auth_path
# ---------------------------------------------------------------------------
def test_qwen_cli_auth_path_returns_expected_location():
path = _qwen_cli_auth_path()
assert path == Path.home() / ".qwen" / "oauth_creds.json"
# ---------------------------------------------------------------------------
# _read_qwen_cli_tokens
# ---------------------------------------------------------------------------
def test_read_qwen_cli_tokens_success(qwen_env):
tokens = _make_qwen_tokens(access_token="my-access")
_write_qwen_creds(qwen_env, tokens)
result = _read_qwen_cli_tokens()
assert result["access_token"] == "my-access"
assert result["refresh_token"] == "test-refresh-token"
def test_read_qwen_cli_tokens_missing_file(qwen_env):
with pytest.raises(AuthError) as exc:
_read_qwen_cli_tokens()
assert exc.value.code == "qwen_auth_missing"
def test_read_qwen_cli_tokens_invalid_json(qwen_env):
creds_path = qwen_env / ".qwen" / "oauth_creds.json"
creds_path.parent.mkdir(parents=True, exist_ok=True)
creds_path.write_text("not json{{{", encoding="utf-8")
with pytest.raises(AuthError) as exc:
_read_qwen_cli_tokens()
assert exc.value.code == "qwen_auth_read_failed"
def test_read_qwen_cli_tokens_non_dict(qwen_env):
creds_path = qwen_env / ".qwen" / "oauth_creds.json"
creds_path.parent.mkdir(parents=True, exist_ok=True)
creds_path.write_text(json.dumps(["a", "b"]), encoding="utf-8")
with pytest.raises(AuthError) as exc:
_read_qwen_cli_tokens()
assert exc.value.code == "qwen_auth_invalid"
# ---------------------------------------------------------------------------
# _save_qwen_cli_tokens
# ---------------------------------------------------------------------------
def test_save_qwen_cli_tokens_roundtrip(qwen_env):
tokens = _make_qwen_tokens(access_token="saved-token")
saved_path = _save_qwen_cli_tokens(tokens)
assert saved_path.exists()
loaded = json.loads(saved_path.read_text(encoding="utf-8"))
assert loaded["access_token"] == "saved-token"
def test_save_qwen_cli_tokens_creates_parent(qwen_env):
tokens = _make_qwen_tokens()
saved_path = _save_qwen_cli_tokens(tokens)
assert saved_path.parent.exists()
def test_save_qwen_cli_tokens_permissions(qwen_env):
tokens = _make_qwen_tokens()
saved_path = _save_qwen_cli_tokens(tokens)
mode = saved_path.stat().st_mode
assert mode & stat.S_IRUSR # owner read
assert mode & stat.S_IWUSR # owner write
assert not (mode & stat.S_IRGRP) # no group read
assert not (mode & stat.S_IROTH) # no other read
# ---------------------------------------------------------------------------
# _qwen_access_token_is_expiring
# ---------------------------------------------------------------------------
def test_expiring_token_not_expired():
# 1 hour from now in milliseconds
future_ms = int((time.time() + 3600) * 1000)
assert not _qwen_access_token_is_expiring(future_ms)
def test_expiring_token_already_expired():
# 1 hour ago in milliseconds
past_ms = int((time.time() - 3600) * 1000)
assert _qwen_access_token_is_expiring(past_ms)
def test_expiring_token_within_skew():
# Just inside the default skew window
near_ms = int((time.time() + QWEN_ACCESS_TOKEN_REFRESH_SKEW_SECONDS - 5) * 1000)
assert _qwen_access_token_is_expiring(near_ms)
def test_expiring_token_none_returns_true():
assert _qwen_access_token_is_expiring(None)
def test_expiring_token_non_numeric_returns_true():
assert _qwen_access_token_is_expiring("not-a-number")
# ---------------------------------------------------------------------------
# _refresh_qwen_cli_tokens
# ---------------------------------------------------------------------------
def test_refresh_qwen_cli_tokens_success(qwen_env):
tokens = _make_qwen_tokens(refresh_token="old-refresh")
resp = MagicMock()
resp.status_code = 200
resp.json.return_value = {
"access_token": "new-access",
"refresh_token": "new-refresh",
"expires_in": 7200,
}
with patch("hermes_cli.auth.httpx") as mock_httpx:
mock_httpx.post.return_value = resp
result = _refresh_qwen_cli_tokens(tokens)
assert result["access_token"] == "new-access"
assert result["refresh_token"] == "new-refresh"
assert "expiry_date" in result
def test_refresh_qwen_cli_tokens_preserves_old_refresh_if_not_in_response(qwen_env):
tokens = _make_qwen_tokens(refresh_token="keep-me")
resp = MagicMock()
resp.status_code = 200
resp.json.return_value = {
"access_token": "new-access",
# No refresh_token in response — should keep old one
"expires_in": 3600,
}
with patch("hermes_cli.auth.httpx") as mock_httpx:
mock_httpx.post.return_value = resp
result = _refresh_qwen_cli_tokens(tokens)
assert result["refresh_token"] == "keep-me"
def test_refresh_qwen_cli_tokens_missing_refresh_token():
tokens = {"access_token": "at", "refresh_token": ""}
with pytest.raises(AuthError) as exc:
_refresh_qwen_cli_tokens(tokens)
assert exc.value.code == "qwen_refresh_token_missing"
def test_refresh_qwen_cli_tokens_http_error(qwen_env):
tokens = _make_qwen_tokens()
resp = MagicMock()
resp.status_code = 401
resp.text = "unauthorized"
with patch("hermes_cli.auth.httpx") as mock_httpx:
mock_httpx.post.return_value = resp
with pytest.raises(AuthError) as exc:
_refresh_qwen_cli_tokens(tokens)
assert exc.value.code == "qwen_refresh_failed"
def test_refresh_qwen_cli_tokens_network_error(qwen_env):
tokens = _make_qwen_tokens()
with patch("hermes_cli.auth.httpx") as mock_httpx:
mock_httpx.post.side_effect = ConnectionError("timeout")
with pytest.raises(AuthError) as exc:
_refresh_qwen_cli_tokens(tokens)
assert exc.value.code == "qwen_refresh_failed"
def test_refresh_qwen_cli_tokens_invalid_json_response(qwen_env):
tokens = _make_qwen_tokens()
resp = MagicMock()
resp.status_code = 200
resp.json.side_effect = ValueError("bad json")
with patch("hermes_cli.auth.httpx") as mock_httpx:
mock_httpx.post.return_value = resp
with pytest.raises(AuthError) as exc:
_refresh_qwen_cli_tokens(tokens)
assert exc.value.code == "qwen_refresh_invalid_json"
def test_refresh_qwen_cli_tokens_missing_access_token_in_response(qwen_env):
tokens = _make_qwen_tokens()
resp = MagicMock()
resp.status_code = 200
resp.json.return_value = {"something": "but no access_token"}
with patch("hermes_cli.auth.httpx") as mock_httpx:
mock_httpx.post.return_value = resp
with pytest.raises(AuthError) as exc:
_refresh_qwen_cli_tokens(tokens)
assert exc.value.code == "qwen_refresh_invalid_response"
def test_refresh_qwen_cli_tokens_default_expires_in(qwen_env):
"""When expires_in is missing, default to 6 hours."""
tokens = _make_qwen_tokens()
resp = MagicMock()
resp.status_code = 200
resp.json.return_value = {"access_token": "new"}
with patch("hermes_cli.auth.httpx") as mock_httpx:
mock_httpx.post.return_value = resp
result = _refresh_qwen_cli_tokens(tokens)
# Verify expiry_date is roughly now + 6h (within 60s tolerance)
expected_ms = int(time.time() * 1000) + 6 * 60 * 60 * 1000
assert abs(result["expiry_date"] - expected_ms) < 60_000
def test_refresh_qwen_cli_tokens_saves_to_disk(qwen_env):
tokens = _make_qwen_tokens()
resp = MagicMock()
resp.status_code = 200
resp.json.return_value = {
"access_token": "disk-check",
"expires_in": 3600,
}
with patch("hermes_cli.auth.httpx") as mock_httpx:
mock_httpx.post.return_value = resp
_refresh_qwen_cli_tokens(tokens)
# Verify it was persisted
creds_path = qwen_env / ".qwen" / "oauth_creds.json"
assert creds_path.exists()
saved = json.loads(creds_path.read_text(encoding="utf-8"))
assert saved["access_token"] == "disk-check"
# ---------------------------------------------------------------------------
# resolve_qwen_runtime_credentials
# ---------------------------------------------------------------------------
def test_resolve_qwen_runtime_credentials_fresh_token(qwen_env):
tokens = _make_qwen_tokens(access_token="fresh-at")
_write_qwen_creds(qwen_env, tokens)
creds = resolve_qwen_runtime_credentials(refresh_if_expiring=False)
assert creds["provider"] == "qwen-oauth"
assert creds["api_key"] == "fresh-at"
assert creds["base_url"] == DEFAULT_QWEN_BASE_URL
assert creds["source"] == "qwen-cli"
def test_resolve_qwen_runtime_credentials_triggers_refresh(qwen_env):
# Write an expired token
expired_ms = int((time.time() - 3600) * 1000)
tokens = _make_qwen_tokens(access_token="old", expiry_date=expired_ms)
_write_qwen_creds(qwen_env, tokens)
refreshed = _make_qwen_tokens(access_token="refreshed-at")
with patch(
"hermes_cli.auth._refresh_qwen_cli_tokens", return_value=refreshed
) as mock_refresh:
creds = resolve_qwen_runtime_credentials()
mock_refresh.assert_called_once()
assert creds["api_key"] == "refreshed-at"
def test_resolve_qwen_runtime_credentials_force_refresh(qwen_env):
tokens = _make_qwen_tokens(access_token="old-at")
_write_qwen_creds(qwen_env, tokens)
refreshed = _make_qwen_tokens(access_token="force-refreshed")
with patch(
"hermes_cli.auth._refresh_qwen_cli_tokens", return_value=refreshed
) as mock_refresh:
creds = resolve_qwen_runtime_credentials(force_refresh=True)
mock_refresh.assert_called_once()
assert creds["api_key"] == "force-refreshed"
def test_resolve_qwen_runtime_credentials_missing_access_token(qwen_env):
tokens = _make_qwen_tokens(access_token="")
_write_qwen_creds(qwen_env, tokens)
with pytest.raises(AuthError) as exc:
resolve_qwen_runtime_credentials(refresh_if_expiring=False)
assert exc.value.code == "qwen_access_token_missing"
def test_resolve_qwen_runtime_credentials_base_url_env_override(qwen_env, monkeypatch):
tokens = _make_qwen_tokens(access_token="at")
_write_qwen_creds(qwen_env, tokens)
monkeypatch.setenv("HERMES_QWEN_BASE_URL", "https://custom.qwen.ai/v1")
creds = resolve_qwen_runtime_credentials(refresh_if_expiring=False)
assert creds["base_url"] == "https://custom.qwen.ai/v1"
# ---------------------------------------------------------------------------
# get_qwen_auth_status
# ---------------------------------------------------------------------------
def test_get_qwen_auth_status_logged_in(qwen_env):
tokens = _make_qwen_tokens(access_token="status-at")
_write_qwen_creds(qwen_env, tokens)
status = get_qwen_auth_status()
assert status["logged_in"] is True
assert status["api_key"] == "status-at"
def test_get_qwen_auth_status_refreshes_expired_token(qwen_env):
expired_ms = int((time.time() - 3600) * 1000)
tokens = _make_qwen_tokens(access_token="old-at", expiry_date=expired_ms)
_write_qwen_creds(qwen_env, tokens)
refreshed = _make_qwen_tokens(access_token="refreshed-at")
with patch(
"hermes_cli.auth._refresh_qwen_cli_tokens", return_value=refreshed
) as mock_refresh:
status = get_qwen_auth_status()
mock_refresh.assert_called_once()
assert status["logged_in"] is True
assert status["api_key"] == "refreshed-at"
def test_get_qwen_auth_status_expired_unrefreshable_token_is_not_logged_in(qwen_env):
expired_ms = int((time.time() - 3600) * 1000)
tokens = _make_qwen_tokens(access_token="dead-at", expiry_date=expired_ms)
_write_qwen_creds(qwen_env, tokens)
with patch(
"hermes_cli.auth._refresh_qwen_cli_tokens",
side_effect=AuthError(
"Qwen refresh rejected. Re-run 'qwen auth qwen-oauth'.",
provider="qwen-oauth",
code="qwen_refresh_failed",
),
) as mock_refresh:
status = get_qwen_auth_status()
mock_refresh.assert_called_once()
assert status["logged_in"] is False
assert "qwen auth qwen-oauth" in status["error"]
def test_get_qwen_auth_status_not_logged_in(qwen_env):
# No credentials file
status = get_qwen_auth_status()
assert status["logged_in"] is False
assert "error" in status
def test_model_flow_qwen_oauth_stale_token_shows_reauth_guidance(qwen_env, monkeypatch, capsys):
from hermes_cli.main import _model_flow_qwen_oauth
expired_ms = int((time.time() - 3600) * 1000)
tokens = _make_qwen_tokens(access_token="dead-at", expiry_date=expired_ms)
_write_qwen_creds(qwen_env, tokens)
monkeypatch.setattr(
"hermes_cli.auth._refresh_qwen_cli_tokens",
lambda *args, **kwargs: (_ for _ in ()).throw(
AuthError(
"Qwen refresh rejected. Re-run 'qwen auth qwen-oauth'.",
provider="qwen-oauth",
code="qwen_refresh_failed",
)
),
)
prompt_called = {"value": False}
update_called = {"value": False}
monkeypatch.setattr(
"hermes_cli.auth._prompt_model_selection",
lambda *args, **kwargs: prompt_called.__setitem__("value", True),
)
monkeypatch.setattr(
"hermes_cli.auth._update_config_for_provider",
lambda *args, **kwargs: update_called.__setitem__("value", True),
)
_model_flow_qwen_oauth({}, current_model="qwen3-coder-plus")
out = capsys.readouterr().out
assert "Run: qwen auth qwen-oauth" in out
assert "Qwen refresh rejected" in out
assert prompt_called["value"] is False
assert update_called["value"] is False
+115
View File
@@ -0,0 +1,115 @@
"""Tests for hermes_cli.auth._default_verify platform-aware fallback.
On macOS with Homebrew Python, the system OpenSSL cannot locate the
system trust store, so we explicitly load certifi's bundle. On other
platforms we defer to httpx's own default (which itself uses certifi).
Most tests use monkeypatching — no real SSL handshakes. A handful use
an openssl-generated self-signed cert via the `real_bundle_file`
fixture because `ssl.create_default_context(cafile=...)` parses the
bundle and refuses stubs.
"""
import os
import shutil
import ssl
import subprocess
import sys
from pathlib import Path
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from hermes_cli.auth import _default_verify, _resolve_verify
@pytest.fixture
def real_bundle_file(tmp_path: Path) -> str:
"""Return a path to a real openssl-generated self-signed cert.
Skips the test when the `openssl` binary isn't on PATH, so CI images
without it degrade gracefully instead of erroring out.
"""
if shutil.which("openssl") is None:
pytest.skip("openssl binary not available")
cert = tmp_path / "ca.pem"
key = tmp_path / "key.pem"
result = subprocess.run(
[
"openssl", "req", "-x509", "-newkey", "rsa:2048",
"-keyout", str(key), "-out", str(cert),
"-sha256", "-days", "1", "-nodes",
"-subj", "/CN=test",
],
capture_output=True,
timeout=10,
)
if result.returncode != 0:
pytest.skip(f"openssl failed: {result.stderr.decode('utf-8', 'ignore')[:200]}")
return str(cert)
class TestDefaultVerify:
def test_returns_ssl_context_on_darwin(self, monkeypatch):
monkeypatch.setattr(sys, "platform", "darwin")
result = _default_verify()
assert isinstance(result, ssl.SSLContext)
def test_returns_true_on_linux(self, monkeypatch):
monkeypatch.setattr(sys, "platform", "linux")
assert _default_verify() is True
def test_returns_true_on_windows(self, monkeypatch):
monkeypatch.setattr(sys, "platform", "win32")
assert _default_verify() is True
def test_darwin_falls_back_to_true_when_certifi_missing(self, monkeypatch):
monkeypatch.setattr(sys, "platform", "darwin")
real_import = __import__
def fake_import(name, *args, **kwargs):
if name == "certifi":
raise ImportError("simulated missing certifi")
return real_import(name, *args, **kwargs)
monkeypatch.setattr("builtins.__import__", fake_import)
assert _default_verify() is True
class TestResolveVerifyIntegration:
"""_resolve_verify should defer to _default_verify in the no-CA path."""
def test_no_ca_uses_default_verify_on_darwin(self, monkeypatch):
monkeypatch.setattr(sys, "platform", "darwin")
for var in ("HERMES_CA_BUNDLE", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE"):
monkeypatch.delenv(var, raising=False)
result = _resolve_verify()
assert isinstance(result, ssl.SSLContext)
def test_no_ca_uses_default_verify_on_linux(self, monkeypatch):
monkeypatch.setattr(sys, "platform", "linux")
for var in ("HERMES_CA_BUNDLE", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE"):
monkeypatch.delenv(var, raising=False)
assert _resolve_verify() is True
def test_requests_ca_bundle_respected(self, monkeypatch, real_bundle_file):
for var in ("HERMES_CA_BUNDLE", "SSL_CERT_FILE"):
monkeypatch.delenv(var, raising=False)
monkeypatch.setenv("REQUESTS_CA_BUNDLE", real_bundle_file)
result = _resolve_verify()
assert isinstance(result, ssl.SSLContext)
def test_missing_ca_path_falls_back_to_default_verify(self, monkeypatch, tmp_path):
monkeypatch.setattr(sys, "platform", "linux")
monkeypatch.setenv("HERMES_CA_BUNDLE", str(tmp_path / "missing.pem"))
for var in ("SSL_CERT_FILE", "REQUESTS_CA_BUNDLE"):
monkeypatch.delenv(var, raising=False)
assert _resolve_verify() is True
def test_insecure_wins_over_everything(self, monkeypatch, tmp_path):
bundle = tmp_path / "ca.pem"
bundle.write_text("stub")
monkeypatch.setenv("HERMES_CA_BUNDLE", str(bundle))
assert _resolve_verify(insecure=True) is False
@@ -0,0 +1,202 @@
"""Regression tests for TOCTOU-safe credential file writers in ``hermes_cli.auth``.
Background
==========
The three writers below used to create a temp file via ``Path.write_text`` /
``Path.open('w')`` and only ``chmod``'d it to ``0o600`` afterward. Between
create and chmod the file existed at the process umask (typically ``0o644``),
briefly exposing OAuth tokens to other local users on multi-user hosts. The
fix switches them to ``os.open(O_EXCL, mode=0o600)`` + ``os.fdopen`` +
``fsync`` so the file is atomic at ``0o600`` on creation. Mirrors the fixes
shipped for ``agent/google_oauth.py`` (#19673) and ``tools/mcp_oauth.py``
(#21148).
These tests stay green only while the token file and its parent directory
end up at ``0o600`` / ``0o700`` after every write. POSIX-only — the mode-bit
enforcement does not exist on Windows.
"""
from __future__ import annotations
import json
import os
import stat
import sys
from unittest.mock import patch
import pytest
pytestmark = pytest.mark.skipif(
sys.platform.startswith("win"),
reason="POSIX mode bits not enforced on Windows",
)
# ---------------------------------------------------------------------------
# _save_auth_store (~/.hermes/auth.json — every native OAuth provider)
# ---------------------------------------------------------------------------
def test_save_auth_store_writes_0o600_with_0o700_parent(tmp_path, monkeypatch):
"""``_save_auth_store`` must land ``auth.json`` at 0o600 and parent at 0o700."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
old_umask = os.umask(0o022) # make the race observable if it regresses
try:
from hermes_cli import auth as auth_mod
auth_store = {
"version": auth_mod.AUTH_STORE_VERSION,
"providers": {"openai-codex": {"tokens": {"access_token": "secret-x"}}},
"active_provider": "openai-codex",
}
auth_path = auth_mod._save_auth_store(auth_store)
finally:
os.umask(old_umask)
mode = stat.S_IMODE(auth_path.stat().st_mode)
parent_mode = stat.S_IMODE(auth_path.parent.stat().st_mode)
assert mode == 0o600, (
f"auth.json mode 0o{mode:o} != 0o600 — TOCTOU race regressed"
)
assert parent_mode == 0o700, (
f"auth.json parent dir mode 0o{parent_mode:o} != 0o700 — siblings can traverse"
)
# Content survived the rewrite
data = json.loads(auth_path.read_text())
assert data["providers"]["openai-codex"]["tokens"]["access_token"] == "secret-x"
# ---------------------------------------------------------------------------
# _save_qwen_cli_tokens (Qwen CLI OAuth tokens)
# ---------------------------------------------------------------------------
def test_save_qwen_cli_tokens_writes_0o600_with_0o700_parent(tmp_path, monkeypatch):
"""``_save_qwen_cli_tokens`` must land the token file at 0o600 and parent at 0o700."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
# The Qwen CLI auth path lives under $HOME/.qwen by default — isolate it.
monkeypatch.setenv("HOME", str(tmp_path))
old_umask = os.umask(0o022)
try:
from hermes_cli import auth as auth_mod
tokens = {
"access_token": "qwen-secret",
"refresh_token": "qwen-refresh",
"token_type": "Bearer",
"expiry_date": 123,
}
auth_path = auth_mod._save_qwen_cli_tokens(tokens)
finally:
os.umask(old_umask)
mode = stat.S_IMODE(auth_path.stat().st_mode)
parent_mode = stat.S_IMODE(auth_path.parent.stat().st_mode)
assert mode == 0o600, (
f"Qwen token file mode 0o{mode:o} != 0o600 — TOCTOU race regressed"
)
assert parent_mode == 0o700, (
f"Qwen token parent dir mode 0o{parent_mode:o} != 0o700"
)
data = json.loads(auth_path.read_text())
assert data["access_token"] == "qwen-secret"
# ---------------------------------------------------------------------------
# Nous shared-credential store write (inside _write_shared_nous_state)
# ---------------------------------------------------------------------------
def test_shared_nous_store_writes_0o600_with_0o700_parent(tmp_path, monkeypatch):
"""The Nous shared-credential store must land at 0o600 / parent 0o700."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
# _nous_shared_store_path() refuses to touch the real shared store during
# pytest runs; redirect it into tmp_path explicitly. Use a distinct
# subdirectory name (``shared_override``) so the guard's "real user
# home" reference — which currently tracks HERMES_HOME via
# get_default_hermes_root() — can't collide with our override and
# falsely claim we're writing to the real user's shared store.
monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared_override"))
old_umask = os.umask(0o022)
try:
from hermes_cli import auth as auth_mod
state = {
"access_token": "nous-access-xxx",
"refresh_token": "nous-refresh-xxx",
"token_type": "Bearer",
"scope": "openid profile",
"client_id": "test-client",
"obtained_at": "2026-01-01T00:00:00Z",
"expires_at": "2026-01-01T01:00:00Z",
}
auth_mod._write_shared_nous_state(state)
path = auth_mod._nous_shared_store_path()
finally:
os.umask(old_umask)
assert path.exists(), "shared Nous store was not written"
mode = stat.S_IMODE(path.stat().st_mode)
parent_mode = stat.S_IMODE(path.parent.stat().st_mode)
assert mode == 0o600, (
f"Nous shared store mode 0o{mode:o} != 0o600 — TOCTOU race regressed"
)
assert parent_mode == 0o700, (
f"Nous shared store parent dir mode 0o{parent_mode:o} != 0o700"
)
data = json.loads(path.read_text())
assert data["refresh_token"] == "nous-refresh-xxx"
# ---------------------------------------------------------------------------
# Atomicity: verify ``os.open`` is called with an explicit 0o600 mode.
# ---------------------------------------------------------------------------
def test_save_auth_store_uses_os_open_with_0o600_mode(tmp_path, monkeypatch):
"""Regression: the writer must call ``os.open`` with an explicit restricted
mode so the file is created at 0o600 atomically — closing the TOCTOU
window the previous ``Path.open('w')`` left open (fd inherited process
umask and was briefly 0o644 before post-write chmod)."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
observed_opens: list[tuple[str, int, int]] = []
real_os_open = os.open
def spying_os_open(path, flags, mode=0o777, *args, **kwargs):
observed_opens.append((str(path), flags, mode))
return real_os_open(path, flags, mode, *args, **kwargs)
with patch.object(os, "open", spying_os_open):
from hermes_cli import auth as auth_mod
auth_mod._save_auth_store(
{"version": auth_mod.AUTH_STORE_VERSION, "providers": {}}
)
auth_tmp_opens = [
(p, fl, m) for (p, fl, m) in observed_opens if "auth.json.tmp" in p
]
assert auth_tmp_opens, (
f"os.open was never called for the auth.json temp file; "
f"observed={observed_opens!r}"
)
for path, flags, mode in auth_tmp_opens:
assert flags & os.O_CREAT, f"auth.json temp open missing O_CREAT: path={path}"
assert flags & os.O_EXCL, (
f"auth.json temp open missing O_EXCL — TOCTOU-safe pattern regressed: "
f"path={path}, flags={flags}"
)
# Must be exactly S_IRUSR | S_IWUSR (0o600) — no group/other bits.
expected = stat.S_IRUSR | stat.S_IWUSR
assert mode == expected, (
f"auth.json temp open mode 0o{mode:o} != 0o{expected:o}"
f"umask would apply and potentially expose tokens"
)
@@ -0,0 +1,13 @@
"""Tests for placeholder API key detection in hermes_cli.auth."""
from hermes_cli.auth import has_usable_secret
def test_has_usable_secret_rejects_documented_placeholder_key() -> None:
"""Network-exposed API server key must reject static documentation placeholders."""
assert not has_usable_secret("your_api_key_here", min_length=8)
def test_has_usable_secret_accepts_generated_key() -> None:
"""Random-looking keys should still be accepted."""
assert has_usable_secret("b4d59f7fe8b857d0b367ef0f5710b6a4", min_length=8)
File diff suppressed because it is too large Load Diff
+301
View File
@@ -0,0 +1,301 @@
"""Tests for the auxiliary-model configuration UI in ``hermes model``.
Covers the helper functions:
- ``_save_aux_choice`` writes to config.yaml without touching main model config
- ``_reset_aux_to_auto`` clears routing fields but preserves timeouts
- ``_format_aux_current`` renders current task config for the menu
- ``_AUX_TASKS`` stays in sync with ``DEFAULT_CONFIG["auxiliary"]``
These are pure-function tests — the interactive menu loops are not covered
here (they're stdin-driven curses prompts).
"""
from __future__ import annotations
import pytest
from hermes_cli.config import DEFAULT_CONFIG, load_config
from hermes_cli.main import (
_AUX_TASKS,
_format_aux_current,
_reset_aux_to_auto,
_save_aux_choice,
)
# ── Default config ──────────────────────────────────────────────────────────
def test_title_generation_present_in_default_config():
"""`title_generation` task must be defined in DEFAULT_CONFIG.
Regression for an existing gap: title_generator.py calls
``call_llm(task="title_generation", ...)`` but the task was missing
from DEFAULT_CONFIG["auxiliary"], so the config-backed timeout/provider
overrides never worked for that task.
"""
assert "title_generation" in DEFAULT_CONFIG["auxiliary"]
tg = DEFAULT_CONFIG["auxiliary"]["title_generation"]
assert tg["provider"] == "auto"
assert tg["model"] == ""
assert tg["timeout"] > 0
assert tg["extra_body"] == {}
def test_session_search_no_longer_appears_in_auxiliary_model_config():
"""session_search is a direct DB-backed tool, not an auxiliary LLM task."""
assert "session_search" not in DEFAULT_CONFIG["auxiliary"]
assert "session_search" not in {key for key, _name, _desc in _AUX_TASKS}
def test_aux_tasks_keys_all_exist_in_default_config():
"""Every task the menu offers must be defined in DEFAULT_CONFIG."""
aux_keys = {k for k, _name, _desc in _AUX_TASKS}
default_keys = set(DEFAULT_CONFIG["auxiliary"].keys())
missing = aux_keys - default_keys
assert not missing, (
f"_AUX_TASKS references tasks not in DEFAULT_CONFIG.auxiliary: {missing}"
)
# ── _format_aux_current ─────────────────────────────────────────────────────
@pytest.mark.parametrize(
"task_cfg,expected",
[
({}, "auto"),
({"provider": "", "model": ""}, "auto"),
({"provider": "auto", "model": ""}, "auto"),
({"provider": "auto", "model": "gpt-4o"}, "auto · gpt-4o"),
({"provider": "openrouter", "model": ""}, "openrouter"),
(
{"provider": "openrouter", "model": "google/gemini-2.5-flash"},
"openrouter · google/gemini-2.5-flash",
),
({"provider": "nous", "model": "gemini-3-flash"}, "nous · gemini-3-flash"),
(
{"provider": "custom", "base_url": "http://localhost:11434/v1", "model": ""},
"custom (localhost:11434/v1)",
),
(
{
"provider": "custom",
"base_url": "http://localhost:11434/v1/",
"model": "qwen2.5:32b",
},
"custom (localhost:11434/v1) · qwen2.5:32b",
),
],
)
def test_format_aux_current(task_cfg, expected):
assert _format_aux_current(task_cfg) == expected
def test_format_aux_current_handles_non_dict():
assert _format_aux_current(None) == "auto"
assert _format_aux_current("string") == "auto"
# ── _save_aux_choice ────────────────────────────────────────────────────────
def test_save_aux_choice_persists_to_config_yaml(tmp_path, monkeypatch):
"""Saving a task writes provider/model/base_url/api_key to auxiliary.<task>."""
from pathlib import Path
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
(tmp_path / ".hermes").mkdir(exist_ok=True)
_save_aux_choice(
"vision", provider="openrouter", model="google/gemini-2.5-flash",
)
cfg = load_config()
v = cfg["auxiliary"]["vision"]
assert v["provider"] == "openrouter"
assert v["model"] == "google/gemini-2.5-flash"
assert v["base_url"] == ""
assert v["api_key"] == ""
def test_save_aux_choice_preserves_timeout(tmp_path, monkeypatch):
"""Saving must NOT clobber user-tuned timeout values."""
from pathlib import Path
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
(tmp_path / ".hermes").mkdir(exist_ok=True)
# Default vision timeout is 120
cfg_before = load_config()
default_timeout = cfg_before["auxiliary"]["vision"]["timeout"]
assert default_timeout == 120
_save_aux_choice("vision", provider="nous", model="gemini-3-flash")
cfg_after = load_config()
assert cfg_after["auxiliary"]["vision"]["timeout"] == default_timeout
# download_timeout also preserved for vision
assert cfg_after["auxiliary"]["vision"].get("download_timeout") == 30
def test_save_aux_choice_does_not_touch_main_model(tmp_path, monkeypatch):
"""Aux config must never mutate model.default / model.provider / model.base_url."""
from pathlib import Path
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
(tmp_path / ".hermes").mkdir(exist_ok=True)
# Simulate a configured main model
from hermes_cli.config import save_config
cfg = load_config()
cfg["model"] = {
"default": "claude-sonnet-4.6",
"provider": "anthropic",
"base_url": "",
}
save_config(cfg)
_save_aux_choice(
"compression", provider="custom",
base_url="http://localhost:11434/v1", model="qwen2.5:32b",
)
cfg = load_config()
# Main model untouched
assert cfg["model"]["default"] == "claude-sonnet-4.6"
assert cfg["model"]["provider"] == "anthropic"
# Aux saved correctly
c = cfg["auxiliary"]["compression"]
assert c["provider"] == "custom"
assert c["model"] == "qwen2.5:32b"
assert c["base_url"] == "http://localhost:11434/v1"
def test_save_aux_choice_creates_missing_task_entry(tmp_path, monkeypatch):
"""Saving a task that was wiped from config.yaml should recreate it."""
from pathlib import Path
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
(tmp_path / ".hermes").mkdir(exist_ok=True)
# Remove vision from config entirely
from hermes_cli.config import save_config
cfg = load_config()
cfg.setdefault("auxiliary", {}).pop("vision", None)
save_config(cfg)
_save_aux_choice("vision", provider="nous", model="gemini-3-flash")
cfg = load_config()
assert cfg["auxiliary"]["vision"]["provider"] == "nous"
assert cfg["auxiliary"]["vision"]["model"] == "gemini-3-flash"
# ── _reset_aux_to_auto ──────────────────────────────────────────────────────
def test_reset_aux_to_auto_clears_routing_preserves_timeouts(tmp_path, monkeypatch):
from pathlib import Path
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
(tmp_path / ".hermes").mkdir(exist_ok=True)
# Configure two tasks non-auto, and bump a timeout
_save_aux_choice("vision", provider="openrouter", model="gpt-4o")
_save_aux_choice("compression", provider="nous", model="gemini-3-flash")
from hermes_cli.config import save_config
cfg = load_config()
cfg["auxiliary"]["vision"]["timeout"] = 300 # user-tuned
save_config(cfg)
n = _reset_aux_to_auto()
assert n == 2 # both changed
cfg = load_config()
for task in ("vision", "compression"):
v = cfg["auxiliary"][task]
assert v["provider"] == "auto"
assert v["model"] == ""
assert v["base_url"] == ""
assert v["api_key"] == ""
# User-tuned timeout survives reset
assert cfg["auxiliary"]["vision"]["timeout"] == 300
# Default compression timeout preserved
assert cfg["auxiliary"]["compression"]["timeout"] == 120
def test_reset_aux_to_auto_idempotent(tmp_path, monkeypatch):
"""Second reset on already-auto config returns 0 without errors."""
from pathlib import Path
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
(tmp_path / ".hermes").mkdir(exist_ok=True)
assert _reset_aux_to_auto() == 0
_save_aux_choice("vision", provider="nous", model="gemini-3-flash")
assert _reset_aux_to_auto() == 1
assert _reset_aux_to_auto() == 0
# ── Menu dispatch ───────────────────────────────────────────────────────────
def test_select_provider_and_model_dispatches_to_aux_menu(tmp_path, monkeypatch):
"""Picking 'Configure auxiliary models...' in the provider list calls _aux_config_menu."""
from pathlib import Path
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
(tmp_path / ".hermes").mkdir(exist_ok=True)
from hermes_cli import main as main_mod
called = {"aux": 0, "flow": 0}
def fake_prompt(choices, *, default=0):
# Find the aux-config entry by its label text and return its index
for i, label in enumerate(choices):
if "Configure auxiliary models" in label:
return i
raise AssertionError("aux entry not in provider list")
monkeypatch.setattr(main_mod, "_prompt_provider_choice", fake_prompt)
monkeypatch.setattr(main_mod, "_aux_config_menu", lambda: called.__setitem__("aux", called["aux"] + 1))
# Guard against any main flow accidentally running
monkeypatch.setattr(main_mod, "_model_flow_openrouter",
lambda *a, **kw: called.__setitem__("flow", called["flow"] + 1))
main_mod.select_provider_and_model()
assert called["aux"] == 1, "aux menu not invoked"
assert called["flow"] == 0, "main provider flow should not run"
def test_leave_unchanged_replaces_cancel_label(tmp_path, monkeypatch):
"""The bottom cancel entry now reads 'Leave unchanged' (UX polish)."""
from pathlib import Path
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
(tmp_path / ".hermes").mkdir(exist_ok=True)
from hermes_cli import main as main_mod
captured: list[list[str]] = []
def fake_prompt(choices, *, default=0):
captured.append(list(choices))
# Pick 'Leave unchanged' (last item) to exit cleanly
for i, label in enumerate(choices):
if label == "Leave unchanged":
return i
raise AssertionError("Leave unchanged not in provider list")
monkeypatch.setattr(main_mod, "_prompt_provider_choice", fake_prompt)
main_mod.select_provider_and_model()
assert captured, "provider menu never rendered"
labels = captured[0]
assert "Leave unchanged" in labels
assert "Cancel" not in labels, "Cancel label should be replaced"
assert any("Configure auxiliary models" in label for label in labels)
+237
View File
@@ -0,0 +1,237 @@
"""Tests for hermes_cli.azure_detect — transport & model auto-detection."""
from __future__ import annotations
import json
from unittest.mock import MagicMock, patch
import pytest
from hermes_cli import azure_detect
# ----------------------------------------------------------------------
# Helpers
# ----------------------------------------------------------------------
class _FakeHTTPResponse:
"""Minimal stand-in for urllib.request.urlopen's context manager."""
def __init__(self, status: int, body: bytes):
self.status = status
self._body = body
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def read(self) -> bytes:
return self._body
def _openai_models_body(*ids: str) -> bytes:
return json.dumps({
"object": "list",
"data": [{"id": i, "object": "model"} for i in ids],
}).encode()
def _anthropic_error_body(msg: str = "model not found") -> bytes:
return json.dumps({
"type": "error",
"error": {"type": "invalid_request_error", "message": msg},
}).encode()
# ----------------------------------------------------------------------
# _looks_like_anthropic_path
# ----------------------------------------------------------------------
@pytest.mark.parametrize("url, expected", [
("https://foo.services.ai.azure.com/anthropic", True),
("https://foo.services.ai.azure.com/anthropic/", True),
("https://foo.services.ai.azure.com/anthropic/v1", True),
("https://foo.openai.azure.com/openai/v1", False),
("https://foo.openai.azure.com/", False),
("https://openrouter.ai/api/v1", False),
])
def test_looks_like_anthropic_path(url, expected):
assert azure_detect._looks_like_anthropic_path(url) is expected
# ----------------------------------------------------------------------
# _extract_model_ids
# ----------------------------------------------------------------------
def test_extract_model_ids_openai_shape():
body = {
"object": "list",
"data": [
{"id": "gpt-4.1-mini", "object": "model"},
{"id": "claude-sonnet-4-6", "object": "model"},
],
}
assert azure_detect._extract_model_ids(body) == ["gpt-4.1-mini", "claude-sonnet-4-6"]
def test_extract_model_ids_bad_shape_returns_empty():
assert azure_detect._extract_model_ids({}) == []
assert azure_detect._extract_model_ids({"data": "not-a-list"}) == []
assert azure_detect._extract_model_ids({"data": [{"no-id": True}]}) == []
# ----------------------------------------------------------------------
# detect() integration
# ----------------------------------------------------------------------
def test_detect_anthropic_path_wins_without_http():
"""URL path sniff short-circuits — no HTTP call happens."""
with patch.object(azure_detect, "_http_get_json") as fake_get, \
patch.object(azure_detect, "_probe_anthropic_messages") as fake_probe:
result = azure_detect.detect(
"https://foo.services.ai.azure.com/anthropic", "key-abc",
)
assert result.api_mode == "anthropic_messages"
assert result.is_anthropic is True
assert "path" in result.reason.lower()
fake_get.assert_not_called()
fake_probe.assert_not_called()
def test_detect_openai_models_probe_success():
"""/models probe returning a model list → chat_completions."""
def _fake_get(url, api_key, timeout=6.0, **kwargs):
assert "key-abc" == api_key
return 200, json.loads(_openai_models_body("gpt-5.4", "claude-opus-4-6"))
with patch.object(azure_detect, "_http_get_json", side_effect=_fake_get):
result = azure_detect.detect(
"https://my.openai.azure.com/openai/v1", "key-abc",
)
assert result.api_mode == "chat_completions"
assert result.models_probe_ok is True
assert result.models == ["gpt-5.4", "claude-opus-4-6"]
assert "/models" in result.reason
def test_detect_openai_models_probe_empty_list_still_counts():
"""Endpoint returned OpenAI shape but no models → still chat_completions."""
def _fake_get(url, api_key, timeout=6.0, **kwargs):
return 200, {"object": "list", "data": []}
with patch.object(azure_detect, "_http_get_json", side_effect=_fake_get):
result = azure_detect.detect(
"https://my.openai.azure.com/openai/v1", "key-abc",
)
assert result.api_mode == "chat_completions"
assert result.models == []
assert result.models_probe_ok is True
def test_detect_falls_back_to_anthropic_probe():
"""/models fails but Anthropic Messages probe succeeds."""
def _fake_get(url, api_key, timeout=6.0, **kwargs):
return 401, None # /models forbidden
with patch.object(azure_detect, "_http_get_json", side_effect=_fake_get), \
patch.object(azure_detect, "_probe_anthropic_messages", return_value=True):
result = azure_detect.detect(
"https://my.services.ai.azure.com/v1", "key-abc",
)
assert result.api_mode == "anthropic_messages"
assert result.is_anthropic is True
def test_detect_all_probes_fail_returns_none():
"""Every probe fails → api_mode is None and caller falls back to manual."""
with patch.object(azure_detect, "_http_get_json", return_value=(500, None)), \
patch.object(azure_detect, "_probe_anthropic_messages", return_value=False):
result = azure_detect.detect(
"https://some-private.example.com/", "key-abc",
)
assert result.api_mode is None
assert result.models == []
assert "manual" in result.reason.lower()
# ----------------------------------------------------------------------
# _probe_openai_models URL list (Azure vs v1 api-version)
# ----------------------------------------------------------------------
def test_probe_openai_models_tries_multiple_api_versions():
"""First call (no api-version) fails, api-version fallback succeeds."""
calls = []
def _fake_get(url, api_key, timeout=6.0, **kwargs):
calls.append(url)
if "api-version" not in url:
return 404, None
return 200, json.loads(_openai_models_body("gpt-4.1"))
with patch.object(azure_detect, "_http_get_json", side_effect=_fake_get):
ok, models = azure_detect._probe_openai_models(
"https://my.openai.azure.com/openai/v1", "k",
)
assert ok is True
assert models == ["gpt-4.1"]
# Should have tried without api-version first, then with at least one
assert any("api-version" not in u for u in calls)
assert any("api-version" in u for u in calls)
# ----------------------------------------------------------------------
# _http_get_json error handling
# ----------------------------------------------------------------------
def test_http_get_json_on_urlerror_returns_zero_none():
"""Network failure returns (0, None), never raises."""
import urllib.error
with patch("hermes_cli.azure_detect.urllib_request.urlopen",
side_effect=urllib.error.URLError("dns fail")):
status, body = azure_detect._http_get_json("https://bad.example/", "k")
assert status == 0
assert body is None
def test_http_get_json_on_http_error_returns_code_none():
"""HTTP 4xx/5xx returns (code, None)."""
import urllib.error
err = urllib.error.HTTPError("https://x/", 403, "Forbidden", {}, None)
with patch("hermes_cli.azure_detect.urllib_request.urlopen", side_effect=err):
status, body = azure_detect._http_get_json("https://x/", "k")
assert status == 403
assert body is None
# ----------------------------------------------------------------------
# lookup_context_length
# ----------------------------------------------------------------------
def test_lookup_context_length_returns_known():
"""When model_metadata returns a non-fallback value, we pass it through."""
fake = MagicMock(return_value=400000)
with patch("agent.model_metadata.get_model_context_length", fake), \
patch("agent.model_metadata.DEFAULT_FALLBACK_CONTEXT", 128000):
n = azure_detect.lookup_context_length(
"gpt-5.4", "https://x.openai.azure.com/openai/v1", "k",
)
assert n == 400000
def test_lookup_context_length_returns_none_on_fallback():
"""When resolver falls through to DEFAULT_FALLBACK_CONTEXT, we return None."""
with patch("agent.model_metadata.get_model_context_length", return_value=128000), \
patch("agent.model_metadata.DEFAULT_FALLBACK_CONTEXT", 128000):
n = azure_detect.lookup_context_length(
"totally-unknown-model", "https://x.openai.azure.com/openai/v1", "k",
)
assert n is None
def test_lookup_context_length_swallows_exceptions():
"""Resolver raising must not crash the wizard."""
with patch("agent.model_metadata.get_model_context_length",
side_effect=RuntimeError("boom")):
assert azure_detect.lookup_context_length("m", "https://x/", "k") is None
@@ -0,0 +1,403 @@
"""Tests for Azure Foundry Entra ID runtime resolution.
Covers the contract introduced in PR for Microsoft Entra ID auth on
``azure-foundry``:
* ``_resolve_azure_foundry_runtime`` returns a callable ``api_key`` for
``model.auth_mode = entra_id`` (OpenAI-style only).
* Anthropic-style endpoints with ``auth_mode = entra_id`` return the same
callable runtime credential as OpenAI-style endpoints.
* The legacy ``api_key`` path is unchanged when ``auth_mode`` is absent
or set to ``api_key``.
* Explicit ``--api-key`` overrides at runtime still work in entra mode
(escape hatch for one-off testing).
* ``model.entra.scope`` propagates to the token-provider config; Azure
identity selection stays in standard AZURE_* env vars.
* ``_get_azure_foundry_auth_status`` is structural — never mints a
token (verified by checking the credential cache untouched).
* ``has_usable_secret`` for ``AZURE_FOUNDRY_API_KEY`` is irrelevant
when ``auth_mode == entra_id``.
"""
from __future__ import annotations
import sys
from types import SimpleNamespace
from typing import cast
import pytest
@pytest.fixture(autouse=True)
def _reset_credential_cache():
from agent.azure_identity_adapter import reset_credential_cache
reset_credential_cache()
yield
reset_credential_cache()
@pytest.fixture
def fake_azure_identity(monkeypatch):
"""Identical fake to test_azure_identity_adapter — keeps Azure SDK
out of these tests so they run in CI without the package installed."""
from agent import azure_identity_adapter as _adapter
last = {"scope": None, "kwargs": None, "credential_count": 0}
def _provider(scope):
return lambda: f"jwt-for-{scope}"
fake_module = SimpleNamespace(
DefaultAzureCredential=lambda **kw: SimpleNamespace(
kwargs=kw,
get_token=lambda scope: SimpleNamespace(token="fake", expires_on=9999999999),
),
get_bearer_token_provider=lambda credential, scope: (
last.__setitem__("scope", scope),
last.__setitem__("kwargs", credential.kwargs),
last.__setitem__("credential_count", cast(int, last["credential_count"]) + 1),
_provider(scope),
)[-1],
)
monkeypatch.setattr(_adapter, "_require_azure_identity", lambda: fake_module)
monkeypatch.setitem(sys.modules, "azure.identity", fake_module)
return last
# ---------------------------------------------------------------------------
# _resolve_azure_foundry_runtime: entra_id branch
# ---------------------------------------------------------------------------
class TestResolveAzureFoundryRuntimeEntra:
def test_returns_callable_api_key_for_entra(self, fake_azure_identity):
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
runtime = _resolve_azure_foundry_runtime(
requested_provider="azure-foundry",
model_cfg={
"provider": "azure-foundry",
"base_url": "https://my-resource.openai.azure.com/openai/v1",
"api_mode": "chat_completions",
"auth_mode": "entra_id",
"default": "gpt-4o", # stays on chat_completions (no codex auto-upgrade)
},
)
assert runtime["provider"] == "azure-foundry"
assert runtime["auth_mode"] == "entra_id"
assert runtime["api_mode"] == "chat_completions"
assert callable(runtime["api_key"])
assert runtime["source"] == "entra_id"
def test_entra_inherits_codex_responses_for_gpt5_family(self, fake_azure_identity):
"""GPT-5.x / o-series / codex models on Azure are Responses-API-only.
The runtime auto-upgrades api_mode regardless of auth mode — this is
the same behaviour as the static-key path (see
``hermes_cli/models.py::azure_foundry_model_api_mode``)."""
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
runtime = _resolve_azure_foundry_runtime(
requested_provider="azure-foundry",
model_cfg={
"provider": "azure-foundry",
"base_url": "https://my-resource.openai.azure.com/openai/v1",
"api_mode": "chat_completions",
"auth_mode": "entra_id",
"default": "gpt-5.4",
},
)
# GPT-5.x is upgraded to codex_responses — Entra path inherits.
assert runtime["api_mode"] == "codex_responses"
assert callable(runtime["api_key"])
assert runtime["auth_mode"] == "entra_id"
def test_entra_propagates_scope_only(self, fake_azure_identity):
"""``model.entra.scope`` is the only Hermes-managed Azure SDK
setting. Identity selection (client ID, tenant, authority,
service principal secret, federated token file) flows through
standard ``AZURE_*`` env vars read by azure-identity directly.
Legacy ``model.entra.client_id`` / ``tenant_id`` / ``authority``
keys in config.yaml are silently ignored."""
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
_resolve_azure_foundry_runtime(
requested_provider="azure-foundry",
model_cfg={
"provider": "azure-foundry",
"base_url": "https://my-resource.services.ai.azure.com/v1",
"api_mode": "chat_completions",
"auth_mode": "entra_id",
"entra": {
"scope": "https://custom.example/.default",
"client_id": "client-uuid",
# Legacy keys must not crash — they are accepted in
# from_dict but never propagated to the SDK.
"tenant_id": "legacy-tenant",
"authority": "https://login.microsoftonline.us",
},
},
)
assert fake_azure_identity["scope"] == "https://custom.example/.default"
kw = fake_azure_identity["kwargs"]
assert "managed_identity_client_id" not in kw
assert "workload_identity_client_id" not in kw
assert "interactive_browser_tenant_id" not in kw
assert "authority" not in kw
def test_entra_default_scope_when_unset(self, fake_azure_identity):
"""When ``model.entra.scope`` is not set, the runtime resolves
Microsoft's documented inference scope —
``https://ai.azure.com/.default`` — regardless of whether the
endpoint is ``*.openai.azure.com`` or ``*.services.ai.azure.com``.
Both shapes use the SAME scope per Microsoft's docs; the
``cognitiveservices.azure.com`` scope is the control-plane
audience and is rejected for inference by newer resources."""
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
from agent.azure_identity_adapter import SCOPE_AI_AZURE_DEFAULT
_resolve_azure_foundry_runtime(
requested_provider="azure-foundry",
model_cfg={
"provider": "azure-foundry",
"base_url": "https://r.openai.azure.com/openai/v1",
"api_mode": "chat_completions",
"auth_mode": "entra_id",
},
)
assert fake_azure_identity["scope"] == SCOPE_AI_AZURE_DEFAULT
def test_entra_scope_override_wins(self, fake_azure_identity):
"""Users on sovereign clouds / unusual tenants can set
``model.entra.scope`` to override the default."""
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
_resolve_azure_foundry_runtime(
requested_provider="azure-foundry",
model_cfg={
"provider": "azure-foundry",
"base_url": "https://r.openai.azure.com/openai/v1",
"api_mode": "chat_completions",
"auth_mode": "entra_id",
"entra": {
"scope": "https://cognitiveservices.azure.com/.default",
},
},
)
assert (
fake_azure_identity["scope"]
== "https://cognitiveservices.azure.com/.default"
)
def test_entra_with_anthropic_messages_is_supported(self, fake_azure_identity):
"""Entra ID now works for both OpenAI-style and Anthropic-style
Azure Foundry endpoints. The runtime returns a callable
``api_key``; downstream
:func:`agent.anthropic_adapter.build_anthropic_client` detects
the callable and installs an httpx event hook that mints a
fresh bearer JWT per request (the Anthropic SDK does not
accept callable auth_token natively)."""
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
runtime = _resolve_azure_foundry_runtime(
requested_provider="azure-foundry",
model_cfg={
"provider": "azure-foundry",
"base_url": "https://r.services.ai.azure.com/anthropic",
"api_mode": "anthropic_messages",
"auth_mode": "entra_id",
"default": "claude-sonnet-4-5",
},
)
assert runtime["provider"] == "azure-foundry"
assert runtime["auth_mode"] == "entra_id"
assert runtime["api_mode"] == "anthropic_messages"
# Callable api_key — the anthropic_adapter detects this and
# plumbs through an httpx event hook.
assert callable(runtime["api_key"])
assert not isinstance(runtime["api_key"], str)
def test_entra_with_explicit_api_key_uses_string_escape_hatch(self, fake_azure_identity):
"""Passing --api-key on the CLI overrides the entra path so a
user can debug a single request with a static key without
editing config.yaml."""
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
runtime = _resolve_azure_foundry_runtime(
requested_provider="azure-foundry",
model_cfg={
"provider": "azure-foundry",
"base_url": "https://r.openai.azure.com/openai/v1",
"api_mode": "chat_completions",
"auth_mode": "entra_id",
},
explicit_api_key="explicit-string-key",
)
assert runtime["api_key"] == "explicit-string-key"
assert runtime["auth_mode"] == "api_key"
assert runtime["source"] == "explicit"
def test_entra_runtime_dict_keeps_only_scope_override(self, fake_azure_identity):
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
runtime = _resolve_azure_foundry_runtime(
requested_provider="azure-foundry",
model_cfg={
"provider": "azure-foundry",
"base_url": "https://r.openai.azure.com/openai/v1",
"api_mode": "chat_completions",
"auth_mode": "entra_id",
"entra": {
"scope": "https://custom.example/.default",
"client_id": "legacy-client",
},
},
)
assert runtime["entra"] == {"scope": "https://custom.example/.default"}
# ---------------------------------------------------------------------------
# _resolve_azure_foundry_runtime: legacy api_key branch (regression)
# ---------------------------------------------------------------------------
class TestResolveAzureFoundryRuntimeApiKey:
def test_default_auth_mode_uses_static_key(self, monkeypatch):
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "sk-azure-static-key")
runtime = _resolve_azure_foundry_runtime(
requested_provider="azure-foundry",
model_cfg={
"provider": "azure-foundry",
"base_url": "https://r.openai.azure.com/openai/v1",
"api_mode": "chat_completions",
},
)
assert runtime["api_key"] == "sk-azure-static-key"
assert runtime["auth_mode"] == "api_key"
assert "entra" not in runtime # only present in entra mode
def test_explicit_auth_mode_api_key(self, monkeypatch):
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "sk-static")
runtime = _resolve_azure_foundry_runtime(
requested_provider="azure-foundry",
model_cfg={
"provider": "azure-foundry",
"base_url": "https://r.openai.azure.com/openai/v1",
"api_mode": "chat_completions",
"auth_mode": "api_key",
},
)
assert runtime["api_key"] == "sk-static"
assert runtime["auth_mode"] == "api_key"
def test_anthropic_messages_strips_v1_suffix(self, monkeypatch):
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "k")
runtime = _resolve_azure_foundry_runtime(
requested_provider="azure-foundry",
model_cfg={
"provider": "azure-foundry",
"base_url": "https://r.services.ai.azure.com/anthropic/v1",
"api_mode": "anthropic_messages",
},
)
assert runtime["base_url"] == "https://r.services.ai.azure.com/anthropic"
def test_missing_api_key_raises_with_entra_hint(self, monkeypatch):
from hermes_cli.auth import AuthError
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
monkeypatch.delenv("AZURE_FOUNDRY_API_KEY", raising=False)
with pytest.raises(AuthError) as exc_info:
_resolve_azure_foundry_runtime(
requested_provider="azure-foundry",
model_cfg={
"provider": "azure-foundry",
"base_url": "https://r.openai.azure.com/openai/v1",
"api_mode": "chat_completions",
},
)
msg = str(exc_info.value)
assert "AZURE_FOUNDRY_API_KEY" in msg
# Surface the Entra alternative so users discover the keyless path.
assert "entra_id" in msg
# ---------------------------------------------------------------------------
# _get_azure_foundry_auth_status (auth.py) — never mints a token
# ---------------------------------------------------------------------------
class TestAzureFoundryAuthStatus:
def test_entra_status_does_not_mint_token(self, monkeypatch, tmp_path):
"""Structural check — must return logged_in=True based on
importable + config, never call get_bearer_token_provider."""
from hermes_cli import auth as _auth
# Force load_config to return our entra config.
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {
"model": {
"provider": "azure-foundry",
"auth_mode": "entra_id",
"base_url": "https://r.openai.azure.com/openai/v1",
},
},
)
# Patch has_azure_identity_installed to True; do NOT patch the
# token provider — if the code path tried to mint, the SDK
# missing would raise.
monkeypatch.setattr(
"agent.azure_identity_adapter.has_azure_identity_installed",
lambda: True,
)
info = _auth._get_azure_foundry_auth_status()
assert info["logged_in"] is True
assert info["auth_mode"] == "entra_id"
assert info["azure_identity_installed"] is True
assert info["scope"].endswith("/.default")
def test_entra_status_reports_missing_package(self, monkeypatch):
from hermes_cli import auth as _auth
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {
"model": {
"provider": "azure-foundry",
"auth_mode": "entra_id",
"base_url": "https://r.openai.azure.com/openai/v1",
},
},
)
monkeypatch.setattr(
"agent.azure_identity_adapter.has_azure_identity_installed",
lambda: False,
)
info = _auth._get_azure_foundry_auth_status()
assert info["logged_in"] is False
assert info["azure_identity_installed"] is False
assert "azure-identity" in info["hint"]
def test_api_key_status_uses_env_var(self, monkeypatch):
from hermes_cli import auth as _auth
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {
"model": {
"provider": "azure-foundry",
"auth_mode": "api_key",
"base_url": "https://r.openai.azure.com/openai/v1",
},
},
)
monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "sk-real-key-xxx")
info = _auth._get_azure_foundry_auth_status()
assert info["auth_mode"] == "api_key"
assert info["logged_in"] is True
def test_api_key_status_false_when_missing(self, monkeypatch):
from hermes_cli import auth as _auth
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {
"model": {
"provider": "azure-foundry",
"auth_mode": "api_key",
},
},
)
monkeypatch.delenv("AZURE_FOUNDRY_API_KEY", raising=False)
info = _auth._get_azure_foundry_auth_status()
assert info["logged_in"] is False
File diff suppressed because it is too large Load Diff
+135
View File
@@ -0,0 +1,135 @@
"""Tests for banner toolset name normalization and skin color usage."""
from unittest.mock import patch
from rich.console import Console
import hermes_cli.banner as banner
import model_tools
import tools.mcp_tool
def test_display_toolset_name_strips_legacy_suffix():
assert banner._display_toolset_name("homeassistant_tools") == "homeassistant"
assert banner._display_toolset_name("honcho_tools") == "honcho"
assert banner._display_toolset_name("web_tools") == "web"
def test_display_toolset_name_preserves_clean_names():
assert banner._display_toolset_name("browser") == "browser"
assert banner._display_toolset_name("file") == "file"
assert banner._display_toolset_name("terminal") == "terminal"
def test_display_toolset_name_handles_empty():
assert banner._display_toolset_name("") == "unknown"
assert banner._display_toolset_name(None) == "unknown"
def test_build_welcome_banner_uses_normalized_toolset_names():
"""Unavailable toolsets should not have '_tools' appended in banner output."""
with (
patch.object(
model_tools,
"check_tool_availability",
return_value=(
["web"],
[
{"name": "homeassistant", "tools": ["ha_call_service"]},
{"name": "honcho", "tools": ["honcho_conclude"]},
],
),
),
patch.object(banner, "get_available_skills", return_value={}),
patch.object(banner, "get_update_result", return_value=None),
patch.object(tools.mcp_tool, "get_mcp_status", return_value=[]),
):
console = Console(
record=True, force_terminal=False, color_system=None, width=160
)
banner.build_welcome_banner(
console=console,
model="anthropic/test-model",
cwd="/tmp/project",
tools=[
{"function": {"name": "web_search"}},
{"function": {"name": "read_file"}},
],
get_toolset_for_tool=lambda name: {
"web_search": "web_tools",
"read_file": "file",
}.get(name),
)
output = console.export_text()
assert "homeassistant:" in output
assert "honcho:" in output
assert "web:" in output
assert "homeassistant_tools:" not in output
assert "honcho_tools:" not in output
assert "web_tools:" not in output
def test_build_welcome_banner_title_is_hyperlinked_to_release():
"""Panel title (version label) is wrapped in an OSC-8 hyperlink to the GitHub release."""
import io
from unittest.mock import patch as _patch
import hermes_cli.banner as _banner
import model_tools as _mt
import tools.mcp_tool as _mcp
_banner._latest_release_cache = None
tag_url = ("v2026.4.23", "https://github.com/NousResearch/hermes-agent/releases/tag/v2026.4.23")
buf = io.StringIO()
with (
_patch.object(_mt, "check_tool_availability", return_value=(["web"], [])),
_patch.object(_banner, "get_available_skills", return_value={}),
_patch.object(_banner, "get_update_result", return_value=None),
_patch.object(_mcp, "get_mcp_status", return_value=[]),
_patch.object(_banner, "get_latest_release_tag", return_value=tag_url),
):
console = Console(file=buf, force_terminal=True, color_system="truecolor", width=160)
_banner.build_welcome_banner(
console=console, model="x", cwd="/tmp",
session_id="abc123",
tools=[{"function": {"name": "read_file"}}],
get_toolset_for_tool=lambda n: "file",
)
raw = buf.getvalue()
# The existing version label must still be present in the title
assert "Hermes Agent v" in raw, "Version label missing from title"
# OSC-8 hyperlink escape sequence present with the release URL
assert "\x1b]8;" in raw, "OSC-8 hyperlink not emitted"
assert "releases/tag/v2026.4.23" in raw, "Release URL missing from banner output"
def test_build_welcome_banner_title_falls_back_when_no_tag():
"""Without a resolvable tag, the panel title renders as plain text (no hyperlink escape)."""
import io
from unittest.mock import patch as _patch
import hermes_cli.banner as _banner
import model_tools as _mt
import tools.mcp_tool as _mcp
_banner._latest_release_cache = None
buf = io.StringIO()
with (
_patch.object(_mt, "check_tool_availability", return_value=(["web"], [])),
_patch.object(_banner, "get_available_skills", return_value={}),
_patch.object(_banner, "get_update_result", return_value=None),
_patch.object(_mcp, "get_mcp_status", return_value=[]),
_patch.object(_banner, "get_latest_release_tag", return_value=None),
):
console = Console(file=buf, force_terminal=True, color_system="truecolor", width=160)
_banner.build_welcome_banner(
console=console, model="x", cwd="/tmp",
session_id="abc123",
tools=[{"function": {"name": "read_file"}}],
get_toolset_for_tool=lambda n: "file",
)
raw = buf.getvalue()
assert "Hermes Agent v" in raw, "Version label missing from title"
assert "\x1b]8;" not in raw, "OSC-8 hyperlink should not be emitted without a tag"
+116
View File
@@ -0,0 +1,116 @@
from unittest.mock import MagicMock, patch
def test_format_banner_version_label_without_git_state():
from hermes_cli import banner
with patch.object(banner, "get_git_banner_state", return_value=None):
value = banner.format_banner_version_label()
assert value == f"Hermes Agent v{banner.VERSION} ({banner.RELEASE_DATE})"
def test_format_banner_version_label_on_upstream_main():
from hermes_cli import banner
with patch.object(
banner,
"get_git_banner_state",
return_value={"upstream": "b2f477a3", "local": "b2f477a3", "ahead": 0},
):
value = banner.format_banner_version_label()
assert value.endswith("· upstream b2f477a3")
assert "local" not in value
def test_format_banner_version_label_with_carried_commits():
from hermes_cli import banner
with patch.object(
banner,
"get_git_banner_state",
return_value={"upstream": "b2f477a3", "local": "af8aad31", "ahead": 3},
):
value = banner.format_banner_version_label()
assert "upstream b2f477a3" in value
assert "local af8aad31" in value
assert "+3 carried commits" in value
def test_get_git_banner_state_reads_origin_and_head(tmp_path):
from hermes_cli import banner
repo_dir = tmp_path / "repo"
(repo_dir / ".git").mkdir(parents=True)
results = {
("git", "rev-parse", "--short=8", "origin/main"): MagicMock(returncode=0, stdout="b2f477a3\n"),
("git", "rev-parse", "--short=8", "HEAD"): MagicMock(returncode=0, stdout="af8aad31\n"),
("git", "rev-list", "--count", "origin/main..HEAD"): MagicMock(returncode=0, stdout="3\n"),
}
def fake_run(cmd, **kwargs):
key = tuple(cmd)
if key not in results:
raise AssertionError(f"unexpected command: {cmd}")
return results[key]
with patch("hermes_cli.banner.subprocess.run", side_effect=fake_run):
state = banner.get_git_banner_state(repo_dir)
assert state == {"upstream": "b2f477a3", "local": "af8aad31", "ahead": 3}
def test_get_git_banner_state_falls_back_to_build_sha_when_no_repo():
"""Docker image case: no .git checkout — baked build SHA fills the gap.
``_resolve_repo_dir`` returns None when neither the running code's
parent nor ``$HERMES_HOME/hermes-agent/`` is a git repo (the canonical
case inside the published container, where .git is dockerignored).
The banner should still report the build SHA so support bug reports
can identify the running commit.
"""
from hermes_cli import banner
with patch.object(banner, "_resolve_repo_dir", return_value=None), \
patch("hermes_cli.build_info.get_build_sha", return_value="abcdef12"):
state = banner.get_git_banner_state()
assert state == {"upstream": "abcdef12", "local": "abcdef12", "ahead": 0}
def test_get_git_banner_state_returns_none_when_no_repo_and_no_build_sha():
"""Pip-installed wheel with neither git checkout nor baked SHA → None.
Banner correctly omits the upstream/local suffix in this case.
"""
from hermes_cli import banner
with patch.object(banner, "_resolve_repo_dir", return_value=None), \
patch("hermes_cli.build_info.get_build_sha", return_value=None):
state = banner.get_git_banner_state()
assert state is None
def test_get_git_banner_state_falls_back_when_live_git_returns_nothing(tmp_path):
"""Shallow clone without origin/main → still surface build SHA if baked.
Some install paths (e.g. ``git clone --depth 1`` without a remote) have
a ``.git`` directory but ``git rev-parse origin/main`` fails. When that
happens AND a baked SHA exists, return the baked one instead of None.
"""
from hermes_cli import banner
repo_dir = tmp_path / "repo"
(repo_dir / ".git").mkdir(parents=True)
# All git invocations fail (returncode=1, empty stdout).
failed = MagicMock(returncode=1, stdout="")
with patch("hermes_cli.banner.subprocess.run", return_value=failed), \
patch("hermes_cli.build_info.get_build_sha", return_value="cafef00d"):
state = banner.get_git_banner_state(repo_dir)
assert state == {"upstream": "cafef00d", "local": "cafef00d", "ahead": 0}
@@ -0,0 +1,35 @@
from unittest.mock import patch
def testcheck_via_pypi_detects_update():
"""check_via_pypi returns 1 when PyPI has newer version."""
from hermes_cli.banner import check_via_pypi
with patch("hermes_cli.banner.VERSION", "0.12.0"):
with patch("hermes_cli.banner._fetch_pypi_latest", return_value="0.13.0"):
result = check_via_pypi()
assert result == 1
def testcheck_via_pypi_up_to_date():
"""check_via_pypi returns 0 when versions match."""
from hermes_cli.banner import check_via_pypi
with patch("hermes_cli.banner.VERSION", "0.13.0"):
with patch("hermes_cli.banner._fetch_pypi_latest", return_value="0.13.0"):
result = check_via_pypi()
assert result == 0
def testcheck_via_pypi_network_failure():
"""check_via_pypi returns None on network error."""
from hermes_cli.banner import check_via_pypi
with patch("hermes_cli.banner._fetch_pypi_latest", return_value=None):
result = check_via_pypi()
assert result is None
def test_version_tuple_comparison():
"""Version comparison works with multi-segment versions."""
from hermes_cli.banner import _version_tuple
assert _version_tuple("0.13.0") > _version_tuple("0.12.0")
assert _version_tuple("0.13.0") == _version_tuple("0.13.0")
assert _version_tuple("1.0.0") > _version_tuple("0.99.99")
+67
View File
@@ -0,0 +1,67 @@
"""Tests for banner get_available_skills() — disabled and platform filtering."""
from unittest.mock import patch
_MOCK_SKILLS = [
{"name": "skill-a", "description": "A skill", "category": "tools"},
{"name": "skill-b", "description": "B skill", "category": "tools"},
{"name": "skill-c", "description": "C skill", "category": "creative"},
]
def test_get_available_skills_delegates_to_find_all_skills():
"""get_available_skills should call _find_all_skills (which handles filtering)."""
with patch("tools.skills_tool._find_all_skills", return_value=list(_MOCK_SKILLS)):
from hermes_cli.banner import get_available_skills
result = get_available_skills()
assert "tools" in result
assert "creative" in result
assert sorted(result["tools"]) == ["skill-a", "skill-b"]
assert result["creative"] == ["skill-c"]
def test_get_available_skills_excludes_disabled():
"""Disabled skills should not appear in the banner count."""
# _find_all_skills already filters disabled skills, so if we give it
# a filtered list, get_available_skills should reflect that.
filtered = [s for s in _MOCK_SKILLS if s["name"] != "skill-b"]
with patch("tools.skills_tool._find_all_skills", return_value=filtered):
from hermes_cli.banner import get_available_skills
result = get_available_skills()
all_names = [n for names in result.values() for n in names]
assert "skill-b" not in all_names
assert "skill-a" in all_names
assert len(all_names) == 2
def test_get_available_skills_empty_when_no_skills():
"""No skills installed returns empty dict."""
with patch("tools.skills_tool._find_all_skills", return_value=[]):
from hermes_cli.banner import get_available_skills
result = get_available_skills()
assert result == {}
def test_get_available_skills_handles_import_failure():
"""If _find_all_skills import fails, return empty dict gracefully."""
with patch("tools.skills_tool._find_all_skills", side_effect=ImportError("boom")):
from hermes_cli.banner import get_available_skills
result = get_available_skills()
assert result == {}
def test_get_available_skills_null_category_becomes_general():
"""Skills with None category should be grouped under 'general'."""
skills = [{"name": "orphan-skill", "description": "No cat", "category": None}]
with patch("tools.skills_tool._find_all_skills", return_value=skills):
from hermes_cli.banner import get_available_skills
result = get_available_skills()
assert "general" in result
assert result["general"] == ["orphan-skill"]
@@ -0,0 +1,361 @@
"""Tests for AWS Bedrock integration in the model picker and provider catalog.
Covers the three paths changed by fix/bedrock-provider-model-ids-live-discovery:
1. provider_model_ids("bedrock") — uses live discover_bedrock_models() instead
of the static _PROVIDER_MODELS table, with curated fallback.
2. list_authenticated_providers() Section 2 (HERMES_OVERLAYS) — bedrock
appears when AWS credentials are present; model list comes from live
discovery keyed by the resolved region, NOT the static us.* table.
3. Region resolution — resolve_bedrock_region() reads from botocore profile
when no AWS_REGION / AWS_DEFAULT_REGION env vars are set, so EU/AP users
in eu-central-1 get eu.* profile IDs, not us.* ones.
All Bedrock API calls are mocked — no real AWS credentials needed.
"""
from contextlib import contextmanager
from types import ModuleType
from unittest.mock import MagicMock, patch
# ---------------------------------------------------------------------------
# Shared helpers / fixtures
# ---------------------------------------------------------------------------
@contextmanager
def _mock_botocore_session(*, return_value=None):
"""Patch botocore.session even when botocore is not installed."""
botocore_mod = ModuleType("botocore")
session_mod = ModuleType("botocore.session")
session_mod.get_session = MagicMock(return_value=return_value)
botocore_mod.session = session_mod
with patch.dict("sys.modules", {"botocore": botocore_mod, "botocore.session": session_mod}):
yield session_mod.get_session
_EU_MODELS = [
{"id": "eu.anthropic.claude-sonnet-4-6-20250514-v1:0", "name": "Claude Sonnet 4.6 (EU)", "provider": "inference-profile"},
{"id": "eu.anthropic.claude-haiku-4-5-20251015-v1:0", "name": "Claude Haiku 4.5 (EU)", "provider": "inference-profile"},
{"id": "eu.amazon.nova-pro-v1:0", "name": "Nova Pro (EU)", "provider": "inference-profile"},
]
_US_MODELS = [
{"id": "us.anthropic.claude-sonnet-4-6-20250514-v1:0", "name": "Claude Sonnet 4.6 (US)", "provider": "inference-profile"},
{"id": "us.amazon.nova-pro-v1:0", "name": "Nova Pro (US)", "provider": "inference-profile"},
]
def _mock_discover(region: str):
"""Return EU models for eu-* regions, US models otherwise."""
return _EU_MODELS if region.startswith("eu-") else _US_MODELS
# ---------------------------------------------------------------------------
# 1. provider_model_ids("bedrock")
# ---------------------------------------------------------------------------
class TestProviderModelIdsBedrock:
"""provider_model_ids("bedrock") should use live Bedrock discovery."""
def test_returns_live_discovered_model_ids(self, monkeypatch):
"""Live discovery result is returned as a flat list of model ID strings."""
from hermes_cli.models import provider_model_ids
monkeypatch.setenv("AWS_REGION", "eu-central-1")
with patch("agent.bedrock_adapter.discover_bedrock_models", side_effect=_mock_discover), \
patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
result = provider_model_ids("bedrock")
assert "eu.anthropic.claude-sonnet-4-6-20250514-v1:0" in result
assert "eu.anthropic.claude-haiku-4-5-20251015-v1:0" in result
assert len(result) == len(_EU_MODELS)
def test_region_determines_model_ids(self, monkeypatch):
"""Different regions produce different model ID prefixes (eu.* vs us.*)."""
from hermes_cli.models import provider_model_ids
with patch("agent.bedrock_adapter.discover_bedrock_models", side_effect=_mock_discover):
with patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
eu_result = provider_model_ids("bedrock")
with patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="us-east-1"):
us_result = provider_model_ids("bedrock")
assert all(m.startswith("eu.") for m in eu_result)
assert all(m.startswith("us.") for m in us_result)
assert eu_result != us_result
def test_falls_back_to_static_list_when_discovery_empty(self, monkeypatch):
"""When discover_bedrock_models() returns [], fall back to curated static list."""
from hermes_cli.models import provider_model_ids
with patch("agent.bedrock_adapter.discover_bedrock_models", return_value=[]), \
patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
result = provider_model_ids("bedrock")
# Should fall back to static table (may be empty or populated depending on
# the current static list, but must not crash and must be a list).
assert isinstance(result, list)
def test_falls_back_to_static_list_on_exception(self, monkeypatch):
"""When discover_bedrock_models() raises, fall back gracefully."""
from hermes_cli.models import provider_model_ids
with patch("agent.bedrock_adapter.discover_bedrock_models",
side_effect=Exception("boto3 not installed")), \
patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
result = provider_model_ids("bedrock")
assert isinstance(result, list) # no crash
def test_accepts_bedrock_aliases(self, monkeypatch):
"""Provider aliases (aws, aws-bedrock, amazon) should also trigger live discovery."""
from hermes_cli.models import provider_model_ids
_expected_ids = [m["id"] for m in _US_MODELS]
with patch("agent.bedrock_adapter.discover_bedrock_models", side_effect=_mock_discover), \
patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="us-east-1"):
for alias in ("aws", "aws-bedrock", "amazon-bedrock"):
result = provider_model_ids(alias)
assert result == _expected_ids, \
f"alias {alias!r} should return live-discovered US model IDs, got {result!r}"
# ---------------------------------------------------------------------------
# 2. list_authenticated_providers() — bedrock via HERMES_OVERLAYS (Section 2)
# ---------------------------------------------------------------------------
class TestListAuthenticatedProvidersBedrock:
"""Bedrock should appear in the /model picker when AWS creds are present."""
def test_bedrock_appears_with_aws_profile(self, monkeypatch):
"""Bedrock shows up when AWS_PROFILE is set."""
from hermes_cli.model_switch import list_authenticated_providers
monkeypatch.setenv("AWS_PROFILE", "my-sso-profile")
monkeypatch.setenv("AWS_REGION", "eu-central-1")
with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \
patch("agent.bedrock_adapter.discover_bedrock_models", side_effect=_mock_discover), \
patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
providers = list_authenticated_providers(current_provider="bedrock")
bedrock = next((p for p in providers if p["slug"] == "bedrock"), None)
assert bedrock is not None, "bedrock should appear when AWS credentials are present"
def test_bedrock_uses_live_discovery_not_static_list(self, monkeypatch):
"""Model IDs come from discover_bedrock_models(), not the static _PROVIDER_MODELS table."""
from hermes_cli.model_switch import list_authenticated_providers
monkeypatch.setenv("AWS_PROFILE", "my-sso-profile")
with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \
patch("agent.bedrock_adapter.discover_bedrock_models", side_effect=_mock_discover), \
patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
providers = list_authenticated_providers(current_provider="bedrock")
bedrock = next((p for p in providers if p["slug"] == "bedrock"), None)
assert bedrock is not None
# All returned model IDs should have eu.* prefix — live discovery result
for model_id in bedrock["models"]:
assert model_id.startswith("eu."), \
f"Expected eu.* model ID from live discovery, got {model_id!r}"
def test_bedrock_total_models_matches_discovery(self, monkeypatch):
"""total_models reflects the actual discovered count."""
from hermes_cli.model_switch import list_authenticated_providers
monkeypatch.setenv("AWS_PROFILE", "my-sso-profile")
with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \
patch("agent.bedrock_adapter.discover_bedrock_models", return_value=_EU_MODELS), \
patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
providers = list_authenticated_providers(current_provider="openai")
bedrock = next((p for p in providers if p["slug"] == "bedrock"), None)
assert bedrock is not None
assert bedrock["total_models"] == len(_EU_MODELS)
def test_bedrock_is_current_when_selected(self, monkeypatch):
"""is_current=True when current_provider matches bedrock."""
from hermes_cli.model_switch import list_authenticated_providers
monkeypatch.setenv("AWS_PROFILE", "my-sso-profile")
with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \
patch("agent.bedrock_adapter.discover_bedrock_models", return_value=_EU_MODELS), \
patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
providers = list_authenticated_providers(current_provider="bedrock")
bedrock = next((p for p in providers if p["slug"] == "bedrock"), None)
assert bedrock is not None
assert bedrock["is_current"] is True
def test_bedrock_not_shown_without_credentials(self, monkeypatch):
"""Bedrock must not appear when no AWS credentials are present."""
from hermes_cli.model_switch import list_authenticated_providers
monkeypatch.delenv("AWS_PROFILE", raising=False)
monkeypatch.delenv("AWS_ACCESS_KEY_ID", raising=False)
monkeypatch.delenv("AWS_SECRET_ACCESS_KEY", raising=False)
monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False)
monkeypatch.delenv("AWS_WEB_IDENTITY_TOKEN_FILE", raising=False)
monkeypatch.delenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", raising=False)
with patch("agent.bedrock_adapter.has_aws_credentials", return_value=False):
providers = list_authenticated_providers(current_provider="openai")
bedrock = next((p for p in providers if p["slug"] == "bedrock"), None)
assert bedrock is None, "bedrock should NOT appear when AWS credentials are absent"
def test_non_bedrock_picker_does_not_probe_full_aws_chain(self, monkeypatch):
"""Non-Bedrock provider discovery must not touch boto3's full credential chain."""
from hermes_cli.model_switch import list_authenticated_providers
monkeypatch.delenv("AWS_PROFILE", raising=False)
monkeypatch.delenv("AWS_ACCESS_KEY_ID", raising=False)
monkeypatch.delenv("AWS_SECRET_ACCESS_KEY", raising=False)
monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False)
monkeypatch.delenv("AWS_WEB_IDENTITY_TOKEN_FILE", raising=False)
monkeypatch.delenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", raising=False)
monkeypatch.delenv("AWS_CONTAINER_CREDENTIALS_FULL_URI", raising=False)
calls = {"has_aws_credentials": 0}
def _has_aws_credentials():
calls["has_aws_credentials"] += 1
return False
with patch("agent.bedrock_adapter.has_aws_credentials", side_effect=_has_aws_credentials):
providers = list_authenticated_providers(current_provider="openrouter", max_models=0)
assert calls["has_aws_credentials"] == 0
assert all(p["slug"] != "bedrock" for p in providers)
def test_bedrock_falls_back_to_curated_when_discovery_fails(self, monkeypatch):
"""When discover_bedrock_models() raises, fall back to curated list without crashing."""
from hermes_cli.model_switch import list_authenticated_providers
monkeypatch.setenv("AWS_PROFILE", "my-sso-profile")
with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \
patch("agent.bedrock_adapter.discover_bedrock_models",
side_effect=Exception("API call failed")), \
patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
providers = list_authenticated_providers(current_provider="bedrock")
# Should not raise — bedrock entry may or may not appear depending on
# whether the curated fallback has entries, but the call must succeed.
assert isinstance(providers, list)
def test_bedrock_no_duplicate_entries(self, monkeypatch):
"""Bedrock must appear at most once — not in both Section 1 and Section 2."""
from hermes_cli.model_switch import list_authenticated_providers
monkeypatch.setenv("AWS_PROFILE", "my-sso-profile")
with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \
patch("agent.bedrock_adapter.discover_bedrock_models", return_value=_EU_MODELS), \
patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
providers = list_authenticated_providers(current_provider="bedrock")
bedrock_entries = [p for p in providers if p["slug"] == "bedrock"]
assert len(bedrock_entries) <= 1, \
f"bedrock should appear at most once, got {len(bedrock_entries)} entries"
# ---------------------------------------------------------------------------
# 3. Region routing: EU/AP users see regional model IDs
# ---------------------------------------------------------------------------
class TestBedrockRegionRouting:
"""End-to-end: region from botocore profile is used for discovery, so EU/AP
users get eu.*/ap.* model IDs rather than the hardcoded us-east-1 list."""
def test_eu_region_from_botocore_profile_yields_eu_models(self):
"""When botocore resolves eu-central-1, picker shows eu.* model IDs."""
from hermes_cli.model_switch import list_authenticated_providers
mock_session = MagicMock()
mock_session.get_config_variable.return_value = "eu-central-1"
with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \
patch("agent.bedrock_adapter.discover_bedrock_models", side_effect=_mock_discover), \
_mock_botocore_session(return_value=mock_session):
providers = list_authenticated_providers(current_provider="bedrock")
bedrock = next((p for p in providers if p["slug"] == "bedrock"), None)
assert bedrock is not None
for model_id in bedrock["models"]:
assert model_id.startswith("eu."), \
f"Expected eu.* model ID from eu-central-1 profile, got {model_id!r}"
def test_us_region_from_env_var_yields_us_models(self, monkeypatch):
"""Explicit AWS_REGION=us-east-1 returns us.* model IDs."""
from hermes_cli.model_switch import list_authenticated_providers
monkeypatch.setenv("AWS_REGION", "us-east-1")
with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \
patch("agent.bedrock_adapter.discover_bedrock_models", side_effect=_mock_discover):
providers = list_authenticated_providers(current_provider="bedrock")
bedrock = next((p for p in providers if p["slug"] == "bedrock"), None)
assert bedrock is not None
for model_id in bedrock["models"]:
assert model_id.startswith("us."), \
f"Expected us.* model ID from us-east-1, got {model_id!r}"
def test_env_var_takes_priority_over_botocore_profile(self, monkeypatch):
"""AWS_REGION env var wins over botocore profile region."""
from agent.bedrock_adapter import resolve_bedrock_region
monkeypatch.setenv("AWS_REGION", "us-west-2")
mock_session = MagicMock()
mock_session.get_config_variable.return_value = "eu-central-1"
with _mock_botocore_session(return_value=mock_session):
region = resolve_bedrock_region()
assert region == "us-west-2", "env var should override botocore profile"
# ---------------------------------------------------------------------------
# 4. providers.py overlay registration
# ---------------------------------------------------------------------------
class TestBedrockOverlayRegistration:
"""bedrock entry in HERMES_OVERLAYS is correctly configured."""
def test_bedrock_overlay_exists(self):
from hermes_cli.providers import HERMES_OVERLAYS
assert "bedrock" in HERMES_OVERLAYS
def test_bedrock_overlay_transport(self):
from hermes_cli.providers import HERMES_OVERLAYS
assert HERMES_OVERLAYS["bedrock"].transport == "bedrock_converse"
def test_bedrock_overlay_auth_type(self):
from hermes_cli.providers import HERMES_OVERLAYS
assert HERMES_OVERLAYS["bedrock"].auth_type == "aws_sdk"
def test_bedrock_label(self):
from hermes_cli.providers import get_label
label = get_label("bedrock")
assert label # non-empty
assert "bedrock" in label.lower() or "aws" in label.lower()
def test_bedrock_aliases_resolve(self):
from hermes_cli.providers import normalize_provider
for alias in ("aws", "aws-bedrock", "amazon-bedrock", "amazon"):
assert normalize_provider(alias) == "bedrock", \
f"alias {alias!r} should normalize to 'bedrock'"
+78
View File
@@ -0,0 +1,78 @@
"""Tests for hermes_cli.build_info — baked-in build SHA resolution.
The build SHA is written by the Dockerfile's ``HERMES_GIT_SHA`` build-arg
into ``<project_root>/.hermes_build_sha``. These tests cover the read-side
helper: missing file, malformed file, truncation, and error tolerance.
"""
from pathlib import Path
from unittest.mock import patch
def test_get_build_sha_returns_none_when_file_absent(tmp_path):
"""Source installs: no file present → None, callers fall back to git."""
from hermes_cli import build_info
missing = tmp_path / ".hermes_build_sha" # never created
with patch.object(build_info, "_BUILD_SHA_FILE", missing):
assert build_info.get_build_sha() is None
def test_get_build_sha_reads_baked_file(tmp_path):
"""Docker image case: file exists with full 40-char SHA → truncated to 8."""
from hermes_cli import build_info
sha_file = tmp_path / ".hermes_build_sha"
sha_file.write_text("abcdef1234567890abcdef1234567890abcdef12\n")
with patch.object(build_info, "_BUILD_SHA_FILE", sha_file):
assert build_info.get_build_sha() == "abcdef12"
def test_get_build_sha_respects_short_argument(tmp_path):
"""``short=N`` truncates to N chars; ``short<=0`` returns full SHA."""
from hermes_cli import build_info
sha_file = tmp_path / ".hermes_build_sha"
full_sha = "abcdef1234567890abcdef1234567890abcdef12"
sha_file.write_text(full_sha + "\n")
with patch.object(build_info, "_BUILD_SHA_FILE", sha_file):
assert build_info.get_build_sha(short=12) == "abcdef123456"
assert build_info.get_build_sha(short=0) == full_sha
assert build_info.get_build_sha(short=-1) == full_sha
def test_get_build_sha_strips_whitespace(tmp_path):
"""The Dockerfile uses ``printf '%s\\n'`` — strip the trailing newline."""
from hermes_cli import build_info
sha_file = tmp_path / ".hermes_build_sha"
sha_file.write_text(" abcdef1234567890\n\n")
with patch.object(build_info, "_BUILD_SHA_FILE", sha_file):
assert build_info.get_build_sha() == "abcdef12"
def test_get_build_sha_returns_none_for_empty_file(tmp_path):
"""A whitespace-only file is treated as absent."""
from hermes_cli import build_info
sha_file = tmp_path / ".hermes_build_sha"
sha_file.write_text(" \n\n")
with patch.object(build_info, "_BUILD_SHA_FILE", sha_file):
assert build_info.get_build_sha() is None
def test_get_build_sha_swallows_read_errors(tmp_path):
"""Any IO exception from the read returns None — never raises."""
from hermes_cli import build_info
sha_file = tmp_path / ".hermes_build_sha"
sha_file.write_text("abcdef1234567890\n")
with patch.object(build_info, "_BUILD_SHA_FILE", sha_file), \
patch.object(Path, "read_text", side_effect=OSError("boom")):
assert build_info.get_build_sha() is None
+92
View File
@@ -0,0 +1,92 @@
"""Tests for hermes_cli/bundles.py — the `hermes bundles` CLI subcommand."""
import argparse
import pytest
from hermes_cli.bundles import (
bundles_command,
register_cli,
)
@pytest.fixture
def bundles_env(tmp_path, monkeypatch):
bundles_dir = tmp_path / "skill-bundles"
monkeypatch.setenv("HERMES_BUNDLES_DIR", str(bundles_dir))
# Reset module-level cache between tests.
import agent.skill_bundles as mod
mod._bundles_cache = {}
mod._bundles_cache_mtime = None
return bundles_dir
def _parse(argv):
parser = argparse.ArgumentParser()
register_cli(parser)
return parser.parse_args(argv)
class TestBundlesCli:
def test_create_and_list(self, bundles_env, capsys):
args = _parse(["create", "my-bundle", "--skill", "a", "--skill", "b", "-d", "desc"])
bundles_command(args)
out = capsys.readouterr().out
assert "Created bundle" in out
# File should exist
assert (bundles_env / "my-bundle.yaml").exists()
args = _parse(["list"])
bundles_command(args)
out = capsys.readouterr().out
assert "my-bundle" in out
def test_show(self, bundles_env, capsys):
bundles_command(_parse(["create", "x", "--skill", "s1", "--skill", "s2"]))
capsys.readouterr() # clear
bundles_command(_parse(["show", "x"]))
out = capsys.readouterr().out
assert "/x" in out
assert "s1" in out
assert "s2" in out
def test_delete(self, bundles_env, capsys):
bundles_command(_parse(["create", "doomed", "--skill", "s1"]))
capsys.readouterr()
bundles_command(_parse(["delete", "doomed"]))
out = capsys.readouterr().out
assert "Deleted bundle" in out
assert not (bundles_env / "doomed.yaml").exists()
def test_create_refuses_overwrite(self, bundles_env, capsys):
bundles_command(_parse(["create", "dup", "--skill", "s1"]))
capsys.readouterr()
with pytest.raises(SystemExit) as ei:
bundles_command(_parse(["create", "dup", "--skill", "s2"]))
assert ei.value.code == 1
out = capsys.readouterr().out
assert "already exists" in out.lower() or "--force" in out.lower()
def test_create_force_overwrites(self, bundles_env, capsys):
bundles_command(_parse(["create", "dup", "--skill", "s1"]))
capsys.readouterr()
bundles_command(_parse(["create", "dup", "--skill", "s2", "--force"]))
out = capsys.readouterr().out
assert "Created bundle" in out
def test_create_requires_skills(self, bundles_env, capsys, monkeypatch):
# Simulate user pressing Ctrl-D immediately at the interactive prompt.
monkeypatch.setattr("builtins.input", lambda *_a, **_kw: (_ for _ in ()).throw(EOFError()))
with pytest.raises(SystemExit):
bundles_command(_parse(["create", "empty"]))
def test_show_missing(self, bundles_env, capsys):
with pytest.raises(SystemExit) as ei:
bundles_command(_parse(["show", "ghost"]))
assert ei.value.code == 1
def test_reload(self, bundles_env, capsys):
# Reload on an empty dir reports no changes.
bundles_command(_parse(["reload"]))
out = capsys.readouterr().out
assert "No changes" in out or "0" in out
+101
View File
@@ -0,0 +1,101 @@
import sys
def test_top_level_skills_flag_defaults_to_chat(monkeypatch):
import hermes_cli.main as main_mod
captured = {}
def fake_cmd_chat(args):
captured["skills"] = args.skills
captured["command"] = args.command
monkeypatch.setattr(main_mod, "cmd_chat", fake_cmd_chat)
monkeypatch.setattr(
sys,
"argv",
["hermes", "-s", "hermes-agent-dev,github-auth"],
)
main_mod.main()
assert captured == {
"skills": ["hermes-agent-dev,github-auth"],
"command": None,
}
def test_chat_subcommand_accepts_skills_flag(monkeypatch):
import hermes_cli.main as main_mod
captured = {}
def fake_cmd_chat(args):
captured["skills"] = args.skills
captured["query"] = args.query
monkeypatch.setattr(main_mod, "cmd_chat", fake_cmd_chat)
monkeypatch.setattr(
sys,
"argv",
["hermes", "chat", "-s", "github-auth", "-q", "hello"],
)
main_mod.main()
assert captured == {
"skills": ["github-auth"],
"query": "hello",
}
def test_chat_subcommand_accepts_image_flag(monkeypatch):
import hermes_cli.main as main_mod
captured = {}
def fake_cmd_chat(args):
captured["query"] = args.query
captured["image"] = args.image
monkeypatch.setattr(main_mod, "cmd_chat", fake_cmd_chat)
monkeypatch.setattr(
sys,
"argv",
["hermes", "chat", "-q", "hello", "--image", "~/storage/shared/Pictures/cat.png"],
)
main_mod.main()
assert captured == {
"query": "hello",
"image": "~/storage/shared/Pictures/cat.png",
}
def test_continue_worktree_and_skills_flags_work_together(monkeypatch):
import hermes_cli.main as main_mod
captured = {}
def fake_cmd_chat(args):
captured["continue_last"] = args.continue_last
captured["worktree"] = args.worktree
captured["skills"] = args.skills
captured["command"] = args.command
monkeypatch.setattr(main_mod, "cmd_chat", fake_cmd_chat)
monkeypatch.setattr(
sys,
"argv",
["hermes", "-c", "-w", "-s", "hermes-agent-dev"],
)
main_mod.main()
assert captured == {
"continue_last": True,
"worktree": True,
"skills": ["hermes-agent-dev"],
"command": "chat",
}
+799
View File
@@ -0,0 +1,799 @@
"""Tests for hermes claw commands."""
from argparse import Namespace
import subprocess
from types import ModuleType
from unittest.mock import MagicMock, patch
import pytest
from hermes_cli import claw as claw_mod
# ---------------------------------------------------------------------------
# _find_migration_script
# ---------------------------------------------------------------------------
class TestFindMigrationScript:
"""Test script discovery in known locations."""
def test_finds_project_root_script(self, tmp_path):
script = tmp_path / "openclaw_to_hermes.py"
script.write_text("# placeholder")
with patch.object(claw_mod, "_OPENCLAW_SCRIPT", script):
assert claw_mod._find_migration_script() == script
def test_finds_installed_script(self, tmp_path):
installed = tmp_path / "installed.py"
installed.write_text("# placeholder")
with (
patch.object(claw_mod, "_OPENCLAW_SCRIPT", tmp_path / "nonexistent.py"),
patch.object(claw_mod, "_OPENCLAW_SCRIPT_INSTALLED", installed),
):
assert claw_mod._find_migration_script() == installed
def test_returns_none_when_missing(self, tmp_path):
with (
patch.object(claw_mod, "_OPENCLAW_SCRIPT", tmp_path / "a.py"),
patch.object(claw_mod, "_OPENCLAW_SCRIPT_INSTALLED", tmp_path / "b.py"),
):
assert claw_mod._find_migration_script() is None
# ---------------------------------------------------------------------------
# _find_openclaw_dirs
# ---------------------------------------------------------------------------
class TestFindOpenclawDirs:
"""Test discovery of OpenClaw directories."""
def test_finds_openclaw_dir(self, tmp_path):
openclaw = tmp_path / ".openclaw"
openclaw.mkdir()
with patch("pathlib.Path.home", return_value=tmp_path):
found = claw_mod._find_openclaw_dirs()
assert openclaw in found
def test_finds_legacy_dirs(self, tmp_path):
clawdbot = tmp_path / ".clawdbot"
clawdbot.mkdir()
moltbot = tmp_path / ".moltbot"
moltbot.mkdir()
with patch("pathlib.Path.home", return_value=tmp_path):
found = claw_mod._find_openclaw_dirs()
assert len(found) == 2
assert clawdbot in found
assert moltbot in found
def test_returns_empty_when_none_exist(self, tmp_path):
with patch("pathlib.Path.home", return_value=tmp_path):
found = claw_mod._find_openclaw_dirs()
assert found == []
# ---------------------------------------------------------------------------
# _scan_workspace_state
# ---------------------------------------------------------------------------
class TestScanWorkspaceState:
"""Test scanning for workspace state files."""
def test_finds_root_state_files(self, tmp_path):
(tmp_path / "todo.json").write_text("{}")
(tmp_path / "sessions").mkdir()
findings = claw_mod._scan_workspace_state(tmp_path)
descs = [desc for _, desc in findings]
assert any("todo.json" in d for d in descs)
assert any("sessions" in d for d in descs)
def test_finds_workspace_state_files(self, tmp_path):
ws = tmp_path / "workspace"
ws.mkdir()
(ws / "todo.json").write_text("{}")
(ws / "sessions").mkdir()
findings = claw_mod._scan_workspace_state(tmp_path)
descs = [desc for _, desc in findings]
assert any("workspace/todo.json" in d for d in descs)
assert any("workspace/sessions" in d for d in descs)
def test_ignores_hidden_dirs(self, tmp_path):
scan_dir = tmp_path / "scan_target"
scan_dir.mkdir()
hidden = scan_dir / ".git"
hidden.mkdir()
(hidden / "todo.json").write_text("{}")
findings = claw_mod._scan_workspace_state(scan_dir)
assert len(findings) == 0
def test_empty_dir_returns_empty(self, tmp_path):
scan_dir = tmp_path / "scan_target"
scan_dir.mkdir()
findings = claw_mod._scan_workspace_state(scan_dir)
assert findings == []
# ---------------------------------------------------------------------------
# _archive_directory
# ---------------------------------------------------------------------------
class TestArchiveDirectory:
"""Test directory archival (rename)."""
def test_renames_to_pre_migration(self, tmp_path):
source = tmp_path / ".openclaw"
source.mkdir()
(source / "test.txt").write_text("data")
archive_path = claw_mod._archive_directory(source)
assert archive_path == tmp_path / ".openclaw.pre-migration"
assert archive_path.is_dir()
assert not source.exists()
assert (archive_path / "test.txt").read_text() == "data"
def test_adds_timestamp_when_archive_exists(self, tmp_path):
source = tmp_path / ".openclaw"
source.mkdir()
# Pre-existing archive
(tmp_path / ".openclaw.pre-migration").mkdir()
archive_path = claw_mod._archive_directory(source)
assert ".pre-migration-" in archive_path.name
assert archive_path.is_dir()
assert not source.exists()
def test_dry_run_does_not_rename(self, tmp_path):
source = tmp_path / ".openclaw"
source.mkdir()
archive_path = claw_mod._archive_directory(source, dry_run=True)
assert archive_path == tmp_path / ".openclaw.pre-migration"
assert source.is_dir() # Still exists
# ---------------------------------------------------------------------------
# claw_command routing
# ---------------------------------------------------------------------------
class TestClawCommand:
"""Test the claw_command router."""
def test_routes_to_migrate(self):
args = Namespace(claw_action="migrate", source=None, dry_run=True,
preset="full", overwrite=False, migrate_secrets=False,
workspace_target=None, skill_conflict="skip", yes=False)
with patch.object(claw_mod, "_cmd_migrate") as mock:
claw_mod.claw_command(args)
mock.assert_called_once_with(args)
def test_routes_to_cleanup(self):
args = Namespace(claw_action="cleanup", source=None, dry_run=False, yes=False)
with patch.object(claw_mod, "_cmd_cleanup") as mock:
claw_mod.claw_command(args)
mock.assert_called_once_with(args)
def test_routes_clean_alias(self):
args = Namespace(claw_action="clean", source=None, dry_run=False, yes=False)
with patch.object(claw_mod, "_cmd_cleanup") as mock:
claw_mod.claw_command(args)
mock.assert_called_once_with(args)
def test_shows_help_for_no_action(self, capsys):
args = Namespace(claw_action=None)
claw_mod.claw_command(args)
captured = capsys.readouterr()
assert "migrate" in captured.out
assert "cleanup" in captured.out
# ---------------------------------------------------------------------------
# _cmd_migrate
# ---------------------------------------------------------------------------
class TestCmdMigrate:
"""Test the migrate command handler."""
@pytest.fixture(autouse=True)
def _mock_openclaw_running(self):
with patch.object(claw_mod, "_detect_openclaw_processes", return_value=[]):
yield
def test_error_when_source_missing(self, tmp_path, capsys):
args = Namespace(
source=str(tmp_path / "nonexistent"),
dry_run=True, preset="full", overwrite=False,
migrate_secrets=False, workspace_target=None,
skill_conflict="skip", yes=False,
)
claw_mod._cmd_migrate(args)
captured = capsys.readouterr()
assert "not found" in captured.out
def test_error_when_script_missing(self, tmp_path, capsys):
openclaw_dir = tmp_path / ".openclaw"
openclaw_dir.mkdir()
args = Namespace(
source=str(openclaw_dir),
dry_run=True, preset="full", overwrite=False,
migrate_secrets=False, workspace_target=None,
skill_conflict="skip", yes=False,
)
with (
patch.object(claw_mod, "_OPENCLAW_SCRIPT", tmp_path / "a.py"),
patch.object(claw_mod, "_OPENCLAW_SCRIPT_INSTALLED", tmp_path / "b.py"),
):
claw_mod._cmd_migrate(args)
captured = capsys.readouterr()
assert "Migration script not found" in captured.out
def test_dry_run_succeeds(self, tmp_path, capsys):
openclaw_dir = tmp_path / ".openclaw"
openclaw_dir.mkdir()
script = tmp_path / "script.py"
script.write_text("# placeholder")
# Build a fake migration module
fake_mod = ModuleType("openclaw_to_hermes")
fake_mod.resolve_selected_options = MagicMock(return_value={"soul", "memory"})
fake_migrator = MagicMock()
fake_migrator.migrate.return_value = {
"summary": {"migrated": 0, "skipped": 5, "conflict": 0, "error": 0},
"items": [
{"kind": "soul", "status": "skipped", "reason": "Not found"},
],
"preset": "full",
}
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
args = Namespace(
source=str(openclaw_dir),
dry_run=True, preset="full", overwrite=False,
migrate_secrets=False, workspace_target=None,
skill_conflict="skip", yes=False,
)
with (
patch.object(claw_mod, "_find_migration_script", return_value=script),
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
patch.object(claw_mod, "get_config_path", return_value=tmp_path / "config.yaml"),
patch.object(claw_mod, "save_config"),
patch.object(claw_mod, "load_config", return_value={}),
):
claw_mod._cmd_migrate(args)
captured = capsys.readouterr()
assert "Dry Run Results" in captured.out
assert "5 skipped" in captured.out
def test_execute_with_confirmation(self, tmp_path, capsys):
openclaw_dir = tmp_path / ".openclaw"
openclaw_dir.mkdir()
config_path = tmp_path / "config.yaml"
config_path.write_text("agent:\n max_turns: 90\n")
fake_mod = ModuleType("openclaw_to_hermes")
fake_mod.resolve_selected_options = MagicMock(return_value={"soul"})
fake_migrator = MagicMock()
fake_migrator.migrate.return_value = {
"summary": {"migrated": 2, "skipped": 1, "conflict": 0, "error": 0},
"items": [
{"kind": "soul", "status": "migrated", "destination": str(tmp_path / "SOUL.md")},
{"kind": "memory", "status": "migrated", "destination": str(tmp_path / "memories/MEMORY.md")},
],
}
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
args = Namespace(
source=str(openclaw_dir),
dry_run=False, preset="user-data", overwrite=False,
migrate_secrets=False, workspace_target=None,
skill_conflict="skip", yes=False,
)
mock_stdin = MagicMock()
mock_stdin.isatty.return_value = True
with (
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
patch.object(claw_mod, "get_config_path", return_value=config_path),
patch.object(claw_mod, "prompt_yes_no", return_value=True),
patch("sys.stdin", mock_stdin),
):
claw_mod._cmd_migrate(args)
captured = capsys.readouterr()
assert "Migration Results" in captured.out
assert "Migration complete!" in captured.out
def test_dry_run_does_not_touch_source(self, tmp_path, capsys):
"""Dry run should not modify the source directory."""
openclaw_dir = tmp_path / ".openclaw"
openclaw_dir.mkdir()
fake_mod = ModuleType("openclaw_to_hermes")
fake_mod.resolve_selected_options = MagicMock(return_value=set())
fake_migrator = MagicMock()
fake_migrator.migrate.return_value = {
"summary": {"migrated": 2, "skipped": 0, "conflict": 0, "error": 0},
"items": [],
"preset": "full",
}
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
args = Namespace(
source=str(openclaw_dir),
dry_run=True, preset="full", overwrite=False,
migrate_secrets=False, workspace_target=None,
skill_conflict="skip", yes=False,
)
with (
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
patch.object(claw_mod, "get_config_path", return_value=tmp_path / "config.yaml"),
patch.object(claw_mod, "save_config"),
patch.object(claw_mod, "load_config", return_value={}),
):
claw_mod._cmd_migrate(args)
assert openclaw_dir.is_dir() # Source untouched
def test_execute_cancelled_by_user(self, tmp_path, capsys):
openclaw_dir = tmp_path / ".openclaw"
openclaw_dir.mkdir()
config_path = tmp_path / "config.yaml"
config_path.write_text("")
# Preview must succeed before the confirmation prompt is shown
fake_mod = ModuleType("openclaw_to_hermes")
fake_mod.resolve_selected_options = MagicMock(return_value=set())
fake_migrator = MagicMock()
fake_migrator.migrate.return_value = {
"summary": {"migrated": 1, "skipped": 0, "conflict": 0, "error": 0},
"items": [{"kind": "soul", "status": "migrated", "source": "s", "destination": "d", "reason": ""}],
}
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
args = Namespace(
source=str(openclaw_dir),
dry_run=False, preset="full", overwrite=False,
migrate_secrets=False, workspace_target=None,
skill_conflict="skip", yes=False,
)
mock_stdin = MagicMock()
mock_stdin.isatty.return_value = True
with (
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
patch.object(claw_mod, "get_config_path", return_value=config_path),
patch.object(claw_mod, "prompt_yes_no", return_value=False),
patch("sys.stdin", mock_stdin),
):
claw_mod._cmd_migrate(args)
captured = capsys.readouterr()
assert "Migration cancelled" in captured.out
def test_execute_with_yes_skips_confirmation(self, tmp_path, capsys):
openclaw_dir = tmp_path / ".openclaw"
openclaw_dir.mkdir()
config_path = tmp_path / "config.yaml"
config_path.write_text("")
fake_mod = ModuleType("openclaw_to_hermes")
fake_mod.resolve_selected_options = MagicMock(return_value=set())
fake_migrator = MagicMock()
fake_migrator.migrate.return_value = {
"summary": {"migrated": 0, "skipped": 0, "conflict": 0, "error": 0},
"items": [],
}
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
args = Namespace(
source=str(openclaw_dir),
dry_run=False, preset="full", overwrite=False,
migrate_secrets=False, workspace_target=None,
skill_conflict="skip", yes=True,
)
with (
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
patch.object(claw_mod, "get_config_path", return_value=config_path),
patch.object(claw_mod, "prompt_yes_no") as mock_prompt,
):
claw_mod._cmd_migrate(args)
mock_prompt.assert_not_called()
def test_handles_migration_error(self, tmp_path, capsys):
openclaw_dir = tmp_path / ".openclaw"
openclaw_dir.mkdir()
config_path = tmp_path / "config.yaml"
config_path.write_text("")
args = Namespace(
source=str(openclaw_dir),
dry_run=True, preset="full", overwrite=False,
migrate_secrets=False, workspace_target=None,
skill_conflict="skip", yes=False,
)
with (
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
patch.object(claw_mod, "_load_migration_module", side_effect=RuntimeError("boom")),
patch.object(claw_mod, "get_config_path", return_value=config_path),
patch.object(claw_mod, "save_config"),
patch.object(claw_mod, "load_config", return_value={}),
):
claw_mod._cmd_migrate(args)
captured = capsys.readouterr()
assert "Could not load migration script" in captured.out
def test_full_preset_does_not_enable_secrets_silently(self, tmp_path, capsys):
"""The 'full' preset must NOT auto-enable migrate_secrets.
Users have to opt in to secret import explicitly via --migrate-secrets,
even under the 'full' preset. This mirrors OpenClaw's migrate-hermes
posture (two-phase import) and prevents a 'full' run from silently
copying API keys.
"""
openclaw_dir = tmp_path / ".openclaw"
openclaw_dir.mkdir()
fake_mod = ModuleType("openclaw_to_hermes")
fake_mod.resolve_selected_options = MagicMock(return_value=set())
fake_migrator = MagicMock()
fake_migrator.migrate.return_value = {
"summary": {"migrated": 0, "skipped": 0, "conflict": 0, "error": 0},
"items": [],
}
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
args = Namespace(
source=str(openclaw_dir),
dry_run=True, preset="full", overwrite=False,
migrate_secrets=False, # Not explicitly set by user
workspace_target=None,
skill_conflict="skip", yes=False,
no_backup=False,
)
with (
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
patch.object(claw_mod, "get_config_path", return_value=tmp_path / "config.yaml"),
patch.object(claw_mod, "save_config"),
patch.object(claw_mod, "load_config", return_value={}),
):
claw_mod._cmd_migrate(args)
# Migrator should have been called with migrate_secrets=False — the
# 'full' preset on its own no longer opts the user into secret import.
call_kwargs = fake_mod.Migrator.call_args[1]
assert call_kwargs["migrate_secrets"] is False
def test_full_preset_with_explicit_migrate_secrets_passes_through(self, tmp_path, capsys):
"""Explicit --migrate-secrets still works under --preset full."""
openclaw_dir = tmp_path / ".openclaw"
openclaw_dir.mkdir()
fake_mod = ModuleType("openclaw_to_hermes")
fake_mod.resolve_selected_options = MagicMock(return_value=set())
fake_migrator = MagicMock()
fake_migrator.migrate.return_value = {
"summary": {"migrated": 0, "skipped": 0, "conflict": 0, "error": 0},
"items": [],
}
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
args = Namespace(
source=str(openclaw_dir),
dry_run=True, preset="full", overwrite=False,
migrate_secrets=True, # Explicitly requested
workspace_target=None,
skill_conflict="skip", yes=False,
no_backup=False,
)
with (
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
patch.object(claw_mod, "get_config_path", return_value=tmp_path / "config.yaml"),
patch.object(claw_mod, "save_config"),
patch.object(claw_mod, "load_config", return_value={}),
):
claw_mod._cmd_migrate(args)
call_kwargs = fake_mod.Migrator.call_args[1]
assert call_kwargs["migrate_secrets"] is True
# ---------------------------------------------------------------------------
# _cmd_cleanup
# ---------------------------------------------------------------------------
class TestCmdCleanup:
"""Test the cleanup command handler."""
@pytest.fixture(autouse=True)
def _mock_openclaw_running(self):
with patch.object(claw_mod, "_detect_openclaw_processes", return_value=[]):
yield
def test_no_dirs_found(self, tmp_path, capsys):
args = Namespace(source=None, dry_run=False, yes=False)
with patch.object(claw_mod, "_find_openclaw_dirs", return_value=[]):
claw_mod._cmd_cleanup(args)
captured = capsys.readouterr()
assert "No OpenClaw directories found" in captured.out
def test_dry_run_lists_dirs(self, tmp_path, capsys):
openclaw = tmp_path / ".openclaw"
openclaw.mkdir()
ws = openclaw / "workspace"
ws.mkdir()
(ws / "todo.json").write_text("{}")
args = Namespace(source=None, dry_run=True, yes=False)
with patch.object(claw_mod, "_find_openclaw_dirs", return_value=[openclaw]):
claw_mod._cmd_cleanup(args)
captured = capsys.readouterr()
assert "Would archive" in captured.out
assert openclaw.is_dir() # Not actually archived
def test_archives_with_yes(self, tmp_path, capsys):
openclaw = tmp_path / ".openclaw"
openclaw.mkdir()
(openclaw / "workspace").mkdir()
(openclaw / "workspace" / "todo.json").write_text("{}")
args = Namespace(source=None, dry_run=False, yes=True)
with patch.object(claw_mod, "_find_openclaw_dirs", return_value=[openclaw]):
claw_mod._cmd_cleanup(args)
captured = capsys.readouterr()
assert "Archived" in captured.out
assert "Cleaned up 1" in captured.out
assert not openclaw.exists()
assert (tmp_path / ".openclaw.pre-migration").is_dir()
def test_skips_when_user_declines(self, tmp_path, capsys):
openclaw = tmp_path / ".openclaw"
openclaw.mkdir()
mock_stdin = MagicMock()
mock_stdin.isatty.return_value = True
args = Namespace(source=None, dry_run=False, yes=False)
with (
patch.object(claw_mod, "_find_openclaw_dirs", return_value=[openclaw]),
patch.object(claw_mod, "prompt_yes_no", return_value=False),
patch("sys.stdin", mock_stdin),
):
claw_mod._cmd_cleanup(args)
captured = capsys.readouterr()
assert "Skipped" in captured.out
assert openclaw.is_dir()
def test_explicit_source(self, tmp_path, capsys):
custom_dir = tmp_path / "my-openclaw"
custom_dir.mkdir()
(custom_dir / "todo.json").write_text("{}")
args = Namespace(source=str(custom_dir), dry_run=False, yes=True)
claw_mod._cmd_cleanup(args)
captured = capsys.readouterr()
assert "Archived" in captured.out
assert not custom_dir.exists()
def test_shows_workspace_details(self, tmp_path, capsys):
openclaw = tmp_path / ".openclaw"
openclaw.mkdir()
ws = openclaw / "workspace"
ws.mkdir()
(ws / "todo.json").write_text("{}")
(ws / "SOUL.md").write_text("# Soul")
args = Namespace(source=None, dry_run=True, yes=False)
with patch.object(claw_mod, "_find_openclaw_dirs", return_value=[openclaw]):
claw_mod._cmd_cleanup(args)
captured = capsys.readouterr()
assert "workspace/" in captured.out
assert "todo.json" in captured.out
def test_handles_multiple_dirs(self, tmp_path, capsys):
openclaw = tmp_path / ".openclaw"
openclaw.mkdir()
clawdbot = tmp_path / ".clawdbot"
clawdbot.mkdir()
args = Namespace(source=None, dry_run=False, yes=True)
with patch.object(claw_mod, "_find_openclaw_dirs", return_value=[openclaw, clawdbot]):
claw_mod._cmd_cleanup(args)
captured = capsys.readouterr()
assert "Cleaned up 2" in captured.out
assert not openclaw.exists()
assert not clawdbot.exists()
# ---------------------------------------------------------------------------
# _print_migration_report
# ---------------------------------------------------------------------------
class TestPrintMigrationReport:
"""Test the report formatting function."""
def test_dry_run_report(self, capsys):
report = {
"summary": {"migrated": 2, "skipped": 1, "conflict": 1, "error": 0},
"items": [
{"kind": "soul", "status": "migrated", "destination": "/home/user/.hermes/SOUL.md"},
{"kind": "memory", "status": "migrated", "destination": "/home/user/.hermes/memories/MEMORY.md"},
{"kind": "skills", "status": "conflict", "reason": "already exists"},
{"kind": "tts-assets", "status": "skipped", "reason": "not found"},
],
"preset": "full",
}
claw_mod._print_migration_report(report, dry_run=True)
captured = capsys.readouterr()
assert "Dry Run Results" in captured.out
assert "Would migrate" in captured.out
assert "2 would migrate" in captured.out
assert "--dry-run" in captured.out
def test_execute_report(self, capsys):
report = {
"summary": {"migrated": 3, "skipped": 0, "conflict": 0, "error": 0},
"items": [
{"kind": "soul", "status": "migrated", "destination": "/home/user/.hermes/SOUL.md"},
],
"output_dir": "/home/user/.hermes/migration/openclaw/20250312T120000",
}
claw_mod._print_migration_report(report, dry_run=False)
captured = capsys.readouterr()
assert "Migration Results" in captured.out
assert "Migrated" in captured.out
assert "Full report saved to" in captured.out
def test_empty_report(self, capsys):
report = {
"summary": {"migrated": 0, "skipped": 0, "conflict": 0, "error": 0},
"items": [],
}
claw_mod._print_migration_report(report, dry_run=False)
captured = capsys.readouterr()
assert "Nothing to migrate" in captured.out
class TestDetectOpenclawProcesses:
def test_returns_match_when_pgrep_finds_openclaw(self):
with patch.object(claw_mod, "sys") as mock_sys:
mock_sys.platform = "linux"
with patch.object(claw_mod, "subprocess") as mock_subprocess:
# systemd check misses, pgrep finds openclaw
mock_subprocess.run.side_effect = [
MagicMock(returncode=1, stdout=""), # systemctl
MagicMock(returncode=0, stdout="1234\n"), # pgrep
]
mock_subprocess.TimeoutExpired = subprocess.TimeoutExpired
result = claw_mod._detect_openclaw_processes()
assert len(result) == 1
assert "1234" in result[0]
def test_returns_empty_when_pgrep_finds_nothing(self):
with patch.object(claw_mod, "sys") as mock_sys:
mock_sys.platform = "darwin"
with patch.object(claw_mod, "subprocess") as mock_subprocess:
mock_subprocess.run.side_effect = [
MagicMock(returncode=1, stdout=""), # systemctl (not found)
MagicMock(returncode=1, stdout=""), # pgrep
]
mock_subprocess.TimeoutExpired = subprocess.TimeoutExpired
result = claw_mod._detect_openclaw_processes()
assert result == []
def test_detects_systemd_service(self):
with patch.object(claw_mod, "sys") as mock_sys:
mock_sys.platform = "linux"
with patch.object(claw_mod, "subprocess") as mock_subprocess:
mock_subprocess.run.side_effect = [
MagicMock(returncode=0, stdout="active\n"), # systemctl
MagicMock(returncode=1, stdout=""), # pgrep
]
mock_subprocess.TimeoutExpired = subprocess.TimeoutExpired
result = claw_mod._detect_openclaw_processes()
assert len(result) == 1
assert "systemd" in result[0]
def test_returns_match_on_windows_when_openclaw_exe_running(self):
with patch.object(claw_mod, "sys") as mock_sys:
mock_sys.platform = "win32"
with patch.object(claw_mod, "subprocess") as mock_subprocess:
mock_subprocess.run.side_effect = [
MagicMock(returncode=0, stdout="openclaw.exe 1234 Console 1 45,056 K\n"),
]
result = claw_mod._detect_openclaw_processes()
assert len(result) >= 1
assert any("openclaw.exe" in r for r in result)
def test_returns_match_on_windows_when_node_exe_has_openclaw_in_cmdline(self):
with patch.object(claw_mod, "sys") as mock_sys:
mock_sys.platform = "win32"
with patch.object(claw_mod, "subprocess") as mock_subprocess:
mock_subprocess.run.side_effect = [
MagicMock(returncode=0, stdout=""), # tasklist openclaw.exe
MagicMock(returncode=0, stdout=""), # tasklist clawd.exe
MagicMock(returncode=0, stdout="1234\n"), # PowerShell
]
result = claw_mod._detect_openclaw_processes()
assert len(result) >= 1
assert any("node.exe" in r for r in result)
def test_returns_empty_on_windows_when_nothing_found(self):
with patch.object(claw_mod, "sys") as mock_sys:
mock_sys.platform = "win32"
with patch.object(claw_mod, "subprocess") as mock_subprocess:
mock_subprocess.run.side_effect = [
MagicMock(returncode=0, stdout=""),
MagicMock(returncode=0, stdout=""),
MagicMock(returncode=0, stdout=""),
]
result = claw_mod._detect_openclaw_processes()
assert result == []
class TestWarnIfOpenclawRunning:
def test_noop_when_not_running(self, capsys):
with patch.object(claw_mod, "_detect_openclaw_processes", return_value=[]):
claw_mod._warn_if_openclaw_running(auto_yes=False)
captured = capsys.readouterr()
assert captured.out == ""
def test_warns_and_exits_when_running_and_user_declines(self, capsys):
with patch.object(claw_mod, "_detect_openclaw_processes", return_value=["openclaw process(es) (PIDs: 1234)"]):
with patch.object(claw_mod, "prompt_yes_no", return_value=False):
with patch.object(claw_mod.sys.stdin, "isatty", return_value=True):
with pytest.raises(SystemExit) as exc_info:
claw_mod._warn_if_openclaw_running(auto_yes=False)
assert exc_info.value.code == 0
captured = capsys.readouterr()
assert "OpenClaw appears to be running" in captured.out
def test_warns_and_continues_when_running_and_user_accepts(self, capsys):
with patch.object(claw_mod, "_detect_openclaw_processes", return_value=["openclaw process(es) (PIDs: 1234)"]):
with patch.object(claw_mod, "prompt_yes_no", return_value=True):
with patch.object(claw_mod.sys.stdin, "isatty", return_value=True):
claw_mod._warn_if_openclaw_running(auto_yes=False)
captured = capsys.readouterr()
assert "OpenClaw appears to be running" in captured.out
def test_warns_and_continues_in_auto_yes_mode(self, capsys):
with patch.object(claw_mod, "_detect_openclaw_processes", return_value=["openclaw process(es) (PIDs: 1234)"]):
claw_mod._warn_if_openclaw_running(auto_yes=True)
captured = capsys.readouterr()
assert "OpenClaw appears to be running" in captured.out
def test_warns_and_continues_in_non_interactive_session(self, capsys):
with patch.object(claw_mod, "_detect_openclaw_processes", return_value=["openclaw process(es) (PIDs: 1234)"]):
with patch.object(claw_mod.sys.stdin, "isatty", return_value=False):
claw_mod._warn_if_openclaw_running(auto_yes=False)
captured = capsys.readouterr()
assert "OpenClaw appears to be running" in captured.out
assert "Non-interactive session" in captured.out
@@ -0,0 +1,74 @@
"""Tests for _clear_stale_openai_base_url() cleanup after provider switch (#5161)."""
from __future__ import annotations
from hermes_cli.config import load_config, save_config, save_env_value, get_env_value
def _write_provider(provider: str, model: str = "test-model"):
"""Helper: write a provider + model to config.yaml."""
cfg = load_config()
model_cfg = cfg.get("model", {})
if not isinstance(model_cfg, dict):
model_cfg = {}
model_cfg["provider"] = provider
model_cfg["default"] = model
cfg["model"] = model_cfg
save_config(cfg)
class TestClearStaleOpenaiBaseUrl:
"""_clear_stale_openai_base_url() removes OPENAI_BASE_URL when provider is not custom."""
def test_clears_when_provider_is_named(self, monkeypatch):
"""OPENAI_BASE_URL is cleared when config provider is a named provider."""
from hermes_cli.main import _clear_stale_openai_base_url
_write_provider("openrouter")
save_env_value("OPENAI_BASE_URL", "http://localhost:11434/v1")
_clear_stale_openai_base_url()
result = get_env_value("OPENAI_BASE_URL")
assert not result, f"Expected OPENAI_BASE_URL to be cleared, got: {result!r}"
def test_preserves_when_provider_is_custom(self, monkeypatch):
"""OPENAI_BASE_URL is NOT cleared when config provider is 'custom'."""
from hermes_cli.main import _clear_stale_openai_base_url
_write_provider("custom")
save_env_value("OPENAI_BASE_URL", "http://localhost:11434/v1")
_clear_stale_openai_base_url()
result = get_env_value("OPENAI_BASE_URL")
assert result == "http://localhost:11434/v1", \
f"Expected OPENAI_BASE_URL to be preserved, got: {result!r}"
def test_noop_when_no_openai_base_url(self, monkeypatch):
"""No error when OPENAI_BASE_URL is not set."""
from hermes_cli.main import _clear_stale_openai_base_url
_write_provider("openrouter")
# Ensure it's not set
save_env_value("OPENAI_BASE_URL", "")
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
# Should not raise
_clear_stale_openai_base_url()
def test_noop_when_provider_empty(self, monkeypatch):
"""No cleanup when provider is not set in config."""
from hermes_cli.main import _clear_stale_openai_base_url
cfg = load_config()
cfg.pop("model", None)
save_config(cfg)
save_env_value("OPENAI_BASE_URL", "http://localhost:11434/v1")
_clear_stale_openai_base_url()
result = get_env_value("OPENAI_BASE_URL")
assert result == "http://localhost:11434/v1", \
"Should not clear when provider is not configured"
+20
View File
@@ -0,0 +1,20 @@
from hermes_cli import cli_output
def test_password_prompt_uses_masked_secret_prompt(monkeypatch):
seen = {}
def fake_masked_secret_prompt(display):
seen["display"] = display
return " secret "
monkeypatch.setattr(cli_output, "masked_secret_prompt", fake_masked_secret_prompt)
assert cli_output.prompt("API key", default="old", password=True) == "secret"
assert "API key [old]" in seen["display"]
def test_empty_password_prompt_returns_default(monkeypatch):
monkeypatch.setattr(cli_output, "masked_secret_prompt", lambda _display: "")
assert cli_output.prompt("API key", default="old", password=True) == "old"
+700
View File
@@ -0,0 +1,700 @@
"""Tests for cmd_update — branch fallback when remote branch doesn't exist."""
import subprocess
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from hermes_cli.main import cmd_update, PROJECT_ROOT
def _make_run_side_effect(branch="main", verify_ok=True, commit_count="0"):
"""Build a side_effect function for subprocess.run that simulates git commands."""
def side_effect(cmd, **kwargs):
joined = " ".join(str(c) for c in cmd)
# git rev-parse --abbrev-ref HEAD (get current branch)
if "rev-parse" in joined and "--abbrev-ref" in joined:
return subprocess.CompletedProcess(cmd, 0, stdout=f"{branch}\n", stderr="")
# git rev-parse --verify origin/{branch} (check remote branch exists)
if "rev-parse" in joined and "--verify" in joined:
rc = 0 if verify_ok else 128
return subprocess.CompletedProcess(cmd, rc, stdout="", stderr="")
# git rev-list HEAD..origin/{branch} --count
if "rev-list" in joined:
return subprocess.CompletedProcess(cmd, 0, stdout=f"{commit_count}\n", stderr="")
# Fallback: return a successful CompletedProcess with empty stdout
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
return side_effect
@pytest.fixture
def mock_args():
return SimpleNamespace()
class TestCmdUpdatePip:
"""Regression tests for pip-install update flows."""
@patch("shutil.which", return_value="/usr/bin/uv")
@patch("subprocess.run")
def test_update_pip_exports_virtualenv_from_sys_prefix(
self, mock_run, _mock_which, mock_args, monkeypatch
):
from hermes_cli import main as hm
mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="")
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
monkeypatch.setattr(hm.sys, "prefix", "/tmp/hermes-launcher-venv")
monkeypatch.setattr(hm.sys, "base_prefix", "/usr")
hm._cmd_update_pip(mock_args)
assert mock_run.call_count == 1
assert mock_run.call_args.args[0] == ["/usr/bin/uv", "pip", "install", "--upgrade", "hermes-agent"]
assert mock_run.call_args.kwargs["env"]["VIRTUAL_ENV"] == "/tmp/hermes-launcher-venv"
@patch("shutil.which", return_value="/usr/bin/uv")
@patch("subprocess.run")
def test_update_pip_does_not_export_virtualenv_for_system_python(
self, mock_run, _mock_which, mock_args, monkeypatch
):
from hermes_cli import main as hm
mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="")
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
monkeypatch.setattr(hm.sys, "prefix", "/usr")
monkeypatch.setattr(hm.sys, "base_prefix", "/usr")
hm._cmd_update_pip(mock_args)
assert mock_run.call_count == 1
assert "env" not in mock_run.call_args.kwargs
class TestCmdUpdateBranchFallback:
"""cmd_update falls back to main when current branch has no remote counterpart."""
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_update_falls_back_to_main_when_branch_not_on_remote(
self, mock_run, _mock_which, mock_args, capsys
):
mock_run.side_effect = _make_run_side_effect(
branch="fix/stoicneko", verify_ok=False, commit_count="3"
)
cmd_update(mock_args)
commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list]
# rev-list should use origin/main, not origin/fix/stoicneko
rev_list_cmds = [c for c in commands if "rev-list" in c]
assert len(rev_list_cmds) == 1
assert "origin/main" in rev_list_cmds[0]
assert "origin/fix/stoicneko" not in rev_list_cmds[0]
# pull should use main, not fix/stoicneko
pull_cmds = [c for c in commands if "pull" in c]
assert len(pull_cmds) == 1
assert "main" in pull_cmds[0]
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_update_uses_current_branch_when_on_remote(
self, mock_run, _mock_which, mock_args, capsys
):
mock_run.side_effect = _make_run_side_effect(
branch="main", verify_ok=True, commit_count="2"
)
cmd_update(mock_args)
commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list]
rev_list_cmds = [c for c in commands if "rev-list" in c]
assert len(rev_list_cmds) == 1
assert "origin/main" in rev_list_cmds[0]
pull_cmds = [c for c in commands if "pull" in c]
assert len(pull_cmds) == 1
assert "main" in pull_cmds[0]
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_update_already_up_to_date(
self, mock_run, _mock_which, mock_args, capsys
):
mock_run.side_effect = _make_run_side_effect(
branch="main", verify_ok=True, commit_count="0"
)
cmd_update(mock_args)
captured = capsys.readouterr()
assert "Already up to date!" in captured.out
# Should NOT have called pull
commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list]
pull_cmds = [c for c in commands if "pull" in c]
assert len(pull_cmds) == 0
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_update_on_fork_checks_upstream_when_origin_up_to_date(
self, mock_run, _mock_which, mock_args, capsys
):
"""Regression for issue #26172: forks whose local HEAD already matches
origin/main must still consult upstream/main before printing
"Already up to date!" — otherwise a fork that's caught up to its own
origin but behind NousResearch/hermes-agent silently misses updates.
"""
from hermes_cli import main as hm
mock_run.side_effect = _make_run_side_effect(
branch="main", verify_ok=True, commit_count="0"
)
with patch.object(
hm,
"_get_origin_url",
return_value="https://github.com/example/hermes-agent.git",
), patch.object(hm, "_sync_with_upstream_if_needed") as sync_mock:
cmd_update(mock_args)
sync_mock.assert_called_once_with(["git"], PROJECT_ROOT)
captured = capsys.readouterr()
assert "Already up to date!" in captured.out
@patch("shutil.which")
@patch("subprocess.run")
def test_update_refreshes_repo_and_tui_node_dependencies(
self, mock_run, mock_which, mock_args
):
from hermes_cli import main as hm
mock_which.side_effect = {"uv": "/usr/bin/uv", "npm": "/usr/bin/npm"}.get
mock_run.side_effect = _make_run_side_effect(
branch="main", verify_ok=True, commit_count="1"
)
# The web UI build runs through _run_with_idle_timeout now (issue
# #33788) so it no longer appears in subprocess.run's call list.
# Mock it so the test doesn't actually shell out to ``tsc``.
import subprocess as _subprocess
build_ok = _subprocess.CompletedProcess([], 0, stdout="", stderr="")
with patch.object(hm, "_is_termux_env", return_value=False), \
patch.object(hm, "_run_with_idle_timeout", return_value=build_ok) as mock_idle:
cmd_update(mock_args)
npm_calls = [
(call.args[0], call.kwargs.get("cwd"))
for call in mock_run.call_args_list
if call.args and call.args[0][0] == "/usr/bin/npm"
]
# cmd_update runs npm commands in four locations:
# 1. repo root — slash-command / TUI bridge deps (subprocess.run)
# 2. ui-tui/ — Ink TUI deps (subprocess.run)
# 3. web/ — npm install (subprocess.run)
# 4. web/ — npm run build (_run_with_idle_timeout)
#
# Repo-root and ui-tui installs intentionally omit `--silent` and run
# without `capture_output` so optional postinstall scripts (e.g.
# `@askjo/camofox-browser`'s browser-binary fetch) print progress —
# otherwise long downloads look like a hang (#18840). The web/ install
# keeps `--silent` because its build step is short and noisy.
update_flags = [
"/usr/bin/npm",
"ci",
"--no-fund",
"--no-audit",
"--progress=false",
]
assert npm_calls[:2] == [
(update_flags, PROJECT_ROOT),
(update_flags, PROJECT_ROOT / "ui-tui"),
]
if len(npm_calls) > 2:
# Only the web/ install is left in subprocess.run; the build moved
# to _run_with_idle_timeout to make Vite progress visible (#33788).
assert npm_calls[2:] == [
(["/usr/bin/npm", "ci", "--silent"], PROJECT_ROOT / "web"),
]
# The web UI build itself went through the streaming helper.
mock_idle.assert_called_once()
idle_args, idle_kwargs = mock_idle.call_args
assert idle_args[0] == ["/usr/bin/npm", "run", "build"]
assert idle_kwargs["cwd"] == PROJECT_ROOT / "web"
# Regression for #18840: repo root + ui-tui installs must stream
# output (capture_output=False) so postinstall progress is visible
# to the user.
repo_and_tui_calls = [
call
for call in mock_run.call_args_list
if call.args
and call.args[0][0] == "/usr/bin/npm"
and call.args[0][1] == "ci"
and call.kwargs.get("cwd") in {PROJECT_ROOT, PROJECT_ROOT / "ui-tui"}
]
assert len(repo_and_tui_calls) == 2
for call in repo_and_tui_calls:
assert call.kwargs.get("capture_output") is False, (
"repo-root / ui-tui npm install must stream output "
"(no capture_output) so postinstall progress is visible"
)
def test_update_non_interactive_runs_safe_config_migrations(self, mock_args, capsys):
"""Dashboard/web updates apply non-interactive migrations before restart."""
with patch("shutil.which", return_value=None), patch(
"subprocess.run"
) as mock_run, patch("builtins.input") as mock_input, patch(
"hermes_cli.config.get_missing_env_vars", return_value=["MISSING_KEY"]
), patch(
"hermes_cli.config.get_missing_config_fields",
return_value=[{"key": "new.option", "default": True}],
), patch("hermes_cli.config.check_config_version", return_value=(1, 2)), patch(
"hermes_cli.config.migrate_config",
return_value={"env_added": [], "config_added": ["new.option"]},
), patch("hermes_cli.main.sys") as mock_sys:
mock_sys.stdin.isatty.return_value = False
mock_sys.stdout.isatty.return_value = False
mock_run.side_effect = _make_run_side_effect(
branch="main", verify_ok=True, commit_count="1"
)
cmd_update(mock_args)
mock_input.assert_not_called()
from hermes_cli.config import migrate_config
migrate_config.assert_called_once_with(interactive=False, quiet=False)
captured = capsys.readouterr()
assert "applying safe config migrations" in captured.out
assert "API keys require manual entry" in captured.out
class TestCmdUpdateProfileSkillSync:
"""cmd_update syncs bundled skills to all profiles, including the active one.
Regression guard for #16176: previously the active profile was excluded
from the seed_profile_skills loop, leaving it on stale skill content.
"""
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_active_profile_included_in_skill_sync(
self, mock_run, _mock_which, mock_args, capsys
):
from pathlib import Path
mock_run.side_effect = _make_run_side_effect(
branch="main", verify_ok=True, commit_count="1"
)
default_p = SimpleNamespace(name="default", path=Path("/fake/.hermes"))
active_p = SimpleNamespace(name="bit", path=Path("/fake/.hermes/profiles/bit"))
other_p = SimpleNamespace(name="work", path=Path("/fake/.hermes/profiles/work"))
all_profiles = [default_p, active_p, other_p]
synced_paths = []
def fake_seed(path, quiet=False):
synced_paths.append(path)
return {"copied": [], "updated": [], "user_modified": []}
empty_sync = {"copied": [], "updated": [], "user_modified": [], "cleaned": []}
with (
patch("hermes_cli.profiles.list_profiles", return_value=all_profiles),
patch("hermes_cli.profiles.seed_profile_skills", side_effect=fake_seed),
patch("tools.skills_sync.sync_skills", return_value=empty_sync),
):
cmd_update(mock_args)
assert active_p.path in synced_paths, (
f"Active profile 'bit' must be included in skill sync; got: {synced_paths}"
)
assert set(synced_paths) == {p.path for p in all_profiles}, (
f"All profiles must be synced; got: {synced_paths}"
)
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_single_profile_default_is_synced(
self, mock_run, _mock_which, mock_args, capsys
):
from pathlib import Path
mock_run.side_effect = _make_run_side_effect(
branch="main", verify_ok=True, commit_count="1"
)
default_p = SimpleNamespace(name="default", path=Path("/fake/.hermes"))
synced_paths = []
def fake_seed(path, quiet=False):
synced_paths.append(path)
return {"copied": [], "updated": [], "user_modified": []}
empty_sync = {"copied": [], "updated": [], "user_modified": [], "cleaned": []}
with (
patch("hermes_cli.profiles.list_profiles", return_value=[default_p]),
patch("hermes_cli.profiles.seed_profile_skills", side_effect=fake_seed),
patch("tools.skills_sync.sync_skills", return_value=empty_sync),
):
cmd_update(mock_args)
assert default_p.path in synced_paths
class TestCmdUpdateBranchFlag:
"""``hermes update --branch <name>`` targets the requested branch.
The CLI default stays 'main'; --branch lets callers pick a different
target without monkey-patching the implementation.
"""
def _branch_side_effect(self, current_branch, target_branch, *, checkout_fails=False, track_fails=False, commit_count="0"):
"""Mock side-effect that knows about checkout/track behavior.
- ``current_branch`` what ``git rev-parse --abbrev-ref HEAD`` returns
- ``target_branch`` passed via --branch; what we expect the code to switch to
- ``checkout_fails`` if True, ``git checkout <target>`` returns non-zero
(simulates branch absent locally; code should retry with -B)
- ``track_fails`` if True, ``git checkout -B <target> origin/<target>`` ALSO fails
(simulates branch absent on origin too)
- ``commit_count`` rev-list count returned (0 = up-to-date, >0 = behind)
"""
def side_effect(cmd, **kwargs):
joined = " ".join(str(c) for c in cmd)
if "rev-parse" in joined and "--abbrev-ref" in joined:
return subprocess.CompletedProcess(cmd, 0, stdout=f"{current_branch}\n", stderr="")
if "checkout" in joined and "-B" in joined:
rc = 128 if track_fails else 0
err = f"fatal: '{target_branch}' did not match any file(s) known to git\n" if track_fails else ""
return subprocess.CompletedProcess(cmd, rc, stdout="", stderr=err)
if "checkout" in joined and "-B" not in joined and "rev-parse" not in joined:
rc = 128 if checkout_fails else 0
err = f"error: pathspec '{target_branch}' did not match\n" if checkout_fails else ""
return subprocess.CompletedProcess(cmd, rc, stdout="", stderr=err)
if "rev-list" in joined:
return subprocess.CompletedProcess(cmd, 0, stdout=f"{commit_count}\n", stderr="")
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
return side_effect
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_branch_flag_pulls_against_named_branch(self, mock_run, _mock_which, capsys):
"""--branch bb/gui makes rev-list and pull target origin/bb/gui."""
mock_run.side_effect = self._branch_side_effect(
current_branch="bb/gui", target_branch="bb/gui", commit_count="3"
)
args = SimpleNamespace(branch="bb/gui")
cmd_update(args)
commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list]
# rev-list must compare against origin/bb/gui, not origin/main
rev_list_cmds = [c for c in commands if "rev-list" in c]
assert any("origin/bb/gui" in c for c in rev_list_cmds), rev_list_cmds
assert not any("origin/main" in c for c in rev_list_cmds), rev_list_cmds
# pull must target bb/gui
pull_cmds = [c for c in commands if "pull" in c and "ff-only" in c]
assert any("bb/gui" in c and "main" not in c.split() for c in pull_cmds), pull_cmds
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_branch_flag_defaults_to_main_when_none(self, mock_run, _mock_which, capsys):
"""No --branch (or --branch=None) preserves the historical 'main' default."""
mock_run.side_effect = self._branch_side_effect(
current_branch="main", target_branch="main", commit_count="0"
)
args = SimpleNamespace(branch=None)
cmd_update(args)
commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list]
rev_list_cmds = [c for c in commands if "rev-list" in c]
assert all("origin/main" in c for c in rev_list_cmds), rev_list_cmds
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_branch_flag_switches_from_different_branch(self, mock_run, _mock_which, capsys):
"""When HEAD is on main and --branch=bb/gui, switch to bb/gui first."""
mock_run.side_effect = self._branch_side_effect(
current_branch="main", target_branch="bb/gui", commit_count="2"
)
args = SimpleNamespace(branch="bb/gui")
cmd_update(args)
commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list]
# First checkout call should switch us to bb/gui (not -B; happy-path branch exists locally)
checkout_cmds = [c for c in commands if "checkout" in c and "rev-parse" not in c]
assert len(checkout_cmds) >= 1
assert "bb/gui" in checkout_cmds[0]
out = capsys.readouterr().out
assert "switching to bb/gui" in out
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_branch_flag_tracks_remote_when_branch_absent_locally(self, mock_run, _mock_which, capsys):
"""If local lacks the branch but origin has it, fall back to ``checkout -B``."""
mock_run.side_effect = self._branch_side_effect(
current_branch="main",
target_branch="bb/gui",
checkout_fails=True, # plain checkout fails
track_fails=False, # -B from origin/bb/gui succeeds
commit_count="2",
)
args = SimpleNamespace(branch="bb/gui")
cmd_update(args)
commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list]
# Should have BOTH a failed `checkout bb/gui` AND a successful `checkout -B bb/gui origin/bb/gui`
track_cmds = [c for c in commands if "checkout" in c and "-B" in c]
assert len(track_cmds) == 1
assert "bb/gui" in track_cmds[0]
assert "origin/bb/gui" in track_cmds[0]
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_branch_flag_fails_when_branch_missing_everywhere(self, mock_run, _mock_which, capsys):
"""If branch doesn't exist locally OR on origin, exit non-zero with clear error."""
mock_run.side_effect = self._branch_side_effect(
current_branch="main",
target_branch="nonexistent",
checkout_fails=True,
track_fails=True,
commit_count="0",
)
args = SimpleNamespace(branch="nonexistent")
with pytest.raises(SystemExit) as exc_info:
cmd_update(args)
assert exc_info.value.code == 1
out = capsys.readouterr().out
assert "does not exist locally or on origin" in out
assert "nonexistent" in out
class TestCmdUpdateCheckBranchFlag:
"""``hermes update --check --branch <name>`` honors the branch override.
The check path used to call ``git rev-list HEAD..origin/<branch> --count``
with ``check=True``. When the branch didn't exist on origin, the fetch
silently succeeded (no refspec) but rev-list exited 128 and a raw
``CalledProcessError`` propagated to the user. These tests pin the
friendlier behavior: detect-the-missing-ref before rev-list, exit 1
with a clear message.
"""
def _check_side_effect(
self,
target_branch: str,
*,
verify_ok: bool = True,
commit_count: str = "0",
upstream_fetch_ok: bool = True,
):
"""Mock side-effect for the _cmd_update_check git pipeline.
- ``target_branch`` what we expect compare ref to point at
- ``verify_ok`` if False, ``git rev-parse --verify --quiet
origin/<branch>`` fails (branch missing
on origin)
- ``commit_count`` rev-list count (0 = up-to-date)
- ``upstream_fetch_ok`` if False, ``git fetch upstream`` fails
(forces fallback to origin on branch==main)
"""
def side_effect(cmd, **kwargs):
joined = " ".join(str(c) for c in cmd)
if "fetch" in joined and "upstream" in joined:
rc = 0 if upstream_fetch_ok else 128
err = "" if upstream_fetch_ok else "fatal: 'upstream' does not appear to be a git repository\n"
return subprocess.CompletedProcess(cmd, rc, stdout="", stderr=err)
if "fetch" in joined and "origin" in joined:
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
if "rev-parse" in joined and "--verify" in joined:
rc = 0 if verify_ok else 1
return subprocess.CompletedProcess(cmd, rc, stdout="", stderr="")
if "rev-list" in joined:
return subprocess.CompletedProcess(cmd, 0, stdout=f"{commit_count}\n", stderr="")
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
return side_effect
@patch("hermes_cli.config.detect_install_method", return_value="git")
@patch("subprocess.run")
def test_check_branch_compares_against_named_origin_branch(
self, mock_run, _mock_method, capsys
):
"""--check --branch bb/gui compares against origin/bb/gui, never origin/main."""
mock_run.side_effect = self._check_side_effect(
target_branch="bb/gui", verify_ok=True, commit_count="2"
)
args = SimpleNamespace(check=True, branch="bb/gui")
cmd_update(args)
commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list]
# Non-main branch skips upstream probe entirely.
assert not any("fetch" in c and "upstream" in c for c in commands), commands
# Verify and rev-list both target origin/bb/gui.
verify_cmds = [c for c in commands if "rev-parse" in c and "--verify" in c]
assert any("origin/bb/gui" in c for c in verify_cmds), verify_cmds
rev_list_cmds = [c for c in commands if "rev-list" in c]
assert any("origin/bb/gui" in c for c in rev_list_cmds), rev_list_cmds
assert not any("origin/main" in c for c in rev_list_cmds), rev_list_cmds
@patch("hermes_cli.config.detect_install_method", return_value="git")
@patch("subprocess.run")
def test_check_branch_missing_on_origin_exits_cleanly(
self, mock_run, _mock_method, capsys
):
"""If origin/<branch> doesn't exist, surface a friendly error and exit 1.
Pre-fix this case raised CalledProcessError from rev-list's check=True
and dumped a Python traceback to stdout.
"""
mock_run.side_effect = self._check_side_effect(
target_branch="ghost", verify_ok=False
)
args = SimpleNamespace(check=True, branch="ghost")
with pytest.raises(SystemExit) as exc_info:
cmd_update(args)
assert exc_info.value.code == 1
out = capsys.readouterr().out
# No raw Python traceback.
assert "Traceback" not in out
assert "CalledProcessError" not in out
# Friendly message naming the branch.
assert "ghost" in out
assert "not found" in out
# rev-list must never have been called once verify failed.
commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list]
assert not any("rev-list" in c for c in commands), commands
@patch("hermes_cli.config.detect_install_method", return_value="git")
@patch("subprocess.run")
def test_check_default_main_still_prefers_upstream(
self, mock_run, _mock_method, capsys
):
"""No --branch (or --branch=None) preserves the upstream-then-origin probe."""
mock_run.side_effect = self._check_side_effect(
target_branch="main", verify_ok=True, commit_count="0"
)
args = SimpleNamespace(check=True, branch=None)
cmd_update(args)
commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list]
# Should have tried upstream first.
assert any("fetch" in c and "upstream" in c for c in commands), commands
# Compare ref is upstream/main (upstream fetch succeeded).
rev_list_cmds = [c for c in commands if "rev-list" in c]
assert any("upstream/main" in c for c in rev_list_cmds), rev_list_cmds
@patch("hermes_cli.config.detect_install_method", return_value="pip")
@patch("hermes_cli.banner.check_via_pypi", return_value=0)
@patch("subprocess.run")
def test_check_branch_warns_on_pypi_install(
self, mock_run, _mock_pypi, _mock_method, capsys
):
"""PyPI install + --branch=<non-main> surfaces a warning instead of silent drop."""
args = SimpleNamespace(check=True, branch="bb/gui")
cmd_update(args)
out = capsys.readouterr().out
assert "--branch is ignored for PyPI installs" in out
assert "bb/gui" in out
class TestCmdUpdateZipBranchRefusal:
"""``hermes update --branch=<non-main>`` must refuse on the ZIP fallback path.
The ZIP fallback hard-codes a GitHub archive URL for main.zip; honoring
--branch arbitrarily would require remote-branch existence checks the
fallback can't easily do. Refusing is the right move — silently lying
about which branch got installed is the bug --branch was meant to prevent.
"""
def test_zip_fallback_refuses_non_main_branch(self, capsys):
from hermes_cli.main import _update_via_zip
args = SimpleNamespace(branch="bb/gui")
with pytest.raises(SystemExit) as exc_info:
_update_via_zip(args)
assert exc_info.value.code == 1
out = capsys.readouterr().out
assert "bb/gui" in out
assert "not supported" in out
# No actual download attempted.
assert "Downloading latest version" not in out
def test_is_termux_env_true_for_termux_prefix():
from hermes_cli import main as hm
assert hm._is_termux_env({"PREFIX": "/data/data/com.termux/files/usr"}) is True
def test_is_termux_env_false_for_non_termux_prefix():
from hermes_cli import main as hm
assert hm._is_termux_env({"PREFIX": "/usr/local"}) is False
def test_load_installable_optional_extras_supports_termux_group(tmp_path, monkeypatch):
from hermes_cli import main as hm
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text(
"""
[project]
name = "x"
version = "0.0.0"
[project.optional-dependencies]
all = ["x[mcp]"]
termux-all = ["x[termux]", "x[mcp]"]
mcp = ["mcp>=1"]
termux = ["rich>=14"]
""".strip()
)
monkeypatch.setattr(hm, "PROJECT_ROOT", tmp_path)
assert hm._load_installable_optional_extras(group="all") == ["mcp"]
assert hm._load_installable_optional_extras(group="termux-all") == ["termux", "mcp"]
+185
View File
@@ -0,0 +1,185 @@
"""Tests for ``hermes update`` / ``--check`` inside the Docker container.
Background: ``.dockerignore`` excludes ``.git``, so the existing git-pull
update path can never succeed inside the published image. Before this
fix, ``hermes update`` would fall through to ``"✗ Not a git repository.
Please reinstall: curl ... install.sh"`` — that script installs a *new*
host-side Hermes, not an update to the running container, so the message
was actively misleading.
These tests pin the new behaviour: when ``detect_install_method`` reports
``"docker"`` (stamped by ``docker/stage2-hook.sh``), both the apply path
(``cmd_update``) and the check path (``_cmd_update_check``) print the
``docker pull`` guidance from ``format_docker_update_message`` and exit
with status 1, without running ``git fetch`` / ``subprocess.run``.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from hermes_cli.main import _cmd_update_check, cmd_update
# ---------- cmd_update (apply path) ----------
@patch("hermes_cli.config.is_managed", return_value=False)
@patch("hermes_cli.config.detect_install_method", return_value="docker")
@patch("subprocess.run")
def test_cmd_update_in_docker_prints_guidance_and_exits(
mock_run, _mock_method, _mock_managed, capsys
):
"""``hermes update`` inside Docker → friendly message + exit 1, no git calls."""
with pytest.raises(SystemExit) as excinfo:
cmd_update(SimpleNamespace(check=False))
assert excinfo.value.code == 1
out = capsys.readouterr().out
# Spot-check the key guidance — exhaustive wording is locked in by the
# config-module test below to keep these CLI tests resilient to copy edits.
assert "doesn't apply inside the Docker container" in out
assert "docker pull nousresearch/hermes-agent:latest" in out
# No git invocations — the early-return must beat every git command.
git_calls = [c for c in mock_run.call_args_list if c.args and c.args[0] and "git" in str(c.args[0][0])]
assert git_calls == [], f"expected no git calls, got: {git_calls}"
@patch("hermes_cli.config.is_managed", return_value=False)
@patch("hermes_cli.config.detect_install_method", return_value="docker")
@patch("subprocess.run")
def test_cmd_update_check_in_docker_prints_guidance_and_exits(
mock_run, _mock_method, _mock_managed, capsys
):
"""``hermes update --check`` inside Docker → same message + exit 1, no fetch."""
with pytest.raises(SystemExit) as excinfo:
cmd_update(SimpleNamespace(check=True, branch=None))
assert excinfo.value.code == 1
out = capsys.readouterr().out
assert "doesn't apply inside the Docker container" in out
assert "docker pull nousresearch/hermes-agent:latest" in out
git_calls = [c for c in mock_run.call_args_list if c.args and c.args[0] and "git" in str(c.args[0][0])]
assert git_calls == [], f"expected no git calls, got: {git_calls}"
@patch("hermes_cli.config.is_managed", return_value=False)
@patch("hermes_cli.config.detect_install_method", return_value="docker")
@patch("subprocess.run")
def test_cmd_update_in_docker_ignores_yes_and_force(
mock_run, _mock_method, _mock_managed, capsys
):
"""``--yes`` / ``--force`` don't bypass the Docker bail-out.
The point of the bail-out is "git pull will never work here", so even
a user trying to barge through with ``--yes --force`` should see the
docker-pull guidance.
"""
with pytest.raises(SystemExit):
cmd_update(SimpleNamespace(check=False, yes=True, force=True))
assert "docker pull" in capsys.readouterr().out
git_calls = [c for c in mock_run.call_args_list if c.args and c.args[0] and "git" in str(c.args[0][0])]
assert git_calls == []
# ---------- _cmd_update_check (check path, direct entry) ----------
@patch("hermes_cli.config.detect_install_method", return_value="docker")
@patch("subprocess.run")
def test_cmd_update_check_direct_in_docker(mock_run, _mock_method, capsys):
"""Calling ``_cmd_update_check`` directly (no apply path) also bails."""
with pytest.raises(SystemExit) as excinfo:
_cmd_update_check()
assert excinfo.value.code == 1
assert "docker pull" in capsys.readouterr().out
git_calls = [c for c in mock_run.call_args_list if c.args and c.args[0] and "git" in str(c.args[0][0])]
assert git_calls == []
# ---------- Non-Docker installs unaffected ----------
@patch("hermes_cli.config.is_managed", return_value=False)
@patch("hermes_cli.config.detect_install_method", return_value="git")
@patch(
"subprocess.run",
return_value=SimpleNamespace(returncode=0, stdout="0\n", stderr=""),
)
def test_cmd_update_on_git_install_does_not_print_docker_message(
_mock_run, _mock_method, _mock_managed, capsys
):
"""Source/git installs MUST NOT hit the Docker branch.
Regression guard: an over-eager detection refactor could accidentally
route git users through the docker-pull message. We swallow
SystemExit / unrelated errors from the rest of the update flow —
those don't matter for this assertion; what matters is that the
docker text is absent.
``subprocess.run`` is mocked because the git path will otherwise shell
out to ``git fetch upstream`` / ``git fetch origin`` — on CI runners
with no ``upstream`` remote configured this can hang past the 30s
pytest-timeout depending on git's network behaviour. The stub
returns a successful CompletedProcess-shaped object with ``"0\\n"``
stdout, which both keeps the flow shell-free AND parses cleanly as
the "0 commits behind" rev-list output the check path later parses
via ``int(rev_result.stdout.strip())``.
"""
try:
cmd_update(SimpleNamespace(check=True, branch=None))
except (SystemExit, Exception):
# Update flow may exit for unrelated reasons in a stubbed env —
# that's fine; we only care about the banner not appearing.
pass
assert "doesn't apply inside the Docker container" not in capsys.readouterr().out
@patch("hermes_cli.config.detect_install_method", return_value="pip")
@patch("hermes_cli.banner.check_via_pypi", return_value=0)
def test_cmd_update_check_on_pip_install_still_uses_pypi(
_mock_pypi, _mock_method, capsys
):
"""PyPI installs route to PyPI check, not the Docker bail-out."""
_cmd_update_check()
out = capsys.readouterr().out
assert "Already up to date" in out
assert "doesn't apply inside the Docker container" not in out
# ---------- format_docker_update_message — content lock ----------
def test_format_docker_update_message_contents():
"""Lock in the high-value content of the Docker update message.
These are the bits a user actually needs to act on; if any of them
disappear in a copy edit, the message has lost its value. Specific
wording around them is free to evolve (we don't assert full text).
"""
from hermes_cli.config import format_docker_update_message
msg = format_docker_update_message()
# Primary command — the entire reason this message exists.
assert "docker pull nousresearch/hermes-agent:latest" in msg
# The four key concepts the message must cover:
assert "restart" in msg.lower(), "must explain that a restart is required"
assert "--version" in msg, "must show how to verify the new version"
assert ":latest" in msg, "must mention tag pinning caveat"
assert "HERMES_HOME" in msg or "/opt/data" in msg, (
"must address config persistence across upgrades"
)
# Acknowledges that forks exist (build-your-own-image escape hatch).
assert "fork" in msg.lower() or "Dockerfile" in msg
@@ -0,0 +1,112 @@
"""Tests for _coalesce_session_name_args — multi-word session name merging."""
from hermes_cli.main import _coalesce_session_name_args
class TestCoalesceSessionNameArgs:
"""Ensure unquoted multi-word session names are merged into one token."""
# ── -c / --continue ──────────────────────────────────────────────────
def test_continue_multiword_unquoted(self):
"""hermes -c Pokemon Agent Dev → -c 'Pokemon Agent Dev'"""
assert _coalesce_session_name_args(
["-c", "Pokemon", "Agent", "Dev"]
) == ["-c", "Pokemon Agent Dev"]
def test_continue_long_form_multiword(self):
"""hermes --continue Pokemon Agent Dev"""
assert _coalesce_session_name_args(
["--continue", "Pokemon", "Agent", "Dev"]
) == ["--continue", "Pokemon Agent Dev"]
def test_continue_single_word(self):
"""hermes -c MyProject (no merging needed)"""
assert _coalesce_session_name_args(["-c", "MyProject"]) == [
"-c",
"MyProject",
]
def test_continue_already_quoted(self):
"""hermes -c 'Pokemon Agent Dev' (shell already merged)"""
assert _coalesce_session_name_args(
["-c", "Pokemon Agent Dev"]
) == ["-c", "Pokemon Agent Dev"]
def test_continue_bare_flag(self):
"""hermes -c (no name — means 'continue latest')"""
assert _coalesce_session_name_args(["-c"]) == ["-c"]
def test_continue_followed_by_flag(self):
"""hermes -c -w (no name consumed, -w stays separate)"""
assert _coalesce_session_name_args(["-c", "-w"]) == ["-c", "-w"]
def test_continue_multiword_then_flag(self):
"""hermes -c my project -w"""
assert _coalesce_session_name_args(
["-c", "my", "project", "-w"]
) == ["-c", "my project", "-w"]
def test_continue_multiword_then_subcommand(self):
"""hermes -c my project chat -q hello"""
assert _coalesce_session_name_args(
["-c", "my", "project", "chat", "-q", "hello"]
) == ["-c", "my project", "chat", "-q", "hello"]
# ── -r / --resume ────────────────────────────────────────────────────
def test_resume_multiword(self):
"""hermes -r My Session Name"""
assert _coalesce_session_name_args(
["-r", "My", "Session", "Name"]
) == ["-r", "My Session Name"]
def test_resume_long_form_multiword(self):
"""hermes --resume My Session Name"""
assert _coalesce_session_name_args(
["--resume", "My", "Session", "Name"]
) == ["--resume", "My Session Name"]
def test_resume_multiword_then_flag(self):
"""hermes -r My Session -w"""
assert _coalesce_session_name_args(
["-r", "My", "Session", "-w"]
) == ["-r", "My Session", "-w"]
# ── combined flags ───────────────────────────────────────────────────
def test_worktree_and_continue_multiword(self):
"""hermes -w -c Pokemon Agent Dev (the original failing case)"""
assert _coalesce_session_name_args(
["-w", "-c", "Pokemon", "Agent", "Dev"]
) == ["-w", "-c", "Pokemon Agent Dev"]
def test_continue_multiword_and_worktree(self):
"""hermes -c Pokemon Agent Dev -w (order reversed)"""
assert _coalesce_session_name_args(
["-c", "Pokemon", "Agent", "Dev", "-w"]
) == ["-c", "Pokemon Agent Dev", "-w"]
# ── passthrough (no session flags) ───────────────────────────────────
def test_no_session_flags_passthrough(self):
"""hermes -w chat -q hello (nothing to merge)"""
result = _coalesce_session_name_args(["-w", "chat", "-q", "hello"])
assert result == ["-w", "chat", "-q", "hello"]
def test_empty_argv(self):
assert _coalesce_session_name_args([]) == []
# ── subcommand boundary ──────────────────────────────────────────────
def test_stops_at_sessions_subcommand(self):
"""hermes -c my project sessions list → stops before 'sessions'"""
assert _coalesce_session_name_args(
["-c", "my", "project", "sessions", "list"]
) == ["-c", "my project", "sessions", "list"]
def test_stops_at_setup_subcommand(self):
"""hermes -c my setup → 'setup' is a subcommand, not part of name"""
assert _coalesce_session_name_args(
["-c", "my", "setup"]
) == ["-c", "my", "setup"]
@@ -0,0 +1,192 @@
"""Regression tests for the /model picker's credential-discovery paths.
Covers:
- Normal path (tokens already in Hermes auth store)
- Claude Code fallback (tokens only in ~/.claude/.credentials.json)
- Negative case (no credentials anywhere)
Note: auto-import from ~/.codex/auth.json was removed in #12360 — Hermes
now owns its own openai-codex auth state, and users explicitly adopt
existing Codex CLI tokens via `hermes auth openai-codex`. The old
"Codex CLI shared file" discovery tests were removed with that change.
"""
import base64
import json
import time
from pathlib import Path
import pytest
def _make_fake_jwt(expiry_offset: int = 3600) -> str:
"""Build a fake JWT with a future expiry."""
header = base64.urlsafe_b64encode(b'{"alg":"RS256"}').rstrip(b"=").decode()
exp = int(time.time()) + expiry_offset
payload_bytes = json.dumps({"exp": exp, "sub": "test"}).encode()
payload = base64.urlsafe_b64encode(payload_bytes).rstrip(b"=").decode()
return f"{header}.{payload}.fakesig"
@pytest.fixture()
def hermes_auth_only_env(tmp_path, monkeypatch):
"""Tokens already in Hermes auth store (no Codex CLI needed)."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
# Point CODEX_HOME to nonexistent dir to prove it's not needed
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "no_codex"))
(hermes_home / "auth.json").write_text(json.dumps({
"version": 2,
"providers": {
"openai-codex": {
"tokens": {
"access_token": _make_fake_jwt(),
"refresh_token": "fake-refresh",
},
"last_refresh": "2026-04-12T00:00:00Z",
}
},
}))
for var in [
"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
"NOUS_API_KEY", "DEEPSEEK_API_KEY",
]:
monkeypatch.delenv(var, raising=False)
return hermes_home
def test_normal_path_still_works(hermes_auth_only_env):
"""openai-codex appears when tokens are already in Hermes auth store."""
from hermes_cli.model_switch import list_authenticated_providers
providers = list_authenticated_providers(
current_provider="openai-codex",
max_models=10,
)
slugs = [p["slug"] for p in providers]
assert "openai-codex" in slugs
def test_codex_picker_uses_live_codex_catalog(hermes_auth_only_env, tmp_path, monkeypatch):
"""The gateway /model picker should surface Codex CLI-only listed models."""
from hermes_cli.model_switch import list_authenticated_providers
codex_home = tmp_path / "codex-home"
codex_home.mkdir()
(codex_home / "models_cache.json").write_text(json.dumps({
"models": [
{"slug": "gpt-5.5", "priority": 0, "supported_in_api": True},
{"slug": "gpt-5.3-codex-spark", "priority": 7, "supported_in_api": False},
]
}))
monkeypatch.setenv("CODEX_HOME", str(codex_home))
# Force the cache fallback path — without this the test issues a real
# 10s HTTP probe to chatgpt.com/backend-api/codex/models which is both
# slow and non-deterministic in CI/sandboxed environments.
monkeypatch.setattr(
"hermes_cli.codex_models._fetch_models_from_api",
lambda access_token: [],
)
providers = list_authenticated_providers(
current_provider="openai-codex",
max_models=10,
)
codex = next(p for p in providers if p["slug"] == "openai-codex")
assert "gpt-5.3-codex-spark" in codex["models"]
assert codex["total_models"] == len(codex["models"])
@pytest.fixture()
def claude_code_only_env(tmp_path, monkeypatch):
"""Set up an environment where Anthropic credentials only exist in
~/.claude/.credentials.json (Claude Code) — not in env vars or Hermes
auth store."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
# No Codex CLI
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "no_codex"))
(hermes_home / "auth.json").write_text(
json.dumps({"version": 2, "providers": {}})
)
# Claude Code credentials in the correct format
claude_dir = tmp_path / ".claude"
claude_dir.mkdir()
(claude_dir / ".credentials.json").write_text(json.dumps({
"claudeAiOauth": {
"accessToken": _make_fake_jwt(),
"refreshToken": "fake-refresh",
"expiresAt": int(time.time() * 1000) + 3_600_000,
}
}))
# Patch Path.home() so the adapter finds the file
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path))
for var in [
"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
"ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN",
"NOUS_API_KEY", "DEEPSEEK_API_KEY",
]:
monkeypatch.delenv(var, raising=False)
return hermes_home
def test_claude_code_file_detected_by_model_picker(claude_code_only_env):
"""anthropic should appear when credentials only exist in ~/.claude/.credentials.json."""
from hermes_cli.model_switch import list_authenticated_providers
providers = list_authenticated_providers(
current_provider="anthropic",
max_models=10,
)
slugs = [p["slug"] for p in providers]
assert "anthropic" in slugs, (
f"anthropic not found in /model picker providers: {slugs}"
)
anthropic = next(p for p in providers if p["slug"] == "anthropic")
assert anthropic["is_current"] is True
assert anthropic["total_models"] > 0
def test_no_codex_when_no_credentials(tmp_path, monkeypatch):
"""openai-codex should NOT appear when no credentials exist anywhere."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "no_codex"))
(hermes_home / "auth.json").write_text(
json.dumps({"version": 2, "providers": {}})
)
for var in [
"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
"NOUS_API_KEY", "DEEPSEEK_API_KEY", "COPILOT_GITHUB_TOKEN",
"GH_TOKEN", "GEMINI_API_KEY",
]:
monkeypatch.delenv(var, raising=False)
from hermes_cli.model_switch import list_authenticated_providers
providers = list_authenticated_providers(
current_provider="openrouter",
max_models=10,
)
slugs = [p["slug"] for p in providers]
assert "openai-codex" not in slugs, (
"openai-codex should not appear without any credentials"
)
+394
View File
@@ -0,0 +1,394 @@
import json
from unittest.mock import patch
from hermes_cli.codex_models import DEFAULT_CODEX_MODELS, get_codex_model_ids
def test_get_codex_model_ids_prioritizes_default_and_cache(tmp_path, monkeypatch):
codex_home = tmp_path / "codex-home"
codex_home.mkdir(parents=True, exist_ok=True)
(codex_home / "config.toml").write_text('model = "gpt-5.2-codex"\n')
(codex_home / "models_cache.json").write_text(
json.dumps(
{
"models": [
{"slug": "gpt-5.3-codex", "priority": 20, "supported_in_api": True},
{"slug": "gpt-5.3-codex-spark", "priority": 6, "supported_in_api": False},
{"slug": "gpt-5.1-codex", "priority": 5, "supported_in_api": True},
{"slug": "gpt-5.4", "priority": 1, "supported_in_api": True},
{"slug": "gpt-5-hidden-codex", "priority": 2, "visibility": "hidden"},
]
}
)
)
monkeypatch.setenv("CODEX_HOME", str(codex_home))
models = get_codex_model_ids()
assert models[0] == "gpt-5.2-codex"
assert "gpt-5.1-codex" in models
assert "gpt-5.3-codex" in models
# Codex CLI marks Spark unsupported in the public API, but the Codex
# backend still accepts it via the OAuth-backed CLI/Hermes route.
assert "gpt-5.3-codex-spark" in models
# Non-codex-suffixed models are included when the cache says they're available
assert "gpt-5.4" in models
assert "gpt-5.4-mini" in models
assert "gpt-5-hidden-codex" not in models
def test_setup_wizard_codex_import_resolves():
"""Regression test for #712: setup.py must import the correct function name."""
# This mirrors the exact import used in hermes_cli/setup.py line 873.
# A prior bug had 'get_codex_models' (wrong) instead of 'get_codex_model_ids'.
from hermes_cli.codex_models import get_codex_model_ids as setup_import
assert callable(setup_import)
def test_get_codex_model_ids_falls_back_to_curated_defaults(tmp_path, monkeypatch):
codex_home = tmp_path / "codex-home"
codex_home.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("CODEX_HOME", str(codex_home))
models = get_codex_model_ids()
assert models[: len(DEFAULT_CODEX_MODELS)] == DEFAULT_CODEX_MODELS
assert "gpt-5.4" in models
assert "gpt-5.3-codex-spark" in models
def test_get_codex_model_ids_adds_forward_compat_models_from_templates(monkeypatch):
monkeypatch.setattr(
"hermes_cli.codex_models._fetch_models_from_api",
lambda access_token: ["gpt-5.3-codex"],
)
models = get_codex_model_ids(access_token="codex-access-token")
# When live discovery only returns gpt-5.3-codex, forward-compat synthesis
# should surface gpt-5.5, gpt-5.4, gpt-5.4-mini, and gpt-5.3-codex-spark
# (each is templated off gpt-5.3-codex).
assert models == [
"gpt-5.3-codex",
"gpt-5.5",
"gpt-5.4-mini",
"gpt-5.4",
"gpt-5.3-codex-spark",
]
def test_fetch_from_api_keeps_supported_in_api_false_models(monkeypatch):
"""Regression: gpt-5.3-codex-spark is returned by the live Codex backend
with ``supported_in_api: false`` because it isn't in the public OpenAI
API. The Codex CLI / OAuth route still serves it for ChatGPT Pro
accounts, so we must not drop it on that flag. visibility=hidden is
the separate signal that *should* still filter entries out.
"""
import sys
from hermes_cli import codex_models
class _FakeResp:
status_code = 200
def json(self):
return {
"models": [
{"slug": "gpt-5.5", "priority": 0, "supported_in_api": True},
{"slug": "gpt-5.3-codex-spark", "priority": 7, "supported_in_api": False},
{"slug": "gpt-5-internal", "priority": 99, "visibility": "hidden"},
]
}
class _FakeHttpx:
@staticmethod
def get(url, headers=None, timeout=None):
return _FakeResp()
monkeypatch.setitem(sys.modules, "httpx", _FakeHttpx)
models = codex_models._fetch_models_from_api(access_token="tok")
assert "gpt-5.5" in models
assert "gpt-5.3-codex-spark" in models
assert "gpt-5-internal" not in models
def test_model_command_uses_runtime_access_token_for_codex_list(monkeypatch):
from hermes_cli.main import _model_flow_openai_codex
captured = {}
choices = iter(["1"])
monkeypatch.setattr("builtins.input", lambda prompt="": next(choices))
monkeypatch.setattr(
"hermes_cli.auth.get_codex_auth_status",
lambda: {"logged_in": True},
)
monkeypatch.setattr(
"hermes_cli.auth.resolve_codex_runtime_credentials",
lambda *args, **kwargs: {"api_key": "codex-access-token"},
)
def _fake_get_codex_model_ids(access_token=None):
captured["access_token"] = access_token
return ["gpt-5.2-codex", "gpt-5.2"]
def _fake_prompt_model_selection(model_ids, current_model=""):
captured["model_ids"] = list(model_ids)
captured["current_model"] = current_model
return None
monkeypatch.setattr(
"hermes_cli.codex_models.get_codex_model_ids",
_fake_get_codex_model_ids,
)
monkeypatch.setattr(
"hermes_cli.auth._prompt_model_selection",
_fake_prompt_model_selection,
)
_model_flow_openai_codex({}, current_model="openai/gpt-5.4")
assert captured["access_token"] == "codex-access-token"
assert captured["model_ids"] == ["gpt-5.2-codex", "gpt-5.2"]
assert captured["current_model"] == "openai/gpt-5.4"
def test_model_command_prompts_to_reuse_or_reauthenticate_codex_session(monkeypatch, capsys):
from hermes_cli.main import _model_flow_openai_codex
captured = {"login_calls": 0}
choices = iter(["2"])
monkeypatch.setattr("builtins.input", lambda prompt="": next(choices))
monkeypatch.setattr(
"hermes_cli.auth.get_codex_auth_status",
lambda: {"logged_in": True, "source": "hermes-auth-store"},
)
monkeypatch.setattr(
"hermes_cli.auth.resolve_codex_runtime_credentials",
lambda *args, **kwargs: {"api_key": "fresh-codex-token"},
)
def _fake_login(*args, force_new_login=False, **kwargs):
captured["login_calls"] += 1
captured["force_new_login"] = force_new_login
monkeypatch.setattr("hermes_cli.auth._login_openai_codex", _fake_login)
monkeypatch.setattr(
"hermes_cli.codex_models.get_codex_model_ids",
lambda access_token=None: ["gpt-5.4", "gpt-5.3-codex"],
)
monkeypatch.setattr(
"hermes_cli.auth._prompt_model_selection",
lambda model_ids, current_model="": None,
)
_model_flow_openai_codex({}, current_model="gpt-5.4")
out = capsys.readouterr().out
assert "Use existing credentials" in out
assert "Reauthenticate (new OAuth login)" in out
assert captured["login_calls"] == 1
assert captured["force_new_login"] is True
def test_model_command_uses_existing_codex_session_without_relogin(monkeypatch):
from hermes_cli.main import _model_flow_openai_codex
choices = iter(["1"])
captured = {}
monkeypatch.setattr("builtins.input", lambda prompt="": next(choices))
monkeypatch.setattr(
"hermes_cli.auth.get_codex_auth_status",
lambda: {"logged_in": True, "source": "hermes-auth-store"},
)
monkeypatch.setattr(
"hermes_cli.auth.resolve_codex_runtime_credentials",
lambda *args, **kwargs: {"api_key": "existing-codex-token"},
)
def _fake_get_codex_model_ids(access_token=None):
captured["access_token"] = access_token
return ["gpt-5.4"]
monkeypatch.setattr(
"hermes_cli.codex_models.get_codex_model_ids",
_fake_get_codex_model_ids,
)
monkeypatch.setattr(
"hermes_cli.auth._prompt_model_selection",
lambda model_ids, current_model="": None,
)
monkeypatch.setattr(
"hermes_cli.auth._login_openai_codex",
lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("should not reauthenticate")),
)
_model_flow_openai_codex({}, current_model="gpt-5.4")
assert captured["access_token"] == "existing-codex-token"
# ── Tests for _normalize_model_for_provider ──────────────────────────
def _make_cli(model="anthropic/claude-opus-4.6", **kwargs):
"""Create a HermesCLI with minimal mocking."""
import cli as _cli_mod
from cli import HermesCLI
_clean_config = {
"model": {
"default": "anthropic/claude-opus-4.6",
"base_url": "https://openrouter.ai/api/v1",
"provider": "auto",
},
"display": {"compact": False, "tool_progress": "all", "resume_display": "full"},
"agent": {},
"terminal": {"env_type": "local"},
}
clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}
with (
patch("cli.get_tool_definitions", return_value=[]),
patch.dict("os.environ", clean_env, clear=False),
patch.dict(_cli_mod.__dict__, {"CLI_CONFIG": _clean_config}),
):
cli = HermesCLI(model=model, **kwargs)
return cli
class TestNormalizeModelForProvider:
"""_normalize_model_for_provider() trusts user-selected models.
Only two things happen:
1. Provider prefixes are stripped (API needs bare slugs)
2. The *untouched default* model is swapped for a Codex model
Everything else passes through — the API is the judge.
"""
def test_non_codex_provider_is_noop(self):
cli = _make_cli(model="gpt-5.4")
changed = cli._normalize_model_for_provider("openrouter")
assert changed is False
assert cli.model == "gpt-5.4"
def test_native_provider_prefix_is_stripped_before_agent_startup(self):
cli = _make_cli(model="zai/glm-5.1")
changed = cli._normalize_model_for_provider("zai")
assert changed is True
assert cli.model == "glm-5.1"
def test_bare_codex_model_passes_through(self):
cli = _make_cli(model="gpt-5.3-codex")
changed = cli._normalize_model_for_provider("openai-codex")
assert changed is False
assert cli.model == "gpt-5.3-codex"
def test_bare_non_codex_model_passes_through(self):
"""gpt-5.4 (no 'codex' suffix) passes through — user chose it."""
cli = _make_cli(model="gpt-5.4")
changed = cli._normalize_model_for_provider("openai-codex")
assert changed is False
assert cli.model == "gpt-5.4"
def test_any_bare_model_trusted(self):
"""Even a non-OpenAI bare model passes through — user explicitly set it."""
cli = _make_cli(model="claude-opus-4-6")
changed = cli._normalize_model_for_provider("openai-codex")
# User explicitly chose this model — we trust them, API will error if wrong
assert changed is False
assert cli.model == "claude-opus-4-6"
def test_provider_prefix_stripped(self):
"""openai/gpt-5.4 → gpt-5.4 (strip prefix, keep model)."""
cli = _make_cli(model="openai/gpt-5.4")
changed = cli._normalize_model_for_provider("openai-codex")
assert changed is True
assert cli.model == "gpt-5.4"
def test_any_provider_prefix_stripped(self):
"""anthropic/claude-opus-4.6 → claude-opus-4.6 (strip prefix only).
User explicitly chose this — let the API decide if it works."""
cli = _make_cli(model="anthropic/claude-opus-4.6")
changed = cli._normalize_model_for_provider("openai-codex")
assert changed is True
assert cli.model == "claude-opus-4.6"
def test_opencode_go_prefix_stripped(self):
cli = _make_cli(model="opencode-go/kimi-k2.5")
cli.api_mode = "chat_completions"
changed = cli._normalize_model_for_provider("opencode-go")
assert changed is True
assert cli.model == "kimi-k2.5"
assert cli.api_mode == "chat_completions"
def test_opencode_zen_claude_sets_messages_mode(self):
cli = _make_cli(model="opencode-zen/claude-sonnet-4-6")
cli.api_mode = "chat_completions"
changed = cli._normalize_model_for_provider("opencode-zen")
assert changed is True
assert cli.model == "claude-sonnet-4-6"
assert cli.api_mode == "anthropic_messages"
def test_default_model_replaced(self):
"""No model configured (empty default) gets swapped for codex."""
import cli as _cli_mod
_clean_config = {
"model": {
"default": "",
"base_url": "",
"provider": "auto",
},
"display": {"compact": False, "tool_progress": "all", "resume_display": "full"},
"agent": {},
"terminal": {"env_type": "local"},
}
# Don't pass model= so _model_is_default is True
with (
patch("cli.get_tool_definitions", return_value=[]),
patch.dict("os.environ", {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}, clear=False),
patch.dict(_cli_mod.__dict__, {"CLI_CONFIG": _clean_config}),
):
from cli import HermesCLI
cli = HermesCLI()
assert cli._model_is_default is True
with patch(
"hermes_cli.codex_models.get_codex_model_ids",
return_value=["gpt-5.3-codex", "gpt-5.4"],
):
changed = cli._normalize_model_for_provider("openai-codex")
assert changed is True
# Uses first from available list
assert cli.model == "gpt-5.3-codex"
def test_default_fallback_when_api_fails(self):
"""No model configured falls back to gpt-5.3-codex when API unreachable."""
import cli as _cli_mod
_clean_config = {
"model": {
"default": "",
"base_url": "",
"provider": "auto",
},
"display": {"compact": False, "tool_progress": "all", "resume_display": "full"},
"agent": {},
"terminal": {"env_type": "local"},
}
with (
patch("cli.get_tool_definitions", return_value=[]),
patch.dict("os.environ", {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}, clear=False),
patch.dict(_cli_mod.__dict__, {"CLI_CONFIG": _clean_config}),
):
from cli import HermesCLI
cli = HermesCLI()
with patch(
"hermes_cli.codex_models.get_codex_model_ids",
side_effect=Exception("offline"),
):
changed = cli._normalize_model_for_provider("openai-codex")
assert changed is True
assert cli.model == "gpt-5.3-codex"
@@ -0,0 +1,863 @@
"""Tests for the codex MCP plugin migration helper."""
from __future__ import annotations
import pytest
from hermes_cli.codex_runtime_plugin_migration import (
MIGRATION_MARKER,
MIGRATION_END_MARKER,
_build_hermes_tools_mcp_entry,
_format_toml_value,
_looks_like_test_tempdir,
_strip_existing_managed_block,
_strip_unmanaged_plugin_tables,
_translate_one_server,
migrate,
render_codex_toml_section,
)
# ---- per-server translation ----
class TestTranslateOneServer:
def test_stdio_basic(self):
cfg, skipped = _translate_one_server("filesystem", {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
"env": {"FOO": "bar"},
})
assert cfg == {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
"env": {"FOO": "bar"},
}
assert skipped == []
def test_stdio_with_cwd(self):
cfg, _ = _translate_one_server("custom", {
"command": "/usr/bin/myserver",
"cwd": "/var/lib/mcp",
})
assert cfg["cwd"] == "/var/lib/mcp"
def test_http_basic(self):
cfg, skipped = _translate_one_server("api", {
"url": "https://x.example/mcp",
"headers": {"Authorization": "Bearer abc"},
})
assert cfg == {
"url": "https://x.example/mcp",
"http_headers": {"Authorization": "Bearer abc"},
}
assert skipped == []
def test_sse_falls_under_streamable_http_with_warning(self):
cfg, skipped = _translate_one_server("sse_server", {
"url": "http://localhost:8000/sse",
"transport": "sse",
})
assert cfg["url"] == "http://localhost:8000/sse"
assert any("sse" in s.lower() for s in skipped)
def test_timeouts_translate(self):
cfg, _ = _translate_one_server("x", {
"command": "y",
"timeout": 180,
"connect_timeout": 30,
})
assert cfg["tool_timeout_sec"] == 180.0
assert cfg["startup_timeout_sec"] == 30.0
def test_non_numeric_timeout_skipped(self):
cfg, skipped = _translate_one_server("x", {
"command": "y",
"timeout": "not-a-number",
})
assert "tool_timeout_sec" not in cfg
assert any("timeout" in s and "numeric" in s for s in skipped)
def test_disabled_server_emits_enabled_false(self):
cfg, _ = _translate_one_server("x", {
"command": "y",
"enabled": False,
})
assert cfg["enabled"] is False
def test_enabled_true_omitted(self):
cfg, _ = _translate_one_server("x", {"command": "y", "enabled": True})
assert "enabled" not in cfg # codex defaults to true
def test_command_and_url_prefers_stdio_warns(self):
cfg, skipped = _translate_one_server("x", {
"command": "y", "url": "http://z",
})
assert "command" in cfg
assert "url" not in cfg
assert any("url" in s for s in skipped)
def test_no_transport_returns_none(self):
cfg, skipped = _translate_one_server("broken", {"description": "x"})
assert cfg is None
assert "no command or url" in skipped[0]
def test_sampling_dropped_with_warning(self):
cfg, skipped = _translate_one_server("x", {
"command": "y",
"sampling": {"enabled": True, "model": "gemini-3-flash"},
})
assert "sampling" not in cfg
assert any("sampling" in s for s in skipped)
def test_unknown_keys_warned(self):
cfg, skipped = _translate_one_server("x", {
"command": "y",
"totally_made_up_key": "value",
})
assert "totally_made_up_key" not in cfg
assert any("totally_made_up_key" in s for s in skipped)
def test_non_dict_input(self):
cfg, skipped = _translate_one_server("x", "notadict") # type: ignore[arg-type]
assert cfg is None
# ---- TOML rendering ----
class TestTomlValueFormatter:
def test_string_quoted(self):
assert _format_toml_value("hello") == '"hello"'
def test_string_with_quotes_escaped(self):
assert _format_toml_value('a"b') == '"a\\"b"'
def test_bool(self):
assert _format_toml_value(True) == "true"
assert _format_toml_value(False) == "false"
def test_int(self):
assert _format_toml_value(42) == "42"
def test_float(self):
assert _format_toml_value(180.0) == "180.0"
def test_list_of_strings(self):
assert _format_toml_value(["a", "b"]) == '["a", "b"]'
def test_inline_table(self):
out = _format_toml_value({"FOO": "bar"})
assert out == '{ FOO = "bar" }'
def test_empty_inline_table(self):
assert _format_toml_value({}) == "{}"
def test_string_with_newline_escaped(self):
"""TOML basic strings don't allow literal newlines — a path or
env var containing a newline must use \\n. Otherwise codex would
refuse to load the config."""
out = _format_toml_value("line one\nline two")
assert "\n" not in out # no raw newline in output
assert "\\n" in out
def test_string_with_tab_escaped(self):
out = _format_toml_value("col1\tcol2")
assert "\t" not in out
assert "\\t" in out
def test_string_with_other_controls_escaped(self):
for raw, expected in [
("\r", "\\r"),
("\f", "\\f"),
("\b", "\\b"),
]:
out = _format_toml_value(f"x{raw}y")
assert raw not in out, f"{raw!r} should be escaped"
assert expected in out, f"{expected!r} should be in output"
def test_windows_path_escaped_correctly(self):
out = _format_toml_value(r"C:\Users\Alice\.codex")
# Each backslash should be doubled
assert out == r'"C:\\Users\\Alice\\.codex"'
def test_atomic_write_no_temp_leak_on_success(self, tmp_path):
"""The atomic-write path uses tempfile.mkstemp + rename. On
success the temp file should not be left behind."""
migrate({"mcp_servers": {"x": {"command": "y"}}},
codex_home=tmp_path,
discover_plugins=False,
expose_hermes_tools=False,
default_permission_profile=None)
# config.toml should exist
assert (tmp_path / "config.toml").exists()
# And no .config.toml.* temp files left behind
leftover = [p.name for p in tmp_path.iterdir()
if p.name.startswith(".config.toml.")]
assert leftover == [], f"temp file leaked after migration: {leftover}"
def test_atomic_write_cleanup_on_rename_failure(self, tmp_path, monkeypatch):
"""If rename fails partway through (out of disk, permissions,
crash), the temp file must be cleaned up. Otherwise repeated
failed migrations would pile up .config.toml.* files."""
from pathlib import Path as _Path
original_replace = _Path.replace
def failing_replace(self, target):
raise OSError("simulated disk full")
monkeypatch.setattr(_Path, "replace", failing_replace)
report = migrate(
{"mcp_servers": {"x": {"command": "y"}}},
codex_home=tmp_path,
discover_plugins=False,
expose_hermes_tools=False,
default_permission_profile=None,
)
# Error surfaced
assert any("simulated disk full" in e for e in report.errors)
# And no leaked temp file
leftover = [p.name for p in tmp_path.iterdir()
if p.name.startswith(".config.toml.")]
assert leftover == [], f"temp files leaked: {leftover}"
def test_unsupported_type_raises(self):
with pytest.raises(ValueError):
_format_toml_value(object())
class TestRenderToml:
def test_starts_with_marker(self):
out = render_codex_toml_section({})
assert out.startswith(MIGRATION_MARKER)
def test_empty_servers_emits_placeholder(self):
out = render_codex_toml_section({})
assert "no MCP servers" in out
def test_servers_sorted_alphabetically(self):
out = render_codex_toml_section({
"zoo": {"command": "z"},
"alpha": {"command": "a"},
"middle": {"command": "m"},
})
# Find the section header positions and confirm order
a_pos = out.find("[mcp_servers.alpha]")
m_pos = out.find("[mcp_servers.middle]")
z_pos = out.find("[mcp_servers.zoo]")
assert 0 < a_pos < m_pos < z_pos
def test_server_with_args_and_env(self):
out = render_codex_toml_section({
"fs": {
"command": "npx",
"args": ["-y", "filesystem"],
"env": {"PATH": "/usr/bin"},
}
})
assert "[mcp_servers.fs]" in out
assert 'command = "npx"' in out
assert 'args = ["-y", "filesystem"]' in out
# Env emitted as inline table
assert 'env = { PATH = "/usr/bin" }' in out
# ---- existing-block stripping ----
class TestStripExistingManagedBlock:
def test_no_managed_block_unchanged(self):
text = "[other]\nfoo = 1\n"
assert _strip_existing_managed_block(text) == text
def test_strips_managed_block_alone(self):
text = (
f"{MIGRATION_MARKER}\n"
"\n"
"[mcp_servers.fs]\n"
'command = "npx"\n'
)
assert _strip_existing_managed_block(text).strip() == ""
def test_preserves_user_content_above_managed_block(self):
text = (
"[model]\n"
'name = "gpt-5.5"\n'
"\n"
f"{MIGRATION_MARKER}\n"
"[mcp_servers.fs]\n"
'command = "x"\n'
)
out = _strip_existing_managed_block(text)
assert "[model]" in out
assert 'name = "gpt-5.5"' in out
assert "mcp_servers.fs" not in out
def test_preserves_unrelated_section_after_managed_block(self):
text = (
f"{MIGRATION_MARKER}\n"
"[mcp_servers.fs]\n"
'command = "x"\n'
"\n"
"[providers]\n"
'foo = "bar"\n'
)
out = _strip_existing_managed_block(text)
assert "mcp_servers.fs" not in out
assert "[providers]" in out
assert 'foo = "bar"' in out
# ---- end-to-end migrate(, expose_hermes_tools=False) ----
class TestMigrate:
def test_no_servers_no_plugins_no_perms_writes_placeholder(self, tmp_path):
report = migrate({}, codex_home=tmp_path,
discover_plugins=False,
default_permission_profile=None, expose_hermes_tools=False)
assert report.written
text = (tmp_path / "config.toml").read_text()
assert MIGRATION_MARKER in text
assert "no MCP servers" in text or "no MCP servers, plugins, or permissions" in text
def test_no_servers_still_writes_permissions_default(self, tmp_path):
"""Even with zero MCP servers, enabling the runtime should write the
default permissions profile so users don't get prompted on every
write attempt. This is the fix for quirk #2."""
report = migrate({}, codex_home=tmp_path, discover_plugins=False, expose_hermes_tools=False)
assert report.written
text = (tmp_path / "config.toml").read_text()
# Codex's schema: top-level `default_permissions` keying a built-in
# profile name (prefixed with ":"). NOT a [permissions] section
# (which is for *user-defined* profiles with structured fields).
assert 'default_permissions = ":workspace"' in text
assert report.wrote_permissions_default == ":workspace"
def test_explicit_none_permissions_skips_block(self, tmp_path):
report = migrate({"mcp_servers": {"x": {"command": "y"}}},
codex_home=tmp_path,
discover_plugins=False,
default_permission_profile=None, expose_hermes_tools=False)
text = (tmp_path / "config.toml").read_text()
assert "default_permissions" not in text
assert "[permissions]" not in text
assert report.wrote_permissions_default is None
def test_plugin_discovery_writes_plugin_blocks(self, tmp_path, monkeypatch):
"""Discovered curated plugins land as [plugins."<name>@<marketplace>"]
blocks. This is what OpenClaw calls 'migrate native codex plugins.'"""
from hermes_cli import codex_runtime_plugin_migration as crpm
def fake_query(codex_home=None, timeout=8.0):
return [
{"name": "google-calendar", "marketplace": "openai-curated",
"enabled": True},
{"name": "github", "marketplace": "openai-curated",
"enabled": True},
], None
monkeypatch.setattr(crpm, "_query_codex_plugins", fake_query)
report = migrate({}, codex_home=tmp_path, discover_plugins=True)
text = (tmp_path / "config.toml").read_text()
assert '[plugins."github@openai-curated"]' in text
assert '[plugins."google-calendar@openai-curated"]' in text
assert "enabled = true" in text
assert "google-calendar@openai-curated" in report.migrated_plugins
assert "github@openai-curated" in report.migrated_plugins
def test_plugin_discovery_skips_unavailable_plugins(self):
"""Plugins where codex reports availability != AVAILABLE should
be skipped — they're broken/uninstallable on codex's side, so
migrating them would write config that fails at activation
time. Cf. openclaw#80815."""
from hermes_cli.codex_runtime_plugin_migration import _query_codex_plugins
from unittest.mock import patch
# Fake a plugin/list response where one plugin is unavailable
fake_response = {
"marketplaces": [{
"name": "openai-curated",
"plugins": [
{"name": "good-plugin", "installed": True,
"enabled": True, "availability": "AVAILABLE"},
{"name": "broken-plugin", "installed": True,
"enabled": True, "availability": "UNAVAILABLE"},
{"name": "auth-pending", "installed": True,
"enabled": True, "availability": "REQUIRES_AUTH"},
# Plugin without availability field — pass through
# (older codex versions or marketplaces that don't
# set it should still work).
{"name": "legacy-plugin", "installed": True,
"enabled": True},
]
}]
}
class FakeClient:
def __init__(self, **kw): pass
def initialize(self, **kw): pass
def request(self, method, params, timeout=None):
return fake_response
def close(self): pass
def __enter__(self): return self
def __exit__(self, *a): pass
with patch("agent.transports.codex_app_server.CodexAppServerClient",
FakeClient):
plugins, err = _query_codex_plugins()
assert err is None
names = [p["name"] for p in plugins]
assert "good-plugin" in names
assert "legacy-plugin" in names # no field → don't skip
assert "broken-plugin" not in names
assert "auth-pending" not in names
def test_plugin_discovery_failure_non_fatal(self, tmp_path, monkeypatch):
"""If codex isn't installed or RPC fails, MCP migration still
completes. The error surfaces in the report but doesn't abort."""
from hermes_cli import codex_runtime_plugin_migration as crpm
def fake_query_fails(codex_home=None, timeout=8.0):
return [], "codex CLI not available"
monkeypatch.setattr(crpm, "_query_codex_plugins", fake_query_fails)
report = migrate({"mcp_servers": {"x": {"command": "y"}}},
codex_home=tmp_path, discover_plugins=True, expose_hermes_tools=False)
assert report.written
assert report.migrated == ["x"]
assert report.plugin_query_error == "codex CLI not available"
assert report.migrated_plugins == []
def test_discover_plugins_false_skips_query(self, tmp_path, monkeypatch):
"""Tests and restricted environments can opt out of the subprocess
spawn entirely."""
from hermes_cli import codex_runtime_plugin_migration as crpm
called = {"yes": False}
def boom(*a, **kw):
called["yes"] = True
return [], None
monkeypatch.setattr(crpm, "_query_codex_plugins", boom)
migrate({"mcp_servers": {"x": {"command": "y"}}},
codex_home=tmp_path, discover_plugins=False, expose_hermes_tools=False)
assert called["yes"] is False
def test_dry_run_skips_plugin_query(self, tmp_path, monkeypatch):
"""Dry run should never spawn codex. Even with discover_plugins=True
the query is skipped because dry_run takes precedence."""
from hermes_cli import codex_runtime_plugin_migration as crpm
called = {"yes": False}
def boom(*a, **kw):
called["yes"] = True
return [], None
monkeypatch.setattr(crpm, "_query_codex_plugins", boom)
migrate({"mcp_servers": {"x": {"command": "y"}}},
codex_home=tmp_path, dry_run=True, discover_plugins=True, expose_hermes_tools=False)
assert called["yes"] is False
def test_re_run_replaces_plugin_block(self, tmp_path, monkeypatch):
"""Plugin blocks are managed and re-runs should replace them
cleanly — same idempotency contract as MCP servers."""
from hermes_cli import codex_runtime_plugin_migration as crpm
# First run: only github
monkeypatch.setattr(crpm, "_query_codex_plugins",
lambda codex_home=None, timeout=8.0: (
[{"name": "github", "marketplace": "openai-curated", "enabled": True}],
None,
))
migrate({}, codex_home=tmp_path, discover_plugins=True,
default_permission_profile=None, expose_hermes_tools=False)
first = (tmp_path / "config.toml").read_text()
assert "github@openai-curated" in first
# Second run: only canva (github went away)
monkeypatch.setattr(crpm, "_query_codex_plugins",
lambda codex_home=None, timeout=8.0: (
[{"name": "canva", "marketplace": "openai-curated", "enabled": True}],
None,
))
migrate({}, codex_home=tmp_path, discover_plugins=True,
default_permission_profile=None, expose_hermes_tools=False)
second = (tmp_path / "config.toml").read_text()
assert "github@openai-curated" not in second
assert "canva@openai-curated" in second
def test_expose_hermes_tools_writes_callback_mcp_entry(self, tmp_path):
"""When expose_hermes_tools=True (production default), an
[mcp_servers.hermes-tools] entry is written so codex calls back
into Hermes for browser/web/delegate_task/vision/memory tools.
This is the fix for 'all other tools that codex doesn't provide
should be useable by hermes' — quirk #7."""
report = migrate({}, codex_home=tmp_path,
discover_plugins=False,
default_permission_profile=None,
expose_hermes_tools=True)
text = (tmp_path / "config.toml").read_text()
assert "[mcp_servers.hermes-tools]" in text
assert "hermes_tools_mcp_server" in text
# Must include startup + tool timeouts so codex doesn't give up
assert "startup_timeout_sec" in text
assert "tool_timeout_sec" in text
# And the entry is reported
assert "hermes-tools" in report.migrated
def test_expose_hermes_tools_disabled_skips_entry(self, tmp_path):
"""expose_hermes_tools=False suppresses the callback registration."""
migrate({}, codex_home=tmp_path,
discover_plugins=False,
default_permission_profile=None,
expose_hermes_tools=False)
text = (tmp_path / "config.toml").read_text()
assert "[mcp_servers.hermes-tools]" not in text
assert "hermes_tools_mcp_server" not in text
def test_dry_run_doesnt_write(self, tmp_path):
report = migrate({"mcp_servers": {"x": {"command": "y"}}},
codex_home=tmp_path, dry_run=True, expose_hermes_tools=False)
assert report.dry_run is True
assert not (tmp_path / "config.toml").exists()
assert "x" in report.migrated
def test_full_migration_round_trip(self, tmp_path):
hermes_cfg = {
"mcp_servers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem"],
},
"github": {
"url": "https://api.github.com/mcp",
"headers": {"Authorization": "Bearer x"},
},
}
}
report = migrate(hermes_cfg, codex_home=tmp_path, expose_hermes_tools=False)
assert report.written
text = (tmp_path / "config.toml").read_text()
assert "[mcp_servers.filesystem]" in text
assert "[mcp_servers.github]" in text
assert 'command = "npx"' in text
assert 'url = "https://api.github.com/mcp"' in text
def test_idempotent_re_run_replaces_managed_block(self, tmp_path):
# First migration
migrate({"mcp_servers": {"a": {"command": "x"}}}, codex_home=tmp_path, expose_hermes_tools=False)
first_text = (tmp_path / "config.toml").read_text()
assert "[mcp_servers.a]" in first_text
# Second migration with different servers
migrate({"mcp_servers": {"b": {"command": "y"}}}, codex_home=tmp_path, expose_hermes_tools=False)
second_text = (tmp_path / "config.toml").read_text()
assert "[mcp_servers.a]" not in second_text
assert "[mcp_servers.b]" in second_text
def test_preserves_user_codex_config_above_marker(self, tmp_path):
target = tmp_path / "config.toml"
target.write_text(
"[model]\n"
'profile = "default"\n'
"\n"
"[providers.openai]\n"
'api_key = "sk-test"\n'
)
migrate({"mcp_servers": {"a": {"command": "x"}}}, codex_home=tmp_path, expose_hermes_tools=False)
new_text = target.read_text()
# User's codex config preserved
assert "[model]" in new_text
assert 'profile = "default"' in new_text
assert "[providers.openai]" in new_text
# And new MCP block inserted without breaking user tables
assert "[mcp_servers.a]" in new_text
assert MIGRATION_MARKER in new_text
def test_managed_root_keys_stay_top_level_when_config_ends_in_table(self, tmp_path):
"""TOML has no explicit 'leave current table' syntax. If Hermes appends
root keys like default_permissions after a user table such as [features],
Codex parses them as features.default_permissions and rejects the config.
The managed block must therefore be inserted before the first table."""
import tomllib
target = tmp_path / "config.toml"
target.write_text(
'model = "gpt-5.5"\n'
"\n"
"[features]\n"
"terminal_resize_reflow = true\n"
)
migrate({}, codex_home=tmp_path, discover_plugins=False, expose_hermes_tools=False)
new_text = target.read_text()
parsed = tomllib.loads(new_text)
assert parsed["default_permissions"] == ":workspace"
assert "default_permissions" not in parsed["features"]
assert new_text.index(MIGRATION_MARKER) < new_text.index("[features]")
def test_preserves_user_mcp_server_outside_managed_block(self, tmp_path):
"""Quirk #6: when a user adds their own MCP server entry directly
to ~/.codex/config.toml outside Hermes' managed block, re-running
migration must preserve it. Tested both above and below the
managed block."""
target = tmp_path / "config.toml"
target.write_text(
"[mcp_servers.user-above]\n"
'command = "/usr/bin/above-server"\n'
'args = ["--above"]\n'
)
# First migrate — adds managed block below user content
migrate({"mcp_servers": {"hermes-mcp": {"command": "npx"}}},
codex_home=tmp_path, discover_plugins=False,
expose_hermes_tools=False)
text = target.read_text()
assert "user-above" in text, "user MCP server above managed block got nuked"
assert 'command = "/usr/bin/above-server"' in text
# Append another user entry below the managed block
target.write_text(
text + "\n[mcp_servers.user-below]\ncommand = \"below-server\"\n"
)
# Re-migrate — both should survive
migrate({"mcp_servers": {"hermes-mcp": {"command": "npx"}}},
codex_home=tmp_path, discover_plugins=False,
expose_hermes_tools=False)
final = target.read_text()
assert "user-above" in final
assert "user-below" in final
# And our managed block is still there with the new content
assert "[mcp_servers.hermes-mcp]" in final
def test_skipped_keys_reported(self, tmp_path):
report = migrate({
"mcp_servers": {
"x": {
"command": "y",
"sampling": {"enabled": True}, # codex has no equivalent
}
}
}, codex_home=tmp_path, expose_hermes_tools=False)
assert "x" in report.skipped_keys_per_server
assert any("sampling" in s for s in report.skipped_keys_per_server["x"])
def test_invalid_mcp_servers_value(self, tmp_path):
report = migrate({"mcp_servers": "notadict"}, codex_home=tmp_path, expose_hermes_tools=False)
assert any("not a dict" in e for e in report.errors)
def test_server_without_transport_skipped_with_error(self, tmp_path):
report = migrate({
"mcp_servers": {"broken": {"description": "no command/url"}}
}, codex_home=tmp_path, expose_hermes_tools=False)
assert "broken" not in report.migrated
assert any("broken" in e for e in report.errors)
def test_summary_reports_migration_count(self, tmp_path):
report = migrate({
"mcp_servers": {"a": {"command": "x"}, "b": {"command": "y"}}
}, codex_home=tmp_path, expose_hermes_tools=False)
summary = report.summary()
assert "Migrated 2 MCP server(s)" in summary
assert "- a" in summary
assert "- b" in summary
# ---- Bug B: duplicate [plugins.X] tables ----
class TestStripUnmanagedPluginTables:
"""Regression tests for issue #26250 Bug B.
When codex itself writes ``[plugins."<name>@<marketplace>"]`` tables
(via the user running ``codex plugins enable`` directly), re-running
``hermes codex-runtime migrate`` would re-emit them inside the managed
block and the resulting duplicate-table-header would crash codex.
"""
def test_strips_plugin_tables_outside_managed_block(self):
text = (
'model = "gpt-5.5"\n'
"\n"
"[mcp_servers.user-thing]\n"
'command = "x"\n'
"\n"
'[plugins."tasks@openai-curated"]\n'
"enabled = true\n"
"\n"
'[plugins."web-search@openai-curated"]\n'
"enabled = true\n"
"\n"
"[features]\n"
"terminal_resize_reflow = true\n"
)
stripped = _strip_unmanaged_plugin_tables(text)
assert "[plugins." not in stripped
# Non-plugin content preserved
assert "[mcp_servers.user-thing]" in stripped
assert "[features]" in stripped
assert "terminal_resize_reflow = true" in stripped
def test_preserves_content_when_no_plugin_tables(self):
text = (
'model = "gpt-5.5"\n'
"\n"
"[mcp_servers.x]\n"
'command = "y"\n'
)
assert _strip_unmanaged_plugin_tables(text) == text
def test_multi_line_array_in_plugin_table_does_not_leak(self):
"""A multi-line TOML array inside a [plugins.X] table whose
continuation lines start with ``[`` (e.g. nested arrays) must NOT
prematurely exit the strip region — otherwise array fragments
leak into top-level output and produce invalid TOML on the next
codex startup. Regression guard for #26260 review.
"""
text = (
'[plugins."tasks@openai-curated"]\n'
"allowed = [\n"
' "a",\n'
' ["nested"],\n'
"]\n"
"[features]\n"
"x = 1\n"
)
stripped = _strip_unmanaged_plugin_tables(text)
# Everything inside the plugin table — including the multi-line
# array's continuation lines starting with `[` — should be gone.
assert '["nested"]' not in stripped
assert "allowed" not in stripped
# Sibling user table survives intact.
assert "[features]" in stripped
assert "x = 1" in stripped
# Result is still valid TOML.
import tomllib
tomllib.loads(stripped)
def test_migrate_dedups_codex_owned_plugin_tables(self, tmp_path, monkeypatch):
"""End-to-end: codex's pre-existing [plugins.X] tables get replaced by
the managed block's re-emission rather than duplicated."""
target = tmp_path / "config.toml"
target.write_text(
"[mcp_servers.user-server]\n"
'command = "x"\n'
"\n"
'[plugins."tasks@openai-curated"]\n'
"enabled = true\n"
)
# Simulate codex's plugin/list reporting the same plugin tasks@openai-curated.
def fake_query(codex_home=None, timeout=8.0):
return (
[{"name": "tasks", "marketplace": "openai-curated", "enabled": True}],
None,
)
monkeypatch.setattr(
"hermes_cli.codex_runtime_plugin_migration._query_codex_plugins",
fake_query,
)
migrate({}, codex_home=tmp_path, discover_plugins=True, expose_hermes_tools=False)
new_text = target.read_text()
# Only ONE [plugins."tasks@openai-curated"] header should remain — inside
# the managed block — not the original outside-the-block copy.
assert new_text.count('[plugins."tasks@openai-curated"]') == 1
# And the surviving one is inside our managed section.
managed_start = new_text.index(MIGRATION_MARKER)
managed_end = new_text.index(MIGRATION_END_MARKER)
plugin_idx = new_text.index('[plugins."tasks@openai-curated"]')
assert managed_start < plugin_idx < managed_end
# File parses cleanly as TOML (the original duplicate-key error is gone).
import tomllib
tomllib.loads(new_text)
def test_migrate_preserves_plugin_tables_when_plugin_list_fails(self, tmp_path, monkeypatch):
"""If plugin/list RPC fails, we can't re-emit plugins authoritatively,
so we must NOT strip the user's existing [plugins.X] tables — that
would silently lose them."""
target = tmp_path / "config.toml"
target.write_text(
'[plugins."tasks@openai-curated"]\n'
"enabled = true\n"
)
def fake_query(codex_home=None, timeout=8.0):
return ([], "plugin/list query failed: codex not installed")
monkeypatch.setattr(
"hermes_cli.codex_runtime_plugin_migration._query_codex_plugins",
fake_query,
)
migrate({}, codex_home=tmp_path, discover_plugins=True, expose_hermes_tools=False)
new_text = target.read_text()
# User's plugin table preserved verbatim — we can't re-emit it.
assert '[plugins."tasks@openai-curated"]' in new_text
# ---- Bug C: HERMES_HOME tempdir leak into ~/.codex/config.toml ----
class TestHermesHomeLeakGuard:
"""Regression tests for issue #26250 Bug C.
Previously ``_build_hermes_tools_mcp_entry()`` read ``HERMES_HOME``
directly from ``os.environ``, so a pytest ``monkeypatch.setenv`` would
leak a transient tempdir path into the user's real ``~/.codex/config.toml``
once codex spawned the hermes-tools MCP subprocess.
"""
def test_tempdir_detector_recognizes_pytest_paths(self):
assert _looks_like_test_tempdir(
"/private/var/folders/abc/pytest-of-kshitij/pytest-137/popen-gw2/test_X/hermes_test"
)
assert _looks_like_test_tempdir(
"/tmp/pytest-of-user/pytest-12/test_X/hermes"
)
assert _looks_like_test_tempdir(
"/private/var/folders/zz/T/pytest-of-bob/pytest-1"
)
def test_tempdir_detector_accepts_real_hermes_home(self):
assert not _looks_like_test_tempdir("/Users/alice/.hermes")
assert not _looks_like_test_tempdir("/home/bob/.hermes")
assert not _looks_like_test_tempdir("/opt/hermes")
assert not _looks_like_test_tempdir("")
def test_pytest_tempdir_not_burned_into_mcp_env(self, monkeypatch):
"""The headline regression: even when HERMES_HOME points at a pytest
tempdir, _build_hermes_tools_mcp_entry() must NOT propagate it."""
monkeypatch.setenv(
"HERMES_HOME",
"/private/var/folders/xx/pytest-of-user/pytest-99/test_x/hermes_test",
)
entry = _build_hermes_tools_mcp_entry()
env = entry.get("env", {})
assert "HERMES_HOME" not in env, (
f"pytest-tempdir HERMES_HOME leaked into codex MCP entry: "
f"{env.get('HERMES_HOME')!r}"
)
def test_real_hermes_home_propagates(self, monkeypatch, tmp_path):
"""A legitimate HERMES_HOME (not a tempdir path) DOES propagate so the
MCP subprocess sees the same config as the parent CLI."""
# Use a path that looks real — under /Users or /home, not /var/folders.
# We can't easily create one in the test, so just use a stable path
# outside any tempdir-detector needle. The detector checks for tempdir
# markers, not for path existence.
real_path = "/Users/alice/.hermes"
monkeypatch.setenv("HERMES_HOME", real_path)
entry = _build_hermes_tools_mcp_entry()
env = entry.get("env", {})
assert env.get("HERMES_HOME") == real_path
def test_unset_hermes_home_omits_env_key(self, monkeypatch):
"""When HERMES_HOME is unset in the environment, the MCP entry MUST
NOT bake in a resolved-default path. The codex subprocess should
inherit whatever HERMES_HOME its launcher (systemd, gateway, shell)
sets at runtime, rather than being pinned to migrate-time defaults.
Regression guard for issue #26250 follow-up review."""
monkeypatch.delenv("HERMES_HOME", raising=False)
entry = _build_hermes_tools_mcp_entry()
env = entry.get("env", {})
assert "HERMES_HOME" not in env, (
f"HERMES_HOME should not be set when env var is unset, got: "
f"{env.get('HERMES_HOME')!r}"
)
@@ -0,0 +1,238 @@
"""Tests for the /codex-runtime slash-command shared logic.
These cover the pure-Python state machine; CLI and gateway handlers are
tested separately because they involve config persistence and prompt
formatting that's surface-specific."""
from __future__ import annotations
from unittest.mock import patch
import pytest
from hermes_cli import codex_runtime_switch as crs
class TestParseArgs:
@pytest.mark.parametrize("arg,expected", [
("", None),
(" ", None),
("auto", "auto"),
("codex_app_server", "codex_app_server"),
("on", "codex_app_server"),
("off", "auto"),
("codex", "codex_app_server"),
("default", "auto"),
("hermes", "auto"),
("ENABLE", "codex_app_server"), # case-insensitive
("DiSaBlE", "auto"),
])
def test_valid_args(self, arg, expected):
value, errors = crs.parse_args(arg)
assert errors == []
assert value == expected
def test_invalid_arg_returns_error(self):
value, errors = crs.parse_args("turbo")
assert value is None
assert errors and "Unknown runtime" in errors[0]
class TestGetCurrentRuntime:
def test_default_when_unset(self):
assert crs.get_current_runtime({}) == "auto"
assert crs.get_current_runtime({"model": {}}) == "auto"
assert crs.get_current_runtime({"model": {"openai_runtime": ""}}) == "auto"
def test_unrecognized_falls_back_to_auto(self):
assert crs.get_current_runtime(
{"model": {"openai_runtime": "garbage"}}
) == "auto"
def test_explicit_codex(self):
assert crs.get_current_runtime(
{"model": {"openai_runtime": "codex_app_server"}}
) == "codex_app_server"
def test_handles_non_dict_config(self):
assert crs.get_current_runtime(None) == "auto" # type: ignore[arg-type]
assert crs.get_current_runtime("notadict") == "auto" # type: ignore[arg-type]
assert crs.get_current_runtime({"model": "notadict"}) == "auto"
class TestSetRuntime:
def test_creates_model_section_if_missing(self):
cfg = {}
old = crs.set_runtime(cfg, "codex_app_server")
assert old == "auto"
assert cfg["model"]["openai_runtime"] == "codex_app_server"
def test_returns_previous_value(self):
cfg = {"model": {"openai_runtime": "codex_app_server"}}
old = crs.set_runtime(cfg, "auto")
assert old == "codex_app_server"
assert cfg["model"]["openai_runtime"] == "auto"
def test_invalid_value_raises(self):
with pytest.raises(ValueError):
crs.set_runtime({}, "garbage")
class TestApply:
def test_read_only_call_reports_state(self):
cfg = {"model": {"openai_runtime": "codex_app_server"}}
with patch.object(crs, "check_codex_binary_ok",
return_value=(True, "0.130.0")):
r = crs.apply(cfg, None)
assert r.success
assert r.new_value == "codex_app_server"
assert r.old_value == "codex_app_server"
assert "codex_app_server" in r.message
assert "0.130.0" in r.message
def test_no_change_when_already_set(self):
cfg = {"model": {"openai_runtime": "auto"}}
r = crs.apply(cfg, "auto")
assert r.success
assert r.message == "openai_runtime already set to auto"
def test_enable_blocked_when_codex_missing(self):
cfg = {}
with patch.object(crs, "check_codex_binary_ok",
return_value=(False, "codex not found")):
r = crs.apply(cfg, "codex_app_server")
assert r.success is False
assert "Cannot enable" in r.message
assert "npm i -g @openai/codex" in r.message
# Config NOT mutated on failure
assert cfg.get("model", {}).get("openai_runtime") in {None, ""}
def test_enable_succeeds_when_codex_present(self):
cfg = {}
persisted = {}
def persist(c):
persisted.update(c)
# Patch migrate so this test doesn't reach into the user's real
# ~/.codex/config.toml. See issue #26250 Bug C — without this patch,
# crs.apply() invokes the real migrate() which writes to
# Path.home() / ".codex" using whatever HERMES_HOME the running pytest
# session has set, leaking pytest tempdir paths into the user's
# codex config.
with patch.object(crs, "check_codex_binary_ok",
return_value=(True, "0.130.0")), \
patch("hermes_cli.codex_runtime_plugin_migration.migrate"):
r = crs.apply(cfg, "codex_app_server", persist_callback=persist)
assert r.success
assert r.new_value == "codex_app_server"
assert r.old_value == "auto"
assert r.requires_new_session is True
assert "via MCP" in r.message # hermes-tools callback message
assert cfg["model"]["openai_runtime"] == "codex_app_server"
assert persisted["model"]["openai_runtime"] == "codex_app_server"
def test_disable_does_not_check_binary(self):
cfg = {"model": {"openai_runtime": "codex_app_server"}}
with patch.object(crs, "check_codex_binary_ok") as bin_check:
r = crs.apply(cfg, "auto")
assert r.success
# Binary check is irrelevant when disabling — should not be called
# with the codex_app_server enable-gate signature.
assert r.new_value == "auto"
assert r.old_value == "codex_app_server"
def test_persist_callback_failure_reported(self):
cfg = {}
def persist_boom(c):
raise IOError("disk full")
with patch.object(crs, "check_codex_binary_ok",
return_value=(True, "0.130.0")):
r = crs.apply(cfg, "codex_app_server", persist_callback=persist_boom)
assert r.success is False
assert "persist failed" in r.message
assert "disk full" in r.message
def test_enable_triggers_mcp_migration(self):
"""Enabling codex_app_server should auto-migrate Hermes mcp_servers
to ~/.codex/config.toml so the spawned subprocess sees them."""
cfg = {
"mcp_servers": {
"filesystem": {"command": "npx", "args": ["-y", "fs-server"]},
}
}
with patch.object(crs, "check_codex_binary_ok",
return_value=(True, "0.130.0")), \
patch("hermes_cli.codex_runtime_plugin_migration.migrate") as mig:
mig.return_value.migrated = ["filesystem", "hermes-tools"]
mig.return_value.migrated_plugins = []
mig.return_value.plugin_query_error = None
mig.return_value.wrote_permissions_default = ":workspace"
mig.return_value.errors = []
mig.return_value.target_path = "/fake/.codex/config.toml"
r = crs.apply(cfg, "codex_app_server")
assert r.success
assert mig.called # migration was triggered
# User MCP servers are reported (excluding internal hermes-tools)
assert "Migrated 1 MCP server" in r.message
assert "filesystem" in r.message
# Permissions default surfaces
assert "Default sandbox: :workspace" in r.message
# Hermes tool callback announcement
assert "via MCP" in r.message
def test_disable_does_not_trigger_migration(self):
"""Switching back to auto must not write to ~/.codex/."""
cfg = {
"model": {"openai_runtime": "codex_app_server"},
"mcp_servers": {"x": {"command": "y"}},
}
with patch("hermes_cli.codex_runtime_plugin_migration.migrate") as mig:
r = crs.apply(cfg, "auto")
assert r.success
assert not mig.called # disabling does not migrate
def test_migration_failure_does_not_block_enable(self):
"""If MCP migration raises, the runtime change still proceeds —
users can manually re-run migration later."""
cfg = {"mcp_servers": {"x": {"command": "y"}}}
with patch.object(crs, "check_codex_binary_ok",
return_value=(True, "0.130.0")), \
patch("hermes_cli.codex_runtime_plugin_migration.migrate",
side_effect=RuntimeError("disk full")):
r = crs.apply(cfg, "codex_app_server")
assert r.success # change still applied
assert r.new_value == "codex_app_server"
assert "MCP migration skipped" in r.message
assert "disk full" in r.message
def test_binary_check_cached_within_apply(self):
"""check_codex_binary_ok is invoked at most once per apply() call.
The enable path has three sites that need the version (state report,
enable gate, success message). Without caching, a single
/codex-runtime invocation spawns `codex --version` three times.
Regression guard against a refactor that drops the cache.
"""
cfg = {}
with patch.object(crs, "check_codex_binary_ok",
return_value=(True, "0.130.0")) as bin_check, \
patch("hermes_cli.codex_runtime_plugin_migration.migrate"):
r = crs.apply(cfg, "codex_app_server")
assert r.success
assert bin_check.call_count == 1, (
f"check_codex_binary_ok was called {bin_check.call_count} time(s); "
"should be cached and called exactly once per apply()"
)
def test_binary_check_cached_on_read_only_call(self):
"""Read-only call (new_value=None) calls the binary check exactly
once and reuses the result for the message."""
cfg = {"model": {"openai_runtime": "codex_app_server"}}
with patch.object(crs, "check_codex_binary_ok",
return_value=(True, "0.130.0")) as bin_check:
crs.apply(cfg, None)
assert bin_check.call_count == 1
File diff suppressed because it is too large Load Diff
+319
View File
@@ -0,0 +1,319 @@
"""Tests for hermes_cli/completion.py — shell completion script generation."""
import argparse
import os
import re
import shutil
import subprocess
import tempfile
import pytest
from hermes_cli.completion import _walk, generate_bash, generate_zsh, generate_fish
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_parser() -> argparse.ArgumentParser:
"""Build a minimal parser that mirrors the real hermes structure."""
p = argparse.ArgumentParser(prog="hermes")
p.add_argument("--version", "-V", action="store_true")
p.add_argument("-p", "--profile", help="Profile name")
sub = p.add_subparsers(dest="command")
chat = sub.add_parser("chat", help="Interactive chat with the agent")
chat.add_argument("-q", "--query")
chat.add_argument("-m", "--model")
gw = sub.add_parser("gateway", help="Messaging gateway management")
gw_sub = gw.add_subparsers(dest="gateway_command")
gw_sub.add_parser("start", help="Start service")
gw_sub.add_parser("stop", help="Stop service")
gw_sub.add_parser("status", help="Show status")
# alias — should NOT appear as a duplicate in completions
gw_sub.add_parser("run", aliases=["foreground"], help="Run in foreground")
sess = sub.add_parser("sessions", help="Manage session history")
sess_sub = sess.add_subparsers(dest="sessions_action")
sess_sub.add_parser("list", help="List sessions")
sess_sub.add_parser("delete", help="Delete a session")
prof = sub.add_parser("profile", help="Manage profiles")
prof_sub = prof.add_subparsers(dest="profile_command")
prof_sub.add_parser("list", help="List profiles")
prof_sub.add_parser("use", help="Switch to a profile")
prof_sub.add_parser("create", help="Create a new profile")
prof_sub.add_parser("delete", help="Delete a profile")
prof_sub.add_parser("show", help="Show profile details")
prof_sub.add_parser("alias", help="Set profile alias")
prof_sub.add_parser("rename", help="Rename a profile")
prof_sub.add_parser("export", help="Export a profile")
sub.add_parser("version", help="Show version")
return p
# ---------------------------------------------------------------------------
# 1. Parser extraction
# ---------------------------------------------------------------------------
class TestWalk:
def test_top_level_subcommands_extracted(self):
tree = _walk(_make_parser())
assert set(tree["subcommands"].keys()) == {"chat", "gateway", "sessions", "profile", "version"}
def test_nested_subcommands_extracted(self):
tree = _walk(_make_parser())
gw_subs = set(tree["subcommands"]["gateway"]["subcommands"].keys())
assert {"start", "stop", "status", "run"}.issubset(gw_subs)
def test_aliases_not_duplicated(self):
"""'foreground' is an alias of 'run' — must not appear as separate entry."""
tree = _walk(_make_parser())
gw_subs = tree["subcommands"]["gateway"]["subcommands"]
assert "foreground" not in gw_subs
def test_flags_extracted(self):
tree = _walk(_make_parser())
chat_flags = tree["subcommands"]["chat"]["flags"]
assert "-q" in chat_flags or "--query" in chat_flags
def test_help_text_captured(self):
tree = _walk(_make_parser())
assert tree["subcommands"]["chat"]["help"] != ""
assert tree["subcommands"]["gateway"]["help"] != ""
# ---------------------------------------------------------------------------
# 2. Bash output
# ---------------------------------------------------------------------------
class TestGenerateBash:
def test_contains_completion_function_and_register(self):
out = generate_bash(_make_parser())
assert "_hermes_completion()" in out
assert "complete -F _hermes_completion hermes" in out
def test_top_level_commands_present(self):
out = generate_bash(_make_parser())
for cmd in ("chat", "gateway", "sessions", "version"):
assert cmd in out
def test_nested_subcommands_in_case(self):
out = generate_bash(_make_parser())
assert "start" in out
assert "stop" in out
def test_valid_bash_syntax(self):
"""Script must pass `bash -n` syntax check."""
out = generate_bash(_make_parser())
with tempfile.NamedTemporaryFile(mode="w", suffix=".bash", delete=False) as f:
f.write(out)
path = f.name
try:
result = subprocess.run(["bash", "-n", path], capture_output=True)
assert result.returncode == 0, result.stderr.decode()
finally:
os.unlink(path)
# ---------------------------------------------------------------------------
# 3. Zsh output
# ---------------------------------------------------------------------------
class TestGenerateZsh:
def test_contains_compdef_header(self):
out = generate_zsh(_make_parser())
assert "#compdef hermes" in out
def test_top_level_commands_present(self):
out = generate_zsh(_make_parser())
for cmd in ("chat", "gateway", "sessions", "version"):
assert cmd in out
def test_nested_describe_blocks(self):
out = generate_zsh(_make_parser())
assert "_describe" in out
# gateway has subcommands so a _cmds array must be generated
assert "gateway_cmds" in out
def test_registers_compdef_instead_of_invoking_completion_function(self):
out = generate_zsh(_make_parser())
assert 'compdef _hermes hermes' in out
assert '_hermes "$@"' not in out
def test_preserves_valid_zsh_arguments_alias_syntax(self):
out = generate_zsh(_make_parser())
assert "'(-)'{-h,--help}'[Show help and exit]'" in out
assert "'(-)'{-V,--version}'[Show version and exit]'" in out
assert "'(-)'{-p,--profile}'[Profile name]:profile:_hermes_profiles'" in out
assert "'(-h --help){-h,--help}[Show help and exit]'" not in out
assert '"(-h --help)"{-h,--help}"[Show help and exit]"' not in out
def test_valid_zsh_syntax(self):
if not shutil.which("zsh"):
pytest.skip("zsh not installed")
out = generate_zsh(_make_parser())
with tempfile.NamedTemporaryFile(mode="w", suffix=".zsh", delete=False) as f:
f.write(out)
path = f.name
try:
result = subprocess.run(["zsh", "-n", path], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
finally:
os.unlink(path)
def test_zsh_eval_style_source_registers_after_compinit(self):
if not shutil.which("zsh"):
pytest.skip("zsh not installed")
out = generate_zsh(_make_parser())
with tempfile.NamedTemporaryFile(mode="w", suffix=".zsh", delete=False) as f:
f.write(out)
path = f.name
try:
result = subprocess.run(
[
"zsh",
"-fc",
f"autoload -Uz compinit && compinit -D; source {path}; [[ ${{_comps[hermes]}} == _hermes ]]",
],
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
assert result.stderr == ""
finally:
os.unlink(path)
# ---------------------------------------------------------------------------
# 4. Fish output
# ---------------------------------------------------------------------------
class TestGenerateFish:
def test_disables_file_completion(self):
out = generate_fish(_make_parser())
assert "complete -c hermes -f" in out
def test_top_level_commands_present(self):
out = generate_fish(_make_parser())
for cmd in ("chat", "gateway", "sessions", "version"):
assert cmd in out
def test_subcommand_guard_present(self):
out = generate_fish(_make_parser())
assert "__fish_seen_subcommand_from" in out
def test_valid_fish_syntax(self):
"""Script must be accepted by fish without errors."""
if not shutil.which("fish"):
pytest.skip("fish not installed")
out = generate_fish(_make_parser())
with tempfile.NamedTemporaryFile(mode="w", suffix=".fish", delete=False) as f:
f.write(out)
path = f.name
try:
result = subprocess.run(["fish", path], capture_output=True)
assert result.returncode == 0, result.stderr.decode()
finally:
os.unlink(path)
# ---------------------------------------------------------------------------
# 5. Subcommand drift prevention
# ---------------------------------------------------------------------------
class TestSubcommandDrift:
def test_SUBCOMMANDS_covers_required_commands(self):
"""_SUBCOMMANDS must include all known top-level commands so that
multi-word session names after -c/-r are never accidentally split.
"""
import inspect
from hermes_cli.main import _coalesce_session_name_args
source = inspect.getsource(_coalesce_session_name_args)
match = re.search(r'_SUBCOMMANDS\s*=\s*\{([^}]+)\}', source, re.DOTALL)
assert match, "_SUBCOMMANDS block not found in _coalesce_session_name_args()"
defined = set(re.findall(r'"(\w+)"', match.group(1)))
required = {
"chat", "model", "gateway", "setup", "login", "logout", "auth",
"status", "cron", "config", "sessions", "version", "update",
"uninstall", "profile", "skills", "tools", "mcp", "plugins",
"acp", "claw", "honcho", "completion", "logs",
}
missing = required - defined
assert not missing, f"Missing from _SUBCOMMANDS: {missing}"
# ---------------------------------------------------------------------------
# 6. Profile completion (regression prevention)
# ---------------------------------------------------------------------------
class TestProfileCompletion:
"""Ensure profile name completion is present in all shell outputs."""
def test_bash_has_profiles_helper(self):
out = generate_bash(_make_parser())
assert "_hermes_profiles()" in out
assert 'profiles_dir="$HOME/.hermes/profiles"' in out
def test_bash_completes_profiles_after_p_flag(self):
out = generate_bash(_make_parser())
assert '"-p"' in out or "== \"-p\"" in out
assert '"--profile"' in out or '== "--profile"' in out
assert "_hermes_profiles" in out
def test_bash_profile_subcommand_has_action_completion(self):
out = generate_bash(_make_parser())
assert "use|delete|show|alias|rename|export)" in out
def test_bash_profile_actions_complete_profile_names(self):
"""After 'hermes profile use', complete with profile names."""
out = generate_bash(_make_parser())
# The profile case should have _hermes_profiles for name-taking actions
lines = out.split("\n")
in_profile_case = False
has_profiles_in_action = False
for line in lines:
if "profile)" in line:
in_profile_case = True
if in_profile_case and "_hermes_profiles" in line:
has_profiles_in_action = True
break
assert has_profiles_in_action, "profile actions should complete with _hermes_profiles"
def test_zsh_has_profiles_helper(self):
out = generate_zsh(_make_parser())
assert "_hermes_profiles()" in out
assert "$HOME/.hermes/profiles" in out
def test_zsh_has_profile_flag_completion(self):
out = generate_zsh(_make_parser())
assert "--profile" in out
assert "_hermes_profiles" in out
def test_zsh_profile_actions_complete_names(self):
out = generate_zsh(_make_parser())
assert "use|delete|show|alias|rename|export)" in out
def test_fish_has_profiles_helper(self):
out = generate_fish(_make_parser())
assert "__hermes_profiles" in out
assert "$HOME/.hermes/profiles" in out
def test_fish_has_profile_flag_completion(self):
out = generate_fish(_make_parser())
assert "-s p -l profile" in out
assert "(__hermes_profiles)" in out
def test_fish_profile_actions_complete_names(self):
out = generate_fish(_make_parser())
# Should have profile name completion for actions like use, delete, etc.
assert "__hermes_profiles" in out
count = out.count("(__hermes_profiles)")
# At least the -p flag + the profile action completions
assert count >= 2, f"Expected >=2 profile completion entries, got {count}"
+895
View File
@@ -0,0 +1,895 @@
"""Tests for hermes_cli configuration management."""
import os
from pathlib import Path
from unittest.mock import patch
import pytest
import yaml
from hermes_cli.config import (
DEFAULT_CONFIG,
get_hermes_home,
ensure_hermes_home,
get_compatible_custom_providers,
load_config,
load_env,
migrate_config,
remove_env_value,
save_config,
save_env_value,
save_env_value_secure,
sanitize_env_file,
_sanitize_env_lines,
)
class TestGetHermesHome:
def test_default_path(self):
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("HERMES_HOME", None)
home = get_hermes_home()
assert home == Path.home() / ".hermes"
def test_env_override(self):
with patch.dict(os.environ, {"HERMES_HOME": "/custom/path"}):
home = get_hermes_home()
assert home == Path("/custom/path")
class TestEnsureHermesHome:
def test_creates_subdirs(self, tmp_path):
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
ensure_hermes_home()
assert (tmp_path / "cron").is_dir()
assert (tmp_path / "sessions").is_dir()
assert (tmp_path / "logs").is_dir()
assert (tmp_path / "memories").is_dir()
def test_creates_default_soul_md_if_missing(self, tmp_path):
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
ensure_hermes_home()
soul_path = tmp_path / "SOUL.md"
assert soul_path.exists()
assert soul_path.read_text(encoding="utf-8").strip() != ""
def test_does_not_overwrite_existing_soul_md(self, tmp_path):
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
soul_path = tmp_path / "SOUL.md"
soul_path.write_text("custom soul", encoding="utf-8")
ensure_hermes_home()
assert soul_path.read_text(encoding="utf-8") == "custom soul"
class TestLoadConfigDefaults:
def test_returns_defaults_when_no_file(self, tmp_path):
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
config = load_config()
assert config["model"] == DEFAULT_CONFIG["model"]
assert config["agent"]["max_turns"] == DEFAULT_CONFIG["agent"]["max_turns"]
assert "max_turns" not in config
assert "terminal" in config
assert config["terminal"]["backend"] == "local"
assert config["display"]["interim_assistant_messages"] is True
def test_legacy_root_level_max_turns_migrates_to_agent_config(self, tmp_path):
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
config_path = tmp_path / "config.yaml"
config_path.write_text("max_turns: 42\n")
config = load_config()
assert config["agent"]["max_turns"] == 42
assert "max_turns" not in config
class TestLoadConfigParseFailure:
"""A YAML parse failure must NOT silently fall back to defaults.
Before issue #23570 this was a single ``print(...)`` that scrolled past
on the first invocation users saw aux-fallback misbehavior with no clue
their config.yaml was being ignored. The helper must:
* log at WARNING (so ``hermes logs`` surfaces it)
* also write to stderr (so it's visible at startup even before
``setup_logging()`` has wired up file handlers)
* dedup on (path, mtime_ns, size) so concurrent loads don't spam
* re-warn after the user edits the file (different mtime)
"""
def test_logs_and_warns_on_parse_failure(self, tmp_path, caplog, capsys):
# Reset the dedup cache so this test isn't affected by other tests
# that may have warned about a different broken config.
from hermes_cli import config as cfg_mod
cfg_mod._CONFIG_PARSE_WARNED.clear()
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
(tmp_path / "config.yaml").write_text("\tbroken tab indent:\n")
import logging
with caplog.at_level(logging.WARNING, logger="hermes_cli.config"):
config = load_config()
# Falls back to defaults — confirms the silent-fallback we're warning about
assert config["model"] == DEFAULT_CONFIG["model"]
# WARNING-level log was emitted with file path + reason
assert any(
str(tmp_path / "config.yaml") in rec.message
and "Falling back to default config" in rec.message
for rec in caplog.records
), f"expected WARNING log, got: {[r.message for r in caplog.records]}"
# stderr also got a user-visible message (with the ⚠️ marker so it
# stands out at hermes startup before logging is configured)
captured = capsys.readouterr()
assert "hermes config:" in captured.err
assert str(tmp_path / "config.yaml") in captured.err
def test_dedup_on_repeated_load_same_file(self, tmp_path, capsys):
from hermes_cli import config as cfg_mod
cfg_mod._CONFIG_PARSE_WARNED.clear()
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
(tmp_path / "config.yaml").write_text("\tbroken:\n")
load_config()
first = capsys.readouterr().err
assert "hermes config:" in first
load_config()
second = capsys.readouterr().err
assert second == "", "second load should NOT re-warn (same file, same mtime)"
def test_rewarns_after_file_edit(self, tmp_path, capsys):
import time
from hermes_cli import config as cfg_mod
cfg_mod._CONFIG_PARSE_WARNED.clear()
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
(tmp_path / "config.yaml").write_text("\tbroken:\n")
load_config()
capsys.readouterr() # discard first warning
# Edit the file (still broken, but different content) — mtime changes
time.sleep(0.05)
(tmp_path / "config.yaml").write_text("\tstill broken differently:\n")
load_config()
after_edit = capsys.readouterr().err
assert "hermes config:" in after_edit, "edited file should re-warn"
class TestSaveAndLoadRoundtrip:
def test_roundtrip(self, tmp_path):
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
config = load_config()
config["model"] = "test/custom-model"
config["agent"]["max_turns"] = 42
save_config(config)
reloaded = load_config()
assert reloaded["model"] == "test/custom-model"
assert reloaded["agent"]["max_turns"] == 42
saved = yaml.safe_load((tmp_path / "config.yaml").read_text())
assert saved["agent"]["max_turns"] == 42
assert "max_turns" not in saved
def test_save_config_normalizes_legacy_root_level_max_turns(self, tmp_path):
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
save_config({"model": "test/custom-model", "max_turns": 37})
saved = yaml.safe_load((tmp_path / "config.yaml").read_text())
assert saved["agent"]["max_turns"] == 37
assert "max_turns" not in saved
def test_nested_values_preserved(self, tmp_path):
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
config = load_config()
config["terminal"]["timeout"] = 999
save_config(config)
reloaded = load_config()
assert reloaded["terminal"]["timeout"] == 999
class TestSaveEnvValueSecure:
def test_save_env_value_writes_without_stdout(self, tmp_path, capsys):
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
save_env_value("TENOR_API_KEY", "sk-test-secret")
captured = capsys.readouterr()
assert captured.out == ""
assert captured.err == ""
env_values = load_env()
assert env_values["TENOR_API_KEY"] == "sk-test-secret"
def test_secure_save_returns_metadata_only(self, tmp_path):
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
result = save_env_value_secure("GITHUB_TOKEN", "ghp_test_secret")
assert result == {
"success": True,
"stored_as": "GITHUB_TOKEN",
"validated": False,
}
assert "secret" not in str(result).lower()
def test_save_env_value_updates_process_environment(self, tmp_path):
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}, clear=False):
os.environ.pop("TENOR_API_KEY", None)
save_env_value("TENOR_API_KEY", "sk-test-secret")
assert os.environ["TENOR_API_KEY"] == "sk-test-secret"
def test_save_env_value_hardens_file_permissions_on_posix(self, tmp_path):
if os.name == "nt":
return
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
save_env_value("TENOR_API_KEY", "sk-test-secret")
env_mode = (tmp_path / ".env").stat().st_mode & 0o777
assert env_mode == 0o600
class TestRemoveEnvValue:
def test_removes_key_from_env_file(self, tmp_path):
env_path = tmp_path / ".env"
env_path.write_text("KEY_A=value_a\nKEY_B=value_b\nKEY_C=value_c\n")
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path), "KEY_B": "value_b"}):
result = remove_env_value("KEY_B")
assert result is True
content = env_path.read_text()
assert "KEY_B" not in content
assert "KEY_A=value_a" in content
assert "KEY_C=value_c" in content
def test_clears_os_environ(self, tmp_path):
env_path = tmp_path / ".env"
env_path.write_text("MY_KEY=my_value\n")
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path), "MY_KEY": "my_value"}):
remove_env_value("MY_KEY")
assert "MY_KEY" not in os.environ
def test_returns_false_when_key_not_found(self, tmp_path):
env_path = tmp_path / ".env"
env_path.write_text("OTHER_KEY=value\n")
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
result = remove_env_value("MISSING_KEY")
assert result is False
# File should be untouched
assert env_path.read_text() == "OTHER_KEY=value\n"
def test_handles_missing_env_file(self, tmp_path):
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path), "GHOST_KEY": "ghost"}):
result = remove_env_value("GHOST_KEY")
assert result is False
# os.environ should still be cleared
assert "GHOST_KEY" not in os.environ
def test_clears_os_environ_even_when_not_in_file(self, tmp_path):
env_path = tmp_path / ".env"
env_path.write_text("OTHER=stuff\n")
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path), "ORPHAN_KEY": "orphan"}):
remove_env_value("ORPHAN_KEY")
assert "ORPHAN_KEY" not in os.environ
class TestSaveConfigAtomicity:
"""Verify save_config uses atomic writes (tempfile + os.replace)."""
def test_no_partial_write_on_crash(self, tmp_path):
"""If save_config crashes mid-write, the previous file stays intact."""
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
# Write an initial config
config = load_config()
config["model"] = "original-model"
save_config(config)
config_path = tmp_path / "config.yaml"
assert config_path.exists()
# Simulate a crash during yaml.dump by making atomic_yaml_write's
# yaml.dump raise after the temp file is created but before replace.
with patch("utils.yaml.dump", side_effect=OSError("disk full")):
try:
config["model"] = "should-not-persist"
save_config(config)
except OSError:
pass
# Original file must still be intact
reloaded = load_config()
assert reloaded["model"] == "original-model"
def test_no_leftover_temp_files(self, tmp_path):
"""Failed writes must clean up their temp files."""
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
config = load_config()
save_config(config)
with patch("utils.yaml.dump", side_effect=OSError("disk full")):
try:
save_config(config)
except OSError:
pass
# No .tmp files should remain
tmp_files = list(tmp_path.glob(".*config*.tmp"))
assert tmp_files == []
def test_atomic_write_creates_valid_yaml(self, tmp_path):
"""The written file must be valid YAML matching the input."""
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
config = load_config()
config["model"] = "test/atomic-model"
config["agent"]["max_turns"] = 77
save_config(config)
# Read raw YAML to verify it's valid and correct
config_path = tmp_path / "config.yaml"
with open(config_path) as f:
raw = yaml.safe_load(f)
assert raw["model"] == "test/atomic-model"
assert raw["agent"]["max_turns"] == 77
class TestSanitizeEnvLines:
"""Tests for .env file corruption repair."""
def test_splits_concatenated_keys(self):
"""Two KEY=VALUE pairs jammed on one line get split."""
lines = ["ANTHROPIC_API_KEY=sk-ant-xxxOPENAI_BASE_URL=https://api.openai.com/v1\n"]
result = _sanitize_env_lines(lines)
assert result == [
"ANTHROPIC_API_KEY=sk-ant-xxx\n",
"OPENAI_BASE_URL=https://api.openai.com/v1\n",
]
def test_preserves_clean_file(self):
"""A well-formed .env file passes through unchanged (modulo trailing newlines)."""
lines = [
"OPENROUTER_API_KEY=sk-or-xxx\n",
"FIRECRAWL_API_KEY=fc-xxx\n",
"# a comment\n",
"\n",
]
result = _sanitize_env_lines(lines)
assert result == lines
def test_preserves_comments_and_blanks(self):
lines = ["# comment\n", "\n", "KEY=val\n"]
result = _sanitize_env_lines(lines)
assert result == lines
def test_adds_missing_trailing_newline(self):
"""Lines missing trailing newline get one added."""
lines = ["FOO_BAR=baz"]
result = _sanitize_env_lines(lines)
assert result == ["FOO_BAR=baz\n"]
def test_three_concatenated_keys(self):
"""Three known keys on one line all get separated."""
lines = ["FAL_KEY=111FIRECRAWL_API_KEY=222GITHUB_TOKEN=333\n"]
result = _sanitize_env_lines(lines)
assert result == [
"FAL_KEY=111\n",
"FIRECRAWL_API_KEY=222\n",
"GITHUB_TOKEN=333\n",
]
def test_value_with_equals_sign_not_split(self):
"""A value containing '=' shouldn't be falsely split (lowercase in value)."""
lines = ["OPENAI_BASE_URL=https://api.example.com/v1?key=abc123\n"]
result = _sanitize_env_lines(lines)
assert result == lines
def test_unknown_keys_not_split(self):
"""Unknown key names on one line are NOT split (avoids false positives)."""
lines = ["CUSTOM_VAR=value123OTHER_THING=value456\n"]
result = _sanitize_env_lines(lines)
# Unknown keys stay on one line — no false split
assert len(result) == 1
def test_value_ending_with_digits_still_splits(self):
"""Concatenation is detected even when value ends with digits."""
lines = ["OPENROUTER_API_KEY=sk-or-v1-abc123OPENAI_BASE_URL=https://api.openai.com/v1\n"]
result = _sanitize_env_lines(lines)
assert len(result) == 2
assert result[0].startswith("OPENROUTER_API_KEY=")
assert result[1].startswith("OPENAI_BASE_URL=")
def test_glm_suffix_collision_not_split(self):
"""GLM_API_KEY / GLM_BASE_URL must not be mangled by LM_API_KEY / LM_BASE_URL suffixes (#17138)."""
lines = [
"GLM_API_KEY=glm-secret\n",
"GLM_BASE_URL=https://api.z.ai/api/paas/v4\n",
]
result = _sanitize_env_lines(lines)
assert result == lines, f"GLM_* lines were corrupted by suffix collision: {result}"
def test_suffix_collision_does_not_break_real_concatenation(self):
"""A genuine concatenation that happens to start with a suffix-superset key still splits."""
lines = ["GLM_API_KEY=glmLM_API_KEY=lm-key\n"]
result = _sanitize_env_lines(lines)
assert len(result) == 2
assert result[0].startswith("GLM_API_KEY=")
assert result[1].startswith("LM_API_KEY=")
def test_save_env_value_fixes_corruption_on_write(self, tmp_path):
"""save_env_value sanitizes corrupted lines when writing a new key."""
env_file = tmp_path / ".env"
env_file.write_text(
"ANTHROPIC_API_KEY=sk-antOPENAI_BASE_URL=https://api.openai.com/v1\n"
"FAL_KEY=existing\n"
)
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
save_env_value("MESSAGING_CWD", "/tmp")
content = env_file.read_text()
lines = content.strip().split("\n")
# Corrupted line should be split, new key added
assert "ANTHROPIC_API_KEY=sk-ant" in lines
assert "OPENAI_BASE_URL=https://api.openai.com/v1" in lines
assert "MESSAGING_CWD=/tmp" in lines
def test_sanitize_env_file_returns_fix_count(self, tmp_path):
"""sanitize_env_file reports how many entries were fixed."""
env_file = tmp_path / ".env"
env_file.write_text(
"FAL_KEY=good\n"
"OPENROUTER_API_KEY=valFIRECRAWL_API_KEY=val2\n"
)
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
fixes = sanitize_env_file()
assert fixes > 0
# Verify file is now clean
content = env_file.read_text()
assert "OPENROUTER_API_KEY=val\n" in content
assert "FIRECRAWL_API_KEY=val2\n" in content
def test_sanitize_env_file_noop_on_clean_file(self, tmp_path):
"""No changes when file is already clean."""
env_file = tmp_path / ".env"
env_file.write_text("GOOD_KEY=good\nOTHER_KEY=other\n")
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
fixes = sanitize_env_file()
assert fixes == 0
class TestOptionalEnvVarsRegistry:
"""Verify that key env vars are registered in OPTIONAL_ENV_VARS."""
def test_tavily_api_key_registered(self):
"""TAVILY_API_KEY is listed in OPTIONAL_ENV_VARS."""
from hermes_cli.config import OPTIONAL_ENV_VARS
assert "TAVILY_API_KEY" in OPTIONAL_ENV_VARS
def test_tavily_api_key_is_tool_category(self):
"""TAVILY_API_KEY is in the 'tool' category."""
from hermes_cli.config import OPTIONAL_ENV_VARS
assert OPTIONAL_ENV_VARS["TAVILY_API_KEY"]["category"] == "tool"
def test_tavily_api_key_is_password(self):
"""TAVILY_API_KEY is marked as password."""
from hermes_cli.config import OPTIONAL_ENV_VARS
assert OPTIONAL_ENV_VARS["TAVILY_API_KEY"]["password"] is True
def test_tavily_api_key_has_url(self):
"""TAVILY_API_KEY has a URL."""
from hermes_cli.config import OPTIONAL_ENV_VARS
assert OPTIONAL_ENV_VARS["TAVILY_API_KEY"]["url"] == "https://app.tavily.com/home"
def test_tavily_in_env_vars_by_version(self):
"""TAVILY_API_KEY is listed in ENV_VARS_BY_VERSION."""
from hermes_cli.config import ENV_VARS_BY_VERSION
all_vars = []
for vars_list in ENV_VARS_BY_VERSION.values():
all_vars.extend(vars_list)
assert "TAVILY_API_KEY" in all_vars
class TestConfigMigrationSecretPrompts:
def test_required_secret_env_prompt_uses_masked_prompt(self, tmp_path, monkeypatch):
from hermes_cli import config as cfg_mod
saved = {}
monkeypatch.setattr(cfg_mod, "sanitize_env_file", lambda: 0)
monkeypatch.setattr(cfg_mod, "check_config_version", lambda: (999, 999))
monkeypatch.setattr(cfg_mod, "get_missing_config_fields", lambda: [])
monkeypatch.setattr(cfg_mod, "get_missing_skill_config_vars", lambda: [])
monkeypatch.setattr(
cfg_mod,
"get_missing_env_vars",
lambda required_only=True: [
{
"name": "TEST_API_KEY",
"description": "Test key",
"prompt": "Test API key",
"password": True,
}
]
if required_only
else [],
)
def fake_masked_secret_prompt(prompt):
saved["prompt"] = prompt
return "secret"
monkeypatch.setattr(cfg_mod, "masked_secret_prompt", fake_masked_secret_prompt)
monkeypatch.setattr(
cfg_mod,
"save_env_value",
lambda name, value: saved.update({name: value}),
)
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
results = cfg_mod.migrate_config(interactive=True, quiet=True)
assert saved["prompt"] == " Test API key: "
assert saved["TEST_API_KEY"] == "secret"
assert results["env_added"] == ["TEST_API_KEY"]
class TestAnthropicTokenMigration:
"""Test that config version 8→9 clears ANTHROPIC_TOKEN."""
def _write_config_version(self, tmp_path, version):
config_path = tmp_path / "config.yaml"
import yaml
config_path.write_text(yaml.safe_dump({"_config_version": version}))
def test_clears_token_on_upgrade_to_v9(self, tmp_path):
"""ANTHROPIC_TOKEN is cleared unconditionally when upgrading to v9."""
self._write_config_version(tmp_path, 8)
(tmp_path / ".env").write_text("ANTHROPIC_TOKEN=old-token\n")
with patch.dict(os.environ, {
"HERMES_HOME": str(tmp_path),
"ANTHROPIC_TOKEN": "old-token",
}):
migrate_config(interactive=False, quiet=True)
assert load_env().get("ANTHROPIC_TOKEN") == ""
def test_skips_on_version_9_or_later(self, tmp_path):
"""Already at v9 — ANTHROPIC_TOKEN is not touched."""
self._write_config_version(tmp_path, 9)
(tmp_path / ".env").write_text("ANTHROPIC_TOKEN=current-token\n")
with patch.dict(os.environ, {
"HERMES_HOME": str(tmp_path),
"ANTHROPIC_TOKEN": "current-token",
}):
migrate_config(interactive=False, quiet=True)
assert load_env().get("ANTHROPIC_TOKEN") == "current-token"
class TestCustomProviderCompatibility:
"""Custom provider compatibility across legacy and v12+ config schemas."""
def test_v11_upgrade_moves_custom_providers_into_providers(self, tmp_path):
config_path = tmp_path / "config.yaml"
config_path.write_text(
yaml.safe_dump(
{
"_config_version": 11,
"model": {
"default": "openai/gpt-5.4",
"provider": "openrouter",
},
"custom_providers": [
{
"name": "OpenAI Direct",
"base_url": "https://api.openai.com/v1",
"api_key": "test-key",
"api_mode": "codex_responses",
"model": "gpt-5-mini",
}
],
"fallback_providers": [
{"provider": "openai-direct", "model": "gpt-5-mini"}
],
}
),
encoding="utf-8",
)
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
migrate_config(interactive=False, quiet=True)
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
from hermes_cli.config import DEFAULT_CONFIG
assert raw["_config_version"] == DEFAULT_CONFIG["_config_version"]
assert raw["providers"]["openai-direct"] == {
"api": "https://api.openai.com/v1",
"api_key": "test-key",
"default_model": "gpt-5-mini",
"name": "OpenAI Direct",
"transport": "codex_responses",
}
# custom_providers removed by migration — runtime reads via compat layer
assert "custom_providers" not in raw
def test_providers_dict_resolves_at_runtime(self, tmp_path):
"""After migration deleted custom_providers, get_compatible_custom_providers
still finds entries from the providers dict."""
config_path = tmp_path / "config.yaml"
config_path.write_text(
yaml.safe_dump(
{
"_config_version": 17,
"providers": {
"openai-direct": {
"api": "https://api.openai.com/v1",
"api_key": "test-key",
"default_model": "gpt-5-mini",
"name": "OpenAI Direct",
"transport": "codex_responses",
}
},
}
),
encoding="utf-8",
)
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
compatible = get_compatible_custom_providers()
assert len(compatible) == 1
assert compatible[0]["name"] == "OpenAI Direct"
assert compatible[0]["base_url"] == "https://api.openai.com/v1"
assert compatible[0]["provider_key"] == "openai-direct"
assert compatible[0]["api_mode"] == "codex_responses"
def test_compatible_custom_providers_prefers_base_url_then_url_then_api(self, tmp_path):
"""URL field precedence is base_url > url > api (PR #9332)."""
config_path = tmp_path / "config.yaml"
config_path.write_text(
yaml.safe_dump(
{
"_config_version": 17,
"providers": {
"my-provider": {
"name": "My Provider",
"api": "https://api.example.com/v1",
"url": "https://url.example.com/v1",
"base_url": "https://base.example.com/v1",
}
},
}
),
encoding="utf-8",
)
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
compatible = get_compatible_custom_providers()
assert compatible == [
{
"name": "My Provider",
"base_url": "https://base.example.com/v1",
"provider_key": "my-provider",
}
]
def test_dedup_across_legacy_and_providers(self, tmp_path):
"""Same name+url in both schemas should not produce duplicates."""
config_path = tmp_path / "config.yaml"
config_path.write_text(
yaml.safe_dump(
{
"_config_version": 17,
"custom_providers": [
{
"name": "OpenAI Direct",
"base_url": "https://api.openai.com/v1",
"api_key": "legacy-key",
}
],
"providers": {
"openai-direct": {
"api": "https://api.openai.com/v1",
"api_key": "new-key",
"name": "OpenAI Direct",
}
},
}
),
encoding="utf-8",
)
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
compatible = get_compatible_custom_providers()
assert len(compatible) == 1
# Legacy entry wins (read first)
assert compatible[0]["api_key"] == "legacy-key"
def test_dedup_preserves_entries_with_different_models(self, tmp_path):
"""Entries with same name+URL but different models must not be collapsed."""
config_path = tmp_path / "config.yaml"
config_path.write_text(
yaml.safe_dump(
{
"_config_version": 17,
"custom_providers": [
{"name": "Ollama Cloud", "base_url": "https://ollama.com/v1", "model": "qwen3-coder"},
{"name": "Ollama Cloud", "base_url": "https://ollama.com/v1", "model": "glm-5.1"},
{"name": "Ollama Cloud", "base_url": "https://ollama.com/v1", "model": "kimi-k2.5"},
],
}
),
encoding="utf-8",
)
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
compatible = get_compatible_custom_providers()
assert len(compatible) == 3
models = [e.get("model") for e in compatible]
assert models == ["qwen3-coder", "glm-5.1", "kimi-k2.5"]
class TestInterimAssistantMessageConfig:
"""Test the explicit gateway interim-message config gate."""
def test_default_config_enables_interim_assistant_messages(self):
assert DEFAULT_CONFIG["display"]["interim_assistant_messages"] is True
def test_migrate_to_v15_adds_interim_assistant_message_gate(self, tmp_path):
config_path = tmp_path / "config.yaml"
config_path.write_text(
yaml.safe_dump({"_config_version": 14, "display": {"tool_progress": "off"}}),
encoding="utf-8",
)
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
migrate_config(interactive=False, quiet=True)
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
from hermes_cli.config import DEFAULT_CONFIG
assert raw["_config_version"] == DEFAULT_CONFIG["_config_version"]
assert raw["display"]["tool_progress"] == "off"
assert raw["display"]["interim_assistant_messages"] is True
class TestDiscordChannelPromptsConfig:
def test_default_config_includes_discord_channel_prompts(self):
assert DEFAULT_CONFIG["discord"]["channel_prompts"] == {}
def test_migrate_adds_discord_channel_prompts_default(self, tmp_path):
config_path = tmp_path / "config.yaml"
config_path.write_text(
yaml.safe_dump({"_config_version": 17, "discord": {"auto_thread": True}}),
encoding="utf-8",
)
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
migrate_config(interactive=False, quiet=True)
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
from hermes_cli.config import DEFAULT_CONFIG
assert raw["_config_version"] == DEFAULT_CONFIG["_config_version"]
assert raw["discord"]["auto_thread"] is True
assert raw["discord"]["channel_prompts"] == {}
class TestUserMessagePreviewConfig:
def test_default_config_preview_line_counts(self):
preview = DEFAULT_CONFIG["display"]["user_message_preview"]
assert preview["first_lines"] == 2
assert preview["last_lines"] == 2
class TestEnvWriteDenylist:
"""``save_env_value`` refuses to persist env-var names that
influence how subprocesses execute ``LD_PRELOAD``, ``PYTHONPATH``,
``PATH``, ``EDITOR``, etc. or any ``HERMES_*`` runtime flag.
The dashboard exposes ``PUT /api/env`` to any authed caller (and
the session token lives in the SPA's HTML where any future plugin
XSS or local process could exfiltrate it). Without this gate, an
attacker who steals the token could plant
``LD_PRELOAD=/tmp/evil.so`` in ``.env`` and own the next Hermes
process on next startup via the dotenv ``os.environ`` chain in
``hermes_cli/env_loader.py``.
Regression test for the dashboard pentest finding filed alongside
the ``web-pentest`` skill (PR #32265 / issue #32267).
"""
@pytest.fixture(autouse=True)
def _hermes_home(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
ensure_hermes_home()
@pytest.mark.parametrize(
"denied_key",
[
"LD_PRELOAD",
"LD_LIBRARY_PATH",
"LD_AUDIT",
"DYLD_INSERT_LIBRARIES",
"DYLD_LIBRARY_PATH",
"PYTHONPATH",
"PYTHONHOME",
"PYTHONSTARTUP",
"NODE_OPTIONS",
"NODE_PATH",
"PATH",
"SHELL",
"EDITOR",
"VISUAL",
"PAGER",
"BROWSER",
"GIT_SSH_COMMAND",
"GIT_EXEC_PATH",
"HERMES_HOME",
"HERMES_PROFILE",
"HERMES_CONFIG",
"HERMES_ENV",
],
)
def test_denylisted_keys_rejected(self, denied_key):
"""Each denylisted name raises ``ValueError`` and never reaches
the on-disk ``.env`` file."""
with pytest.raises(ValueError, match="denylist"):
save_env_value(denied_key, "anything")
# And nothing landed on disk either.
env = load_env()
assert denied_key not in env
@pytest.mark.parametrize(
"allowed_key",
[
"HERMES_GEMINI_CLIENT_ID",
"HERMES_LANGFUSE_PUBLIC_KEY",
"HERMES_SPOTIFY_CLIENT_ID",
"HERMES_QWEN_BASE_URL",
"HERMES_MAX_ITERATIONS",
],
)
def test_hermes_integration_keys_still_writable(self, allowed_key):
"""``HERMES_*`` overall is NOT blocked — only the four runtime
location names (HOME/PROFILE/CONFIG/ENV) are. Integration
credentials following the ``HERMES_*`` convention must keep
working or we'd regress every provider setup wizard that
currently writes one of these (auth.py, Spotify, Langfuse, )."""
save_env_value(allowed_key, "test-value-123")
env = load_env()
assert env[allowed_key] == "test-value-123"
def test_legitimate_provider_key_still_works(self):
"""The denylist must not regress on real provider key writes."""
save_env_value("OPENROUTER_API_KEY", "sk-or-test-1234")
env = load_env()
assert env["OPENROUTER_API_KEY"] == "sk-or-test-1234"
def test_arbitrary_user_key_still_works(self):
"""Plugin / user-defined env vars (anything outside the
denylist and outside ``HERMES_*``) keep working. The denylist
is narrow on purpose."""
save_env_value("MY_PLUGIN_TOKEN", "plugin-secret-123")
env = load_env()
assert env["MY_PLUGIN_TOKEN"] == "plugin-secret-123"
def test_save_env_value_secure_inherits_denylist(self):
"""The ``_secure`` variant goes through ``save_env_value`` so
it inherits the gate verify, don't assume."""
with pytest.raises(ValueError, match="denylist"):
save_env_value_secure("LD_PRELOAD", "/tmp/evil.so")
def test_pre_existing_value_in_env_file_is_left_alone(self, tmp_path):
"""The gate is on *write*. If ``.env`` already contains
``LD_PRELOAD`` (set out-of-band by the operator before this
change shipped, or hand-edited), we don't blow up — we just
refuse to add or update it via the API."""
env_path = tmp_path / ".env"
env_path.write_text("LD_PRELOAD=/something/legit.so\n")
# load_env returns it (the read path is intentionally permissive)
env = load_env()
assert env["LD_PRELOAD"] == "/something/legit.so"
# But the write path still refuses to update it
with pytest.raises(ValueError, match="denylist"):
save_env_value("LD_PRELOAD", "/tmp/evil.so")
+36
View File
@@ -0,0 +1,36 @@
"""Regression tests for removed dead config keys.
This file guards against accidental re-introduction of config keys that were
documented or declared at some point but never actually wired up to read code.
Future dead-config regressions can accumulate here.
"""
import inspect
def test_delegation_default_toolsets_removed_from_cli_config():
"""delegation.default_toolsets was dead config — never read by
_load_config() or anywhere else. Removed.
Guards against accidental re-introduction in cli.py's CLI_CONFIG default
dict. If this test fails, someone re-added the key without wiring it up
to _load_config() in tools/delegate_tool.py.
We inspect the source of load_cli_config() instead of asserting on the
runtime CLI_CONFIG dict because CLI_CONFIG is populated by deep-merging
the user's ~/.hermes/config.yaml over the defaults (cli.py:359-366).
A contributor who still has the legacy key set in their own config
would cause a false failure, and HERMES_HOME patching via conftest
doesn't help because cli._hermes_home is frozen at module import time
(cli.py:76) before any autouse fixture can fire. Source inspection
sidesteps all of that: it tests the defaults literal directly.
"""
from cli import load_cli_config
source = inspect.getsource(load_cli_config)
assert '"default_toolsets"' not in source, (
"delegation.default_toolsets was removed because it was never read. "
"Do not re-add it to cli.py's CLI_CONFIG default dict; "
"use tools/delegate_tool.py's DEFAULT_TOOLSETS module constant or "
"wire a new config key through _load_config()."
)
@@ -0,0 +1,133 @@
"""Tests for ${ENV_VAR} substitution in config.yaml values."""
import pytest
from hermes_cli.config import _expand_env_vars, load_config
class TestExpandEnvVars:
def test_simple_substitution(self):
with pytest.MonkeyPatch().context() as mp:
mp.setenv("MY_KEY", "secret123")
assert _expand_env_vars("${MY_KEY}") == "secret123"
def test_missing_var_kept_verbatim(self):
with pytest.MonkeyPatch().context() as mp:
mp.delenv("UNDEFINED_VAR_XYZ", raising=False)
assert _expand_env_vars("${UNDEFINED_VAR_XYZ}") == "${UNDEFINED_VAR_XYZ}"
def test_no_placeholder_unchanged(self):
assert _expand_env_vars("plain-value") == "plain-value"
def test_dict_recursive(self):
with pytest.MonkeyPatch().context() as mp:
mp.setenv("TOKEN", "tok-abc")
result = _expand_env_vars({"key": "${TOKEN}", "other": "literal"})
assert result == {"key": "tok-abc", "other": "literal"}
def test_nested_dict(self):
with pytest.MonkeyPatch().context() as mp:
mp.setenv("API_KEY", "sk-xyz")
result = _expand_env_vars({"model": {"api_key": "${API_KEY}"}})
assert result["model"]["api_key"] == "sk-xyz"
def test_list_items(self):
with pytest.MonkeyPatch().context() as mp:
mp.setenv("VAL", "hello")
result = _expand_env_vars(["${VAL}", "literal", 42])
assert result == ["hello", "literal", 42]
def test_non_string_values_untouched(self):
assert _expand_env_vars(42) == 42
assert _expand_env_vars(3.14) == 3.14
assert _expand_env_vars(True) is True
assert _expand_env_vars(None) is None
def test_multiple_placeholders_in_one_string(self):
with pytest.MonkeyPatch().context() as mp:
mp.setenv("HOST", "localhost")
mp.setenv("PORT", "5432")
assert _expand_env_vars("${HOST}:${PORT}") == "localhost:5432"
def test_dict_keys_not_expanded(self):
with pytest.MonkeyPatch().context() as mp:
mp.setenv("KEY", "value")
result = _expand_env_vars({"${KEY}": "no-expand-key"})
assert "${KEY}" in result
class TestLoadConfigExpansion:
def test_load_config_expands_env_vars(self, tmp_path, monkeypatch):
config_yaml = (
"model:\n"
" api_key: ${GOOGLE_API_KEY}\n"
"platforms:\n"
" telegram:\n"
" token: ${TELEGRAM_BOT_TOKEN}\n"
"plain: no-substitution\n"
)
config_file = tmp_path / "config.yaml"
config_file.write_text(config_yaml)
monkeypatch.setenv("GOOGLE_API_KEY", "gsk-test-key")
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "1234567:ABC-token")
# Patch the imported function's own globals. Other tests may reload
# hermes_cli.config, making string-target monkeypatches hit a different
# module object than this collection-time imported load_config().
monkeypatch.setitem(load_config.__globals__, "get_config_path", lambda: config_file)
config = load_config()
assert config["model"]["api_key"] == "gsk-test-key"
assert config["platforms"]["telegram"]["token"] == "1234567:ABC-token"
assert config["plain"] == "no-substitution"
def test_load_config_unresolved_kept_verbatim(self, tmp_path, monkeypatch):
config_yaml = "model:\n api_key: ${NOT_SET_XYZ_123}\n"
config_file = tmp_path / "config.yaml"
config_file.write_text(config_yaml)
monkeypatch.delenv("NOT_SET_XYZ_123", raising=False)
monkeypatch.setitem(load_config.__globals__, "get_config_path", lambda: config_file)
config = load_config()
assert config["model"]["api_key"] == "${NOT_SET_XYZ_123}"
class TestLoadCliConfigExpansion:
"""Verify that load_cli_config() also expands ${VAR} references."""
def test_cli_config_expands_auxiliary_api_key(self, tmp_path, monkeypatch):
config_yaml = (
"auxiliary:\n"
" vision:\n"
" api_key: ${TEST_VISION_KEY_XYZ}\n"
)
config_file = tmp_path / "config.yaml"
config_file.write_text(config_yaml)
monkeypatch.setenv("TEST_VISION_KEY_XYZ", "vis-key-123")
# Patch the hermes home so load_cli_config finds our test config
monkeypatch.setattr("cli._hermes_home", tmp_path)
from cli import load_cli_config
config = load_cli_config()
assert config["auxiliary"]["vision"]["api_key"] == "vis-key-123"
def test_cli_config_unresolved_kept_verbatim(self, tmp_path, monkeypatch):
config_yaml = (
"auxiliary:\n"
" vision:\n"
" api_key: ${UNSET_CLI_VAR_ABC}\n"
)
config_file = tmp_path / "config.yaml"
config_file.write_text(config_yaml)
monkeypatch.delenv("UNSET_CLI_VAR_ABC", raising=False)
monkeypatch.setattr("cli._hermes_home", tmp_path)
from cli import load_cli_config
config = load_cli_config()
assert config["auxiliary"]["vision"]["api_key"] == "${UNSET_CLI_VAR_ABC}"
+169
View File
@@ -0,0 +1,169 @@
import textwrap
from hermes_cli.config import load_config, save_config
def _write_config(tmp_path, body: str):
(tmp_path / "config.yaml").write_text(textwrap.dedent(body), encoding="utf-8")
def _read_config(tmp_path) -> str:
return (tmp_path / "config.yaml").read_text(encoding="utf-8")
def test_save_config_preserves_env_refs_on_unrelated_change(monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("TU_ZI_API_KEY", "sk-realsecret")
monkeypatch.setenv("ALT_SECRET", "alt-secret")
_write_config(
tmp_path,
"""\
custom_providers:
- name: tuzi
base_url: https://api.tu-zi.com
api_key: ${TU_ZI_API_KEY}
headers:
Authorization: Bearer ${ALT_SECRET}
model: claude-opus-4-6
model:
default: claude-opus-4-6
""",
)
config = load_config()
config["model"]["default"] = "doubao-pro"
save_config(config)
saved = _read_config(tmp_path)
assert "api_key: ${TU_ZI_API_KEY}" in saved
assert "Authorization: Bearer ${ALT_SECRET}" in saved
assert "sk-realsecret" not in saved
assert "alt-secret" not in saved
def test_save_config_preserves_unresolved_env_refs(monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.delenv("MISSING_SECRET", raising=False)
_write_config(
tmp_path,
"""\
custom_providers:
- name: unresolved
api_key: ${MISSING_SECRET}
model: claude-opus-4-6
model:
default: claude-opus-4-6
""",
)
config = load_config()
config["display"]["compact"] = True
save_config(config)
assert "api_key: ${MISSING_SECRET}" in _read_config(tmp_path)
def test_save_config_allows_intentional_secret_value_change(monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("TU_ZI_API_KEY", "sk-old-secret")
_write_config(
tmp_path,
"""\
custom_providers:
- name: tuzi
api_key: ${TU_ZI_API_KEY}
model: claude-opus-4-6
model:
default: claude-opus-4-6
""",
)
config = load_config()
config["custom_providers"][0]["api_key"] = "sk-new-secret"
save_config(config)
saved = _read_config(tmp_path)
assert "api_key: sk-new-secret" in saved
assert "${TU_ZI_API_KEY}" not in saved
def test_save_config_preserves_template_when_env_rotates_after_load(monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("TU_ZI_API_KEY", "sk-old-secret")
_write_config(
tmp_path,
"""\
custom_providers:
- name: tuzi
api_key: ${TU_ZI_API_KEY}
model: claude-opus-4-6
model:
default: claude-opus-4-6
""",
)
config = load_config()
monkeypatch.setenv("TU_ZI_API_KEY", "sk-rotated-secret")
config["model"]["default"] = "doubao-pro"
save_config(config)
saved = _read_config(tmp_path)
assert "api_key: ${TU_ZI_API_KEY}" in saved
assert "sk-old-secret" not in saved
assert "sk-rotated-secret" not in saved
def test_save_config_keeps_edited_partial_template_strings_literal(monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("ALT_SECRET", "alt-secret")
_write_config(
tmp_path,
"""\
custom_providers:
- name: tuzi
headers:
Authorization: Bearer ${ALT_SECRET}
model: claude-opus-4-6
model:
default: claude-opus-4-6
""",
)
config = load_config()
config["custom_providers"][0]["headers"]["Authorization"] = "Token alt-secret"
save_config(config)
saved = _read_config(tmp_path)
assert "Authorization: Token alt-secret" in saved
assert "Authorization: Bearer ${ALT_SECRET}" not in saved
def test_save_config_falls_back_to_positional_matching_for_duplicate_names(monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("FIRST_SECRET", "first-secret")
monkeypatch.setenv("SECOND_SECRET", "second-secret")
_write_config(
tmp_path,
"""\
custom_providers:
- name: duplicate
api_key: ${FIRST_SECRET}
model: claude-opus-4-6
- name: duplicate
api_key: ${SECOND_SECRET}
model: doubao-pro
model:
default: claude-opus-4-6
""",
)
config = load_config()
config["display"]["compact"] = True
save_config(config)
saved = _read_config(tmp_path)
assert saved.count("name: duplicate") == 2
assert "api_key: ${FIRST_SECRET}" in saved
assert "api_key: ${SECOND_SECRET}" in saved
assert "first-secret" not in saved
assert "second-secret" not in saved
+207
View File
@@ -0,0 +1,207 @@
"""Tests for config.yaml structure validation (validate_config_structure)."""
from hermes_cli.config import validate_config_structure, ConfigIssue
class TestCustomProvidersValidation:
"""custom_providers must be a YAML list, not a dict."""
def test_dict_instead_of_list(self):
"""The exact Discord user scenario — custom_providers as flat dict."""
issues = validate_config_structure({
"custom_providers": {
"name": "Generativelanguage.googleapis.com",
"base_url": "https://generativelanguage.googleapis.com/v1beta",
"api_key": "xxx",
"model": "models/gemini-2.5-flash",
"rate_limit_delay": 2.0,
"fallback_model": {
"provider": "openrouter",
"model": "qwen/qwen3.6-plus:free",
},
},
"fallback_providers": [],
})
errors = [i for i in issues if i.severity == "error"]
assert any("dict" in i.message and "list" in i.message for i in errors), (
"Should detect custom_providers as dict instead of list"
)
def test_dict_detects_misplaced_fields(self):
"""When custom_providers is a dict, detect fields that look misplaced."""
issues = validate_config_structure({
"custom_providers": {
"name": "test",
"base_url": "https://example.com",
"api_key": "xxx",
},
})
warnings = [i for i in issues if i.severity == "warning"]
# Should flag base_url, api_key as looking like custom_providers entry fields
misplaced = [i for i in warnings if "custom_providers entry fields" in i.message]
assert len(misplaced) == 1
def test_dict_detects_nested_fallback(self):
"""When fallback_model gets swallowed into custom_providers dict."""
issues = validate_config_structure({
"custom_providers": {
"name": "test",
"fallback_model": {"provider": "openrouter", "model": "test"},
},
})
errors = [i for i in issues if i.severity == "error"]
assert any("fallback_model" in i.message and "inside" in i.message for i in errors)
def test_valid_list_no_issues(self):
"""Properly formatted custom_providers should produce no issues."""
issues = validate_config_structure({
"custom_providers": [
{"name": "gemini", "base_url": "https://example.com/v1"},
],
"model": {"provider": "custom", "default": "test"},
})
assert len(issues) == 0
def test_list_entry_missing_name(self):
"""List entry without name should warn."""
issues = validate_config_structure({
"custom_providers": [{"base_url": "https://example.com/v1"}],
"model": {"provider": "custom"},
})
assert any("missing 'name'" in i.message for i in issues)
def test_list_entry_missing_base_url(self):
"""List entry without base_url should warn."""
issues = validate_config_structure({
"custom_providers": [{"name": "test"}],
"model": {"provider": "custom"},
})
assert any("missing 'base_url'" in i.message for i in issues)
def test_list_entry_not_dict(self):
"""Non-dict list entries should warn."""
issues = validate_config_structure({
"custom_providers": ["not-a-dict"],
"model": {"provider": "custom"},
})
assert any("not a dict" in i.message for i in issues)
def test_none_custom_providers_no_issues(self):
"""No custom_providers at all should be fine."""
issues = validate_config_structure({
"model": {"provider": "openrouter"},
})
assert len(issues) == 0
class TestFallbackModelValidation:
"""fallback_model should be a top-level dict with provider + model."""
def test_missing_provider(self):
issues = validate_config_structure({
"fallback_model": {"model": "anthropic/claude-sonnet-4"},
})
assert any("missing 'provider'" in i.message for i in issues)
def test_missing_model(self):
issues = validate_config_structure({
"fallback_model": {"provider": "openrouter"},
})
assert any("missing 'model'" in i.message for i in issues)
def test_valid_fallback(self):
issues = validate_config_structure({
"fallback_model": {
"provider": "openrouter",
"model": "anthropic/claude-sonnet-4",
},
})
# Only fallback-related issues should be absent
fb_issues = [i for i in issues if "fallback" in i.message.lower()]
assert len(fb_issues) == 0
def test_non_dict_fallback(self):
issues = validate_config_structure({
"fallback_model": "openrouter:anthropic/claude-sonnet-4",
})
assert any("should be a dict" in i.message for i in issues)
def test_empty_fallback_dict_no_issues(self):
"""Empty fallback_model dict means disabled — no warnings needed."""
issues = validate_config_structure({
"fallback_model": {},
})
fb_issues = [i for i in issues if "fallback" in i.message.lower()]
assert len(fb_issues) == 0
def test_valid_fallback_list(self):
"""List-form fallback_model (chain) should validate when every entry has provider+model."""
issues = validate_config_structure({
"fallback_model": [
{"provider": "openrouter", "model": "anthropic/claude-sonnet-4"},
{"provider": "anthropic", "model": "claude-sonnet-4-6"},
],
})
fb_issues = [i for i in issues if "fallback" in i.message.lower()]
assert len(fb_issues) == 0
def test_fallback_list_entry_missing_provider(self):
issues = validate_config_structure({
"fallback_model": [
{"provider": "openrouter", "model": "anthropic/claude-sonnet-4"},
{"model": "claude-sonnet-4-6"},
],
})
assert any("fallback_model[1]" in i.message and "provider" in i.message for i in issues)
def test_fallback_list_entry_missing_model(self):
issues = validate_config_structure({
"fallback_model": [
{"provider": "openrouter"},
],
})
assert any("fallback_model[0]" in i.message and "model" in i.message for i in issues)
def test_fallback_list_entry_not_a_dict(self):
issues = validate_config_structure({
"fallback_model": ["openrouter:anthropic/claude-sonnet-4"],
})
assert any("fallback_model[0]" in i.message and "should be a dict" in i.message for i in issues)
class TestMissingModelSection:
"""Warn when custom_providers exists but model section is missing."""
def test_custom_providers_without_model(self):
issues = validate_config_structure({
"custom_providers": [
{"name": "test", "base_url": "https://example.com/v1"},
],
})
assert any("no 'model' section" in i.message for i in issues)
def test_custom_providers_with_model(self):
issues = validate_config_structure({
"custom_providers": [
{"name": "test", "base_url": "https://example.com/v1"},
],
"model": {"provider": "custom", "default": "test-model"},
})
# Should not warn about missing model section
assert not any("no 'model' section" in i.message for i in issues)
class TestConfigIssueDataclass:
"""ConfigIssue should be a proper dataclass."""
def test_fields(self):
issue = ConfigIssue(severity="error", message="test msg", hint="test hint")
assert issue.severity == "error"
assert issue.message == "test msg"
assert issue.hint == "test hint"
def test_equality(self):
a = ConfigIssue("error", "msg", "hint")
b = ConfigIssue("error", "msg", "hint")
assert a == b
@@ -0,0 +1,303 @@
"""Tests for container-aware CLI routing (NixOS container mode).
When container.enable = true in the NixOS module, the activation script
writes a .container-mode metadata file. The host CLI detects this and
execs into the container instead of running locally.
"""
import os
import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from hermes_cli.config import (
get_container_exec_info,
)
# =============================================================================
# get_container_exec_info
# =============================================================================
@pytest.fixture
def container_env(tmp_path, monkeypatch):
"""Set up a fake HERMES_HOME with .container-mode file."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("HERMES_DEV", raising=False)
container_mode = hermes_home / ".container-mode"
container_mode.write_text(
"# Written by NixOS activation script. Do not edit manually.\n"
"backend=podman\n"
"container_name=hermes-agent\n"
"exec_user=hermes\n"
"hermes_bin=/data/current-package/bin/hermes\n"
)
return hermes_home
def test_get_container_exec_info_returns_metadata(container_env):
"""Reads .container-mode and returns all fields including exec_user."""
with patch("hermes_constants.is_container", return_value=False):
info = get_container_exec_info()
assert info is not None
assert info["backend"] == "podman"
assert info["container_name"] == "hermes-agent"
assert info["exec_user"] == "hermes"
assert info["hermes_bin"] == "/data/current-package/bin/hermes"
def test_get_container_exec_info_none_inside_container(container_env):
"""Returns None when we're already inside a container."""
with patch("hermes_constants.is_container", return_value=True):
info = get_container_exec_info()
assert info is None
def test_get_container_exec_info_none_without_file(tmp_path, monkeypatch):
"""Returns None when .container-mode doesn't exist (native mode)."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("HERMES_DEV", raising=False)
with patch("hermes_constants.is_container", return_value=False):
info = get_container_exec_info()
assert info is None
def test_get_container_exec_info_skipped_when_hermes_dev(container_env, monkeypatch):
"""Returns None when HERMES_DEV=1 is set (dev mode bypass)."""
monkeypatch.setenv("HERMES_DEV", "1")
with patch("hermes_constants.is_container", return_value=False):
info = get_container_exec_info()
assert info is None
def test_get_container_exec_info_not_skipped_when_hermes_dev_zero(container_env, monkeypatch):
"""HERMES_DEV=0 does NOT trigger bypass — only '1' does."""
monkeypatch.setenv("HERMES_DEV", "0")
with patch("hermes_constants.is_container", return_value=False):
info = get_container_exec_info()
assert info is not None
def test_get_container_exec_info_defaults():
"""Falls back to defaults for missing keys."""
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
hermes_home = Path(tmpdir) / ".hermes"
hermes_home.mkdir()
(hermes_home / ".container-mode").write_text(
"# minimal file with no keys\n"
)
with patch("hermes_constants.is_container", return_value=False), \
patch.dict(get_container_exec_info.__globals__, {"get_hermes_home": lambda: hermes_home}), \
patch.dict(os.environ, {}, clear=False):
os.environ.pop("HERMES_DEV", None)
info = get_container_exec_info()
assert info is not None
assert info["backend"] == "docker"
assert info["container_name"] == "hermes-agent"
assert info["exec_user"] == "hermes"
assert info["hermes_bin"] == "/data/current-package/bin/hermes"
def test_get_container_exec_info_docker_backend(container_env):
"""Correctly reads docker backend with custom exec_user."""
(container_env / ".container-mode").write_text(
"backend=docker\n"
"container_name=hermes-custom\n"
"exec_user=myuser\n"
"hermes_bin=/opt/hermes/bin/hermes\n"
)
with patch("hermes_constants.is_container", return_value=False):
info = get_container_exec_info()
assert info["backend"] == "docker"
assert info["container_name"] == "hermes-custom"
assert info["exec_user"] == "myuser"
assert info["hermes_bin"] == "/opt/hermes/bin/hermes"
def test_get_container_exec_info_crashes_on_permission_error(container_env):
"""PermissionError propagates instead of being silently swallowed."""
with patch("hermes_constants.is_container", return_value=False), \
patch("builtins.open", side_effect=PermissionError("permission denied")):
with pytest.raises(PermissionError):
get_container_exec_info()
# =============================================================================
# _exec_in_container
# =============================================================================
@pytest.fixture
def docker_container_info():
return {
"backend": "docker",
"container_name": "hermes-agent",
"exec_user": "hermes",
"hermes_bin": "/data/current-package/bin/hermes",
}
@pytest.fixture
def podman_container_info():
return {
"backend": "podman",
"container_name": "hermes-agent",
"exec_user": "hermes",
"hermes_bin": "/data/current-package/bin/hermes",
}
def test_exec_in_container_calls_execvp(docker_container_info):
"""Verifies os.execvp is called with correct args: runtime, tty flags,
user, env vars, container name, binary, and CLI args."""
from hermes_cli.main import _exec_in_container
with patch("shutil.which", return_value="/usr/bin/docker"), \
patch("subprocess.run") as mock_run, \
patch("sys.stdin") as mock_stdin, \
patch("os.execvp") as mock_execvp, \
patch.dict(os.environ, {"TERM": "xterm-256color", "LANG": "en_US.UTF-8"},
clear=False):
mock_stdin.isatty.return_value = True
mock_run.return_value = MagicMock(returncode=0)
_exec_in_container(docker_container_info, ["chat", "-m", "opus"])
mock_execvp.assert_called_once()
cmd = mock_execvp.call_args[0][1]
assert cmd[0] == "/usr/bin/docker"
assert cmd[1] == "exec"
assert "-it" in cmd
idx_u = cmd.index("-u")
assert cmd[idx_u + 1] == "hermes"
e_indices = [i for i, v in enumerate(cmd) if v == "-e"]
e_values = [cmd[i + 1] for i in e_indices]
assert "TERM=xterm-256color" in e_values
assert "LANG=en_US.UTF-8" in e_values
assert "hermes-agent" in cmd
assert "/data/current-package/bin/hermes" in cmd
assert "chat" in cmd
def test_exec_in_container_non_tty_uses_i_only(docker_container_info):
"""Non-TTY mode uses -i instead of -it."""
from hermes_cli.main import _exec_in_container
with patch("shutil.which", return_value="/usr/bin/docker"), \
patch("subprocess.run") as mock_run, \
patch("sys.stdin") as mock_stdin, \
patch("os.execvp") as mock_execvp:
mock_stdin.isatty.return_value = False
mock_run.return_value = MagicMock(returncode=0)
_exec_in_container(docker_container_info, ["sessions", "list"])
cmd = mock_execvp.call_args[0][1]
assert "-i" in cmd
assert "-it" not in cmd
def test_exec_in_container_no_runtime_hard_fails(podman_container_info):
"""Hard fails when runtime not found (no fallback)."""
from hermes_cli.main import _exec_in_container
with patch("shutil.which", return_value=None), \
patch("subprocess.run") as mock_run, \
patch("os.execvp") as mock_execvp, \
pytest.raises(SystemExit) as exc_info:
_exec_in_container(podman_container_info, ["chat"])
mock_run.assert_not_called()
mock_execvp.assert_not_called()
assert exc_info.value.code != 0
def test_exec_in_container_sudo_probe_sets_prefix(podman_container_info):
"""When first probe fails and sudo probe succeeds, execvp is called
with sudo -n prefix."""
from hermes_cli.main import _exec_in_container
def which_side_effect(name):
if name == "podman":
return "/usr/bin/podman"
if name == "sudo":
return "/usr/bin/sudo"
return None
with patch("shutil.which", side_effect=which_side_effect), \
patch("subprocess.run") as mock_run, \
patch("sys.stdin") as mock_stdin, \
patch("os.execvp") as mock_execvp:
mock_stdin.isatty.return_value = True
mock_run.side_effect = [
MagicMock(returncode=1), # direct probe fails
MagicMock(returncode=0), # sudo probe succeeds
]
_exec_in_container(podman_container_info, ["chat"])
mock_execvp.assert_called_once()
cmd = mock_execvp.call_args[0][1]
assert cmd[0] == "/usr/bin/sudo"
assert cmd[1] == "-n"
assert cmd[2] == "/usr/bin/podman"
assert cmd[3] == "exec"
def test_exec_in_container_probe_timeout_prints_message(docker_container_info):
"""TimeoutExpired from probe produces a human-readable error, not a
raw traceback."""
from hermes_cli.main import _exec_in_container
with patch("shutil.which", return_value="/usr/bin/docker"), \
patch("subprocess.run", side_effect=subprocess.TimeoutExpired(
cmd=["docker", "inspect"], timeout=15)), \
patch("os.execvp") as mock_execvp, \
pytest.raises(SystemExit) as exc_info:
_exec_in_container(docker_container_info, ["chat"])
mock_execvp.assert_not_called()
assert exc_info.value.code == 1
def test_exec_in_container_container_not_running_no_sudo(docker_container_info):
"""When runtime exists but container not found and no sudo available,
prints helpful error about root containers."""
from hermes_cli.main import _exec_in_container
def which_side_effect(name):
if name == "docker":
return "/usr/bin/docker"
return None
with patch("shutil.which", side_effect=which_side_effect), \
patch("subprocess.run") as mock_run, \
patch("os.execvp") as mock_execvp, \
pytest.raises(SystemExit) as exc_info:
mock_run.return_value = MagicMock(returncode=1)
_exec_in_container(docker_container_info, ["chat"])
mock_execvp.assert_not_called()
assert exc_info.value.code == 1
+578
View File
@@ -0,0 +1,578 @@
"""Tests for hermes_cli.container_boot — the cont-init.d-time
reconciliation that recreates per-profile gateway s6 service slots
from the persistent profiles directory.
These tests run against a fake $HERMES_HOME under tmp_path; no real
s6 supervision tree is required. The in-container integration test
covering end-to-end "docker restart" survival lives in
tests/docker/test_container_restart.py.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from hermes_cli.container_boot import (
ReconcileAction,
reconcile_profile_gateways,
)
# ---------------------------------------------------------------------------
# Fixtures + helpers
# ---------------------------------------------------------------------------
def _make_profile(
hermes_home: Path,
name: str,
*,
state: str | None,
with_pid: bool = False,
config: bool = True,
) -> Path:
"""Create a fake profile directory under hermes_home/profiles/<name>/."""
p = hermes_home / "profiles" / name
p.mkdir(parents=True)
if config:
# SOUL.md is what the reconciler keys on — it's always seeded by
# `hermes profile create`. See container_boot._render_run_script.
(p / "SOUL.md").write_text("# fake profile\n")
if state is not None:
(p / "gateway_state.json").write_text(json.dumps({
"gateway_state": state, "timestamp": 1234567890,
}))
if with_pid:
(p / "gateway.pid").write_text(json.dumps(
{"pid": 99999, "host": "old-container"},
))
(p / "processes.json").write_text("[]")
return p
def _seed_default_root(
hermes_home: Path,
*,
state: str | None = None,
with_pid: bool = False,
) -> None:
"""Populate gateway_state.json / stale runtime files at the
HERMES_HOME root (the implicit default profile)."""
if state is not None:
(hermes_home / "gateway_state.json").write_text(json.dumps({
"gateway_state": state, "timestamp": 1234567890,
}))
if with_pid:
(hermes_home / "gateway.pid").write_text(json.dumps(
{"pid": 99999, "host": "old-container"},
))
(hermes_home / "processes.json").write_text("[]")
def _named_actions(actions: list[ReconcileAction]) -> list[ReconcileAction]:
"""Drop the always-present default-profile action so tests that
only care about named profiles can assert against a clean list."""
return [a for a in actions if a.profile != "default"]
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_running_profile_is_registered_and_autostarted(tmp_path: Path) -> None:
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(tmp_path, "coder", state="running")
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
assert _named_actions(actions) == [ReconcileAction(
profile="coder", prior_state="running", action="started",
)]
svc = scandir / "gateway-coder"
assert (svc / "run").exists()
assert (svc / "run").stat().st_mode & 0o111 # executable
assert (svc / "type").read_text().strip() == "longrun"
# Auto-start means no down-marker.
assert not (svc / "down").exists()
def test_stopped_profile_is_registered_but_not_started(tmp_path: Path) -> None:
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(tmp_path, "writer", state="stopped")
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
assert _named_actions(actions) == [ReconcileAction(
profile="writer", prior_state="stopped", action="registered",
)]
# down marker tells s6-svscan to NOT start the service.
assert (scandir / "gateway-writer" / "down").exists()
def test_startup_failed_does_not_autostart(tmp_path: Path) -> None:
"""Avoid crash-loop on restart when the gateway was failing to boot."""
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(tmp_path, "broken", state="startup_failed")
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
named = _named_actions(actions)
assert named[0].action == "registered"
assert (scandir / "gateway-broken" / "down").exists()
def test_starting_state_does_not_autostart(tmp_path: Path) -> None:
"""`starting` means the gateway died mid-boot last time; treat as
failed, not as a candidate for auto-restart."""
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(tmp_path, "unlucky", state="starting")
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
named = _named_actions(actions)
assert named[0].action == "registered"
def test_stale_runtime_files_are_removed(tmp_path: Path) -> None:
scandir = tmp_path / "run-service"; scandir.mkdir()
profile = _make_profile(tmp_path, "coder", state="running", with_pid=True)
assert (profile / "gateway.pid").exists()
assert (profile / "processes.json").exists()
reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
assert not (profile / "gateway.pid").exists()
assert not (profile / "processes.json").exists()
def test_profile_without_state_file_is_registered_but_not_started(
tmp_path: Path,
) -> None:
"""A freshly-created profile that's never been started: register
its slot but don't auto-start."""
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(tmp_path, "fresh", state=None)
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
assert _named_actions(actions) == [ReconcileAction(
profile="fresh", prior_state=None, action="registered",
)]
assert (scandir / "gateway-fresh" / "down").exists()
def test_directory_without_marker_file_is_skipped(tmp_path: Path) -> None:
"""A stray dir under profiles/ that isn't actually a profile (no
SOUL.md the marker the reconciler keys on) should be skipped."""
scandir = tmp_path / "run-service"; scandir.mkdir()
# Create a profile dir but without SOUL.md
(tmp_path / "profiles" / "stray").mkdir(parents=True)
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
assert _named_actions(actions) == []
assert not (scandir / "gateway-stray").exists()
def test_corrupt_state_file_treated_as_no_prior_state(tmp_path: Path) -> None:
"""If gateway_state.json is malformed JSON, don't blow up the whole
reconciliation register the slot in the down state."""
scandir = tmp_path / "run-service"; scandir.mkdir()
profile = _make_profile(tmp_path, "junk", state="running")
(profile / "gateway_state.json").write_text("{ not valid json")
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
named = _named_actions(actions)
assert named[0].action == "registered" # not "started"
assert (scandir / "gateway-junk" / "down").exists()
def test_reconcile_log_is_written(tmp_path: Path) -> None:
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(tmp_path, "a", state="running")
_make_profile(tmp_path, "b", state="stopped")
reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
log = (tmp_path / "logs" / "container-boot.log").read_text()
assert "profile=a" in log
assert "action=started" in log
assert "profile=b" in log
assert "action=registered" in log
def test_reconcile_log_rotates_when_size_exceeded(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When container-boot.log exceeds _LOG_ROTATE_BYTES, the existing
file is rotated to .1 before the new entries are appended."""
from hermes_cli import container_boot
# Tighten the threshold so we don't have to write 256 KiB.
monkeypatch.setattr(container_boot, "_LOG_ROTATE_BYTES", 200)
log_path = tmp_path / "logs" / "container-boot.log"
log_path.parent.mkdir()
log_path.write_text("X" * 300) # already over the threshold
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(tmp_path, "coder", state="running")
reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
rotated = tmp_path / "logs" / "container-boot.log.1"
assert rotated.exists(), "expected previous log to be rotated to .1"
assert rotated.read_text().startswith("X" * 300)
# The new entries land in a fresh container-boot.log (no leftover Xs).
new_contents = log_path.read_text()
assert "X" not in new_contents
assert "profile=coder" in new_contents
def test_reconcile_log_does_not_rotate_below_threshold(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A small existing log is appended to in place; no .1 is created."""
from hermes_cli import container_boot
monkeypatch.setattr(container_boot, "_LOG_ROTATE_BYTES", 10_000_000)
log_path = tmp_path / "logs" / "container-boot.log"
log_path.parent.mkdir()
log_path.write_text("previous entry\n")
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(tmp_path, "coder", state="running")
reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
assert not (tmp_path / "logs" / "container-boot.log.1").exists()
contents = log_path.read_text()
assert contents.startswith("previous entry\n")
assert "profile=coder" in contents
def test_reconcile_log_rotation_overwrites_existing_dot1(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Rotating again replaces the prior .1 — we keep at most one
rotated file (soft cap of ~2 × threshold)."""
from hermes_cli import container_boot
monkeypatch.setattr(container_boot, "_LOG_ROTATE_BYTES", 200)
log_dir = tmp_path / "logs"; log_dir.mkdir()
(log_dir / "container-boot.log.1").write_text("OLD ROTATION")
(log_dir / "container-boot.log").write_text("Y" * 300)
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(tmp_path, "coder", state="running")
reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
# .1 now contains the previous .log (Ys), not OLD ROTATION.
rotated = (log_dir / "container-boot.log.1").read_text()
assert "OLD ROTATION" not in rotated
assert rotated.startswith("Y" * 300)
def test_dry_run_makes_no_filesystem_changes(tmp_path: Path) -> None:
scandir = tmp_path / "run-service"; scandir.mkdir()
profile = _make_profile(tmp_path, "coder", state="running", with_pid=True)
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=True,
)
# The action list is still produced...
assert _named_actions(actions) == [ReconcileAction(
profile="coder", prior_state="running", action="started",
)]
# ...but nothing on disk was touched.
assert (profile / "gateway.pid").exists() # not removed under dry_run
assert not (scandir / "gateway-coder").exists()
assert not (tmp_path / "logs" / "container-boot.log").exists()
def test_missing_profiles_root_still_registers_default_slot(
tmp_path: Path,
) -> None:
"""When $HERMES_HOME/profiles doesn't exist (fresh install), the
reconciliation should still register a gateway-default slot for
the root profile and return without raising. Previously this
returned an empty list; the default slot is now always present
so `hermes gateway start` (no -p) has somewhere to land."""
scandir = tmp_path / "run-service"; scandir.mkdir()
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
assert actions == [ReconcileAction(
profile="default", prior_state=None, action="registered",
)]
assert (scandir / "gateway-default").is_dir()
assert (scandir / "gateway-default" / "down").exists()
def test_invalid_profile_name_in_directory_raises(tmp_path: Path) -> None:
"""A profile dir whose name doesn't match validate_profile_name's
rules (uppercase, etc.) must surface as a hard error rather than
silently produce an invalid s6 service dir."""
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(tmp_path, "BadName", state="running")
with pytest.raises(ValueError):
reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
def test_register_service_publishes_atomically(tmp_path: Path) -> None:
"""The reconciler should build the new service dir in a sibling
tmp directory and rename it into place never leaving a half-
populated slot visible to a concurrent s6-svscan rescan.
We verify the invariant indirectly: after a clean reconcile, the
target directory exists with all required files, and no sibling
.tmp leftovers remain. (Atomic publication is the only way to
achieve both with mkdir + write.)
"""
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(tmp_path, "coder", state="running")
reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
# No leftover tmp dir.
leftover = list(scandir.glob("*.tmp"))
assert leftover == [], f"leftover tmp directories: {leftover}"
# Target is fully populated.
svc = scandir / "gateway-coder"
assert (svc / "type").exists()
assert (svc / "run").exists()
assert (svc / "log" / "run").exists()
def test_register_service_overwrites_existing_slot(tmp_path: Path) -> None:
"""A second reconciliation pass cleanly replaces an existing
slot (the tmp+rename publication overwrites the previous one)."""
scandir = tmp_path / "run-service"; scandir.mkdir()
profile = _make_profile(tmp_path, "coder", state="running")
# First pass.
reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
first_run = (scandir / "gateway-coder" / "run").read_text()
# Mutate the profile state so the run-script changes (extra_env
# rendering would differ if we wired profile config through, but
# for now just exercise the overwrite path).
(profile / "gateway_state.json").write_text(
'{"gateway_state": "stopped"}',
)
reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
# Slot still exists, no .tmp remnants.
assert (scandir / "gateway-coder" / "run").read_text() == first_run
assert list(scandir.glob("*.tmp")) == []
# Down marker now present (state went from running → stopped).
assert (scandir / "gateway-coder" / "down").exists()
def test_register_service_cleans_up_stale_tmp_dir(tmp_path: Path) -> None:
"""If a previous interrupted run left a .tmp sibling directory,
a fresh reconcile must clean it up rather than failing on mkdir."""
scandir = tmp_path / "run-service"; scandir.mkdir()
# Simulate a leftover from an interrupted run.
stale_tmp = scandir / "gateway-coder.tmp"
stale_tmp.mkdir()
(stale_tmp / "stale-file").write_text("garbage")
_make_profile(tmp_path, "coder", state="running")
reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
assert not stale_tmp.exists()
assert (scandir / "gateway-coder" / "run").exists()
# ---------------------------------------------------------------------------
# Default-profile slot — always registered (PR #30136 review item I1)
# ---------------------------------------------------------------------------
def test_default_slot_always_registered_on_empty_home(tmp_path: Path) -> None:
"""Bare HERMES_HOME with nothing under it still produces a
gateway-default slot (down state)."""
scandir = tmp_path / "run-service"; scandir.mkdir()
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
assert actions == [ReconcileAction(
profile="default", prior_state=None, action="registered",
)]
svc = scandir / "gateway-default"
assert svc.is_dir()
assert (svc / "run").exists()
assert (svc / "down").exists()
def test_default_slot_run_script_omits_profile_flag(tmp_path: Path) -> None:
"""The default slot's run script must NOT pass `-p default` —
that would resolve to $HERMES_HOME/profiles/default/ instead of
the root profile. It must call `hermes gateway run` directly."""
scandir = tmp_path / "run-service"; scandir.mkdir()
reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
run = (scandir / "gateway-default" / "run").read_text()
assert "hermes gateway run" in run
assert "-p default" not in run
assert "-p 'default'" not in run
def test_default_slot_autostarts_when_root_state_running(tmp_path: Path) -> None:
"""gateway_state.json at the HERMES_HOME root with state=running
means the default slot auto-starts on container boot."""
scandir = tmp_path / "run-service"; scandir.mkdir()
_seed_default_root(tmp_path, state="running")
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
default_action = next(a for a in actions if a.profile == "default")
assert default_action.prior_state == "running"
assert default_action.action == "started"
assert not (scandir / "gateway-default" / "down").exists()
def test_default_slot_does_not_autostart_when_root_state_stopped(
tmp_path: Path,
) -> None:
scandir = tmp_path / "run-service"; scandir.mkdir()
_seed_default_root(tmp_path, state="stopped")
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
default_action = next(a for a in actions if a.profile == "default")
assert default_action.action == "registered"
assert (scandir / "gateway-default" / "down").exists()
def test_default_slot_does_not_autostart_when_root_state_startup_failed(
tmp_path: Path,
) -> None:
"""Crash-loop guard applies to the default slot too."""
scandir = tmp_path / "run-service"; scandir.mkdir()
_seed_default_root(tmp_path, state="startup_failed")
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
default_action = next(a for a in actions if a.profile == "default")
assert default_action.action == "registered"
def test_default_slot_cleans_up_stale_runtime_files_at_root(
tmp_path: Path,
) -> None:
"""gateway.pid and processes.json at the HERMES_HOME root (left
over from the previous container's default gateway) must be
swept the same way as for named profiles."""
scandir = tmp_path / "run-service"; scandir.mkdir()
_seed_default_root(tmp_path, state="running", with_pid=True)
assert (tmp_path / "gateway.pid").exists()
reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
assert not (tmp_path / "gateway.pid").exists()
assert not (tmp_path / "processes.json").exists()
def test_default_slot_appears_before_named_profiles(tmp_path: Path) -> None:
"""The action list is ordered: default first, then named profiles
in directory order. Operators and the boot-log reader rely on
this ordering being stable."""
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(tmp_path, "z-last-alphabetically", state="stopped")
_make_profile(tmp_path, "a-first-alphabetically", state="stopped")
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
assert [a.profile for a in actions] == [
"default",
"a-first-alphabetically",
"z-last-alphabetically",
]
def test_profiles_default_subdir_is_skipped_with_warning(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A user-created profiles/default/ collides with the reserved
root-profile slot the named entry is skipped (with a warning)
so we don't double-register gateway-default."""
import logging
caplog.set_level(logging.WARNING)
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(tmp_path, "default", state="running")
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
# Only the root-profile default slot appears — not the colliding
# named profile.
default_actions = [a for a in actions if a.profile == "default"]
assert len(default_actions) == 1
# And the warning surfaces so operators know the named profile
# was ignored.
assert any(
"profiles/default/" in record.message for record in caplog.records
)
+201
View File
@@ -0,0 +1,201 @@
"""Tests for hermes_cli.copilot_auth — Copilot token validation and resolution."""
import pytest
from unittest.mock import patch
class TestTokenValidation:
"""Token type validation."""
def test_classic_pat_rejected(self):
from hermes_cli.copilot_auth import validate_copilot_token
valid, msg = validate_copilot_token("ghp_abcdefghijklmnop1234")
assert valid is False
assert "Classic Personal Access Tokens" in msg
assert "ghp_" in msg
def test_oauth_token_accepted(self):
from hermes_cli.copilot_auth import validate_copilot_token
valid, msg = validate_copilot_token("gho_abcdefghijklmnop1234")
assert valid is True
def test_fine_grained_pat_accepted(self):
from hermes_cli.copilot_auth import validate_copilot_token
valid, msg = validate_copilot_token("github_pat_abcdefghijklmnop1234")
assert valid is True
def test_github_app_token_accepted(self):
from hermes_cli.copilot_auth import validate_copilot_token
valid, msg = validate_copilot_token("ghu_abcdefghijklmnop1234")
assert valid is True
def test_empty_token_rejected(self):
from hermes_cli.copilot_auth import validate_copilot_token
valid, msg = validate_copilot_token("")
assert valid is False
class TestResolveToken:
"""Token resolution with env var priority."""
def test_copilot_github_token_first_priority(self, monkeypatch):
from hermes_cli.copilot_auth import resolve_copilot_token
monkeypatch.setenv("COPILOT_GITHUB_TOKEN", "gho_copilot_first")
monkeypatch.setenv("GH_TOKEN", "gho_gh_second")
monkeypatch.setenv("GITHUB_TOKEN", "gho_github_third")
token, source = resolve_copilot_token()
assert token == "gho_copilot_first"
assert source == "COPILOT_GITHUB_TOKEN"
def test_gh_token_second_priority(self, monkeypatch):
from hermes_cli.copilot_auth import resolve_copilot_token
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
monkeypatch.setenv("GH_TOKEN", "gho_gh_second")
monkeypatch.setenv("GITHUB_TOKEN", "gho_github_third")
token, source = resolve_copilot_token()
assert token == "gho_gh_second"
assert source == "GH_TOKEN"
def test_github_token_third_priority(self, monkeypatch):
from hermes_cli.copilot_auth import resolve_copilot_token
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
monkeypatch.delenv("GH_TOKEN", raising=False)
monkeypatch.setenv("GITHUB_TOKEN", "gho_github_third")
token, source = resolve_copilot_token()
assert token == "gho_github_third"
assert source == "GITHUB_TOKEN"
def test_classic_pat_in_env_skipped(self, monkeypatch):
"""Classic PATs in env vars should be skipped, not returned."""
from hermes_cli.copilot_auth import resolve_copilot_token
monkeypatch.setenv("COPILOT_GITHUB_TOKEN", "ghp_classic_pat_nope")
monkeypatch.delenv("GH_TOKEN", raising=False)
monkeypatch.setenv("GITHUB_TOKEN", "gho_valid_oauth")
token, source = resolve_copilot_token()
# Should skip the ghp_ token and find the gho_ one
assert token == "gho_valid_oauth"
assert source == "GITHUB_TOKEN"
def test_gh_cli_fallback(self, monkeypatch):
from hermes_cli.copilot_auth import resolve_copilot_token
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
monkeypatch.delenv("GH_TOKEN", raising=False)
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
with patch("hermes_cli.copilot_auth._try_gh_cli_token", return_value="gho_from_cli"):
token, source = resolve_copilot_token()
assert token == "gho_from_cli"
assert source == "gh auth token"
def test_gh_cli_classic_pat_raises(self, monkeypatch):
from hermes_cli.copilot_auth import resolve_copilot_token
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
monkeypatch.delenv("GH_TOKEN", raising=False)
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
with patch("hermes_cli.copilot_auth._try_gh_cli_token", return_value="ghp_classic"):
with pytest.raises(ValueError, match="classic PAT"):
resolve_copilot_token()
def test_no_token_returns_empty(self, monkeypatch):
from hermes_cli.copilot_auth import resolve_copilot_token
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
monkeypatch.delenv("GH_TOKEN", raising=False)
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
with patch("hermes_cli.copilot_auth._try_gh_cli_token", return_value=None):
token, source = resolve_copilot_token()
assert token == ""
assert source == ""
class TestRequestHeaders:
"""Copilot API header generation."""
def test_default_headers_include_openai_intent(self):
from hermes_cli.copilot_auth import copilot_request_headers
headers = copilot_request_headers()
assert headers["Openai-Intent"] == "conversation-edits"
assert headers["User-Agent"] == "HermesAgent/1.0"
assert "Editor-Version" in headers
def test_agent_turn_sets_initiator(self):
from hermes_cli.copilot_auth import copilot_request_headers
headers = copilot_request_headers(is_agent_turn=True)
assert headers["x-initiator"] == "agent"
def test_user_turn_sets_initiator(self):
from hermes_cli.copilot_auth import copilot_request_headers
headers = copilot_request_headers(is_agent_turn=False)
assert headers["x-initiator"] == "user"
def test_vision_header(self):
from hermes_cli.copilot_auth import copilot_request_headers
headers = copilot_request_headers(is_vision=True)
assert headers["Copilot-Vision-Request"] == "true"
def test_no_vision_header_by_default(self):
from hermes_cli.copilot_auth import copilot_request_headers
headers = copilot_request_headers()
assert "Copilot-Vision-Request" not in headers
class TestCopilotDefaultHeaders:
"""The models.py copilot_default_headers uses copilot_auth."""
def test_includes_openai_intent(self):
from hermes_cli.models import copilot_default_headers
headers = copilot_default_headers()
assert "Openai-Intent" in headers
assert headers["Openai-Intent"] == "conversation-edits"
def test_includes_x_initiator(self):
from hermes_cli.models import copilot_default_headers
headers = copilot_default_headers()
assert "x-initiator" in headers
class TestApiModeSelection:
"""API mode selection matching opencode's shouldUseCopilotResponsesApi."""
def test_gpt5_uses_responses(self):
from hermes_cli.models import _should_use_copilot_responses_api
assert _should_use_copilot_responses_api("gpt-5.4") is True
assert _should_use_copilot_responses_api("gpt-5.4-mini") is True
assert _should_use_copilot_responses_api("gpt-5.3-codex") is True
assert _should_use_copilot_responses_api("gpt-5.2-codex") is True
assert _should_use_copilot_responses_api("gpt-5.2") is True
assert _should_use_copilot_responses_api("gpt-5.1-codex-max") is True
def test_gpt5_mini_excluded(self):
from hermes_cli.models import _should_use_copilot_responses_api
assert _should_use_copilot_responses_api("gpt-5-mini") is False
def test_gpt4_uses_chat(self):
from hermes_cli.models import _should_use_copilot_responses_api
assert _should_use_copilot_responses_api("gpt-4.1") is False
assert _should_use_copilot_responses_api("gpt-4o") is False
assert _should_use_copilot_responses_api("gpt-4o-mini") is False
def test_non_gpt_uses_chat(self):
from hermes_cli.models import _should_use_copilot_responses_api
assert _should_use_copilot_responses_api("claude-sonnet-4.6") is False
assert _should_use_copilot_responses_api("claude-opus-4.6") is False
assert _should_use_copilot_responses_api("gemini-2.5-pro") is False
assert _should_use_copilot_responses_api("grok-code-fast-1") is False
class TestEnvVarOrder:
"""PROVIDER_REGISTRY has correct env var order."""
def test_copilot_env_vars_include_copilot_github_token(self):
from hermes_cli.auth import PROVIDER_REGISTRY
copilot = PROVIDER_REGISTRY["copilot"]
assert "COPILOT_GITHUB_TOKEN" in copilot.api_key_env_vars
# COPILOT_GITHUB_TOKEN should be first
assert copilot.api_key_env_vars[0] == "COPILOT_GITHUB_TOKEN"
def test_copilot_env_vars_order_matches_docs(self):
from hermes_cli.auth import PROVIDER_REGISTRY
copilot = PROVIDER_REGISTRY["copilot"]
assert copilot.api_key_env_vars == (
"COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"
)
@@ -0,0 +1,157 @@
"""Catalog-API-key fallback for the Copilot ``/model`` picker.
Regression for #16708: when the user's only Copilot credential is a
``gho_*`` token (typically obtained via device-code login) stored in
``auth.json`` under ``credential_pool.copilot[]`` placed there by
``hermes auth add copilot`` or by ``_seed_from_env`` when the env var
is set in ``~/.hermes/.env`` the picker was silently dropping back to
a stale hardcoded list because ``_resolve_copilot_catalog_api_key``
only consulted env vars / ``gh auth token`` and never read the
credential pool.
"""
from unittest.mock import patch
from hermes_cli.models import _resolve_copilot_catalog_api_key
class TestCopilotCatalogApiKeyResolution:
def test_env_var_token_wins_over_pool(self):
"""Env-resolved token still short-circuits the pool fallback."""
with patch(
"hermes_cli.auth.resolve_api_key_provider_credentials",
return_value={"api_key": "env-token"},
), patch(
"hermes_cli.auth.read_credential_pool",
) as mock_pool:
assert _resolve_copilot_catalog_api_key() == "env-token"
mock_pool.assert_not_called()
def test_falls_back_to_pool_oauth_token(self):
"""Empty env → walk credential_pool.copilot[] for an OAuth access_token."""
with patch(
"hermes_cli.auth.resolve_api_key_provider_credentials",
return_value={"api_key": ""},
), patch(
"hermes_cli.auth.read_credential_pool",
return_value=[{"access_token": "gho_abc123"}],
), patch(
"hermes_cli.copilot_auth.exchange_copilot_token",
return_value=("tid_exchanged_xyz", 1234567890.0),
):
assert _resolve_copilot_catalog_api_key() == "tid_exchanged_xyz"
def test_falls_back_when_env_resolution_raises(self):
"""Env path raising an exception still falls through to the pool."""
with patch(
"hermes_cli.auth.resolve_api_key_provider_credentials",
side_effect=RuntimeError("auth.json corrupt"),
), patch(
"hermes_cli.auth.read_credential_pool",
return_value=[{"access_token": "gho_xyz"}],
), patch(
"hermes_cli.copilot_auth.exchange_copilot_token",
return_value=("tid_exchanged_xyz", 1234567890.0),
):
assert _resolve_copilot_catalog_api_key() == "tid_exchanged_xyz"
def test_skips_classic_pat_in_pool(self):
"""Classic PATs (``ghp_…``) are unsupported by the Copilot API — skip them."""
with patch(
"hermes_cli.auth.resolve_api_key_provider_credentials",
return_value={"api_key": ""},
), patch(
"hermes_cli.auth.read_credential_pool",
return_value=[{"access_token": "ghp_classic_pat"}],
), patch(
"hermes_cli.copilot_auth.exchange_copilot_token",
) as mock_exchange:
assert _resolve_copilot_catalog_api_key() == ""
mock_exchange.assert_not_called()
def test_skips_invalid_pool_entries_until_first_exchangeable(self):
"""Non-dict entries and entries without an ``access_token`` are skipped."""
with patch(
"hermes_cli.auth.resolve_api_key_provider_credentials",
return_value={"api_key": ""},
), patch(
"hermes_cli.auth.read_credential_pool",
return_value=[
"not-a-dict",
{"label": "no-token-here"},
{"access_token": ""},
{"access_token": "gho_first_real_token"},
{"access_token": "gho_should_not_reach"},
],
), patch(
"hermes_cli.copilot_auth.exchange_copilot_token",
return_value=("tid_from_first", 1234567890.0),
) as mock_exchange:
assert _resolve_copilot_catalog_api_key() == "tid_from_first"
mock_exchange.assert_called_once_with("gho_first_real_token")
def test_skips_pool_entry_that_fails_to_exchange(self):
"""If the first entry won't exchange, try the next — an unsupported pool[0]
must not wedge a later valid entry (Copilot review #16868 finding)."""
attempts: list[str] = []
def fake_exchange(raw_token: str):
attempts.append(raw_token)
if raw_token == "gho_unsupported_account":
raise ValueError("Copilot token exchange failed: HTTP 401")
return ("tid_from_second", 1234567890.0)
with patch(
"hermes_cli.auth.resolve_api_key_provider_credentials",
return_value={"api_key": ""},
), patch(
"hermes_cli.auth.read_credential_pool",
return_value=[
{"access_token": "gho_unsupported_account"},
{"access_token": "gho_valid_token"},
],
), patch(
"hermes_cli.copilot_auth.exchange_copilot_token",
side_effect=fake_exchange,
):
assert _resolve_copilot_catalog_api_key() == "tid_from_second"
assert attempts == ["gho_unsupported_account", "gho_valid_token"]
def test_all_pool_entries_fail_exchange_returns_empty(self):
"""All exchanges fail → return "" so the caller falls back to curated."""
with patch(
"hermes_cli.auth.resolve_api_key_provider_credentials",
return_value={"api_key": ""},
), patch(
"hermes_cli.auth.read_credential_pool",
return_value=[
{"access_token": "gho_expired_a"},
{"access_token": "gho_expired_b"},
],
), patch(
"hermes_cli.copilot_auth.exchange_copilot_token",
side_effect=ValueError("Copilot token exchange failed"),
):
assert _resolve_copilot_catalog_api_key() == ""
def test_returns_empty_string_when_no_credentials_anywhere(self):
"""No env, no pool → empty string (caller falls back to curated list)."""
with patch(
"hermes_cli.auth.resolve_api_key_provider_credentials",
return_value={"api_key": ""},
), patch(
"hermes_cli.auth.read_credential_pool",
return_value=[],
):
assert _resolve_copilot_catalog_api_key() == ""
def test_pool_failure_returns_empty_string(self):
"""If the pool read itself raises, swallow and return ""."""
with patch(
"hermes_cli.auth.resolve_api_key_provider_credentials",
return_value={"api_key": ""},
), patch(
"hermes_cli.auth.read_credential_pool",
side_effect=RuntimeError("auth.json locked"),
):
assert _resolve_copilot_catalog_api_key() == ""
+134
View File
@@ -0,0 +1,134 @@
"""Tests for Copilot live /models context-window resolution."""
from __future__ import annotations
import time
from unittest.mock import patch
import pytest
from hermes_cli.models import get_copilot_model_context
# Sample catalog items mimicking the Copilot /models API response
_SAMPLE_CATALOG = [
{
"id": "claude-opus-4.6-1m",
"capabilities": {
"type": "chat",
"limits": {"max_prompt_tokens": 1000000, "max_output_tokens": 64000},
},
},
{
"id": "gpt-4.1",
"capabilities": {
"type": "chat",
"limits": {"max_prompt_tokens": 128000, "max_output_tokens": 32768},
},
},
{
"id": "claude-sonnet-4",
"capabilities": {
"type": "chat",
"limits": {"max_prompt_tokens": 200000, "max_output_tokens": 64000},
},
},
{
"id": "model-without-limits",
"capabilities": {"type": "chat"},
},
{
"id": "model-zero-limit",
"capabilities": {
"type": "chat",
"limits": {"max_prompt_tokens": 0},
},
},
]
@pytest.fixture(autouse=True)
def _clear_cache():
"""Reset module-level cache before each test."""
import hermes_cli.models as mod
mod._copilot_context_cache = {}
mod._copilot_context_cache_time = 0.0
yield
mod._copilot_context_cache = {}
mod._copilot_context_cache_time = 0.0
class TestGetCopilotModelContext:
"""Tests for get_copilot_model_context()."""
@patch("hermes_cli.models.fetch_github_model_catalog", return_value=_SAMPLE_CATALOG)
def test_returns_max_prompt_tokens(self, mock_fetch):
assert get_copilot_model_context("claude-opus-4.6-1m") == 1_000_000
assert get_copilot_model_context("gpt-4.1") == 128_000
@patch("hermes_cli.models.fetch_github_model_catalog", return_value=_SAMPLE_CATALOG)
def test_returns_none_for_unknown_model(self, mock_fetch):
assert get_copilot_model_context("nonexistent-model") is None
@patch("hermes_cli.models.fetch_github_model_catalog", return_value=_SAMPLE_CATALOG)
def test_skips_models_without_limits(self, mock_fetch):
assert get_copilot_model_context("model-without-limits") is None
@patch("hermes_cli.models.fetch_github_model_catalog", return_value=_SAMPLE_CATALOG)
def test_skips_zero_limit(self, mock_fetch):
assert get_copilot_model_context("model-zero-limit") is None
@patch("hermes_cli.models.fetch_github_model_catalog", return_value=_SAMPLE_CATALOG)
def test_caches_results(self, mock_fetch):
get_copilot_model_context("gpt-4.1")
get_copilot_model_context("claude-sonnet-4")
# Only one API call despite two lookups
assert mock_fetch.call_count == 1
@patch("hermes_cli.models.fetch_github_model_catalog", return_value=_SAMPLE_CATALOG)
def test_cache_expires(self, mock_fetch):
import hermes_cli.models as mod
get_copilot_model_context("gpt-4.1")
assert mock_fetch.call_count == 1
# Expire the cache
mod._copilot_context_cache_time = time.time() - 7200
get_copilot_model_context("gpt-4.1")
assert mock_fetch.call_count == 2
@patch("hermes_cli.models.fetch_github_model_catalog", return_value=None)
def test_returns_none_when_catalog_unavailable(self, mock_fetch):
assert get_copilot_model_context("gpt-4.1") is None
@patch("hermes_cli.models.fetch_github_model_catalog", return_value=[])
def test_returns_none_for_empty_catalog(self, mock_fetch):
assert get_copilot_model_context("gpt-4.1") is None
class TestModelMetadataCopilotIntegration:
"""Test that get_model_context_length() uses Copilot live API for copilot provider."""
@patch("hermes_cli.models.fetch_github_model_catalog", return_value=_SAMPLE_CATALOG)
def test_copilot_provider_uses_live_api(self, mock_fetch):
from agent.model_metadata import get_model_context_length
ctx = get_model_context_length("claude-opus-4.6-1m", provider="copilot")
assert ctx == 1_000_000
@patch("hermes_cli.models.fetch_github_model_catalog", return_value=_SAMPLE_CATALOG)
def test_copilot_acp_provider_uses_live_api(self, mock_fetch):
from agent.model_metadata import get_model_context_length
ctx = get_model_context_length("claude-sonnet-4", provider="copilot-acp")
assert ctx == 200_000
@patch("hermes_cli.models.fetch_github_model_catalog", return_value=None)
def test_falls_through_when_catalog_unavailable(self, mock_fetch):
from agent.model_metadata import get_model_context_length
# Should not raise, should fall through to models.dev or defaults
ctx = get_model_context_length("gpt-4.1", provider="copilot")
assert isinstance(ctx, int)
assert ctx > 0
@@ -0,0 +1,22 @@
"""Tests for GitHub Copilot entries shown in the /model picker."""
import os
from unittest.mock import patch
from hermes_cli.model_switch import list_authenticated_providers
@patch.dict(os.environ, {"GH_TOKEN": "test-key"}, clear=False)
def test_copilot_picker_uses_live_catalog_when_available():
live_models = ["gpt-5.4", "claude-sonnet-4.6", "gemini-3.1-pro-preview"]
with patch("agent.models_dev.fetch_models_dev", return_value={}), \
patch("hermes_cli.models._resolve_copilot_catalog_api_key", return_value="gh-token"), \
patch("hermes_cli.models._fetch_github_models", return_value=live_models):
providers = list_authenticated_providers(current_provider="openrouter", max_models=50)
copilot = next((p for p in providers if p["slug"] == "copilot"), None)
assert copilot is not None
assert copilot["models"] == live_models
assert copilot["total_models"] == len(live_models)
@@ -0,0 +1,159 @@
"""Tests for Copilot token exchange (raw GitHub token → Copilot API token)."""
from __future__ import annotations
import json
import time
from unittest.mock import MagicMock, patch
import pytest
@pytest.fixture(autouse=True)
def _clear_jwt_cache():
"""Reset the module-level JWT cache before each test."""
import hermes_cli.copilot_auth as mod
mod._jwt_cache.clear()
yield
mod._jwt_cache.clear()
class TestExchangeCopilotToken:
"""Tests for exchange_copilot_token()."""
def _mock_urlopen(self, token="tid=abc;exp=123;sku=copilot_individual", expires_at=None):
"""Create a mock urlopen context manager returning a token response."""
if expires_at is None:
expires_at = time.time() + 1800
resp_data = json.dumps({"token": token, "expires_at": expires_at}).encode()
mock_resp = MagicMock()
mock_resp.read.return_value = resp_data
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
mock_resp.__exit__ = MagicMock(return_value=False)
return mock_resp
@patch("urllib.request.urlopen")
def test_exchanges_token_successfully(self, mock_urlopen):
from hermes_cli.copilot_auth import exchange_copilot_token
mock_urlopen.return_value = self._mock_urlopen(token="tid=abc;exp=999")
api_token, expires_at = exchange_copilot_token("gho_test123")
assert api_token == "tid=abc;exp=999"
assert isinstance(expires_at, float)
# Verify request was made with correct headers
call_args = mock_urlopen.call_args
req = call_args[0][0]
assert req.get_header("Authorization") == "token gho_test123"
assert "GitHubCopilotChat" in req.get_header("User-agent")
@patch("urllib.request.urlopen")
def test_caches_result(self, mock_urlopen):
from hermes_cli.copilot_auth import exchange_copilot_token
future = time.time() + 1800
mock_urlopen.return_value = self._mock_urlopen(expires_at=future)
exchange_copilot_token("gho_test123")
exchange_copilot_token("gho_test123")
assert mock_urlopen.call_count == 1
@patch("urllib.request.urlopen")
def test_refreshes_expired_cache(self, mock_urlopen):
from hermes_cli.copilot_auth import exchange_copilot_token, _jwt_cache, _token_fingerprint
# Seed cache with expired entry
fp = _token_fingerprint("gho_test123")
_jwt_cache[fp] = ("old_token", time.time() - 10)
mock_urlopen.return_value = self._mock_urlopen(
token="new_token", expires_at=time.time() + 1800
)
api_token, _ = exchange_copilot_token("gho_test123")
assert api_token == "new_token"
assert mock_urlopen.call_count == 1
@patch("urllib.request.urlopen")
def test_raises_on_empty_token(self, mock_urlopen):
from hermes_cli.copilot_auth import exchange_copilot_token
resp_data = json.dumps({"token": "", "expires_at": 0}).encode()
mock_resp = MagicMock()
mock_resp.read.return_value = resp_data
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
mock_resp.__exit__ = MagicMock(return_value=False)
mock_urlopen.return_value = mock_resp
with pytest.raises(ValueError, match="empty token"):
exchange_copilot_token("gho_test123")
@patch("urllib.request.urlopen", side_effect=Exception("network error"))
def test_raises_on_network_error(self, mock_urlopen):
from hermes_cli.copilot_auth import exchange_copilot_token
with pytest.raises(ValueError, match="network error"):
exchange_copilot_token("gho_test123")
class TestGetCopilotApiToken:
"""Tests for get_copilot_api_token() — the fallback wrapper."""
@patch("hermes_cli.copilot_auth.exchange_copilot_token")
def test_returns_exchanged_token(self, mock_exchange):
from hermes_cli.copilot_auth import get_copilot_api_token
mock_exchange.return_value = ("exchanged_jwt", time.time() + 1800)
assert get_copilot_api_token("gho_raw") == "exchanged_jwt"
@patch("hermes_cli.copilot_auth.exchange_copilot_token", side_effect=ValueError("fail"))
def test_falls_back_to_raw_token(self, mock_exchange):
from hermes_cli.copilot_auth import get_copilot_api_token
assert get_copilot_api_token("gho_raw") == "gho_raw"
def test_empty_token_passthrough(self):
from hermes_cli.copilot_auth import get_copilot_api_token
assert get_copilot_api_token("") == ""
class TestTokenFingerprint:
"""Tests for _token_fingerprint()."""
def test_consistent(self):
from hermes_cli.copilot_auth import _token_fingerprint
fp1 = _token_fingerprint("gho_abc123")
fp2 = _token_fingerprint("gho_abc123")
assert fp1 == fp2
def test_different_tokens_different_fingerprints(self):
from hermes_cli.copilot_auth import _token_fingerprint
fp1 = _token_fingerprint("gho_abc123")
fp2 = _token_fingerprint("gho_xyz789")
assert fp1 != fp2
def test_length(self):
from hermes_cli.copilot_auth import _token_fingerprint
assert len(_token_fingerprint("gho_test")) == 16
class TestCallerIntegration:
"""Test that callers correctly use token exchange."""
@patch("hermes_cli.copilot_auth.resolve_copilot_token", return_value=("gho_raw", "GH_TOKEN"))
@patch("hermes_cli.copilot_auth.get_copilot_api_token", return_value="exchanged_jwt")
def test_auth_resolve_uses_exchange(self, mock_exchange, mock_resolve):
from hermes_cli.auth import _resolve_api_key_provider_secret
# Create a minimal pconfig mock
pconfig = MagicMock()
token, source = _resolve_api_key_provider_secret("copilot", pconfig)
assert token == "exchanged_jwt"
assert source == "GH_TOKEN"
mock_exchange.assert_called_once_with("gho_raw")
+113
View File
@@ -0,0 +1,113 @@
"""Tests for hermes_cli.cron command handling."""
from argparse import Namespace
import pytest
from cron.jobs import create_job, get_job, list_jobs
from hermes_cli.cron import cron_command
@pytest.fixture()
def tmp_cron_dir(tmp_path, monkeypatch):
monkeypatch.setattr("cron.jobs.CRON_DIR", tmp_path / "cron")
monkeypatch.setattr("cron.jobs.JOBS_FILE", tmp_path / "cron" / "jobs.json")
monkeypatch.setattr("cron.jobs.OUTPUT_DIR", tmp_path / "cron" / "output")
return tmp_path
class TestCronCommandLifecycle:
def test_pause_resume_run(self, tmp_cron_dir, capsys):
job = create_job(prompt="Check server status", schedule="every 1h")
cron_command(Namespace(cron_command="pause", job_id=job["id"]))
paused = get_job(job["id"])
assert paused["state"] == "paused"
cron_command(Namespace(cron_command="resume", job_id=job["id"]))
resumed = get_job(job["id"])
assert resumed["state"] == "scheduled"
cron_command(Namespace(cron_command="run", job_id=job["id"]))
triggered = get_job(job["id"])
assert triggered["state"] == "scheduled"
out = capsys.readouterr().out
assert "Paused job" in out
assert "Resumed job" in out
assert "Triggered job" in out
def test_edit_can_replace_and_clear_skills(self, tmp_cron_dir, capsys):
job = create_job(
prompt="Combine skill outputs",
schedule="every 1h",
skill="blogwatcher",
)
cron_command(
Namespace(
cron_command="edit",
job_id=job["id"],
schedule="every 2h",
prompt="Revised prompt",
name="Edited Job",
deliver=None,
repeat=None,
skill=None,
skills=["maps", "blogwatcher"],
profile="default",
clear_skills=False,
)
)
updated = get_job(job["id"])
assert updated["skills"] == ["maps", "blogwatcher"]
assert updated["name"] == "Edited Job"
assert updated["prompt"] == "Revised prompt"
assert updated["schedule_display"] == "every 120m"
assert updated["profile"] == "default"
cron_command(
Namespace(
cron_command="edit",
job_id=job["id"],
schedule=None,
prompt=None,
name=None,
deliver=None,
repeat=None,
skill=None,
skills=None,
profile="",
clear_skills=True,
)
)
cleared = get_job(job["id"])
assert cleared["skills"] == []
assert cleared["skill"] is None
assert cleared["profile"] is None
out = capsys.readouterr().out
assert "Updated job" in out
def test_create_with_multiple_skills(self, tmp_cron_dir, capsys):
cron_command(
Namespace(
cron_command="create",
schedule="every 1h",
prompt="Use both skills",
name="Skill combo",
deliver=None,
repeat=None,
skill=None,
skills=["blogwatcher", "maps"],
profile="default",
)
)
out = capsys.readouterr().out
assert "Created job" in out
jobs = list_jobs()
assert len(jobs) == 1
assert jobs[0]["skills"] == ["blogwatcher", "maps"]
assert jobs[0]["name"] == "Skill combo"
assert jobs[0]["profile"] == "default"
@@ -0,0 +1,265 @@
"""Tests for `hermes curator archive` and `hermes curator prune`.
Covers:
- archive refuses pinned skills with an `unpin` hint
- archive returns 0/1 based on archive_skill() success
- prune filters pinned and already-archived, applies --days threshold
- prune falls back to created_at when last_activity_at is null
- prune --dry-run makes no state changes
- prune --yes skips confirmation
- prune --days validation
"""
from __future__ import annotations
from types import SimpleNamespace
def _ns(**kwargs):
return SimpleNamespace(**kwargs)
# ─── archive ────────────────────────────────────────────────────────────────
def test_archive_refuses_pinned(monkeypatch, capsys):
import hermes_cli.curator as curator_cli
import tools.skill_usage as skill_usage
monkeypatch.setattr(skill_usage, "get_record", lambda name: {"pinned": True})
called = []
monkeypatch.setattr(
skill_usage, "archive_skill",
lambda name: called.append(name) or (True, "should not get here"),
)
rc = curator_cli._cmd_archive(_ns(skill="pinned-skill"))
assert rc == 1
assert called == []
out = capsys.readouterr().out
assert "pinned" in out.lower()
assert "hermes curator unpin" in out
def test_archive_calls_archive_skill(monkeypatch, capsys):
import hermes_cli.curator as curator_cli
import tools.skill_usage as skill_usage
monkeypatch.setattr(skill_usage, "get_record", lambda name: {"pinned": False})
monkeypatch.setattr(
skill_usage, "archive_skill",
lambda name: (True, f"archived to .archive/{name}"),
)
rc = curator_cli._cmd_archive(_ns(skill="my-skill"))
assert rc == 0
assert "archived to .archive/my-skill" in capsys.readouterr().out
def test_archive_reports_failure(monkeypatch, capsys):
import hermes_cli.curator as curator_cli
import tools.skill_usage as skill_usage
monkeypatch.setattr(skill_usage, "get_record", lambda name: {"pinned": False})
monkeypatch.setattr(
skill_usage, "archive_skill",
lambda name: (False, f"skill '{name}' is bundled or hub-installed; never archive"),
)
rc = curator_cli._cmd_archive(_ns(skill="hub-slug"))
assert rc == 1
assert "bundled or hub-installed" in capsys.readouterr().out
# ─── prune ──────────────────────────────────────────────────────────────────
def _mk_record(name, *, idle_days=0, pinned=False, state="active", created_idle_days=None):
import datetime as _dt
now = _dt.datetime.now(_dt.timezone.utc)
last_activity = (now - _dt.timedelta(days=idle_days)).isoformat() if idle_days else None
created_delta = created_idle_days if created_idle_days is not None else idle_days
created = (now - _dt.timedelta(days=created_delta)).isoformat()
return {
"name": name,
"state": state,
"pinned": pinned,
"last_activity_at": last_activity,
"created_at": created,
"activity_count": 0 if idle_days == 0 and last_activity is None else 1,
}
def test_prune_days_validation(monkeypatch, capsys):
import hermes_cli.curator as curator_cli
rc = curator_cli._cmd_prune(_ns(days=0, yes=True, dry_run=False))
assert rc == 2
err = capsys.readouterr().err
assert "--days must be >= 1" in err
def test_prune_nothing_to_do(monkeypatch, capsys):
import hermes_cli.curator as curator_cli
import tools.skill_usage as skill_usage
monkeypatch.setattr(skill_usage, "agent_created_report", lambda: [])
rc = curator_cli._cmd_prune(_ns(days=30, yes=True, dry_run=False))
assert rc == 0
assert "nothing to prune" in capsys.readouterr().out
def test_prune_filters_pinned_and_archived(monkeypatch, capsys):
import hermes_cli.curator as curator_cli
import tools.skill_usage as skill_usage
rows = [
_mk_record("old-pinned", idle_days=200, pinned=True),
_mk_record("old-archived", idle_days=200, state="archived"),
_mk_record("recent", idle_days=10),
_mk_record("old-active", idle_days=200),
]
monkeypatch.setattr(skill_usage, "agent_created_report", lambda: rows)
archived = []
monkeypatch.setattr(
skill_usage, "archive_skill",
lambda name: archived.append(name) or (True, f"archived {name}"),
)
rc = curator_cli._cmd_prune(_ns(days=30, yes=True, dry_run=False))
assert rc == 0
assert archived == ["old-active"]
out = capsys.readouterr().out
assert "old-active" in out
assert "old-pinned" not in out
assert "old-archived" not in out
assert "recent" not in out
assert "archived 1/1" in out
def test_prune_falls_back_to_created_at_when_never_used(monkeypatch, capsys):
"""Never-used skills must be prunable via created_at — otherwise immortal."""
import hermes_cli.curator as curator_cli
import tools.skill_usage as skill_usage
rows = [_mk_record("never-used", idle_days=0, created_idle_days=200)]
# Force last_activity_at to None explicitly
rows[0]["last_activity_at"] = None
monkeypatch.setattr(skill_usage, "agent_created_report", lambda: rows)
archived = []
monkeypatch.setattr(
skill_usage, "archive_skill",
lambda name: archived.append(name) or (True, "ok"),
)
rc = curator_cli._cmd_prune(_ns(days=90, yes=True, dry_run=False))
assert rc == 0
assert archived == ["never-used"]
def test_prune_dry_run_makes_no_changes(monkeypatch, capsys):
import hermes_cli.curator as curator_cli
import tools.skill_usage as skill_usage
rows = [_mk_record("old-skill", idle_days=200)]
monkeypatch.setattr(skill_usage, "agent_created_report", lambda: rows)
archived = []
monkeypatch.setattr(
skill_usage, "archive_skill",
lambda name: archived.append(name) or (True, "ok"),
)
rc = curator_cli._cmd_prune(_ns(days=30, yes=True, dry_run=True))
assert rc == 0
assert archived == []
out = capsys.readouterr().out
assert "old-skill" in out
assert "dry run" in out
def test_prune_prompts_without_yes(monkeypatch, capsys):
import hermes_cli.curator as curator_cli
import tools.skill_usage as skill_usage
rows = [_mk_record("old-skill", idle_days=200)]
monkeypatch.setattr(skill_usage, "agent_created_report", lambda: rows)
archived = []
monkeypatch.setattr(
skill_usage, "archive_skill",
lambda name: archived.append(name) or (True, "ok"),
)
monkeypatch.setattr("builtins.input", lambda _prompt: "n")
rc = curator_cli._cmd_prune(_ns(days=30, yes=False, dry_run=False))
assert rc == 1
assert archived == []
assert "aborted" in capsys.readouterr().out
def test_prune_confirms_with_y(monkeypatch, capsys):
import hermes_cli.curator as curator_cli
import tools.skill_usage as skill_usage
rows = [_mk_record("old-skill", idle_days=200)]
monkeypatch.setattr(skill_usage, "agent_created_report", lambda: rows)
archived = []
monkeypatch.setattr(
skill_usage, "archive_skill",
lambda name: archived.append(name) or (True, "ok"),
)
monkeypatch.setattr("builtins.input", lambda _prompt: "y")
rc = curator_cli._cmd_prune(_ns(days=30, yes=False, dry_run=False))
assert rc == 0
assert archived == ["old-skill"]
def test_prune_reports_partial_failure(monkeypatch, capsys):
import hermes_cli.curator as curator_cli
import tools.skill_usage as skill_usage
rows = [
_mk_record("ok-skill", idle_days=200),
_mk_record("bad-skill", idle_days=200),
]
monkeypatch.setattr(skill_usage, "agent_created_report", lambda: rows)
def fake_archive(name):
if name == "bad-skill":
return False, "disk full"
return True, "ok"
monkeypatch.setattr(skill_usage, "archive_skill", fake_archive)
rc = curator_cli._cmd_prune(_ns(days=30, yes=True, dry_run=False))
assert rc == 1
out = capsys.readouterr().out
assert "archived 1/2" in out
assert "bad-skill: disk full" in out
# ─── argparse wiring ────────────────────────────────────────────────────────
def test_archive_and_prune_registered():
import argparse
import hermes_cli.curator as curator_cli
parser = argparse.ArgumentParser(prog="hermes curator")
curator_cli.register_cli(parser)
args = parser.parse_args(["archive", "my-skill"])
assert args.skill == "my-skill"
assert args.func.__name__ == "_cmd_archive"
args = parser.parse_args(["prune", "--days", "45", "--yes", "--dry-run"])
assert args.days == 45
assert args.yes is True
assert args.dry_run is True
assert args.func.__name__ == "_cmd_prune"
def test_prune_defaults():
import argparse
import hermes_cli.curator as curator_cli
parser = argparse.ArgumentParser(prog="hermes curator")
curator_cli.register_cli(parser)
args = parser.parse_args(["prune"])
assert args.days == 90
assert args.yes is False
assert args.dry_run is False
@@ -0,0 +1,162 @@
"""Tests for `_print_curator_recent_run_notice`.
The notice prints the most recent curator run summary on `hermes update`,
exactly once per run. Show-once is enforced by stamping
`last_run_summary_shown_at` in curator state after printing.
Why this matters: the curator runs in the background (gateway tick + CLI
session start) so users normally never see the rename map. `hermes update`
is the high-attention surface where consolidations should land.
"""
from __future__ import annotations
import importlib
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
@pytest.fixture
def curator_env(tmp_path, monkeypatch, capsys):
home = tmp_path / ".hermes"
home.mkdir()
(home / "skills").mkdir()
(home / "logs").mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
import hermes_constants
importlib.reload(hermes_constants)
from agent import curator
importlib.reload(curator)
from hermes_cli import main as hermes_main
importlib.reload(hermes_main)
yield {
"curator": curator,
"main": hermes_main,
"capsys": capsys,
}
def _set_state(curator_mod, **fields):
state = curator_mod.load_state()
state.update(fields)
curator_mod.save_state(state)
def test_silent_when_no_curator_run_yet(curator_env):
"""First-run notice handles this case; recent-run notice stays silent."""
curator_env["main"]._print_curator_recent_run_notice()
out = curator_env["capsys"].readouterr().out
assert "Skill curator — last run" not in out
def test_silent_when_summary_is_single_line(curator_env):
"""No archives = no rename map = nothing to surface. But still stamps shown."""
now = datetime.now(timezone.utc).isoformat()
_set_state(
curator_env["curator"],
last_run_at=now,
last_run_summary="auto: no changes; llm: no change",
)
curator_env["main"]._print_curator_recent_run_notice()
out = curator_env["capsys"].readouterr().out
assert "Skill curator — last run" not in out
# Should still mark shown so we don't reconsider on every update.
state = curator_env["curator"].load_state()
assert state["last_run_summary_shown_at"] == now
def test_prints_multiline_summary_with_rename_map(curator_env):
"""Multi-line summary (rename map appended) prints with timestamp + footer."""
now = datetime.now(timezone.utc).isoformat()
summary = (
"auto: 1 marked stale; llm: consolidated 2 into 1\n"
"archived 2 skill(s):\n"
" • pdf-extraction → document-tools\n"
" • docx-extraction → document-tools\n"
"full report: hermes curator status"
)
_set_state(
curator_env["curator"],
last_run_at=now,
last_run_summary=summary,
)
curator_env["main"]._print_curator_recent_run_notice()
out = curator_env["capsys"].readouterr().out
assert "Skill curator — last run" in out
assert "pdf-extraction → document-tools" in out
assert "docx-extraction → document-tools" in out
assert "shows once per curator run" in out
def test_show_once_semantics(curator_env):
"""Calling twice prints once; second call is silent until a new run lands."""
now = datetime.now(timezone.utc).isoformat()
summary = (
"auto: no changes; llm: consolidated 1 into 1\n"
"archived 1 skill(s):\n"
" • old → new\n"
"full report: hermes curator status"
)
_set_state(
curator_env["curator"],
last_run_at=now,
last_run_summary=summary,
)
curator_env["main"]._print_curator_recent_run_notice()
first = curator_env["capsys"].readouterr().out
assert "old → new" in first
curator_env["main"]._print_curator_recent_run_notice()
second = curator_env["capsys"].readouterr().out
assert second == "", "second call must be silent (already shown)"
def test_new_run_resets_show_once(curator_env):
"""A newer curator run with rename data prints again, even though one was already shown."""
older = (datetime.now(timezone.utc) - timedelta(hours=8)).isoformat()
_set_state(
curator_env["curator"],
last_run_at=older,
last_run_summary=(
"auto: no changes; llm: consolidated 1 into 1\n"
"archived 1 skill(s):\n"
" • thing-a → umbrella\n"
"full report: hermes curator status"
),
)
curator_env["main"]._print_curator_recent_run_notice()
curator_env["capsys"].readouterr() # drain
# New run lands.
newer = datetime.now(timezone.utc).isoformat()
_set_state(
curator_env["curator"],
last_run_at=newer,
last_run_summary=(
"auto: no changes; llm: consolidated 1 into 1\n"
"archived 1 skill(s):\n"
" • thing-b → umbrella\n"
"full report: hermes curator status"
),
)
curator_env["main"]._print_curator_recent_run_notice()
out = curator_env["capsys"].readouterr().out
assert "thing-b → umbrella" in out
assert "thing-a" not in out # only the newer run shows
def test_format_time_ago_buckets(curator_env):
"""Smoke test the time formatter — drives the `last run Xh ago` line."""
fmt = curator_env["main"]._format_time_ago
now = datetime.now(timezone.utc)
assert fmt((now - timedelta(seconds=10)).isoformat()) == "just now"
assert fmt((now - timedelta(minutes=5)).isoformat()) == "5m ago"
assert fmt((now - timedelta(hours=3)).isoformat()) == "3h ago"
assert fmt((now - timedelta(days=2)).isoformat()) == "2d ago"
assert fmt("not-a-real-iso-string") == "recently"
+87
View File
@@ -0,0 +1,87 @@
"""Tests for `hermes curator run` CLI behavior."""
from __future__ import annotations
from types import SimpleNamespace
def _args(**kwargs):
values = {
"dry_run": False,
"synchronous": False,
"background": False,
}
values.update(kwargs)
return SimpleNamespace(**values)
def test_run_defaults_to_synchronous(monkeypatch, capsys):
import agent.curator as curator_state
import hermes_cli.curator as curator_cli
calls = []
monkeypatch.setattr(curator_state, "is_enabled", lambda: True)
monkeypatch.setattr(
curator_state,
"run_curator_review",
lambda **kwargs: calls.append(kwargs) or {"auto_transitions": {}},
)
assert curator_cli._cmd_run(_args()) == 0
assert calls[0]["synchronous"] is True
assert calls[0]["dry_run"] is False
assert "background" not in capsys.readouterr().out
def test_run_background_opts_into_async(monkeypatch, capsys):
import agent.curator as curator_state
import hermes_cli.curator as curator_cli
calls = []
monkeypatch.setattr(curator_state, "is_enabled", lambda: True)
monkeypatch.setattr(
curator_state,
"run_curator_review",
lambda **kwargs: calls.append(kwargs) or {"auto_transitions": {}},
)
assert curator_cli._cmd_run(_args(background=True)) == 0
assert calls[0]["synchronous"] is False
assert "llm pass running in background" in capsys.readouterr().out
def test_run_sync_wins_over_background(monkeypatch):
import agent.curator as curator_state
import hermes_cli.curator as curator_cli
calls = []
monkeypatch.setattr(curator_state, "is_enabled", lambda: True)
monkeypatch.setattr(
curator_state,
"run_curator_review",
lambda **kwargs: calls.append(kwargs) or {"auto_transitions": {}},
)
assert curator_cli._cmd_run(_args(synchronous=True, background=True)) == 0
assert calls[0]["synchronous"] is True
def test_dry_run_default_reports_synchronous_wording(monkeypatch, capsys):
import agent.curator as curator_state
import hermes_cli.curator as curator_cli
monkeypatch.setattr(curator_state, "is_enabled", lambda: True)
monkeypatch.setattr(
curator_state,
"run_curator_review",
lambda **kwargs: {"auto_transitions": {}},
)
assert curator_cli._cmd_run(_args(dry_run=True)) == 0
out = capsys.readouterr().out
assert "When the report lands" not in out
assert "Read the report with `hermes curator status`" in out
+202
View File
@@ -0,0 +1,202 @@
"""Tests for `hermes curator status` output.
Covers:
- y0shualee's "least recently active" semantic (view/patch/use all count as activity).
- The most-used / least-used rankings by activity_count so users can see which
skills actually get exercised.
"""
from __future__ import annotations
import io
from argparse import Namespace
from contextlib import redirect_stdout
from pathlib import Path
from types import SimpleNamespace
import pytest
def test_status_uses_last_activity_not_only_last_used(monkeypatch, capsys):
import agent.curator as curator_state
import hermes_cli.curator as curator_cli
import tools.skill_usage as skill_usage
monkeypatch.setattr(curator_state, "load_state", lambda: {
"paused": False,
"last_run_at": None,
"last_run_summary": "(none)",
"run_count": 0,
})
monkeypatch.setattr(curator_state, "is_enabled", lambda: True)
monkeypatch.setattr(curator_state, "get_interval_hours", lambda: 168)
monkeypatch.setattr(curator_state, "get_stale_after_days", lambda: 30)
monkeypatch.setattr(curator_state, "get_archive_after_days", lambda: 90)
monkeypatch.setattr(skill_usage, "agent_created_report", lambda: [
{
"name": "recently-viewed",
"state": "active",
"pinned": False,
"use_count": 0,
"view_count": 3,
"patch_count": 1,
"created_at": "2026-01-01T00:00:00+00:00",
"last_used_at": None,
"last_viewed_at": "2026-04-30T10:00:00+00:00",
"last_patched_at": "2026-04-30T11:00:00+00:00",
"last_activity_at": "2026-04-30T11:00:00+00:00",
"activity_count": 4,
}
])
assert curator_cli._cmd_status(SimpleNamespace()) == 0
out = capsys.readouterr().out
assert "least recently active" in out
assert "activity= 4" in out
assert "last_activity=never" not in out
assert "last_used=never" not in out
@pytest.fixture
def curator_status_env(tmp_path, monkeypatch):
"""Isolated HERMES_HOME with real agent-created skills on disk."""
home = tmp_path / ".hermes"
skills = home / "skills"
skills.mkdir(parents=True)
(home / "logs").mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
import importlib
import hermes_constants
importlib.reload(hermes_constants)
from tools import skill_usage
importlib.reload(skill_usage)
from agent import curator
importlib.reload(curator)
from hermes_cli import curator as curator_cli
importlib.reload(curator_cli)
def _write_skill(name: str) -> None:
d = skills / name
d.mkdir()
(d / "SKILL.md").write_text(
"---\n"
f"name: {name}\n"
"description: test\n"
"version: 1.0.0\n"
"metadata:\n"
" hermes:\n"
" agent_created: true\n"
"---\n"
f"# {name}\n"
)
return {
"home": home,
"skills": skills,
"make_skill": _write_skill,
"skill_usage": skill_usage,
"curator_cli": curator_cli,
}
def _capture_status(curator_cli) -> str:
buf = io.StringIO()
with redirect_stdout(buf):
rc = curator_cli._cmd_status(Namespace())
assert rc == 0
return buf.getvalue()
def test_status_shows_most_and_least_used_sections(curator_status_env):
env = curator_status_env
env["make_skill"]("top-dog")
env["make_skill"]("middling")
env["make_skill"]("never-used")
# Mark all three as agent-created so they enter the curator's catalog.
# Under the provenance-marker semantics, skills must be explicitly opted
# into curator management (normally via the background-review fork when
# it creates a skill through skill_manage).
for n in ("top-dog", "middling", "never-used"):
env["skill_usage"].mark_agent_created(n)
# Bump use_count differentially. All three counters (use/view/patch) feed
# into activity_count, so bumping use alone is enough to make activity
# diverge between skills.
for _ in range(10):
env["skill_usage"].bump_use("top-dog")
for _ in range(2):
env["skill_usage"].bump_use("middling")
out = _capture_status(env["curator_cli"])
# Both new sections present
assert "most active (top 5):" in out
assert "least active (top 5):" in out
# y0shualee's section preserved
assert "least recently active (top 5):" in out
# most-active lists top-dog FIRST (highest activity_count)
most_section = out.split("most active (top 5):")[1].split("\n\n")[0]
top_line = most_section.strip().split("\n")[0]
assert "top-dog" in top_line
assert "activity= 10" in top_line
# least-active lists never-used FIRST (activity=0)
least_section = out.split("least active (top 5):")[1].split("\n\n")[0]
bottom_line = least_section.strip().split("\n")[0]
assert "never-used" in bottom_line
assert "activity= 0" in bottom_line
def test_status_hides_most_active_when_all_zero(curator_status_env):
"""If no skills have any activity, skip the most-active block — it's noise.
Least-active still shows so the user sees their catalog."""
env = curator_status_env
env["make_skill"]("a")
env["make_skill"]("b")
# Mark both as agent-created so the catalog lists them. No bumps.
env["skill_usage"].mark_agent_created("a")
env["skill_usage"].mark_agent_created("b")
out = _capture_status(env["curator_cli"])
# most-active section is hidden because the top is 0
assert "most active (top 5):" not in out
# least-active still renders — it's part of the catalog overview
assert "least active (top 5):" in out
def test_status_no_skills_produces_clean_empty_output(curator_status_env):
env = curator_status_env
out = _capture_status(env["curator_cli"])
assert "no agent-created skills" in out
# None of the ranking sections render
assert "most active" not in out
assert "least active" not in out
def test_status_marks_missing_last_report_path(monkeypatch, capsys, tmp_path):
import agent.curator as curator_state
import hermes_cli.curator as curator_cli
import tools.skill_usage as skill_usage
missing_report = tmp_path / "stale-report"
monkeypatch.setattr(curator_state, "load_state", lambda: {
"paused": False,
"last_run_at": None,
"last_run_summary": "auto: no changes",
"run_count": 1,
"last_report_path": str(missing_report),
})
monkeypatch.setattr(curator_state, "is_enabled", lambda: True)
monkeypatch.setattr(curator_state, "get_interval_hours", lambda: 168)
monkeypatch.setattr(curator_state, "get_stale_after_days", lambda: 30)
monkeypatch.setattr(curator_state, "get_archive_after_days", lambda: 90)
monkeypatch.setattr(skill_usage, "agent_created_report", lambda: [])
assert curator_cli._cmd_status(SimpleNamespace()) == 0
out = capsys.readouterr().out
assert f"last report: {missing_report} (missing)" in out
@@ -0,0 +1,130 @@
"""Tests for curses color compatibility on low-color terminals (Docker).
Regression test for #13688: ``hermes plugins`` crashes with
``curses.error: init_pair() : color number is greater than COLORS-1``
in Docker containers where curses.COLORS == 8 (only colors 0-7 exist).
The bug was ``curses.init_pair(4, 8, -1)`` using raw color 8 ("bright
black" / dim gray) which does not exist on 8-color terminals. The fix
clamps with ``min(8, curses.COLORS - 1)``.
"""
import curses
import re
from pathlib import Path
from unittest.mock import patch, MagicMock
# Path to the source files under test
_SRC_ROOT = Path(__file__).parent.parent.parent / "hermes_cli"
class TestInitPairClampingBehavior:
"""Simulate curses color initialization on low-color terminals.
Patches curses.COLORS to 8 (Docker default) and verifies that
init_pair is never called with a color >= COLORS.
"""
def _collect_init_pair_calls(self, draw_fn, colors_value):
"""Run a curses draw function with a mock stdscr and patched COLORS.
Returns list of (pair_number, fg, bg) tuples from init_pair calls.
"""
calls = []
real_init_pair = curses.init_pair
def tracking_init_pair(pair, fg, bg):
calls.append((pair, fg, bg))
mock_stdscr = MagicMock()
mock_stdscr.getmaxyx.return_value = (24, 80)
mock_stdscr.getch.return_value = 27 # ESC to exit
with patch("curses.COLORS", colors_value, create=True), \
patch("curses.init_pair", side_effect=tracking_init_pair), \
patch("curses.has_colors", return_value=True), \
patch("curses.start_color"), \
patch("curses.use_default_colors"), \
patch("curses.curs_set"):
try:
draw_fn(mock_stdscr)
except (SystemExit, StopIteration, Exception):
pass # draw functions loop until keypress
return calls
def test_8_color_terminal_no_color_exceeds_limit(self):
"""On an 8-color terminal (Docker), no init_pair fg color >= 8."""
# Simulate the color init pattern from plugins_cmd.py
def _simulated_color_init(stdscr):
if curses.has_colors():
curses.start_color()
curses.use_default_colors()
curses.init_pair(1, curses.COLOR_GREEN, -1)
curses.init_pair(2, curses.COLOR_YELLOW, -1)
curses.init_pair(3, curses.COLOR_CYAN, -1)
curses.init_pair(4, 8 if curses.COLORS > 8 else curses.COLOR_WHITE, -1)
calls = self._collect_init_pair_calls(_simulated_color_init, 8)
for pair, fg, bg in calls:
assert fg < 8, (
f"init_pair({pair}, {fg}, {bg}) uses color {fg} which "
f"does not exist on an 8-color terminal (valid: 0-7)"
)
def test_256_color_terminal_uses_color_8(self):
"""On a 256-color terminal, color 8 (dim gray) should be used."""
def _simulated_color_init(stdscr):
if curses.has_colors():
curses.start_color()
curses.use_default_colors()
curses.init_pair(4, 8 if curses.COLORS > 8 else curses.COLOR_WHITE, -1)
calls = self._collect_init_pair_calls(_simulated_color_init, 256)
assert any(fg == 8 for _, fg, _ in calls), (
"On 256-color terminals, color 8 (dim gray) should be used"
)
def test_16_color_terminal_uses_color_8(self):
"""On a 16-color terminal, color 8 should be available."""
def _simulated_color_init(stdscr):
if curses.has_colors():
curses.start_color()
curses.use_default_colors()
curses.init_pair(4, 8 if curses.COLORS > 8 else curses.COLOR_WHITE, -1)
calls = self._collect_init_pair_calls(_simulated_color_init, 16)
assert any(fg == 8 for _, fg, _ in calls)
class TestSourceCodeGuardrails:
"""Regression guardrails: raw color 8 must not reappear in source.
These complement the behavioral tests above they catch regressions
introduced by copy-paste of the old pattern.
"""
_RAW_COLOR_8_PATTERN = re.compile(r'init_pair\(\d+,\s*8\s*,')
def test_no_raw_color_8_in_plugins_cmd(self):
source = (_SRC_ROOT / "plugins_cmd.py").read_text()
matches = self._RAW_COLOR_8_PATTERN.findall(source)
assert not matches, (
f"plugins_cmd.py contains unclamped color 8: {matches}"
)
def test_no_raw_color_8_in_main(self):
source = (_SRC_ROOT / "main.py").read_text()
matches = self._RAW_COLOR_8_PATTERN.findall(source)
assert not matches, (
f"main.py contains unclamped color 8: {matches}"
)
def test_no_raw_color_8_in_curses_ui(self):
source = (_SRC_ROOT / "curses_ui.py").read_text()
matches = self._RAW_COLOR_8_PATTERN.findall(source)
assert not matches, (
f"curses_ui.py contains unclamped color 8: {matches}"
)
@@ -0,0 +1,240 @@
"""Regression tests for custom_providers per-model context_length resolution.
Covers the fix for #15779 — mid-session /model switch to a named custom
provider must honor ``custom_providers[].models.<id>.context_length`` the
same way startup already does.
"""
from __future__ import annotations
from unittest.mock import patch
from hermes_cli.config import get_custom_provider_context_length
class TestGetCustomProviderContextLength:
def test_returns_override_for_matching_entry(self):
custom = [
{
"name": "my-endpoint",
"base_url": "https://example.invalid/v1",
"models": {"gpt-5.5": {"context_length": 1_050_000}},
}
]
assert (
get_custom_provider_context_length(
"gpt-5.5", "https://example.invalid/v1", custom
)
== 1_050_000
)
def test_trailing_slash_insensitive(self):
custom = [
{
"base_url": "https://example.invalid/v1/",
"models": {"m": {"context_length": 500_000}},
}
]
# config has trailing slash, runtime doesn't — must match
assert (
get_custom_provider_context_length(
"m", "https://example.invalid/v1", custom
)
== 500_000
)
# and the reverse
custom2 = [
{
"base_url": "https://example.invalid/v1",
"models": {"m": {"context_length": 500_000}},
}
]
assert (
get_custom_provider_context_length(
"m", "https://example.invalid/v1/", custom2
)
== 500_000
)
def test_returns_none_when_url_does_not_match(self):
custom = [
{
"base_url": "https://example.invalid/v1",
"models": {"m": {"context_length": 400_000}},
}
]
assert (
get_custom_provider_context_length(
"m", "https://other.invalid/v1", custom
)
is None
)
def test_returns_none_when_model_does_not_match(self):
custom = [
{
"base_url": "https://example.invalid/v1",
"models": {"gpt-5.5": {"context_length": 400_000}},
}
]
assert (
get_custom_provider_context_length(
"different-model", "https://example.invalid/v1", custom
)
is None
)
def test_returns_none_for_string_value(self):
"""'256K' string is not a valid int — skip silently.
(The inline startup path still emits a user-visible warning; the
helper itself returns None so downstream fallbacks can run.)
"""
custom = [
{
"base_url": "https://example.invalid/v1",
"models": {"m": {"context_length": "256K"}},
}
]
assert (
get_custom_provider_context_length(
"m", "https://example.invalid/v1", custom
)
is None
)
def test_returns_none_for_zero_or_negative(self):
for bad in (0, -1, -100):
custom = [
{
"base_url": "https://example.invalid/v1",
"models": {"m": {"context_length": bad}},
}
]
assert (
get_custom_provider_context_length(
"m", "https://example.invalid/v1", custom
)
is None
), f"value {bad!r} should be rejected"
def test_empty_inputs_return_none(self):
assert get_custom_provider_context_length("", "http://x", [{"base_url": "http://x", "models": {"": {"context_length": 1}}}]) is None
assert get_custom_provider_context_length("m", "", [{"base_url": "", "models": {"m": {"context_length": 1}}}]) is None
assert get_custom_provider_context_length("m", "http://x", None) is None
assert get_custom_provider_context_length("m", "http://x", []) is None
def test_ignores_non_dict_entries(self):
"""Malformed entries must not crash the lookup."""
custom = [
"not a dict",
None,
{"base_url": "https://example.invalid/v1", "models": "not a dict"},
{"base_url": "https://example.invalid/v1", "models": {"m": "not a dict"}},
{
"base_url": "https://example.invalid/v1",
"models": {"m": {"context_length": 400_000}},
},
]
assert (
get_custom_provider_context_length(
"m", "https://example.invalid/v1", custom
)
== 400_000
)
class TestGetModelContextLengthHonorsOverride:
"""agent.model_metadata.get_model_context_length must honor the
custom_providers override at step 0b before any probe, cache hit,
or models.dev lookup can override it.
"""
def _mock_all_probes(self):
"""Context manager that disables every downstream resolution step."""
from agent import model_metadata as _mm
return [
patch.object(_mm, "get_cached_context_length", return_value=None),
patch.object(_mm, "fetch_endpoint_model_metadata", return_value={}),
patch.object(_mm, "fetch_model_metadata", return_value={}),
patch.object(_mm, "is_local_endpoint", return_value=False),
patch.object(_mm, "_is_known_provider_base_url", return_value=False),
]
def test_custom_providers_override_wins_over_default_fallback(self):
from agent.model_metadata import get_model_context_length
custom = [
{
"base_url": "https://example.invalid/v1",
"models": {"gpt-5.5": {"context_length": 1_050_000}},
}
]
patches = self._mock_all_probes()
for p in patches:
p.start()
try:
ctx = get_model_context_length(
"gpt-5.5",
base_url="https://example.invalid/v1",
provider="custom",
custom_providers=custom,
)
finally:
for p in patches:
p.stop()
assert ctx == 1_050_000
def test_explicit_config_context_length_still_wins(self):
"""Top-level model.context_length (step 0) outranks custom_providers (step 0b).
Users who set both should see the top-level value that's the
documented precedence and matches the long-standing step-0 behavior.
"""
from agent.model_metadata import get_model_context_length
custom = [
{
"base_url": "https://example.invalid/v1",
"models": {"m": {"context_length": 1_050_000}},
}
]
ctx = get_model_context_length(
"m",
base_url="https://example.invalid/v1",
provider="custom",
config_context_length=500_000, # explicit top-level wins
custom_providers=custom,
)
assert ctx == 500_000
def test_no_override_falls_through_to_default(self):
"""With custom_providers=None and all probes disabled, resolver
returns DEFAULT_FALLBACK_CONTEXT (256K after the stepdown bump).
"""
from agent.model_metadata import get_model_context_length, DEFAULT_FALLBACK_CONTEXT
patches = self._mock_all_probes()
for p in patches:
p.start()
try:
ctx = get_model_context_length(
"unknown-model",
base_url="https://example.invalid/v1",
provider="custom",
custom_providers=None,
)
finally:
for p in patches:
p.stop()
assert ctx == DEFAULT_FALLBACK_CONTEXT
class TestContextProbeTiers:
def test_256k_is_top_tier_and_default(self):
"""The stepdown probe starts at 256K and 256K is the new default."""
from agent.model_metadata import CONTEXT_PROBE_TIERS, DEFAULT_FALLBACK_CONTEXT
assert CONTEXT_PROBE_TIERS[0] == 256_000
assert DEFAULT_FALLBACK_CONTEXT == 256_000
# Tiers still descend monotonically
for a, b in zip(CONTEXT_PROBE_TIERS, CONTEXT_PROBE_TIERS[1:]):
assert a > b, f"tiers must strictly descend, got {a} then {b}"
# 128K is still a tier (users relying on it probe-down get there)
assert 128_000 in CONTEXT_PROBE_TIERS
@@ -0,0 +1,565 @@
"""Tests that `hermes model` always shows the model selection menu for custom
providers, even when a model is already saved.
Regression test for the bug where _model_flow_named_custom() returned
immediately when provider_info had a saved ``model`` field, making it
impossible to switch models on multi-model endpoints.
"""
from unittest.mock import patch
import pytest
@pytest.fixture
def config_home(tmp_path, monkeypatch):
"""Isolated HERMES_HOME with a minimal config."""
home = tmp_path / "hermes"
home.mkdir()
config_yaml = home / "config.yaml"
config_yaml.write_text("model: old-model\ncustom_providers: []\n")
env_file = home / ".env"
env_file.write_text("")
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.delenv("HERMES_MODEL", raising=False)
monkeypatch.delenv("LLM_MODEL", raising=False)
monkeypatch.delenv("HERMES_INFERENCE_PROVIDER", raising=False)
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
return home
class TestCustomProviderModelSwitch:
"""Ensure _model_flow_named_custom always probes and shows menu."""
def test_saved_model_still_probes_endpoint(self, config_home):
"""When a model is already saved, the function must still call
fetch_api_models to probe the endpoint not skip with early return."""
from hermes_cli.main import _model_flow_named_custom
provider_info = {
"name": "My vLLM",
"base_url": "https://vllm.example.com/v1",
"api_key": "sk-test",
"model": "model-A", # already saved
}
with patch("hermes_cli.models.fetch_api_models", return_value=["model-A", "model-B"]) as mock_fetch, \
patch.dict("sys.modules", {"simple_term_menu": None}), \
patch("builtins.input", return_value="2"), \
patch("builtins.print"):
_model_flow_named_custom({}, provider_info)
# fetch_api_models MUST be called even though model was saved
mock_fetch.assert_called_once_with(
"sk-test",
"https://vllm.example.com/v1",
timeout=8.0,
)
def test_can_switch_to_different_model(self, config_home):
"""User selects a different model than the saved one."""
import yaml
from hermes_cli.main import _model_flow_named_custom
provider_info = {
"name": "My vLLM",
"base_url": "https://vllm.example.com/v1",
"api_key": "sk-test",
"model": "model-A",
}
with patch("hermes_cli.models.fetch_api_models", return_value=["model-A", "model-B"]), \
patch.dict("sys.modules", {"simple_term_menu": None}), \
patch("builtins.input", return_value="2"), \
patch("builtins.print"):
_model_flow_named_custom({}, provider_info)
config = yaml.safe_load((config_home / "config.yaml").read_text()) or {}
model = config.get("model")
assert isinstance(model, dict)
assert model["default"] == "model-B"
def test_probe_failure_falls_back_to_saved(self, config_home):
"""When endpoint probe fails and user presses Enter, saved model is used."""
import yaml
from hermes_cli.main import _model_flow_named_custom
provider_info = {
"name": "My vLLM",
"base_url": "https://vllm.example.com/v1",
"api_key": "sk-test",
"model": "model-A",
}
# fetch returns empty list (probe failed), user presses Enter (empty input)
with patch("hermes_cli.models.fetch_api_models", return_value=[]), \
patch("builtins.input", return_value=""), \
patch("builtins.print"):
_model_flow_named_custom({}, provider_info)
config = yaml.safe_load((config_home / "config.yaml").read_text()) or {}
model = config.get("model")
assert isinstance(model, dict)
assert model["default"] == "model-A"
def test_no_saved_model_still_works(self, config_home):
"""First-time flow (no saved model) still works as before."""
import yaml
from hermes_cli.main import _model_flow_named_custom
provider_info = {
"name": "My vLLM",
"base_url": "https://vllm.example.com/v1",
"api_key": "sk-test",
# no "model" key
}
with patch("hermes_cli.models.fetch_api_models", return_value=["model-X"]), \
patch.dict("sys.modules", {"simple_term_menu": None}), \
patch("builtins.input", return_value="1"), \
patch("builtins.print"):
_model_flow_named_custom({}, provider_info)
config = yaml.safe_load((config_home / "config.yaml").read_text()) or {}
model = config.get("model")
assert isinstance(model, dict)
assert model["default"] == "model-X"
def test_api_mode_set_from_provider_info(self, config_home):
"""When custom_providers entry has api_mode, it should be applied."""
import yaml
from hermes_cli.main import _model_flow_named_custom
provider_info = {
"name": "Anthropic Proxy",
"base_url": "https://proxy.example.com/anthropic",
"api_key": "***",
"model": "claude-3",
"api_mode": "anthropic_messages",
}
with patch("hermes_cli.models.fetch_api_models", return_value=["claude-3"]) as mock_fetch, \
patch.dict("sys.modules", {"simple_term_menu": None}), \
patch("builtins.input", return_value="1"), \
patch("builtins.print"):
_model_flow_named_custom({}, provider_info)
mock_fetch.assert_called_once_with(
"***",
"https://proxy.example.com/anthropic",
timeout=8.0,
api_mode="anthropic_messages",
)
config = yaml.safe_load((config_home / "config.yaml").read_text()) or {}
model = config.get("model")
assert isinstance(model, dict)
assert model.get("api_mode") == "anthropic_messages"
def test_api_mode_cleared_when_not_specified(self, config_home):
"""When custom_providers entry has no api_mode, stale api_mode is removed."""
import yaml
from hermes_cli.main import _model_flow_named_custom
# Pre-seed a stale api_mode in config
config_path = config_home / "config.yaml"
config_path.write_text(yaml.dump({"model": {"api_mode": "anthropic_messages"}}))
provider_info = {
"name": "My vLLM",
"base_url": "https://vllm.example.com/v1",
"api_key": "***",
"model": "llama-3",
}
with patch("hermes_cli.models.fetch_api_models", return_value=["llama-3"]), \
patch.dict("sys.modules", {"simple_term_menu": None}), \
patch("builtins.input", return_value="1"), \
patch("builtins.print"):
_model_flow_named_custom({}, provider_info)
config = yaml.safe_load((config_home / "config.yaml").read_text()) or {}
model = config.get("model")
assert isinstance(model, dict)
assert "api_mode" not in model, "Stale api_mode should be removed"
def test_env_template_api_key_is_preserved_in_model_config(self, config_home, monkeypatch):
"""Selecting an env-backed custom provider must not inline the secret."""
import yaml
from hermes_cli.main import _model_flow_named_custom
config_path = config_home / "config.yaml"
config_path.write_text(
"model:\n"
" default: old-model\n"
" provider: openrouter\n"
"custom_providers:\n"
"- name: Example Provider\n"
" base_url: https://api.example-provider.test/v1\n"
" api_key: ${EXAMPLE_PROVIDER_API_KEY}\n"
" model: qwen3.6-35b-fast\n"
)
monkeypatch.setenv("EXAMPLE_PROVIDER_API_KEY", "sk-live-example-provider")
provider_info = {
"name": "Example Provider",
"base_url": "https://api.example-provider.test/v1",
"api_key": "sk-live-example-provider",
"api_key_ref": "${EXAMPLE_PROVIDER_API_KEY}",
"model": "qwen3.6-35b-fast",
}
with patch("hermes_cli.models.fetch_api_models", return_value=["qwen3.6-35b-fast"]) as mock_fetch, \
patch.dict("sys.modules", {"simple_term_menu": None}), \
patch("builtins.input", return_value="1"), \
patch("builtins.print"):
_model_flow_named_custom({}, provider_info)
mock_fetch.assert_called_once_with(
"sk-live-example-provider",
"https://api.example-provider.test/v1",
timeout=8.0,
)
config = yaml.safe_load(config_path.read_text()) or {}
assert config["model"]["api_key"] == "${EXAMPLE_PROVIDER_API_KEY}"
assert config["custom_providers"][0]["api_key"] == "${EXAMPLE_PROVIDER_API_KEY}"
assert "sk-live-example-provider" not in config_path.read_text()
def test_key_env_custom_provider_persists_reference_not_secret(self, config_home, monkeypatch):
"""key_env custom providers should also avoid writing plaintext keys."""
import yaml
from hermes_cli.main import _model_flow_named_custom
config_path = config_home / "config.yaml"
config_path.write_text(
"model:\n"
" default: old-model\n"
"custom_providers:\n"
"- name: Example Provider\n"
" base_url: https://api.example-provider.test/v1\n"
" key_env: EXAMPLE_PROVIDER_API_KEY\n"
" model: qwen3.6-35b-fast\n"
)
monkeypatch.setenv("EXAMPLE_PROVIDER_API_KEY", "sk-live-example-provider")
provider_info = {
"name": "Example Provider",
"base_url": "https://api.example-provider.test/v1",
"api_key": "",
"key_env": "EXAMPLE_PROVIDER_API_KEY",
"model": "qwen3.6-35b-fast",
}
with patch("hermes_cli.models.fetch_api_models", return_value=["qwen3.6-35b-fast"]), \
patch.dict("sys.modules", {"simple_term_menu": None}), \
patch("builtins.input", return_value="1"), \
patch("builtins.print"):
_model_flow_named_custom({}, provider_info)
config = yaml.safe_load(config_path.read_text()) or {}
assert config["model"]["api_key"] == "${EXAMPLE_PROVIDER_API_KEY}"
assert config["custom_providers"][0]["key_env"] == "EXAMPLE_PROVIDER_API_KEY"
assert "sk-live-example-provider" not in config_path.read_text()
def test_env_ref_base_url_preserves_api_key_ref_through_picker(
self, config_home, monkeypatch
):
"""Integration regression: when BOTH ``base_url`` and ``api_key`` use
``${VAR}`` templates (the Discord-reported NeuralWatt case), the picker
must still preserve the env reference in ``model.api_key``.
The earlier lookup went through ``get_compatible_custom_providers``
which dropped entries whose ``base_url`` was an env-ref template
(``urlparse("${NEURALWATT_API_BASE}")`` has no scheme/netloc), causing
``api_key_ref`` to stay empty and the resolved secret to be written to
``config.yaml``. This test drives the real picker-callsite code path.
"""
import yaml
from hermes_cli.main import select_provider_and_model
config_path = config_home / "config.yaml"
config_path.write_text(
"model:\n"
" default: old-model\n"
" provider: openrouter\n"
"custom_providers:\n"
"- name: NeuralWatt\n"
" base_url: ${NEURALWATT_API_BASE}\n"
" api_key: ${NEURALWATT_API_KEY}\n"
" model: qwen3.6-35b-fast\n"
" models: []\n"
)
monkeypatch.setenv("NEURALWATT_API_BASE", "https://api.neuralwatt.com/v1")
monkeypatch.setenv("NEURALWATT_API_KEY", "sk-live-neuralwatt-secret")
# Exercise the real picker: select "custom:neuralwatt" from the
# provider menu. ``select_provider_and_model`` prompts for a provider
# choice (returns an index), then hands off to
# ``_model_flow_named_custom`` with the provider_info built by
# ``_named_custom_provider_map``.
def _pick_neuralwatt(labels, default=0):
for i, label in enumerate(labels):
if "NeuralWatt" in label:
return i
raise AssertionError(
f"NeuralWatt entry missing from provider menu: {labels}"
)
with patch("hermes_cli.main._prompt_provider_choice",
side_effect=_pick_neuralwatt), \
patch("hermes_cli.models.fetch_api_models",
return_value=["qwen3.6-35b-fast"]) as mock_fetch, \
patch.dict("sys.modules", {"simple_term_menu": None}), \
patch("builtins.input", return_value="1"), \
patch("builtins.print"):
select_provider_and_model()
# The live probe must still use the resolved secret.
mock_fetch.assert_called_once()
probe_args, probe_kwargs = mock_fetch.call_args
assert probe_args[0] == "sk-live-neuralwatt-secret"
# But config.yaml must keep the env reference, not the plaintext secret.
saved = config_path.read_text()
config = yaml.safe_load(saved) or {}
assert config["model"]["api_key"] == "${NEURALWATT_API_KEY}"
assert config["custom_providers"][0]["api_key"] == "${NEURALWATT_API_KEY}"
assert "sk-live-neuralwatt-secret" not in saved
def test_bare_custom_current_provider_matches_env_base_url_before_first_fallback(
self, config_home, monkeypatch
):
"""`hermes model` must mark the custom provider matching model.base_url
as current instead of falling back to the first saved custom provider.
Regression: with ``model.provider: custom`` and multiple
``custom_providers`` entries, the CLI resolved bare ``custom`` through
``resolve_custom_provider()``, whose compatibility fallback returns the
first entry. A config with Cerebras first and NeuralWatt active then
showed Cerebras as current.
"""
from hermes_cli.main import select_provider_and_model
config_path = config_home / "config.yaml"
config_path.write_text(
"model:\n"
" default: kimi-k2.6-fast\n"
" provider: custom\n"
" base_url: ${NEURALWATT_API_BASE}\n"
" api_key: ${NEURALWATT_API_KEY}\n"
"providers: {}\n"
"custom_providers:\n"
"- name: Cerebras.ai\n"
" base_url: ${CEREBRAS_API_BASE}\n"
" api_key: ${CEREBRAS_API_KEY}\n"
" model: qwen-3-235b-a22b-instruct-2507\n"
" models: []\n"
"- name: NeuralWatt\n"
" base_url: ${NEURALWATT_API_BASE}\n"
" api_key: ${NEURALWATT_API_KEY}\n"
" model: kimi-k2.6-fast\n"
" models: []\n"
)
monkeypatch.setenv("CEREBRAS_API_BASE", "https://api.cerebras.ai/v1")
monkeypatch.setenv("CEREBRAS_API_KEY", "sk-live-cerebras-secret")
monkeypatch.setenv("NEURALWATT_API_BASE", "https://api.neuralwatt.com/v1")
monkeypatch.setenv("NEURALWATT_API_KEY", "sk-live-neuralwatt-secret")
captured: dict = {}
def _capture_and_cancel(labels, default=0):
captured["labels"] = labels
captured["default"] = default
return len(labels) - 1 # Leave unchanged
with patch("hermes_cli.main._prompt_provider_choice",
side_effect=_capture_and_cancel), \
patch("builtins.print"):
select_provider_and_model()
labels = captured["labels"]
default_label = labels[captured["default"]]
assert "NeuralWatt" in default_label
assert "currently active" in default_label
assert "Cerebras.ai" not in default_label
assert not any(
"Cerebras.ai" in label and "currently active" in label
for label in labels
)
def test_named_custom_provider_selection_preserves_base_url_env_ref(
self, config_home, monkeypatch
):
"""Selecting an env-backed custom provider should not expand its
``base_url`` template into ``model.base_url`` on disk."""
import yaml
from hermes_cli.main import select_provider_and_model
config_path = config_home / "config.yaml"
config_path.write_text(
"model:\n"
" default: old-model\n"
" provider: openrouter\n"
"custom_providers:\n"
"- name: NeuralWatt\n"
" base_url: ${NEURALWATT_API_BASE}\n"
" api_key: ${NEURALWATT_API_KEY}\n"
" model: qwen3.6-35b-fast\n"
" models: []\n"
)
monkeypatch.setenv("NEURALWATT_API_BASE", "https://api.neuralwatt.com/v1")
monkeypatch.setenv("NEURALWATT_API_KEY", "sk-live-neuralwatt-secret")
def _pick_neuralwatt(labels, default=0):
for i, label in enumerate(labels):
if "NeuralWatt" in label:
return i
raise AssertionError(
f"NeuralWatt entry missing from provider menu: {labels}"
)
with patch("hermes_cli.main._prompt_provider_choice",
side_effect=_pick_neuralwatt), \
patch("hermes_cli.models.fetch_api_models",
return_value=["qwen3.6-35b-fast"]) as mock_fetch, \
patch.dict("sys.modules", {"simple_term_menu": None}), \
patch("builtins.input", return_value="1"), \
patch("builtins.print"):
select_provider_and_model()
mock_fetch.assert_called_once()
probe_args, _ = mock_fetch.call_args
assert probe_args[1] == "https://api.neuralwatt.com/v1"
saved = config_path.read_text()
config = yaml.safe_load(saved) or {}
assert config["model"]["base_url"] == "${NEURALWATT_API_BASE}"
assert config["model"]["api_key"] == "${NEURALWATT_API_KEY}"
assert "https://api.neuralwatt.com/v1" not in saved
assert "sk-live-neuralwatt-secret" not in saved
def test_key_env_providers_dict_entry_does_not_add_api_key(
self, config_home, monkeypatch
):
"""Regression for #15803: a ``providers:`` (keyed-schema) entry that
relies on ``key_env`` must not gain an ``api_key`` field after the
model picker runs.
Before the fix, ``_model_flow_named_custom`` synthesized
``api_key: ${KEY_ENV}`` from the resolved secret and wrote it to the
``providers.<key>`` entry, cluttering configs that intentionally keep
credentials out of ``config.yaml``. The entry already carries
``key_env``; the runtime resolves it directly, so no inline
``api_key`` belongs on disk.
"""
import yaml
from hermes_cli.main import _model_flow_named_custom
config_path = config_home / "config.yaml"
config_path.write_text(
"providers:\n"
" crs-henkee:\n"
" name: CRS Henkee\n"
" base_url: http://127.0.0.1:3000/api/v1\n"
" key_env: HERMES_CRS_HENKEE_KEY\n"
" transport: anthropic_messages\n"
" model: claude-opus-4-7\n"
" default_model: claude-opus-4-7\n"
"custom_providers: []\n"
)
monkeypatch.setenv("HERMES_CRS_HENKEE_KEY", "cr_live_secret_xyz")
# provider_info as built by _named_custom_provider_map for a
# ``providers:`` entry that has key_env but no inline api_key.
provider_info = {
"name": "CRS Henkee",
"base_url": "http://127.0.0.1:3000/api/v1",
"api_key": "",
"key_env": "HERMES_CRS_HENKEE_KEY",
"model": "claude-opus-4-7",
"api_mode": "anthropic_messages",
"provider_key": "crs-henkee",
"api_key_ref": "",
}
with patch(
"hermes_cli.models.fetch_api_models",
return_value=["claude-opus-4-7"],
) as mock_fetch, \
patch.dict("sys.modules", {"simple_term_menu": None}), \
patch("builtins.input", return_value="1"), \
patch("builtins.print"):
_model_flow_named_custom({}, provider_info)
# The /models probe must resolve the secret from the env var.
mock_fetch.assert_called_once()
probe_args, _ = mock_fetch.call_args
assert probe_args[0] == "cr_live_secret_xyz"
# The providers entry must NOT gain an api_key field — neither the
# plaintext secret nor a synthesized ${KEY_ENV} template.
saved_text = config_path.read_text()
saved = yaml.safe_load(saved_text) or {}
entry = saved["providers"]["crs-henkee"]
assert "api_key" not in entry, (
f"providers.crs-henkee gained an api_key field: {entry.get('api_key')!r}"
)
assert entry["key_env"] == "HERMES_CRS_HENKEE_KEY"
assert entry["default_model"] == "claude-opus-4-7"
# And the plaintext secret must never appear anywhere on disk.
assert "cr_live_secret_xyz" not in saved_text
# The synthesized template is also redundant here — key_env owns it.
assert "${HERMES_CRS_HENKEE_KEY}" not in saved_text
def test_key_env_providers_dict_preserves_existing_api_key(
self, config_home, monkeypatch
):
"""A ``providers:`` entry that already has an inline ``api_key``
template must keep it untouched. Only entries that never declared
an ``api_key`` should skip the write."""
import yaml
from hermes_cli.main import _model_flow_named_custom
config_path = config_home / "config.yaml"
config_path.write_text(
"providers:\n"
" crs-henkee:\n"
" name: CRS Henkee\n"
" base_url: http://127.0.0.1:3000/api/v1\n"
" api_key: ${HERMES_CRS_HENKEE_KEY}\n"
" key_env: HERMES_CRS_HENKEE_KEY\n"
" transport: anthropic_messages\n"
" model: claude-opus-4-7\n"
" default_model: claude-opus-4-7\n"
"custom_providers: []\n"
)
monkeypatch.setenv("HERMES_CRS_HENKEE_KEY", "cr_live_secret_xyz")
provider_info = {
"name": "CRS Henkee",
"base_url": "http://127.0.0.1:3000/api/v1",
"api_key": "cr_live_secret_xyz", # expanded by load_config
"key_env": "HERMES_CRS_HENKEE_KEY",
"model": "claude-opus-4-7",
"api_mode": "anthropic_messages",
"provider_key": "crs-henkee",
"api_key_ref": "${HERMES_CRS_HENKEE_KEY}", # raw template preserved
}
with patch(
"hermes_cli.models.fetch_api_models",
return_value=["claude-opus-4-7"],
), \
patch.dict("sys.modules", {"simple_term_menu": None}), \
patch("builtins.input", return_value="1"), \
patch("builtins.print"):
_model_flow_named_custom({}, provider_info)
saved_text = config_path.read_text()
saved = yaml.safe_load(saved_text) or {}
entry = saved["providers"]["crs-henkee"]
# Existing api_key template must survive (the resolved secret must not
# clobber it via _preserve_env_ref_templates).
assert entry["api_key"] == "${HERMES_CRS_HENKEE_KEY}"
assert "cr_live_secret_xyz" not in saved_text
@@ -0,0 +1,488 @@
"""Phase 6 — 401 re-auth + ``next=`` propagation tests.
Verifies the contract documented in Phase 6 v2 of the plan:
- API 401 responses carry ``{"error", "login_url", ...}`` so the SPA
fetch wrapper can ``window.location.assign(body.login_url)``.
- The ``login_url`` embeds a ``next=<original-path>`` query string so
re-auth lands the user back where they were.
- HTML redirects ALSO carry ``next=``.
- ``next=`` validation: protocol-relative paths, absolute URLs, and
loops back to ``/login`` / ``/auth/*`` are dropped.
- Invalid/expired cookies are cleared on 401 so the browser doesn't
keep replaying them.
- ``set_session_cookies(refresh_token="")`` does NOT emit the
``hermes_session_rt`` cookie (contract V1: no RT to persist).
- ``/auth/callback?next=`` honours the same-origin landing path.
"""
from __future__ import annotations
from urllib.parse import quote
import pytest
# Phase 5 / Phase 6: these tests mutate ``web_server.app.state.auth_required``
# at module level. Run them in the same xdist worker so they don't race
# against each other (and against any other file that also touches
# ``app.state``) — the marker name is shared across all dashboard-auth test
# files that gate the app.
pytestmark = pytest.mark.xdist_group("dashboard_auth_app_state")
from fastapi import FastAPI
from fastapi.responses import Response
from fastapi.testclient import TestClient
from hermes_cli import web_server
from hermes_cli.dashboard_auth import clear_providers, register_provider
from hermes_cli.dashboard_auth.cookies import (
SESSION_AT_COOKIE,
SESSION_RT_COOKIE,
clear_session_cookies,
set_session_cookies,
)
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def gated_app():
clear_providers()
register_provider(StubAuthProvider())
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
prev_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.bound_host = "fly-app.fly.dev"
web_server.app.state.bound_port = 443
web_server.app.state.auth_required = True
client = TestClient(web_server.app, base_url="https://fly-app.fly.dev")
yield client
clear_providers()
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
web_server.app.state.auth_required = prev_required
# ---------------------------------------------------------------------------
# set_session_cookies(refresh_token="") skips the RT cookie
# ---------------------------------------------------------------------------
class TestRefreshTokenCookieDeprecation:
def _build_app(self, *, refresh_token: str):
app = FastAPI()
@app.get("/set")
def _set():
r = Response("ok")
set_session_cookies(
r, access_token="AT", refresh_token=refresh_token,
access_token_expires_in=3600, use_https=True,
)
return r
return app
def test_empty_refresh_token_does_not_emit_rt_cookie(self):
client = TestClient(self._build_app(refresh_token=""))
r = client.get("/set")
cookies = r.headers.get_list("set-cookie")
rt_cookies = [c for c in cookies if SESSION_RT_COOKIE in c]
assert rt_cookies == []
# AT cookie still set (whichever variant the request resolves to).
at_cookies = [c for c in cookies if SESSION_AT_COOKIE in c]
assert len(at_cookies) == 1
def test_present_refresh_token_still_emits_rt_cookie(self):
client = TestClient(self._build_app(refresh_token="forward-compat"))
r = client.get("/set")
cookies = r.headers.get_list("set-cookie")
rt_cookies = [c for c in cookies if SESSION_RT_COOKIE in c]
assert len(rt_cookies) == 1
assert "forward-compat" in rt_cookies[0]
def test_clear_session_cookies_still_emits_rt_deletion(self):
"""Even when we never wrote the RT cookie, logout/clear should
emit a Max-Age=0 deletion to flush stale cookies from old
deployments."""
app = FastAPI()
@app.get("/clear")
def _clear():
r = Response("ok")
clear_session_cookies(r)
return r
client = TestClient(app)
r = client.get("/clear")
cookies = r.headers.get_list("set-cookie")
assert any(
SESSION_RT_COOKIE in c and "Max-Age=0" in c
for c in cookies
)
# ---------------------------------------------------------------------------
# Gate middleware: 401 envelope + next= propagation
# ---------------------------------------------------------------------------
class TestApi401Envelope:
# NOTE: probe a gated route (``/api/sessions``) here rather than
# ``/api/status`` — status is in the shared ``PUBLIC_API_PATHS``
# allowlist (portal liveness probe) so it would 200 even without a
# cookie and never exercise the 401-envelope code path.
def test_no_cookie_returns_unauthenticated_envelope(self, gated_app):
r = gated_app.get("/api/sessions")
assert r.status_code == 401
body = r.json()
assert body["error"] == "unauthenticated"
assert "login_url" in body
assert body["login_url"].startswith("/login")
def test_invalid_cookie_returns_session_expired_envelope(self, gated_app):
gated_app.cookies.set(SESSION_AT_COOKIE, "garbage")
r = gated_app.get("/api/sessions")
assert r.status_code == 401
body = r.json()
assert body["error"] == "session_expired"
assert body["login_url"].startswith("/login")
def test_invalid_cookie_clears_dead_cookie(self, gated_app):
"""Dead-cookie cleanup — Phase 6 requirement so the browser
doesn't keep replaying the stale token on every request."""
gated_app.cookies.set(SESSION_AT_COOKIE, "garbage")
r = gated_app.get("/api/sessions")
set_cookies = r.headers.get_list("set-cookie")
assert any(
c.startswith(f"{SESSION_AT_COOKIE}=") and "Max-Age=0" in c
for c in set_cookies
)
def test_login_url_carries_next_for_deep_api_path(self, gated_app):
r = gated_app.get("/api/sessions?page=2")
body = r.json()
# next= is URL-encoded.
assert "next=" in body["login_url"]
assert quote("/api/sessions?page=2", safe="") in body["login_url"]
class TestHtmlRedirectNext:
def test_deep_html_path_redirects_with_next(self, gated_app):
r = gated_app.get("/sessions", follow_redirects=False)
assert r.status_code == 302
assert r.headers["location"] == "/login?next=%2Fsessions"
def test_root_path_redirects_with_next(self, gated_app):
r = gated_app.get("/", follow_redirects=False)
assert r.headers["location"] in ("/login", "/login?next=%2F")
def test_login_loop_avoided(self, gated_app):
"""A request to /login itself must not produce ``?next=/login``
because that'd be a loop after re-auth."""
# /login is on the public allowlist so it doesn't go through the
# 401 path. But sanity: the page renders.
r = gated_app.get("/login")
assert r.status_code == 200
def test_auth_loop_avoided(self, gated_app):
"""A failed cookie on /auth/me (auth-required path) must drop
the next= rather than risk a /login?next=/api/auth/me loop."""
# /api/auth/me requires auth. Without cookie → 401 with login_url
# but next= must NOT point at /api/auth/.
r = gated_app.get("/api/auth/me")
assert r.status_code == 401
body = r.json()
assert "next=" not in body["login_url"]
# ---------------------------------------------------------------------------
# Gate middleware: same-origin next= validation
# ---------------------------------------------------------------------------
class TestNextSameOriginValidation:
def test_protocol_relative_path_dropped(self, gated_app):
# `//evil.com/foo` parses to a protocol-relative URL — browser
# would treat as cross-origin. We drop it at the gate; the path
# we redirect to should NOT contain `//evil.com`.
r = gated_app.get("//evil.com", follow_redirects=False)
# Starlette likely normalizes the path before we see it, so the
# gate may see "/evil.com" — either way the encoded value
# in next= must be safe to feed to window.location.assign.
# Just assert no protocol-relative form survives.
assert r.status_code == 302
location = r.headers["location"]
assert "%2F%2Fevil" not in location # urlencoded // form
assert "//evil" not in location
def test_safe_next_validator_accepts_same_origin(self):
from hermes_cli.dashboard_auth.middleware import _safe_next_target
class FakeRequest:
def __init__(self, path, query=""):
self.url = type("URL", (), {"path": path, "query": query})()
assert _safe_next_target(FakeRequest("/sessions")) == "%2Fsessions"
assert (
_safe_next_target(FakeRequest("/sessions", "page=2"))
== "%2Fsessions%3Fpage%3D2"
)
def test_safe_next_validator_rejects_protocol_relative(self):
from hermes_cli.dashboard_auth.middleware import _safe_next_target
class FakeRequest:
def __init__(self, path):
self.url = type("URL", (), {"path": path, "query": ""})()
assert _safe_next_target(FakeRequest("//evil.com")) == ""
def test_safe_next_validator_rejects_login_loop(self):
from hermes_cli.dashboard_auth.middleware import _safe_next_target
class FakeRequest:
def __init__(self, path):
self.url = type("URL", (), {"path": path, "query": ""})()
assert _safe_next_target(FakeRequest("/login")) == ""
assert _safe_next_target(FakeRequest("/auth/login")) == ""
assert _safe_next_target(FakeRequest("/api/auth/me")) == ""
# ---------------------------------------------------------------------------
# /auth/callback honours next= and validates it
# ---------------------------------------------------------------------------
class TestAuthCallbackNext:
"""End-to-end next= propagation through a full OAuth round trip.
These tests drive the real flow exactly as the gate produces it:
1. unauth GET /sessions 302 /login?next=%2Fsessions
2. GET /login?next=%2Fsessions HTML with provider buttons that
carry next=%2Fsessions in their hrefs
3. GET /auth/login?provider=stub&next=%2Fsessions 302 to IDP +
PKCE cookie carrying provider/state/verifier/next
4. IDP returns to /auth/callback?code=...&state=... (NO next on
the callback URL real IDPs only echo back code+state)
5. /auth/callback reads next from the PKCE cookie, validates it,
and redirects there.
Discrimination: each test drives the flow without smuggling
``next=`` onto the callback URL. Under the pre-fix code paths
(/login ignored next=, /auth/login dropped it, /auth/callback read
it from the wrong place), the callback always lands on ``/``. Only
PKCE-cookie carriage produces the correct landing.
"""
def _drive_oauth_via_login(
self, gated_app, *, next_path: str = "",
expect_next_in_button: bool = True,
):
"""Walk /login → /auth/login → IDP-bounce → /auth/callback like
a real browser. ``next_path`` is the path the gate would have
encoded for the user; nothing about the callback URL is
smuggled. ``expect_next_in_button`` controls whether the
rendered /login page is expected to thread next= into the
provider button False for cases where the same-origin
validator drops the value (e.g. //evil.com, /login)."""
login_path = "/login"
if next_path:
login_path = f"/login?next={quote(next_path, safe='')}"
r_login = gated_app.get(login_path, follow_redirects=False)
assert r_login.status_code == 200
# Click the stub provider button. Real browsers parse the HTML;
# we extract the href the page emitted, so a regression that
# forgets to thread next= through the button will surface here.
body = r_login.text
# Each provider button is emitted as an <a class="provider-btn"
# href="/auth/login?provider=stub..."> line.
marker = 'href="'
i = body.find('class="provider-btn"')
assert i != -1, "no provider button in /login HTML"
h = body.find(marker, i) + len(marker)
j = body.find('"', h)
href = body[h:j]
# Critical: the href must carry next= when /login was given
# next= AND the validator accepted it. (This is the property the
# pre-fix render_login_html didn't satisfy.) For rejected
# next= values, the validator drops them at the /login boundary
# and the button href must NOT carry the rogue value.
if next_path and expect_next_in_button:
assert "next=" in href, (
f"login button dropped next= (href={href!r})"
)
if next_path and not expect_next_in_button:
assert "next=" not in href, (
f"login button leaked rejected next= "
f"(next_path={next_path!r}, href={href!r})"
)
r_to_idp = gated_app.get(href, follow_redirects=False)
assert r_to_idp.status_code == 302
# Stub IDP "returns" code+state on the callback URL — same shape
# as a real IDP. Critical: we do NOT append next= here.
state = r_to_idp.headers["location"].split("state=")[1]
return gated_app.get(
f"/auth/callback?code=stub_code&state={state}",
follow_redirects=False,
)
def test_callback_without_next_lands_at_root(self, gated_app):
r = self._drive_oauth_via_login(gated_app)
assert r.status_code == 302
assert r.headers["location"] == "/"
def test_callback_with_safe_next_lands_there(self, gated_app):
r = self._drive_oauth_via_login(gated_app, next_path="/sessions")
assert r.status_code == 302
assert r.headers["location"] == "/sessions"
def test_callback_with_query_string_in_next(self, gated_app):
r = self._drive_oauth_via_login(
gated_app, next_path="/sessions?page=2"
)
assert r.status_code == 302
assert r.headers["location"] == "/sessions?page=2"
def test_callback_rejects_open_redirect(self, gated_app):
# Attacker tries to inject ``next=//evil.com`` at the /login
# boundary, hoping it survives to the callback redirect. The
# /login validator drops it before it reaches the button href
# (and therefore the cookie), so the callback never sees it and
# the user lands at "/".
r = self._drive_oauth_via_login(
gated_app, next_path="//evil.com/steal",
expect_next_in_button=False,
)
assert r.status_code == 302
assert r.headers["location"] == "/"
def test_callback_rejects_login_loop(self, gated_app):
r = self._drive_oauth_via_login(
gated_app, next_path="/login",
expect_next_in_button=False,
)
assert r.status_code == 302
assert r.headers["location"] == "/"
def test_attacker_callback_next_param_is_ignored(self, gated_app):
"""Hardening: even if an attacker crafts a callback URL with a
rogue ``next=`` query parameter, the server reads from the PKCE
cookie (server-set) and ignores the URL value. This pins the
fix against a regression that re-introduces the URL read."""
# Drive a clean login with no next=.
r_login = gated_app.get("/login", follow_redirects=False)
assert r_login.status_code == 200
r_to_idp = gated_app.get(
"/auth/login?provider=stub", follow_redirects=False
)
state = r_to_idp.headers["location"].split("state=")[1]
# Attacker appends next=/internal-admin to the callback URL.
r = gated_app.get(
f"/auth/callback?code=stub_code&state={state}"
f"&next={quote('/internal-admin', safe='')}",
follow_redirects=False,
)
assert r.status_code == 302
# No next= was in the PKCE cookie, so landing must be "/" —
# NOT /internal-admin.
assert r.headers["location"] == "/"
# ---------------------------------------------------------------------------
# Unit-level coverage: render_login_html threads next= into provider buttons
# ---------------------------------------------------------------------------
class TestRenderLoginHtmlNext:
"""Cover ``render_login_html`` directly so a regression that drops
the ``next_path`` parameter is caught at the function boundary, not
only via the full integration walk."""
def setup_method(self):
clear_providers()
register_provider(StubAuthProvider())
def teardown_method(self):
clear_providers()
def test_no_next_emits_plain_button(self):
from hermes_cli.dashboard_auth.login_page import render_login_html
html_out = render_login_html()
assert 'href="/auth/login?provider=stub"' in html_out
assert "next=" not in html_out
def test_next_threaded_url_encoded(self):
from hermes_cli.dashboard_auth.login_page import render_login_html
html_out = render_login_html(next_path="/sessions?page=2")
# next= is URL-encoded — quote(safe='') turns "/" into "%2F",
# "?" into "%3F", "=" into "%3D". The encoded value never
# contains an "&" so the raw "&" separator in the href is
# unambiguous.
assert "next=%2Fsessions%3Fpage%3D2" in html_out
assert "provider=stub&next=" in html_out
def test_next_with_html_metacharacters_is_escaped(self):
"""Defence in depth: even though the caller validates next_path,
we still HTML-escape the rendered value so a regression in the
caller can't trivially produce an HTML-injection sink."""
from hermes_cli.dashboard_auth.login_page import render_login_html
# `"` in a path is already URL-encoded by quote() to %22, so it
# never reaches the HTML escaper as a raw quote. This test pins
# both layers: quote() does its job AND escape() does its.
html_out = render_login_html(next_path='/x"injected')
assert '"injected' not in html_out
assert "%22injected" in html_out
# ---------------------------------------------------------------------------
# Unit-level coverage: /auth/login persists next= into the PKCE cookie
# ---------------------------------------------------------------------------
class TestAuthLoginPkceCookieNext:
"""Cover the ``/auth/login`` route's PKCE cookie payload directly.
The cookie is the round-trip carrier for ``next=``; if /auth/login
forgets to encode it, the callback has no path to honour even when
everything else is wired correctly.
"""
def test_no_next_query_omits_next_segment(self, gated_app):
r = gated_app.get(
"/auth/login?provider=stub", follow_redirects=False
)
assert r.status_code == 302
cookies = r.headers.get_list("set-cookie")
pkce = next(c for c in cookies if "hermes_session_pkce" in c)
assert "next=" not in pkce
def test_safe_next_query_encoded_into_cookie(self, gated_app):
r = gated_app.get(
f"/auth/login?provider=stub&next={quote('/sessions', safe='')}",
follow_redirects=False,
)
cookies = r.headers.get_list("set-cookie")
pkce = next(c for c in cookies if "hermes_session_pkce" in c)
# ``next=`` segment present, URL-encoded.
assert "next=%2Fsessions" in pkce
def test_unsafe_next_query_dropped_from_cookie(self, gated_app):
"""The validator at /auth/login refuses //evil.com BEFORE
storing it. Defence in depth: even if a regression leaks next=
through /login's button rendering, /auth/login is the second
boundary."""
r = gated_app.get(
f"/auth/login?provider=stub&next={quote('//evil.com/x', safe='')}",
follow_redirects=False,
)
cookies = r.headers.get_list("set-cookie")
pkce = next(c for c in cookies if "hermes_session_pkce" in c)
assert "next=" not in pkce
@@ -0,0 +1,81 @@
"""Audit log for dashboard-auth events.
Profile-aware location: ``$HERMES_HOME/logs/dashboard-auth.log``.
Format: one JSON object per line. Token-like kwargs are dropped before
serialisation so we never leak refresh tokens or JWTs to disk.
"""
from __future__ import annotations
import json
import pytest
from hermes_cli.dashboard_auth.audit import audit_log, AuditEvent
@pytest.fixture
def profile_home(tmp_path, monkeypatch):
"""Redirect $HERMES_HOME and ~ to a tmp dir for the duration of the test."""
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
# Some code paths fall back to Path.home() — patch that too.
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
return home
def test_audit_writes_jsonlines(profile_home):
audit_log(AuditEvent.LOGIN_START, provider="nous", ip="1.2.3.4")
audit_log(
AuditEvent.LOGIN_SUCCESS,
provider="nous", user_id="u1",
email="a@b.com", ip="1.2.3.4",
)
path = profile_home / "logs" / "dashboard-auth.log"
assert path.exists(), f"audit log not created at {path}"
lines = path.read_text().strip().splitlines()
assert len(lines) == 2
second = json.loads(lines[1])
assert second["event"] == "login_success"
assert second["provider"] == "nous"
assert second["user_id"] == "u1"
assert second["email"] == "a@b.com"
assert "ts" in second # ISO-8601 timestamp
def test_audit_redacts_token_like_fields(profile_home):
audit_log(
AuditEvent.LOGIN_SUCCESS,
provider="nous", access_token="should-not-appear",
refresh_token="also-not", code="not-this", state="nope",
)
raw = (profile_home / "logs" / "dashboard-auth.log").read_text()
for forbidden in ("should-not-appear", "also-not", "not-this", "nope"):
assert forbidden not in raw, f"token-like value leaked into audit log: {forbidden}"
def test_audit_all_event_types_have_string_values():
for ev in AuditEvent:
assert isinstance(ev.value, str)
assert ev.value
def test_audit_write_failure_does_not_raise(monkeypatch, tmp_path):
"""A broken audit log must not crash auth."""
# Point HERMES_HOME at a file (not a dir) so mkdir/open will fail.
broken = tmp_path / "not-a-dir"
broken.write_text("blocking file")
monkeypatch.setenv("HERMES_HOME", str(broken))
# Should NOT raise.
audit_log(AuditEvent.LOGIN_FAILURE, provider="nous", reason="x")
def test_audit_creates_logs_dir_if_missing(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
# logs/ deliberately does not exist
audit_log(AuditEvent.LOGIN_START, provider="nous")
assert (home / "logs").is_dir()
assert (home / "logs" / "dashboard-auth.log").exists()
@@ -0,0 +1,233 @@
"""Tests for the dashboard-auth cookie helpers."""
from __future__ import annotations
from fastapi import FastAPI
from fastapi.responses import Response
from fastapi.testclient import TestClient
from starlette.requests import Request
from hermes_cli.dashboard_auth.cookies import (
PKCE_COOKIE,
SESSION_AT_COOKIE,
SESSION_RT_COOKIE,
clear_pkce_cookie,
clear_session_cookies,
read_pkce_cookie,
read_session_cookies,
set_pkce_cookie,
set_session_cookies,
)
def _build_app(use_https: bool = True, prefix: str = ""):
app = FastAPI()
@app.get("/set")
def set_endpoint():
r = Response("ok")
set_session_cookies(
r, access_token="AT", refresh_token="RT",
access_token_expires_in=3600, use_https=use_https,
prefix=prefix,
)
return r
@app.get("/set-pkce")
def set_pkce():
r = Response("ok")
set_pkce_cookie(r, payload="provider=stub;state=s;verifier=v",
use_https=use_https, prefix=prefix)
return r
@app.get("/clear")
def clear():
r = Response("ok")
clear_session_cookies(r, prefix=prefix)
clear_pkce_cookie(r, prefix=prefix)
return r
return app
# Cookie name resolution helpers used throughout — the bare name resolves
# to a request-shape-dependent variant (__Host- / __Secure- / bare).
# Tests pin a specific shape so a regression in the name-resolution
# logic fails loudly rather than silently breaking sessions.
def test_session_cookies_use_host_prefix_on_https_direct():
"""HTTPS + no proxy prefix → __Host- prefix (strongest spec
hardening: bound to exact origin, requires Path=/, requires Secure)."""
client = TestClient(_build_app(use_https=True, prefix=""))
r = client.get("/set")
cookies = r.headers.get_list("set-cookie")
at = next(c for c in cookies if c.startswith(f"__Host-{SESSION_AT_COOKIE}="))
rt = next(c for c in cookies if c.startswith(f"__Host-{SESSION_RT_COOKIE}="))
for c in (at, rt):
assert "HttpOnly" in c
assert "samesite=lax" in c.lower()
assert "Secure" in c
assert "Path=/" in c
def test_session_cookies_use_secure_prefix_when_proxied():
"""HTTPS + /hermes prefix → __Secure- prefix (__Host- forbids
Path != "/"; __Secure- keeps the Secure-required hardening)."""
client = TestClient(_build_app(use_https=True, prefix="/hermes"))
r = client.get("/set")
cookies = r.headers.get_list("set-cookie")
at = next(c for c in cookies if c.startswith(f"__Secure-{SESSION_AT_COOKIE}="))
assert "Path=/hermes" in at
assert "Secure" in at
# __Host- variant must NOT be emitted on the prefix path.
assert not any(
c.startswith(f"__Host-{SESSION_AT_COOKIE}=") for c in cookies
)
def test_session_cookies_use_bare_name_on_http():
"""Loopback HTTP dev: __Host- / __Secure- both require Secure, which
we can't set on HTTP. Use bare cookie names."""
client = TestClient(_build_app(use_https=False))
r = client.get("/set")
cookies = r.headers.get_list("set-cookie")
# Bare name present; no __Host- / __Secure- variant emitted.
assert any(c.startswith(f"{SESSION_AT_COOKIE}=") for c in cookies)
assert not any(
c.startswith(f"__Host-{SESSION_AT_COOKIE}=")
or c.startswith(f"__Secure-{SESSION_AT_COOKIE}=")
for c in cookies
)
# No Secure flag (HTTP).
at = next(c for c in cookies if c.startswith(f"{SESSION_AT_COOKIE}="))
assert "Secure" not in at
def test_session_cookies_have_30day_rt_and_token_ttl_at():
client = TestClient(_build_app(use_https=True))
r = client.get("/set")
cookies = r.headers.get_list("set-cookie")
at = next(c for c in cookies if c.startswith(f"__Host-{SESSION_AT_COOKIE}="))
rt = next(c for c in cookies if c.startswith(f"__Host-{SESSION_RT_COOKIE}="))
assert "Max-Age=3600" in at
assert "Max-Age=2592000" in rt # 30 days = 30 * 86400
def test_clear_session_cookies_emits_expired_at_and_rt():
"""``clear_session_cookies`` emits Max-Age=0 deletions for every
plausible cookie-name variant under the active prefix so we flush
stale cookies that an older deploy may have set under a different
prefix."""
client = TestClient(_build_app())
r = client.get("/clear")
cookies = r.headers.get_list("set-cookie")
# At least one variant of each session cookie should be deleted.
assert any(
SESSION_AT_COOKIE in c and "Max-Age=0" in c for c in cookies
)
assert any(
SESSION_RT_COOKIE in c and "Max-Age=0" in c for c in cookies
)
def test_pkce_cookie_short_ttl_and_path_root():
client = TestClient(_build_app(use_https=True))
r = client.get("/set-pkce")
pkce = next(
c for c in r.headers.get_list("set-cookie")
if PKCE_COOKIE in c
)
assert "HttpOnly" in pkce
assert "Max-Age=600" in pkce # 10 minutes
assert "Path=/" in pkce
assert "Secure" in pkce
def test_read_session_cookies_from_request_bare_name():
"""Reader accepts the bare name (loopback) by default."""
scope = {
"type": "http",
"method": "GET",
"path": "/",
"headers": [(
b"cookie",
f"{SESSION_AT_COOKIE}=at_value; {SESSION_RT_COOKIE}=rt_value".encode(),
)],
}
req = Request(scope)
at, rt = read_session_cookies(req)
assert at == "at_value"
assert rt == "rt_value"
def test_read_session_cookies_from_request_host_prefix():
"""Reader also finds cookies set with the __Host- variant
(HTTPS direct deploy)."""
scope = {
"type": "http",
"method": "GET",
"path": "/",
"headers": [(
b"cookie",
f"__Host-{SESSION_AT_COOKIE}=at_value; "
f"__Host-{SESSION_RT_COOKIE}=rt_value".encode(),
)],
}
req = Request(scope)
at, rt = read_session_cookies(req)
assert at == "at_value"
assert rt == "rt_value"
def test_read_session_cookies_from_request_secure_prefix():
"""Reader also finds cookies set with the __Secure- variant
(HTTPS behind a proxy prefix)."""
scope = {
"type": "http",
"method": "GET",
"path": "/",
"headers": [(
b"cookie",
f"__Secure-{SESSION_AT_COOKIE}=at_value; "
f"__Secure-{SESSION_RT_COOKIE}=rt_value".encode(),
)],
}
req = Request(scope)
at, rt = read_session_cookies(req)
assert at == "at_value"
assert rt == "rt_value"
def test_read_session_cookies_missing_returns_none():
req = Request({"type": "http", "method": "GET", "path": "/", "headers": []})
assert read_session_cookies(req) == (None, None)
def test_read_pkce_cookie_round_trip():
scope = {
"type": "http",
"method": "GET",
"path": "/",
"headers": [(b"cookie", f"{PKCE_COOKIE}=state=s;verifier=v".encode())],
}
req = Request(scope)
assert read_pkce_cookie(req) == "state=s" # NB: cookie value stops at ';'
def test_detect_https_via_scheme():
"""``detect_https`` reads from request.url.scheme.
Under uvicorn proxy_headers=True the scheme is rewritten from
``X-Forwarded-Proto``; that's an integration concern, not unit.
"""
from hermes_cli.dashboard_auth.cookies import detect_https
http_req = Request({
"type": "http", "method": "GET", "path": "/", "scheme": "http",
"headers": [], "server": ("x", 80),
})
https_req = Request({
"type": "http", "method": "GET", "path": "/", "scheme": "https",
"headers": [], "server": ("x", 443),
})
assert detect_https(http_req) is False
assert detect_https(https_req) is True
@@ -0,0 +1,259 @@
"""Regression harness for the dashboard auth gate.
Phase 0 establish a baseline pin on the current (pre-OAuth) behavior so
later phases can prove they didn't break loopback mode.
"""
import pytest
# Phase 5 / Phase 6: these tests mutate ``web_server.app.state.auth_required``
# at module level. Run them in the same xdist worker so they don't race
# against each other (and against any other file that also touches
# ``app.state``) — the marker name is shared across all dashboard-auth test
# files that gate the app.
pytestmark = pytest.mark.xdist_group("dashboard_auth_app_state")
from fastapi.testclient import TestClient
from hermes_cli import web_server
@pytest.fixture
def client_loopback():
# Pin the bound-host state for host_header_middleware so requests with
# default Host: testclient pass the DNS-rebinding check. TestClient
# sends Host: testserver by default, but our middleware accepts the
# loopback aliases when bound_host is loopback.
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
web_server.app.state.bound_host = "127.0.0.1"
web_server.app.state.bound_port = 9119
client = TestClient(web_server.app, base_url="http://127.0.0.1:9119")
yield client
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
def test_loopback_status_is_public(client_loopback):
"""`/api/status` must remain reachable without a token in loopback mode."""
r = client_loopback.get("/api/status")
assert r.status_code == 200
body = r.json()
assert "version" in body
def test_loopback_protected_route_requires_token(client_loopback):
"""Any non-public /api/ route must require the session token."""
# /api/sessions exists and is auth-gated by auth_middleware.
r = client_loopback.get("/api/sessions")
assert r.status_code == 401
def test_loopback_protected_route_accepts_session_token(client_loopback):
"""The injected SPA token unlocks protected /api/ routes."""
r = client_loopback.get(
"/api/sessions",
headers={"X-Hermes-Session-Token": web_server._SESSION_TOKEN},
)
# 200 or 404 (no sessions yet) both prove the auth layer let it through.
# 500 is also acceptable if there's a downstream issue unrelated to auth.
assert r.status_code != 401, (
f"Expected auth to succeed but got 401; body: {r.text}"
)
def test_loopback_index_injects_session_token(client_loopback):
"""Loopback mode keeps injecting the SPA token into index.html.
This is the property that the new auth gate MUST disable once a gated
bind is detected. Phase 3 will add an inverse test for the gated path.
"""
r = client_loopback.get("/")
if r.status_code == 404:
pytest.skip("WEB_DIST not built in this env")
assert "__HERMES_SESSION_TOKEN__" in r.text
def test_loopback_host_header_validation_still_enforced(client_loopback):
"""DNS-rebinding protection: a foreign Host header is rejected."""
r = client_loopback.get("/api/status", headers={"Host": "evil.test"})
assert r.status_code == 400
# ---------------------------------------------------------------------------
# should_require_auth predicate (Task 0.2)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("host,allow_public,expected", [
("127.0.0.1", False, False),
("127.0.0.1", True, False),
("localhost", False, False),
("::1", False, False),
("0.0.0.0", True, False), # --insecure escape hatch
("0.0.0.0", False, True),
("192.168.1.5", False, True),
("10.0.0.1", True, False),
("100.64.0.1", False, True), # Tailscale CGNAT — treated as public
("hermes-agent-prod-abc.fly.dev", False, True),
])
def test_should_require_auth_truth_table(host, allow_public, expected):
from hermes_cli.web_server import should_require_auth
assert should_require_auth(host, allow_public) is expected
# ---------------------------------------------------------------------------
# start_server stashes auth_required on app.state (Task 0.3)
# ---------------------------------------------------------------------------
def _stub_uvicorn_run(monkeypatch):
"""Replace uvicorn.run with a no-op recorder so start_server returns
immediately (rather than blocking on the event loop). Returns the dict
that will capture the keyword args."""
import uvicorn
captured: dict = {}
def _fake_run(*args, **kwargs):
captured["args"] = args
captured["kwargs"] = kwargs
monkeypatch.setattr(uvicorn, "run", _fake_run)
return captured
def test_start_server_loopback_sets_auth_required_false(monkeypatch):
"""Loopback bind: app.state.auth_required is False after start_server."""
_stub_uvicorn_run(monkeypatch)
# Force a fresh state to detect that start_server actually set it.
web_server.app.state.auth_required = None
web_server.start_server(
host="127.0.0.1", port=9119,
open_browser=False, allow_public=False,
)
assert web_server.app.state.auth_required is False
def test_start_server_insecure_public_sets_auth_required_false(monkeypatch):
"""``--insecure`` (allow_public=True) on a public host: gate stays OFF."""
_stub_uvicorn_run(monkeypatch)
web_server.app.state.auth_required = None
web_server.start_server(
host="0.0.0.0", port=9119,
open_browser=False, allow_public=True,
)
assert web_server.app.state.auth_required is False
def test_start_server_public_without_insecure_records_auth_required(monkeypatch):
"""Public bind without --insecure: the gate engages and auth_required=True.
With no providers registered, this fails closed with SystemExit. The
flag-stashing happens BEFORE the exit so the rest of the system can
branch on it. (See task 3.5 tests below for the with-provider path.)
"""
from hermes_cli.dashboard_auth import clear_providers
clear_providers()
_stub_uvicorn_run(monkeypatch)
web_server.app.state.auth_required = None
with pytest.raises(SystemExit):
web_server.start_server(
host="0.0.0.0", port=9119,
open_browser=False, allow_public=False,
)
assert web_server.app.state.auth_required is True
# ---------------------------------------------------------------------------
# Task 3.5: start_server fail-closed + proxy_headers + index-token suppression
# ---------------------------------------------------------------------------
def test_start_server_gate_with_provider_proceeds_and_sets_proxy_headers(monkeypatch):
"""With at least one provider, public bind + no --insecure starts the server.
The SystemExit-refusing-to-bind guard is REPLACED in gated mode by
"the gate engages", so as long as a provider is registered the bind
succeeds. uvicorn is called with proxy_headers=True so X-Forwarded-Proto
from Fly's TLS terminator is honoured for cookie Secure-flag decisions.
"""
from hermes_cli.dashboard_auth import clear_providers, register_provider
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
clear_providers()
register_provider(StubAuthProvider())
captured = _stub_uvicorn_run(monkeypatch)
try:
web_server.app.state.auth_required = None
web_server.start_server(
host="0.0.0.0", port=9119,
open_browser=False, allow_public=False,
)
assert web_server.app.state.auth_required is True
assert captured["kwargs"].get("host") == "0.0.0.0"
assert captured["kwargs"].get("proxy_headers") is True
finally:
clear_providers()
def test_start_server_gate_without_provider_fails_closed(monkeypatch):
"""No providers + gate would activate → SystemExit with a clear message."""
from hermes_cli.dashboard_auth import clear_providers
clear_providers()
_stub_uvicorn_run(monkeypatch)
web_server.app.state.auth_required = None
with pytest.raises(SystemExit, match=r"no auth providers"):
web_server.start_server(
host="0.0.0.0", port=9119,
open_browser=False, allow_public=False,
)
def test_start_server_surfaces_nous_skip_reason_when_unconfigured(monkeypatch):
"""When the bundled Nous plugin loaded but skipped registration (no
env vars set), the gate's fail-closed message should surface the
plugin's LAST_SKIP_REASON so the operator knows the config fix is
'set HERMES_DASHBOARD_OAUTH_CLIENT_ID', not 'install a plugin'."""
from hermes_cli.dashboard_auth import clear_providers
from plugins.dashboard_auth import nous as nous_plugin
# Simulate the plugin running and skipping for "no client_id".
clear_providers()
_stub_uvicorn_run(monkeypatch)
monkeypatch.delenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", raising=False)
monkeypatch.delenv("HERMES_DASHBOARD_PORTAL_URL", raising=False)
from unittest.mock import MagicMock
nous_plugin.register(MagicMock()) # populates LAST_SKIP_REASON
assert "HERMES_DASHBOARD_OAUTH_CLIENT_ID" in nous_plugin.LAST_SKIP_REASON
web_server.app.state.auth_required = None
with pytest.raises(SystemExit) as exc_info:
web_server.start_server(
host="0.0.0.0", port=9119,
open_browser=False, allow_public=False,
)
# The error message embeds the plugin's specific skip reason rather
# than the generic "Install the default Nous provider" boilerplate.
msg = str(exc_info.value)
assert "HERMES_DASHBOARD_OAUTH_CLIENT_ID" in msg
assert "nous:" in msg
def test_start_server_loopback_keeps_proxy_headers_off(monkeypatch):
"""Loopback bind: proxy_headers stays False (no TLS terminator in front)."""
captured = _stub_uvicorn_run(monkeypatch)
web_server.start_server(
host="127.0.0.1", port=9119,
open_browser=False, allow_public=False,
)
assert captured["kwargs"].get("proxy_headers") is False
def test_start_server_insecure_keeps_proxy_headers_off(monkeypatch):
"""--insecure: gate stays off, proxy_headers stays off."""
captured = _stub_uvicorn_run(monkeypatch)
web_server.start_server(
host="0.0.0.0", port=9119,
open_browser=False, allow_public=True,
)
assert web_server.app.state.auth_required is False
assert captured["kwargs"].get("proxy_headers") is False
@@ -0,0 +1,342 @@
"""End-to-end behavioural tests for the dashboard auth gate.
Uses ``StubAuthProvider`` so the OAuth round trip can complete in-process
without any external IDP. Exercises:
* `/api/status` flips from public (loopback) to gated (auth_required)
* `/` redirects to /login when no cookie present
* `/api/auth/providers` is the public bootstrap endpoint
* `/login` renders HTML listing all providers
* /assets/* still passes through unauthenticated
* Full /auth/login /auth/callback / round trip with the stub
* Invalid / missing cookies return 401 (api) or 302 (html)
* Zero-providers + gate-on fails closed
"""
from __future__ import annotations
import pytest
# Phase 5 / Phase 6: these tests mutate ``web_server.app.state.auth_required``
# at module level. Run them in the same xdist worker so they don't race
# against each other (and against any other file that also touches
# ``app.state``) — the marker name is shared across all dashboard-auth test
# files that gate the app.
pytestmark = pytest.mark.xdist_group("dashboard_auth_app_state")
from fastapi.testclient import TestClient
from hermes_cli import web_server
from hermes_cli.dashboard_auth import clear_providers, register_provider
from hermes_cli.dashboard_auth.cookies import SESSION_AT_COOKIE
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
@pytest.fixture
def gated_app():
"""Configure web_server.app for gated mode + register the stub provider."""
clear_providers()
register_provider(StubAuthProvider())
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
prev_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.bound_host = "fly-app.fly.dev"
web_server.app.state.bound_port = 443
web_server.app.state.auth_required = True
# Use https base_url so cookies pick up Secure flag and host_header
# matches the bound interface.
client = TestClient(web_server.app, base_url="https://fly-app.fly.dev")
yield client
clear_providers()
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
web_server.app.state.auth_required = prev_required
# ---------------------------------------------------------------------------
# Allowlist (public) routes
# ---------------------------------------------------------------------------
def test_gated_status_is_public(gated_app):
"""``/api/status`` MUST be public under the OAuth gate.
Regression guard for the wildcard-subdomain rollout: NAS
(``fly-provider.ts`` ``getInstanceRuntimeStatus``) hits
``/api/status`` without a cookie as its sole liveness probe. A 401
here surfaces every healthy agent as STARTING/down in the portal
UI. The endpoint returns only version + gateway/auth-gate metadata
(no user data, no session content), so it stays in the shared
``PUBLIC_API_PATHS`` allowlist under both the legacy ``_SESSION_TOKEN``
gate and the OAuth gate.
The body also reports the gate's shape (``auth_required``,
``auth_providers``) so the SPA's StatusPage and external monitors
can distinguish loopback / gated / no-providers without a separate
round trip.
"""
r = gated_app.get("/api/status")
assert r.status_code == 200, (
f"Expected 200, got {r.status_code}: {r.text}"
)
body = r.json()
assert body["auth_required"] is True
assert "version" in body
assert "gateway_state" in body
@pytest.mark.parametrize("path", [
"/api/config/defaults",
"/api/config/schema",
"/api/model/info",
"/api/dashboard/themes",
"/api/dashboard/plugins",
])
def test_other_public_api_paths_are_public_under_gate(gated_app, path):
"""The remaining ``PUBLIC_API_PATHS`` entries must also bypass the
gate. They're documented as non-sensitive read-only endpoints that
the SPA pre-loads before login (themes, config schema, model
metadata). A 401 / 302-to-login here would block the dashboard
shell from rendering pre-auth.
Accept any non-auth-failure status: 200 when the route succeeds,
or any route-specific error (e.g. 400 / 404 / 500 from a missing
dependency) but NEVER 401, and NEVER a 302 to ``/login``.
"""
r = gated_app.get(path, follow_redirects=False)
assert r.status_code != 401, (
f"{path} returned 401 under the OAuth gate — should be public"
)
if r.status_code == 302:
location = r.headers.get("location", "")
assert "/login" not in location, (
f"{path} redirected to {location} — should be public, "
"not bounced to /login"
)
def test_gated_html_redirects_to_login(gated_app):
r = gated_app.get("/", follow_redirects=False)
assert r.status_code == 302
# Phase 6: gate carries a ``next=`` so post-login bounces back to /.
assert r.headers["location"] in ("/login", "/login?next=%2F")
def test_gated_auth_providers_is_public(gated_app):
r = gated_app.get("/api/auth/providers")
assert r.status_code == 200
body = r.json()
assert any(p["name"] == "stub" for p in body["providers"])
assert body["providers"][0]["display_name"] == "Stub IdP (test only)"
def test_gated_login_html_is_public_and_lists_providers(gated_app):
r = gated_app.get("/login")
assert r.status_code == 200
assert r.headers["content-type"].startswith("text/html")
assert "Stub IdP" in r.text
assert 'href="/auth/login?provider=stub"' in r.text
def test_gated_static_asset_path_is_public(gated_app):
"""``/assets/*`` is allowlisted so the SPA's CSS/JS loads pre-login."""
r = gated_app.get("/assets/_nonexistent.css")
# 404 not 401 — proves middleware let the request through to the
# static-files mount, which then 404'd because the file isn't there.
assert r.status_code == 404
# ---------------------------------------------------------------------------
# OAuth round trip
# ---------------------------------------------------------------------------
def test_full_login_round_trip_unlocks_gated_api(gated_app):
# 1) Click "Sign in with Stub IdP" — /auth/login redirects to the stub
# with a PKCE cookie on the response.
r1 = gated_app.get("/auth/login?provider=stub", follow_redirects=False)
assert r1.status_code == 302
pkce = next(
(c for c in r1.headers.get_list("set-cookie")
if "hermes_session_pkce" in c),
None,
)
assert pkce and "HttpOnly" in pkce
redirect = r1.headers["location"]
# Stub bounces back to {redirect_uri}?code=stub_code&state=<s>
assert "code=stub_code" in redirect
assert "state=" in redirect
state = redirect.split("state=")[1]
# 2) The browser would now follow the redirect to /auth/callback.
# TestClient automatically carries the PKCE cookie forward.
r2 = gated_app.get(
f"/auth/callback?code=stub_code&state={state}",
follow_redirects=False,
)
assert r2.status_code == 302
assert r2.headers["location"] == "/"
set_cookies = r2.headers.get_list("set-cookie")
assert any("hermes_session_at" in c for c in set_cookies)
assert any("hermes_session_rt" in c for c in set_cookies)
# 3) A gated API route (``/api/sessions``) now succeeds because we
# have a valid session cookie. (We deliberately don't probe
# ``/api/status`` here — it's in the shared PUBLIC_API_PATHS
# allowlist and would 200 even without a login, so it can't
# distinguish "logged in" from "gate accidentally disabled".)
r3 = gated_app.get("/api/sessions")
assert r3.status_code == 200, (
f"Expected 200 for /api/sessions post-login, got {r3.status_code}: "
f"{r3.text}"
)
def test_login_unknown_provider_returns_404(gated_app):
r = gated_app.get("/auth/login?provider=nonexistent", follow_redirects=False)
assert r.status_code == 404
def test_callback_without_pkce_cookie_returns_400(gated_app):
# No prior /auth/login → no PKCE cookie.
r = gated_app.get(
"/auth/callback?code=stub_code&state=anything",
follow_redirects=False,
)
assert r.status_code == 400
def test_callback_state_mismatch_returns_400(gated_app):
# Walk through /auth/login first to plant the PKCE cookie.
r1 = gated_app.get("/auth/login?provider=stub", follow_redirects=False)
# ...then pretend the IDP returned a different state.
r2 = gated_app.get(
"/auth/callback?code=stub_code&state=WRONG",
follow_redirects=False,
)
assert r2.status_code == 400
def test_callback_invalid_code_returns_400(gated_app):
r1 = gated_app.get("/auth/login?provider=stub", follow_redirects=False)
state = r1.headers["location"].split("state=")[1]
r2 = gated_app.get(
f"/auth/callback?code=BAD_CODE&state={state}",
follow_redirects=False,
)
assert r2.status_code == 400
# ---------------------------------------------------------------------------
# Cookie validation
# ---------------------------------------------------------------------------
def test_invalid_cookie_returns_401_on_api(gated_app):
gated_app.cookies.set(SESSION_AT_COOKIE, "garbage-not-a-real-token")
r = gated_app.get("/api/sessions")
assert r.status_code == 401
def test_invalid_cookie_redirects_on_html(gated_app):
gated_app.cookies.set(SESSION_AT_COOKIE, "garbage")
r = gated_app.get("/", follow_redirects=False)
assert r.status_code == 302
# Phase 6: gate carries a ``next=`` so post-login bounces back to /.
assert r.headers["location"] in ("/login", "/login?next=%2F")
def test_logout_clears_cookies_and_redirects_to_login(gated_app):
# First log in.
r1 = gated_app.get("/auth/login?provider=stub", follow_redirects=False)
state = r1.headers["location"].split("state=")[1]
gated_app.get(
f"/auth/callback?code=stub_code&state={state}",
follow_redirects=False,
)
# Now log out.
r = gated_app.post("/auth/logout", follow_redirects=False)
assert r.status_code == 302
assert r.headers["location"] == "/login"
set_cookies = r.headers.get_list("set-cookie")
assert any(
c.startswith("hermes_session_at=") and "Max-Age=0" in c
for c in set_cookies
)
assert any(
c.startswith("hermes_session_rt=") and "Max-Age=0" in c
for c in set_cookies
)
# ---------------------------------------------------------------------------
# Identity probe
# ---------------------------------------------------------------------------
def test_api_auth_me_returns_session_after_login(gated_app):
r1 = gated_app.get("/auth/login?provider=stub", follow_redirects=False)
state = r1.headers["location"].split("state=")[1]
gated_app.get(
f"/auth/callback?code=stub_code&state={state}",
follow_redirects=False,
)
r = gated_app.get("/api/auth/me")
assert r.status_code == 200
body = r.json()
assert body["user_id"] == "stub-user-1"
assert body["email"] == "stub@example.test"
assert body["display_name"] == "Stub User"
assert body["provider"] == "stub"
assert body["org_id"] == "stub-org-1"
assert "expires_at" in body
def test_api_auth_me_requires_auth(gated_app):
# No cookies.
r = gated_app.get("/api/auth/me")
assert r.status_code == 401
# ---------------------------------------------------------------------------
# Zero-providers fail-closed
# ---------------------------------------------------------------------------
def test_gated_zero_providers_fails_closed_on_api_auth_providers():
"""If gate is on but no providers are registered, /api/auth/providers 503s."""
clear_providers()
prev_required = getattr(web_server.app.state, "auth_required", None)
prev_host = getattr(web_server.app.state, "bound_host", None)
web_server.app.state.bound_host = "fly-app.fly.dev"
web_server.app.state.auth_required = True
try:
client = TestClient(web_server.app, base_url="https://fly-app.fly.dev")
r = client.get("/api/auth/providers")
assert r.status_code == 503
assert "no auth providers" in r.text.lower()
finally:
web_server.app.state.auth_required = prev_required
web_server.app.state.bound_host = prev_host
def test_gated_zero_providers_login_page_renders_help_text():
clear_providers()
prev_required = getattr(web_server.app.state, "auth_required", None)
prev_host = getattr(web_server.app.state, "bound_host", None)
web_server.app.state.bound_host = "fly-app.fly.dev"
web_server.app.state.auth_required = True
try:
client = TestClient(web_server.app, base_url="https://fly-app.fly.dev")
r = client.get("/login")
assert r.status_code == 200
# Empty-provider HTML mentions the fix-up path. (HTML wraps text
# so we can't grep for the exact phrase; check for the canonical
# fragments instead.)
text = r.text.lower()
assert "sign-in unavailable" in text
assert "no authentication" in text
assert "providers are installed" in text
assert "--insecure" in text
finally:
web_server.app.state.auth_required = prev_required
web_server.app.state.bound_host = prev_host
@@ -0,0 +1,90 @@
"""The plugin context exposes register_dashboard_auth_provider.
Mirrors the image-gen / memory-provider hooks (see plugins.py:531 for prior
art).
"""
from __future__ import annotations
import pytest
from hermes_cli.dashboard_auth import clear_providers, get_provider
from hermes_cli.dashboard_auth.base import (
DashboardAuthProvider, LoginStart, Session,
)
from hermes_cli.plugins import PluginContext, PluginManifest
class _Stub(DashboardAuthProvider):
name = "stub"
display_name = "Stub IdP"
def start_login(self, *, redirect_uri):
return LoginStart(redirect_url="x", cookie_payload={})
def complete_login(self, *, code, state, code_verifier, redirect_uri):
return Session("u", "e", "n", "o", "stub", 0, "a", "r")
def verify_session(self, *, access_token):
return None
def refresh_session(self, *, refresh_token):
return Session("u", "e", "n", "o", "stub", 0, "a", "r")
def revoke_session(self, *, refresh_token):
return None
class _MinimalManager:
"""The fixture only needs whatever PluginContext touches at register-time.
We don't import the real PluginManager because it pulls in the full
plugin-discovery surface. The hook we're testing only reads from
``ctx.manifest``, so the manager attributes don't matter — but we set
the few that other PluginContext methods touch defensively.
"""
_cli_ref = None
_context_engine = None
_tools: dict = {}
@pytest.fixture(autouse=True)
def _isolated_registry():
clear_providers()
yield
clear_providers()
def _make_ctx(name: str = "dashboard-auth-stub") -> PluginContext:
manifest = PluginManifest(name=name, version="0.0.1", description="stub")
return PluginContext(manifest=manifest, manager=_MinimalManager()) # type: ignore[arg-type]
def test_plugin_ctx_exposes_register_dashboard_auth_provider():
ctx = _make_ctx()
assert hasattr(ctx, "register_dashboard_auth_provider")
def test_plugin_ctx_register_dashboard_auth_provider_happy_path():
ctx = _make_ctx()
ctx.register_dashboard_auth_provider(_Stub())
p = get_provider("stub")
assert p is not None
assert p.display_name == "Stub IdP"
def test_plugin_ctx_silently_ignores_non_provider(caplog):
"""Mirror image_gen behaviour: log warning, leave registry empty.
We do NOT raise a misbehaving plugin must not crash the host.
"""
import logging
ctx = _make_ctx("dashboard-auth-bad")
with caplog.at_level(logging.WARNING):
ctx.register_dashboard_auth_provider("not a provider") # type: ignore[arg-type]
assert get_provider("stub") is None
assert any(
"dashboard-auth-bad" in rec.message
and "DashboardAuthProvider" in rec.message
for rec in caplog.records
)
@@ -0,0 +1,559 @@
"""Path-prefix (X-Forwarded-Prefix) awareness for the dashboard-auth gate.
Mission-control style deployments reverse-proxy the dashboard at a path
prefix (e.g. ``mission-control.tilos.com/hermes/*`` -> local Caddy ->
:9119), injecting ``X-Forwarded-Prefix: /hermes`` on every request.
The dashboard already honours this for the SPA bundle (rewriting asset
URLs and the bootstrap ``__HERMES_BASE_PATH__``). The OAuth gate must
honour it too:
1. The gate's ``Location:`` redirect to /login (in
``_unauth_response``) needs to be ``/hermes/login`` so the browser
follows it through the proxy.
2. The 401 JSON envelope's ``login_url`` needs the same prefix so the
SPA's full-page navigation lands at the proxied login page.
3. ``_redirect_uri`` (the OAuth callback URL handed to the IDP) must
reconstruct the public URL including the prefix, otherwise the IDP
redirects back to ``/auth/callback`` instead of
``/hermes/auth/callback`` and the user gets 404.
4. Cookies must use ``Path=/hermes`` when behind a prefix so they
don't leak to other apps on the same origin AND so they get sent
back to the dashboard on subsequent requests under the prefix.
5. The ``__Host-`` cookie prefix requires ``Path=/`` when behind an
X-Forwarded-Prefix we use ``__Secure-`` instead (matches every
hardening property except scope, which the explicit ``Path``
covers).
These tests document the wire-level contract so a regression in any of
those rules surfaces before a Mission Control deploy.
"""
from __future__ import annotations
import pytest
# Same xdist group as the other dashboard-auth tests — they all mutate
# web_server.app.state.auth_required at module level.
pytestmark = pytest.mark.xdist_group("dashboard_auth_app_state")
from fastapi.testclient import TestClient
from hermes_cli import web_server
from hermes_cli.dashboard_auth import clear_providers, register_provider
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
@pytest.fixture
def gated_app_proxied():
"""web_server.app configured for gated mode with proxy_headers + a
public Host that simulates the Mission Control reverse proxy.
The ``base_url`` sets ``host:scheme`` defaults so we don't have to
pass them on every request. ``X-Forwarded-Prefix`` is passed
per-request because the TestClient doesn't have a way to default
request headers.
"""
clear_providers()
register_provider(StubAuthProvider())
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
prev_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.bound_host = "mission-control.tilos.com"
web_server.app.state.bound_port = 443
web_server.app.state.auth_required = True
client = TestClient(
web_server.app,
base_url="https://mission-control.tilos.com",
)
yield client
clear_providers()
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
web_server.app.state.auth_required = prev_required
@pytest.fixture
def gated_app_direct():
"""web_server.app configured for gated mode WITHOUT a proxy prefix,
for the Fly-direct deploy shape (no path mounting).
"""
clear_providers()
register_provider(StubAuthProvider())
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
prev_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.bound_host = "fly-app.fly.dev"
web_server.app.state.bound_port = 443
web_server.app.state.auth_required = True
client = TestClient(
web_server.app,
base_url="https://fly-app.fly.dev",
)
yield client
clear_providers()
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
web_server.app.state.auth_required = prev_required
# ---------------------------------------------------------------------------
# Gate middleware: Location: header and 401 envelope respect prefix
# ---------------------------------------------------------------------------
class TestGateRedirectsCarryPrefix:
def test_html_redirect_to_login_carries_prefix(self, gated_app_proxied):
r = gated_app_proxied.get(
"/sessions",
headers={"x-forwarded-prefix": "/hermes"},
follow_redirects=False,
)
assert r.status_code == 302
# /login redirect must include the prefix or the browser will
# follow it to mission-control.tilos.com/login (which the proxy
# doesn't route to the dashboard).
assert r.headers["location"].startswith("/hermes/login"), (
f"Location header lost prefix: {r.headers['location']!r}"
)
def test_api_401_envelope_login_url_carries_prefix(self, gated_app_proxied):
r = gated_app_proxied.get(
"/api/sessions",
headers={"x-forwarded-prefix": "/hermes"},
follow_redirects=False,
)
assert r.status_code == 401
body = r.json()
# SPA does window.location.assign(body.login_url); this MUST
# include the prefix.
assert body["login_url"].startswith("/hermes/login"), (
f"401 envelope login_url lost prefix: {body['login_url']!r}"
)
def test_no_prefix_header_keeps_unprefixed_paths(self, gated_app_direct):
"""When no X-Forwarded-Prefix is sent, the Location header must
NOT gain a phantom prefix the Fly-direct deploy shape has no
proxy at all."""
r = gated_app_direct.get("/sessions", follow_redirects=False)
assert r.status_code == 302
assert r.headers["location"] == "/login?next=%2Fsessions"
def test_malformed_prefix_header_is_ignored(self, gated_app_proxied):
"""A hostile proxy injects ``X-Forwarded-Prefix: <script>``;
the normaliser rejects it and the gate falls back to unprefixed
URLs. Defence against header-injection HTML inside Location."""
r = gated_app_proxied.get(
"/sessions",
headers={"x-forwarded-prefix": "<script>alert(1)</script>"},
follow_redirects=False,
)
assert r.status_code == 302
assert "<script>" not in r.headers["location"]
assert r.headers["location"].startswith("/login")
# ---------------------------------------------------------------------------
# /auth/login: the OAuth redirect_uri reflects the proxy prefix
# ---------------------------------------------------------------------------
class TestOAuthRedirectUriRespectsPrefix:
def test_redirect_uri_includes_prefix_in_authorize_url(
self, gated_app_proxied
):
"""The IDP returns the user to the redirect_uri we sent. If we
don't include the prefix, the IDP redirects to
``https://mission-control.tilos.com/auth/callback`` instead of
``https://mission-control.tilos.com/hermes/auth/callback`` the
former routes to the MC frontend, not the dashboard, so the
user gets 404."""
r = gated_app_proxied.get(
"/auth/login?provider=stub",
headers={"x-forwarded-prefix": "/hermes"},
follow_redirects=False,
)
assert r.status_code == 302
location = r.headers["location"]
# The stub IDP's redirect_url echoes the redirect_uri back. The
# real IDP would consume it and later use it to redirect the
# user, so the byte-exact value MUST include the prefix.
from urllib.parse import urlparse
# Stub returns ``{redirect_uri}?code=stub_code&state=...`` — so
# we read up to the first ``?``.
redirect_uri = location.split("?", 1)[0]
# Absolute https URL including prefix.
parsed = urlparse(redirect_uri)
assert parsed.scheme == "https"
assert parsed.netloc == "mission-control.tilos.com"
assert parsed.path == "/hermes/auth/callback", (
f"redirect_uri dropped prefix: {redirect_uri!r}"
)
def test_redirect_uri_no_prefix_when_direct_deploy(
self, gated_app_direct
):
r = gated_app_direct.get(
"/auth/login?provider=stub", follow_redirects=False
)
assert r.status_code == 302
redirect_uri = r.headers["location"].split("?", 1)[0]
from urllib.parse import urlparse
parsed = urlparse(redirect_uri)
assert parsed.netloc == "fly-app.fly.dev"
assert parsed.path == "/auth/callback"
# ---------------------------------------------------------------------------
# HERMES_DASHBOARD_PUBLIC_URL / dashboard.public_url override
# ---------------------------------------------------------------------------
class TestPublicUrlOverride:
"""``dashboard.public_url`` (env override:
``HERMES_DASHBOARD_PUBLIC_URL``) lets an operator force the absolute
base URL the OAuth ``redirect_uri`` is built from.
When set, it is the *complete authority* scheme + host + optional
path prefix. ``X-Forwarded-Prefix`` is ignored on that code path
because the operator has explicitly declared the public URL and we
no longer need to guess from proxy headers. This is the relief
valve for deploys behind reverse proxies that don't set
``X-Forwarded-Host`` / ``X-Forwarded-Proto`` / ``X-Forwarded-Prefix``
correctly (or at all) manual nginx setups, on-prem ingresses,
Fly.io deploys with custom domains where the proxy header chain is
incomplete.
When unset, the existing ``proxy_headers=True`` + X-Forwarded-Prefix
reconstruction path runs untouched. Existing Fly.io deploys
continue to work without configuration.
Precedence (mirrors ``client_id``):
env (non-empty) > config.yaml > reconstructed from request
"""
@pytest.fixture
def patch_config(self, monkeypatch):
"""Replace ``hermes_cli.config.load_config`` with a stub
returning the given ``public_url``. Pass ``None`` to set no
config-side value."""
def _set(public_url) -> None:
cfg = {}
if public_url is not None:
cfg = {"dashboard": {"public_url": public_url}}
monkeypatch.setattr(
"hermes_cli.config.load_config", lambda: cfg
)
return _set
def _redirect_uri(self, gated_app, *, headers=None) -> str:
"""Drive /auth/login and read the redirect_uri the IDP saw."""
r = gated_app.get(
"/auth/login?provider=stub",
headers=headers or {},
follow_redirects=False,
)
assert r.status_code == 302, r.text
# Stub IDP echoes redirect_uri back as the prefix of the
# Location header (`{redirect_uri}?code=stub_code&state=…`).
return r.headers["location"].split("?", 1)[0]
def test_public_url_env_overrides_request_reconstruction(
self, gated_app_direct, patch_config, monkeypatch
):
"""``HERMES_DASHBOARD_PUBLIC_URL`` wins over the URL the
request would otherwise reconstruct to. Critical for deploys
whose proxy headers don't match the public URL."""
patch_config(None)
monkeypatch.setenv(
"HERMES_DASHBOARD_PUBLIC_URL", "https://custom.example",
)
redirect_uri = self._redirect_uri(gated_app_direct)
assert redirect_uri == "https://custom.example/auth/callback", (
f"public_url env var didn't override reconstruction "
f"(got {redirect_uri!r})"
)
def test_public_url_config_yaml_used_when_env_unset(
self, gated_app_direct, patch_config, monkeypatch
):
monkeypatch.delenv("HERMES_DASHBOARD_PUBLIC_URL", raising=False)
patch_config("https://from-config.example")
redirect_uri = self._redirect_uri(gated_app_direct)
assert redirect_uri == "https://from-config.example/auth/callback"
def test_env_overrides_config_public_url(
self, gated_app_direct, patch_config, monkeypatch
):
"""Precedence pin — env wins over config.yaml. Fly.io / CI
secret injection depends on this ordering."""
monkeypatch.setenv(
"HERMES_DASHBOARD_PUBLIC_URL", "https://from-env.example",
)
patch_config("https://from-config.example")
redirect_uri = self._redirect_uri(gated_app_direct)
assert redirect_uri == "https://from-env.example/auth/callback", (
"env var must override config.yaml — Fly secret injection "
"depends on this precedence"
)
def test_public_url_with_path_prefix_baked_in(
self, gated_app_direct, patch_config, monkeypatch
):
"""When public_url already carries a path prefix
(``https://example.com/hermes``), the OAuth callback URL is
the path appended verbatim. The operator is declaring the
whole authority; we trust them."""
patch_config(None)
monkeypatch.setenv(
"HERMES_DASHBOARD_PUBLIC_URL", "https://example.com/hermes",
)
redirect_uri = self._redirect_uri(gated_app_direct)
assert redirect_uri == "https://example.com/hermes/auth/callback"
def test_public_url_ignores_x_forwarded_prefix(
self, gated_app_proxied, patch_config, monkeypatch
):
"""X-Forwarded-Prefix is the auto-reconstruction signal; when
public_url is set we no longer need to guess, and stacking the
prefix on top would double-prefix in the common case where
the operator already baked their prefix into public_url."""
patch_config(None)
monkeypatch.setenv(
"HERMES_DASHBOARD_PUBLIC_URL", "https://example.com/already-prefixed",
)
redirect_uri = self._redirect_uri(
gated_app_proxied,
headers={"x-forwarded-prefix": "/should-be-ignored"},
)
assert (
redirect_uri == "https://example.com/already-prefixed/auth/callback"
), (
f"public_url should suppress X-Forwarded-Prefix layering, "
f"got {redirect_uri!r}"
)
def test_public_url_strips_trailing_slash(
self, gated_app_direct, patch_config, monkeypatch
):
"""``https://example.com/`` and ``https://example.com`` must
produce identical results no ``//auth/callback`` double slash."""
patch_config(None)
monkeypatch.setenv(
"HERMES_DASHBOARD_PUBLIC_URL", "https://example.com/",
)
redirect_uri = self._redirect_uri(gated_app_direct)
assert redirect_uri == "https://example.com/auth/callback"
def test_malformed_public_url_falls_through_to_reconstruction(
self, gated_app_direct, patch_config, monkeypatch
):
"""Defence against header injection: a public_url that doesn't
parse as ``http(s)://host[/path]`` is dropped and we fall back
to request reconstruction. The login flow continues to work
rather than dispatching the user to a hostile URL."""
from urllib.parse import urlparse
patch_config(None)
for bad in [
"javascript:alert(1)",
"ftp://example.com",
"example.com", # missing scheme
"https://", # missing host
'https://example.com/"injected', # quote char
"https://example.com/\nhttps://evil", # CRLF injection
]:
monkeypatch.setenv("HERMES_DASHBOARD_PUBLIC_URL", bad)
redirect_uri = self._redirect_uri(gated_app_direct)
# Fell through to request reconstruction — netloc is the
# bound host, NOT the hostile value.
parsed = urlparse(redirect_uri)
assert parsed.netloc == "fly-app.fly.dev", (
f"malformed public_url={bad!r} leaked into redirect_uri: "
f"{redirect_uri!r}"
)
assert parsed.path == "/auth/callback"
def test_empty_public_url_env_treated_as_unset(
self, gated_app_direct, patch_config, monkeypatch
):
"""Same defensive behaviour as the other env vars in this
plugin an empty env var doesn't shadow a valid config.yaml
entry."""
monkeypatch.setenv("HERMES_DASHBOARD_PUBLIC_URL", "")
patch_config("https://from-config.example")
redirect_uri = self._redirect_uri(gated_app_direct)
assert redirect_uri == "https://from-config.example/auth/callback"
# ---------------------------------------------------------------------------
# Cookies: Path attribute + __Host- / __Secure- prefix rules
# ---------------------------------------------------------------------------
class TestCookiePathRespectsPrefix:
"""Cookies must use ``Path=<prefix>`` when behind a proxy so they:
a) get sent back to the dashboard on subsequent requests (browser
only sends a cookie if the request path starts with the cookie's
Path attribute);
b) don't leak to other apps mounted alongside the dashboard
(e.g. ``mission-control.tilos.com/billing/...``).
When the cookie's Path can be ``/`` (no prefix, Fly-direct), we use
the ``__Host-`` cookie prefix for additional hardening it binds
the cookie to the exact host (no Domain attribute) and requires Secure.
"""
def test_pkce_cookie_uses_prefix_path(self, gated_app_proxied):
r = gated_app_proxied.get(
"/auth/login?provider=stub",
headers={"x-forwarded-prefix": "/hermes"},
follow_redirects=False,
)
cookies = r.headers.get_list("set-cookie")
pkce = next(c for c in cookies if "hermes_session_pkce" in c)
# Browser only sends cookie back if the request path is under
# the cookie's Path attribute, so we need /hermes here. Bare
# /-rooted cookies would still be sent but would also be sent
# to /billing/... etc.
assert "Path=/hermes" in pkce, (
f"PKCE cookie has wrong Path: {pkce!r}"
)
def test_pkce_cookie_uses_secure_prefix_when_proxied(
self, gated_app_proxied
):
"""Behind a proxy with Path != /, ``__Host-`` is disallowed
(the spec requires Path=/). Fall back to ``__Secure-``, which
carries the same Secure-required guarantee but allows any Path.
"""
r = gated_app_proxied.get(
"/auth/login?provider=stub",
headers={"x-forwarded-prefix": "/hermes"},
follow_redirects=False,
)
cookies = r.headers.get_list("set-cookie")
# The PKCE cookie name carries the __Secure- prefix.
pkce_candidates = [
c for c in cookies
if c.startswith("__Secure-hermes_session_pkce=")
]
assert pkce_candidates, (
f"PKCE cookie missing __Secure- prefix: {cookies!r}"
)
def test_pkce_cookie_uses_host_prefix_when_direct(
self, gated_app_direct
):
"""Fly-direct deploy: Path=/ is available, so we can use the
stricter ``__Host-`` prefix. This binds the cookie to the
exact origin (no Domain attribute) best practice for
single-host single-app deploys."""
r = gated_app_direct.get(
"/auth/login?provider=stub", follow_redirects=False
)
cookies = r.headers.get_list("set-cookie")
pkce_candidates = [
c for c in cookies
if c.startswith("__Host-hermes_session_pkce=")
]
assert pkce_candidates, (
f"PKCE cookie missing __Host- prefix on direct deploy: "
f"{cookies!r}"
)
# __Host- requires Path=/ and Secure (cookies spec); both must
# be present even if a regression flips one off.
pkce = pkce_candidates[0]
assert "Path=/" in pkce
assert "Secure" in pkce
def test_loopback_cookies_unprefixed(self):
"""Loopback HTTP dev: no Secure, no __Host- / __Secure-.
The bare cookie name is the right choice neither prefix is
spec-compatible without Secure."""
from fastapi import FastAPI
from fastapi.responses import Response
from hermes_cli.dashboard_auth.cookies import set_pkce_cookie
app = FastAPI()
@app.get("/set")
def _set():
r = Response("ok")
set_pkce_cookie(r, payload="x", use_https=False)
return r
client = TestClient(app)
r = client.get("/set")
cookies = r.headers.get_list("set-cookie")
# Bare cookie name, no prefix.
assert any(c.startswith("hermes_session_pkce=") for c in cookies), (
f"Loopback cookie should be bare-named: {cookies!r}"
)
# And no __Host- / __Secure- variant accidentally emitted.
assert not any(
c.startswith("__Host-") or c.startswith("__Secure-")
for c in cookies
)
def test_cookies_read_back_round_trip_through_prefix(
self, gated_app_proxied
):
"""The end-to-end property: after a successful OAuth round
trip via the proxy, the session-AT cookie carries the
__Secure- prefix AND Path=/hermes, so the next request under
the same prefix is authenticated.
Note on TestClient semantics: starlette's TestClient sees the
literal request path (``/auth/login``, ``/auth/callback``)
not the public path the proxy displays to the browser
(``/hermes/auth/login``, ``/hermes/auth/callback``). A cookie
set with ``Path=/hermes`` would therefore NOT be sent back on
the second request through TestClient even though it WOULD be
sent by a real browser hitting ``/hermes/auth/callback``. To
avoid baking that mismatch into the test, we inspect the
``Set-Cookie`` header on the callback's response WITHOUT
depending on the PKCE cookie round-tripping through
TestClient's jar — we drive /auth/callback with an explicit
Cookie header that carries the PKCE value from /auth/login.
"""
# /auth/login sets the PKCE cookie. Capture it from Set-Cookie.
r1 = gated_app_proxied.get(
"/auth/login?provider=stub",
headers={"x-forwarded-prefix": "/hermes"},
follow_redirects=False,
)
pkce_set = next(
c for c in r1.headers.get_list("set-cookie")
if "hermes_session_pkce" in c
)
# Parse "__Secure-hermes_session_pkce=...; HttpOnly; ...".
pkce_kv = pkce_set.split(";", 1)[0] # "__Secure-hermes_session_pkce=value"
state = r1.headers["location"].split("state=")[1]
# Round-trip the cookie by hand because TestClient's jar won't
# automatically send a Path=/hermes cookie to a /auth/callback
# request path.
r2 = gated_app_proxied.get(
f"/auth/callback?code=stub_code&state={state}",
headers={
"x-forwarded-prefix": "/hermes",
"cookie": pkce_kv,
},
follow_redirects=False,
)
assert r2.status_code == 302, r2.text
cookies = r2.headers.get_list("set-cookie")
at_cookies = [
c for c in cookies
if c.startswith("__Secure-hermes_session_at=")
]
assert at_cookies, (
f"session_at missing __Secure- prefix: {cookies!r}"
)
assert "Path=/hermes" in at_cookies[0]
assert "Secure" in at_cookies[0]
assert "HttpOnly" in at_cookies[0]
@@ -0,0 +1,182 @@
"""Contract test for DashboardAuthProvider implementations.
Every provider plugin should call ``assert_protocol_compliance`` on its
provider class in its own unit test. This module tests the abstract base
itself: dataclass fields, ABC rejection of partial impls, and the
protocol-compliance helper.
"""
from __future__ import annotations
import pytest
from hermes_cli.dashboard_auth.base import (
DashboardAuthProvider,
Session,
LoginStart,
assert_protocol_compliance,
)
# ---------------------------------------------------------------------------
# Dataclasses
# ---------------------------------------------------------------------------
def test_session_has_required_fields():
s = Session(
user_id="u1",
email="a@b.com",
display_name="A",
org_id="org_1",
provider="test",
expires_at=1234567890,
access_token="at",
refresh_token="rt",
)
assert s.user_id == "u1"
assert s.provider == "test"
assert s.expires_at == 1234567890
def test_login_start_has_redirect_and_state():
ls = LoginStart(
redirect_url="https://portal/authorize?...",
cookie_payload={"hermes_session_pkce": "verifier=abc;state=xyz"},
)
assert ls.redirect_url.startswith("https://")
assert "hermes_session_pkce" in ls.cookie_payload
# ---------------------------------------------------------------------------
# ABC enforcement
# ---------------------------------------------------------------------------
def test_abstract_provider_cannot_be_instantiated():
with pytest.raises(TypeError):
DashboardAuthProvider() # type: ignore[abstract]
class _BrokenProvider(DashboardAuthProvider):
name = "broken"
display_name = "Broken"
# Deliberately missing all the methods.
def test_assert_protocol_compliance_rejects_partial_impl():
with pytest.raises(TypeError):
assert_protocol_compliance(_BrokenProvider)
class _CompliantProvider(DashboardAuthProvider):
name = "ok"
display_name = "OK"
def start_login(self, *, redirect_uri: str) -> LoginStart:
return LoginStart(redirect_url="x", cookie_payload={})
def complete_login(self, *, code, state, code_verifier, redirect_uri) -> Session:
return Session(
user_id="u", email="x", display_name="x", org_id="o",
provider=self.name, expires_at=0,
access_token="a", refresh_token="r",
)
def verify_session(self, *, access_token: str):
return None
def refresh_session(self, *, refresh_token: str) -> Session:
return Session(
user_id="u", email="x", display_name="x", org_id="o",
provider=self.name, expires_at=0,
access_token="a", refresh_token="r",
)
def revoke_session(self, *, refresh_token: str) -> None:
return None
def test_assert_protocol_compliance_accepts_full_impl():
# Returns None on success; the helper raises on failure.
assert assert_protocol_compliance(_CompliantProvider) is None
def test_assert_protocol_compliance_rejects_missing_name_attr():
class NoName(_CompliantProvider):
name = "" # empty is treated as missing
with pytest.raises(TypeError, match="name"):
assert_protocol_compliance(NoName)
def test_assert_protocol_compliance_rejects_missing_display_name():
class NoDisplay(_CompliantProvider):
display_name = ""
with pytest.raises(TypeError, match="display_name"):
assert_protocol_compliance(NoDisplay)
# ---------------------------------------------------------------------------
# Registry (Task 1.2)
# ---------------------------------------------------------------------------
from hermes_cli.dashboard_auth import ( # noqa: E402 (after-imports for clarity)
register_provider,
get_provider,
list_providers,
clear_providers,
)
@pytest.fixture(autouse=True)
def _isolated_registry():
"""Every test starts with an empty registry and leaves it empty."""
clear_providers()
yield
clear_providers()
def test_registry_register_and_get():
p = _CompliantProvider()
register_provider(p)
assert get_provider("ok") is p
def test_registry_get_missing_returns_none():
assert get_provider("nope") is None
def test_registry_lists_in_registration_order():
class A(_CompliantProvider):
name = "a"
display_name = "A"
class B(_CompliantProvider):
name = "b"
display_name = "B"
register_provider(A())
register_provider(B())
names = [p.name for p in list_providers()]
assert names == ["a", "b"]
def test_registry_rejects_non_compliant_provider():
with pytest.raises(TypeError):
register_provider(_BrokenProvider()) # type: ignore[abstract]
def test_registry_rejects_duplicate_name():
register_provider(_CompliantProvider())
with pytest.raises(ValueError, match="already registered"):
register_provider(_CompliantProvider())
def test_registry_clear_drops_all():
register_provider(_CompliantProvider())
assert get_provider("ok") is not None
clear_providers()
assert get_provider("ok") is None
assert list_providers() == []
@@ -0,0 +1,98 @@
"""Phase 7 — /api/status exposes auth-gate state + AuthWidget integration.
The dashboard's status endpoint now reports ``auth_required`` and
``auth_providers`` so the AuthWidget + StatusPage can render the
correct "gated / loopback" badge without a separate round trip. This
test asserts both shapes (gated and loopback).
The AuthWidget itself is .tsx no Python test here. The widget's
behaviour (renders nothing on 401, shows truncated user_id, etc.) is
documented in AuthWidget.tsx; covered manually via the Phase 4.2
smoke test against staging Portal.
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from hermes_cli import web_server
from hermes_cli.dashboard_auth import clear_providers, register_provider
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
# These tests mutate ``web_server.app.state.auth_required`` so they share
# the same xdist group as the other dashboard-auth gated_app tests.
pytestmark = pytest.mark.xdist_group("dashboard_auth_app_state")
@pytest.fixture
def gated_client():
clear_providers()
register_provider(StubAuthProvider())
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
prev_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.bound_host = "fly-app.fly.dev"
web_server.app.state.bound_port = 443
web_server.app.state.auth_required = True
client = TestClient(web_server.app, base_url="https://fly-app.fly.dev")
yield client
clear_providers()
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
web_server.app.state.auth_required = prev_required
@pytest.fixture
def loopback_client():
clear_providers()
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
prev_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.bound_host = "127.0.0.1"
web_server.app.state.bound_port = 8080
web_server.app.state.auth_required = False
client = TestClient(web_server.app, base_url="http://127.0.0.1:8080")
yield client
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
web_server.app.state.auth_required = prev_required
def test_status_reports_auth_required_in_gated_mode(gated_client):
# No ``_login()`` call — ``/api/status`` is in the shared
# ``PUBLIC_API_PATHS`` allowlist precisely so external probes (and
# the SPA's pre-login bootstrap) can read the gate's shape without
# a cookie. Hit it cold.
r = gated_client.get("/api/status")
assert r.status_code == 200
body = r.json()
assert body["auth_required"] is True
assert body["auth_providers"] == ["stub"]
def test_status_reports_auth_disabled_in_loopback_mode(loopback_client):
r = loopback_client.get("/api/status")
assert r.status_code == 200
body = r.json()
assert body["auth_required"] is False
# Loopback mode has no registered providers (the Nous plugin's env
# vars aren't set in test).
assert body["auth_providers"] == []
def test_status_preserves_existing_fields(loopback_client):
"""Defence-in-depth: adding auth_required/auth_providers must not
have dropped any previous field (the dashboard's React StatusPage
relies on the full payload shape)."""
r = loopback_client.get("/api/status")
body = r.json()
expected_keys = {
"version", "release_date", "hermes_home", "config_path", "env_path",
"config_version", "latest_config_version", "gateway_running",
"gateway_pid", "gateway_health_url", "gateway_state",
"gateway_platforms", "gateway_exit_reason", "gateway_updated_at",
"active_sessions", "auth_required", "auth_providers",
}
missing = expected_keys - set(body.keys())
assert not missing, f"/api/status dropped fields: {missing}"
@@ -0,0 +1,150 @@
"""Contract test for the StubAuthProvider used in dashboard-auth E2E tests.
Phase 2 of the dashboard-OAuth plan. Validates the stub against the
provider protocol so subsequent phases that depend on its behavior
have a guarantee.
"""
from __future__ import annotations
import pytest
from hermes_cli.dashboard_auth.base import (
InvalidCodeError, RefreshExpiredError, assert_protocol_compliance,
)
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
def _pkce_payload(ls) -> dict:
"""Parse ``state=...;verifier=...`` out of the LoginStart cookie payload."""
return dict(
item.split("=", 1)
for item in ls.cookie_payload["hermes_session_pkce"].split(";")
)
def test_stub_complies_with_protocol():
assert assert_protocol_compliance(StubAuthProvider) is None
def test_stub_start_login_returns_callback_redirect():
p = StubAuthProvider()
ls = p.start_login(redirect_uri="https://x.fly.dev/auth/callback")
assert "code=stub_code" in ls.redirect_url
assert "state=" in ls.redirect_url
assert "hermes_session_pkce" in ls.cookie_payload
def test_stub_complete_login_with_matching_state_succeeds():
p = StubAuthProvider()
ls = p.start_login(redirect_uri="https://x.fly.dev/auth/callback")
payload = _pkce_payload(ls)
sess = p.complete_login(
code="stub_code",
state=payload["state"],
code_verifier=payload["verifier"],
redirect_uri="https://x.fly.dev/auth/callback",
)
assert sess.user_id == "stub-user-1"
assert sess.email == "stub@example.test"
assert sess.display_name == "Stub User"
assert sess.org_id == "stub-org-1"
assert sess.provider == "stub"
assert sess.access_token and sess.refresh_token
def test_stub_complete_login_rejects_mismatched_state():
p = StubAuthProvider()
p.start_login(redirect_uri="https://x.fly.dev/auth/callback")
with pytest.raises(InvalidCodeError):
p.complete_login(
code="stub_code",
state="WRONG",
code_verifier="anything",
redirect_uri="https://x.fly.dev/auth/callback",
)
def test_stub_complete_login_rejects_wrong_code():
p = StubAuthProvider()
ls = p.start_login(redirect_uri="https://x.fly.dev/auth/callback")
payload = _pkce_payload(ls)
with pytest.raises(InvalidCodeError):
p.complete_login(
code="BAD",
state=payload["state"],
code_verifier=payload["verifier"],
redirect_uri="https://x.fly.dev/auth/callback",
)
def test_stub_verify_session_round_trips():
p = StubAuthProvider()
ls = p.start_login(redirect_uri="https://x.fly.dev/auth/callback")
payload = _pkce_payload(ls)
sess = p.complete_login(
code="stub_code",
state=payload["state"],
code_verifier=payload["verifier"],
redirect_uri="https://x.fly.dev/auth/callback",
)
verified = p.verify_session(access_token=sess.access_token)
assert verified is not None
assert verified.user_id == "stub-user-1"
assert verified.org_id == "stub-org-1"
def test_stub_verify_expired_session_returns_none():
p = StubAuthProvider(default_ttl=0)
ls = p.start_login(redirect_uri="https://x/auth/callback")
payload = _pkce_payload(ls)
sess = p.complete_login(
code="stub_code",
state=payload["state"],
code_verifier=payload["verifier"],
redirect_uri="https://x/auth/callback",
)
# default_ttl=0 means the access token is born already expired
# (verify uses ``<=`` so exp == now counts as expired).
assert p.verify_session(access_token=sess.access_token) is None
def test_stub_verify_tampered_token_returns_none():
p = StubAuthProvider()
assert p.verify_session(access_token="garbage-not-a-real-token") is None
def test_stub_refresh_round_trips():
p = StubAuthProvider()
ls = p.start_login(redirect_uri="https://x/auth/callback")
payload = _pkce_payload(ls)
sess = p.complete_login(
code="stub_code",
state=payload["state"],
code_verifier=payload["verifier"],
redirect_uri="https://x/auth/callback",
)
refreshed = p.refresh_session(refresh_token=sess.refresh_token)
# Refresh must return a valid Session for the same identity. (Tokens
# may compare equal byte-for-byte if the refresh happens within the
# same wall-clock second as the original — payload contents are
# otherwise identical and HMAC is deterministic. The behavioural
# invariant is just "refresh succeeds and identity survives".)
assert refreshed.user_id == "stub-user-1"
assert refreshed.access_token # non-empty
assert refreshed.refresh_token # non-empty
# And the refreshed access_token is still verifiable.
verified = p.verify_session(access_token=refreshed.access_token)
assert verified is not None
assert verified.user_id == "stub-user-1"
def test_stub_refresh_expired_raises():
p = StubAuthProvider()
with pytest.raises(RefreshExpiredError):
p.refresh_session(refresh_token="garbage")
def test_stub_revoke_is_silent():
p = StubAuthProvider()
# Best-effort; must never raise.
p.revoke_session(refresh_token="anything")
@@ -0,0 +1,403 @@
"""Tests for the WS-upgrade auth helper (Phase 5 task 5.2).
The dashboard's four WS endpoints (``/api/pty``, ``/api/ws``, ``/api/pub``,
``/api/events``) share an auth gate: ``_ws_auth_ok``. In loopback mode it
accepts ``?token=<_SESSION_TOKEN>``; in gated mode it accepts a single-use
``?ticket=`` minted by ``POST /api/auth/ws-ticket``.
These tests exercise the helper at the unit level (no actual WS upgrade)
plus the ticket-mint endpoint under realistic gated-mode setup. We don't
test the full WS upgrade because the starlette TestClient WS path has a
pre-existing regression unrelated to dashboard-auth.
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
# Phase 5 / Phase 6: these tests mutate ``web_server.app.state.auth_required``
# at module level. Run them in the same xdist worker so they don't race
# against each other (and against any other file that also touches
# ``app.state``) — the marker name is shared across all dashboard-auth test
# files that gate the app.
pytestmark = pytest.mark.xdist_group("dashboard_auth_app_state")
from fastapi.testclient import TestClient
from hermes_cli import web_server
from hermes_cli.dashboard_auth import clear_providers, register_provider
from hermes_cli.dashboard_auth.ws_tickets import (
_reset_for_tests,
consume_ticket,
mint_ticket,
)
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def gated_app():
"""web_server.app configured for gated mode + stub provider registered."""
_reset_for_tests()
clear_providers()
register_provider(StubAuthProvider())
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
prev_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.bound_host = "fly-app.fly.dev"
web_server.app.state.bound_port = 443
web_server.app.state.auth_required = True
client = TestClient(web_server.app, base_url="https://fly-app.fly.dev")
yield client
clear_providers()
_reset_for_tests()
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
web_server.app.state.auth_required = prev_required
@pytest.fixture
def loopback_app():
"""web_server.app configured for loopback mode (gate OFF)."""
_reset_for_tests()
clear_providers()
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
prev_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.bound_host = "127.0.0.1"
web_server.app.state.bound_port = 8080
web_server.app.state.auth_required = False
client = TestClient(web_server.app, base_url="http://127.0.0.1:8080")
yield client
_reset_for_tests()
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
web_server.app.state.auth_required = prev_required
@pytest.fixture
def insecure_public_app():
"""web_server.app configured for all-interfaces insecure mode."""
_reset_for_tests()
clear_providers()
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
prev_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.bound_host = "0.0.0.0"
web_server.app.state.bound_port = 9120
web_server.app.state.auth_required = False
client = TestClient(web_server.app, base_url="http://192.168.0.222:9120")
yield client
_reset_for_tests()
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
web_server.app.state.auth_required = prev_required
def _logged_in(client: TestClient) -> None:
"""Drive the stub OAuth round trip so the client holds session cookies."""
r1 = client.get("/auth/login?provider=stub", follow_redirects=False)
assert r1.status_code == 302
state = r1.headers["location"].split("state=")[1]
r2 = client.get(
f"/auth/callback?code=stub_code&state={state}", follow_redirects=False
)
assert r2.status_code == 302
# ---------------------------------------------------------------------------
# POST /api/auth/ws-ticket — the mint endpoint
# ---------------------------------------------------------------------------
class TestWsTicketEndpoint:
def test_authenticated_session_can_mint(self, gated_app):
_logged_in(gated_app)
r = gated_app.post("/api/auth/ws-ticket")
assert r.status_code == 200
body = r.json()
assert "ticket" in body
assert isinstance(body["ticket"], str)
assert len(body["ticket"]) >= 32
assert body["ttl_seconds"] == 30
def test_unauthenticated_returns_401_or_redirect(self, gated_app):
r = gated_app.post("/api/auth/ws-ticket", follow_redirects=False)
# gated_auth_middleware short-circuits before the route — it
# returns either 401 or 302. Either is fine.
assert r.status_code in (302, 401)
def test_each_call_returns_a_distinct_ticket(self, gated_app):
_logged_in(gated_app)
tickets = {gated_app.post("/api/auth/ws-ticket").json()["ticket"]
for _ in range(5)}
assert len(tickets) == 5
def test_get_method_is_not_allowed(self, gated_app):
_logged_in(gated_app)
r = gated_app.get("/api/auth/ws-ticket", follow_redirects=False)
# GET must not mint a ticket (which would be cookie-replayable via
# <img src=…> from a malicious origin). Accepted responses:
# 401 — gated middleware allowlist-miss
# 404 — SPA catch-all swallowed it
# 405 — Method Not Allowed (route only registered for POST)
# 200 — SPA index.html was served (catch-all caught the path)
# In every case the JSON body of a successful ticket mint must
# NOT be present. The assertion below holds even when the SPA
# shell happens to serve a 200.
body = r.text
assert "ticket" not in body or '"ttl_seconds"' not in body, (
f"GET /api/auth/ws-ticket leaked a ticket (status={r.status_code}, "
f"body[:200]={body[:200]!r})"
)
# ---------------------------------------------------------------------------
# _ws_auth_ok — unit-level (synthetic WebSocket-shaped object)
# ---------------------------------------------------------------------------
@pytest.fixture
def insecure_explicit_host_app():
"""web_server.app bound to an explicit non-loopback host (--insecure).
Models `--host 100.64.0.10 --insecure` (e.g. a Tailscale IP behind
`tailscale serve`) a specific address rather than the all-interfaces
0.0.0.0 wildcard.
"""
_reset_for_tests()
clear_providers()
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
prev_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.bound_host = "100.64.0.10"
web_server.app.state.bound_port = 9119
web_server.app.state.auth_required = False
client = TestClient(web_server.app, base_url="http://100.64.0.10:9119")
yield client
_reset_for_tests()
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
web_server.app.state.auth_required = prev_required
def _fake_ws(*, query: dict, client_host: str = "127.0.0.1", path: str = "/api/pty"):
"""Build a stand-in for starlette.WebSocket good enough for _ws_auth_ok."""
class _QP:
def __init__(self, q):
self._q = q
def get(self, k, default=""):
return self._q.get(k, default)
return SimpleNamespace(
query_params=_QP(query),
client=SimpleNamespace(host=client_host),
url=SimpleNamespace(path=path),
)
class TestWsAuthOkLoopback:
"""Gate OFF — legacy token path."""
def test_correct_token_accepted(self, loopback_app):
ws = _fake_ws(query={"token": web_server._SESSION_TOKEN})
assert web_server._ws_auth_ok(ws) is True
def test_wrong_token_rejected(self, loopback_app):
ws = _fake_ws(query={"token": "not-the-real-token"})
assert web_server._ws_auth_ok(ws) is False
def test_missing_token_rejected(self, loopback_app):
ws = _fake_ws(query={})
assert web_server._ws_auth_ok(ws) is False
def test_ticket_param_ignored_in_loopback(self, loopback_app):
# Even if someone sneaks a ticket through, loopback mode only
# cares about ?token=. A naked ticket isn't a token.
ticket = mint_ticket(user_id="u1", provider="stub")
ws = _fake_ws(query={"ticket": ticket})
assert web_server._ws_auth_ok(ws) is False
class TestWsAuthOkGated:
"""Gate ON — ticket path only."""
def test_valid_ticket_accepted(self, gated_app):
ticket = mint_ticket(user_id="u1", provider="stub")
ws = _fake_ws(query={"ticket": ticket})
assert web_server._ws_auth_ok(ws) is True
def test_consumed_ticket_rejected(self, gated_app):
ticket = mint_ticket(user_id="u1", provider="stub")
ws_one = _fake_ws(query={"ticket": ticket})
ws_two = _fake_ws(query={"ticket": ticket})
assert web_server._ws_auth_ok(ws_one) is True
# Single-use — second consumption fails.
assert web_server._ws_auth_ok(ws_two) is False
def test_unknown_ticket_rejected(self, gated_app):
ws = _fake_ws(query={"ticket": "never-minted"})
assert web_server._ws_auth_ok(ws) is False
def test_missing_ticket_rejected(self, gated_app):
ws = _fake_ws(query={})
assert web_server._ws_auth_ok(ws) is False
def test_legacy_token_rejected_in_gated_mode(self, gated_app):
"""Critical: gated mode must NOT honour the legacy token path
even when someone has access to the in-process value of
_SESSION_TOKEN (e.g. a leaked log line)."""
ws = _fake_ws(query={"token": web_server._SESSION_TOKEN})
assert web_server._ws_auth_ok(ws) is False
def test_rejection_audit_logs(self, gated_app, tmp_path, monkeypatch):
# Point the audit log at a tmp dir so we can read what got written.
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
from hermes_cli.dashboard_auth import audit as audit_mod
# The log path is resolved lazily on the first audit_log() call;
# bust any cached handler so it re-resolves.
if hasattr(audit_mod, "_LOGGER"):
monkeypatch.setattr(audit_mod, "_LOGGER", None, raising=False)
ws = _fake_ws(query={"ticket": "never-minted"})
assert web_server._ws_auth_ok(ws) is False
log_file = tmp_path / "logs" / "dashboard-auth.log"
# The audit module may write asynchronously through stdlib logging,
# but flush is synchronous. If the file doesn't exist yet, the
# logger may not have been initialized in this process — that's
# acceptable as long as the rejection path didn't crash.
if log_file.exists():
content = log_file.read_text()
assert "ws_ticket_rejected" in content
# ---------------------------------------------------------------------------
# _build_sidecar_url — gated mode mints a server-internal ticket
# ---------------------------------------------------------------------------
class TestWsRequestIsAllowedGated:
"""Bug fix: in gated mode, the WS peer-IP loopback check must be
bypassed.
When the OAuth gate is active, ``start_server`` runs uvicorn with
``proxy_headers=True`` so the dashboard can honour
``X-Forwarded-Proto`` from Fly's TLS terminator. A side effect is that
``ws.client.host`` is rewritten to the X-Forwarded-For value the
real internet client IP, never loopback. The loopback peer guard
(intended only for unauthenticated loopback dev) must not also reject
those upgrades: the OAuth gate + single-use ticket is the auth.
Regression coverage: every WS endpoint (``/api/pty``, ``/api/ws``,
``/api/pub``, ``/api/events``) calls ``_ws_request_is_allowed`` after
``_ws_auth_ok``. If the peer-IP check rejects gated mode, the chat
tab + sidebar tool feed silently fail to connect even after a
successful OAuth login.
"""
def test_non_loopback_peer_allowed_in_gated_mode(self, gated_app):
ws = _fake_ws(query={}, client_host="203.0.113.7")
# Host header matches the bound host so the DNS-rebinding guard
# passes; only the peer-IP check is under test.
ws.headers = {"host": "fly-app.fly.dev"}
assert web_server._ws_request_is_allowed(ws) is True
def test_non_loopback_peer_rejected_in_loopback_mode(self, loopback_app):
"""Loopback mode still enforces the peer-IP guard — the legacy
token path is the only auth and we don't want random LAN hosts
guessing it."""
ws = _fake_ws(query={}, client_host="192.168.1.42")
ws.headers = {"host": "127.0.0.1:8080"}
assert web_server._ws_request_is_allowed(ws) is False
def test_loopback_peer_allowed_in_loopback_mode(self, loopback_app):
ws = _fake_ws(query={}, client_host="127.0.0.1")
ws.headers = {"host": "127.0.0.1:8080"}
assert web_server._ws_request_is_allowed(ws) is True
def test_non_loopback_peer_allowed_in_insecure_public_mode(self, insecure_public_app):
"""`--host 0.0.0.0 --insecure` is an explicit LAN/public opt-in.
Regression coverage for the dashboard `/chat` breakage where the
HTML shell loaded on 9120 but every WebSocket upgrade was rejected
with 403 because the loopback-only peer guard still ran even though
the operator intentionally exposed the dashboard on all interfaces.
"""
ws = _fake_ws(query={}, client_host="192.168.0.55")
ws.headers = {
"host": "192.168.0.222:9120",
"origin": "http://192.168.0.222:9120",
}
assert web_server._ws_request_is_allowed(ws) is True
def test_peer_allowed_on_explicit_non_loopback_bind(self, insecure_explicit_host_app):
"""`--host 100.64.0.10 --insecure` (Tailscale/LAN IP) is an explicit
non-loopback opt-in too not just the 0.0.0.0 wildcard.
Regression coverage: the merged 0.0.0.0/:: fix did not cover binding
directly to a specific tailnet/LAN address, so `/chat` HTML loaded but
WS upgrades were still rejected by the loopback-only peer guard.
"""
ws = _fake_ws(query={}, client_host="100.64.0.99")
ws.headers = {
"host": "100.64.0.10:9119",
"origin": "http://100.64.0.10:9119",
}
assert web_server._ws_request_is_allowed(ws) is True
def test_rebinding_host_rejected_on_explicit_non_loopback_bind(
self, insecure_explicit_host_app
):
"""Lifting the peer-IP gate for an explicit bind must NOT lift the
DNS-rebinding Host guard: a mismatched Host header is still rejected,
because an explicit non-loopback bind requires an exact Host match in
`_is_accepted_host` (unlike the 0.0.0.0 wildcard, which accepts any).
"""
ws = _fake_ws(query={}, client_host="100.64.0.99")
ws.headers = {"host": "evil.example.com"}
assert web_server._ws_request_is_allowed(ws) is False
def test_host_origin_guard_still_runs_in_gated_mode(self, gated_app):
"""Bypassing the peer-IP check must not bypass the DNS-rebinding
Host header guard that one still protects against attacker
sites resolving DNS to the public IP."""
ws = _fake_ws(query={}, client_host="203.0.113.7")
ws.headers = {"host": "evil.example.com"}
assert web_server._ws_request_is_allowed(ws) is False
class TestSidecarUrl:
def test_loopback_uses_session_token(self, loopback_app):
url = web_server._build_sidecar_url("ch-1")
assert url is not None
assert f"token={web_server._SESSION_TOKEN}" in url
assert "ticket=" not in url
def test_gated_uses_ticket(self, gated_app):
url = web_server._build_sidecar_url("ch-1")
assert url is not None
assert "token=" not in url
assert "ticket=" in url
# And the ticket should be live.
ticket = url.split("ticket=")[1].split("&")[0]
info = consume_ticket(ticket)
# Sidecar tickets are bound to the pseudo-user so audit logs can
# distinguish them from real browser tickets.
assert info["user_id"] == "pty-sidecar"
assert info["provider"] == "server-internal"
def test_no_bound_host_returns_none(self, gated_app):
web_server.app.state.bound_host = None
try:
assert web_server._build_sidecar_url("ch") is None
finally:
web_server.app.state.bound_host = "fly-app.fly.dev"
@@ -0,0 +1,161 @@
"""Tests for the WS-upgrade ticket store (Phase 5 task 5.1).
The store is process-local and threading-safe. Tests run with xdist so
each worker has its own module instance no cross-worker bleed but we
call ``_reset_for_tests`` between tests to keep things deterministic.
"""
from __future__ import annotations
import threading
import pytest
from hermes_cli.dashboard_auth import ws_tickets
from hermes_cli.dashboard_auth.ws_tickets import (
TTL_SECONDS,
TicketInvalid,
_reset_for_tests,
consume_ticket,
mint_ticket,
)
@pytest.fixture(autouse=True)
def _reset():
_reset_for_tests()
yield
_reset_for_tests()
# ---------------------------------------------------------------------------
# Happy path
# ---------------------------------------------------------------------------
class TestMintAndConsume:
def test_round_trip(self):
ticket = mint_ticket(user_id="u1", provider="nous")
info = consume_ticket(ticket)
assert info["user_id"] == "u1"
assert info["provider"] == "nous"
assert "minted_at" in info
def test_ticket_has_minimum_length(self):
# ``secrets.token_urlsafe(32)`` produces ~43 chars; enforce a floor
# so a future refactor can't accidentally shrink the entropy.
ticket = mint_ticket(user_id="u1", provider="nous")
assert len(ticket) >= 32
def test_ticket_values_are_unique(self):
seen = {mint_ticket(user_id="u1", provider="x") for _ in range(50)}
assert len(seen) == 50
# ---------------------------------------------------------------------------
# Single-use
# ---------------------------------------------------------------------------
class TestSingleUse:
def test_second_consume_raises(self):
ticket = mint_ticket(user_id="u1", provider="stub")
consume_ticket(ticket)
with pytest.raises(TicketInvalid, match="unknown"):
consume_ticket(ticket)
def test_unknown_ticket_rejected(self):
with pytest.raises(TicketInvalid, match="unknown"):
consume_ticket("nope-never-minted")
def test_empty_ticket_rejected(self):
with pytest.raises(TicketInvalid):
consume_ticket("")
# ---------------------------------------------------------------------------
# TTL
# ---------------------------------------------------------------------------
class TestTTL:
def test_constant_is_30_seconds(self):
# Pinned so a refactor that doubled the lifetime would surface here.
assert TTL_SECONDS == 30
def test_expired_ticket_rejected(self, monkeypatch):
# Mock time inside the ws_tickets module so mint and consume see
# different clocks. We have to patch the symbol the module actually
# binds; ``time`` is module-level there.
clock = {"now": 1_000_000}
def fake_time():
return clock["now"]
monkeypatch.setattr(ws_tickets.time, "time", fake_time)
ticket = mint_ticket(user_id="u1", provider="stub")
clock["now"] += TTL_SECONDS + 1
with pytest.raises(TicketInvalid, match="expired"):
consume_ticket(ticket)
def test_at_exact_ttl_boundary_still_valid(self, monkeypatch):
clock = {"now": 1_000_000}
monkeypatch.setattr(ws_tickets.time, "time", lambda: clock["now"])
ticket = mint_ticket(user_id="u1", provider="stub")
clock["now"] += TTL_SECONDS # exactly at boundary; expires_at == now
# Implementation: ``expires_at < now`` (strict), so == passes.
info = consume_ticket(ticket)
assert info["user_id"] == "u1"
# ---------------------------------------------------------------------------
# Truncated value in error message (secret hygiene)
# ---------------------------------------------------------------------------
class TestErrorMessages:
def test_unknown_ticket_error_truncates_value(self):
long_value = "a" * 100
with pytest.raises(TicketInvalid) as exc_info:
consume_ticket(long_value)
# Never log more than the first 8 chars of an opaque ticket.
message = str(exc_info.value)
assert long_value not in message
assert long_value[:8] in message
# ---------------------------------------------------------------------------
# Thread safety: mint + consume from many threads doesn't deadlock or
# return duplicates.
# ---------------------------------------------------------------------------
class TestConcurrency:
def test_mint_and_consume_concurrent(self):
results: list[dict] = []
errors: list[Exception] = []
lock = threading.Lock()
def worker(i: int):
try:
t = mint_ticket(user_id=f"u{i}", provider="stub")
info = consume_ticket(t)
with lock:
results.append(info)
except Exception as exc: # noqa: BLE001 — collect for assert
with lock:
errors.append(exc)
threads = [threading.Thread(target=worker, args=(i,)) for i in range(20)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=5.0)
assert not t.is_alive(), "thread deadlocked"
assert errors == []
assert len(results) == 20
# Every consume returns a distinct user_id (no cross-thread bleed).
assert {r["user_id"] for r in results} == {f"u{i}" for i in range(20)}
@@ -0,0 +1,16 @@
"""Static dashboard tests for browser-safe @nous-research/ui imports."""
from pathlib import Path
WEB_SRC = Path(__file__).resolve().parents[2] / "web" / "src"
def test_dashboard_does_not_import_nous_ui_root_barrel():
offenders = []
for ext in ("*.tsx", "*.ts"):
for path in WEB_SRC.rglob(ext):
content = path.read_text(encoding="utf-8")
if 'from "@nous-research/ui"' in content or "from '@nous-research/ui'" in content:
offenders.append(str(path.relative_to(WEB_SRC)))
assert offenders == []
@@ -0,0 +1,181 @@
"""Tests for ``hermes dashboard --stop`` / ``--status`` flags.
These flags share the detection + kill path with the post-``hermes update``
cleanup, so the heavy coverage of SIGTERM / SIGKILL / Windows taskkill lives
in ``test_update_stale_dashboard.py``. This file just verifies the flag
dispatch: argparse wiring, no-op when nothing is running, and correct
exit codes.
"""
from __future__ import annotations
import argparse
import sys
from unittest.mock import patch, MagicMock
import pytest
from hermes_cli.main import cmd_dashboard
def _ns(**kw):
"""Build an argparse.Namespace with dashboard defaults plus overrides."""
defaults = dict(
port=9119, host="127.0.0.1", no_open=False, insecure=False,
tui=False, stop=False, status=False,
)
defaults.update(kw)
return argparse.Namespace(**defaults)
class TestDashboardStatus:
def test_status_no_processes(self, capsys):
with patch("hermes_cli.main._find_stale_dashboard_pids",
return_value=[]), \
pytest.raises(SystemExit) as exc:
cmd_dashboard(_ns(status=True))
assert exc.value.code == 0
out = capsys.readouterr().out
assert "No hermes dashboard processes running" in out
def test_status_with_processes(self, capsys):
with patch("hermes_cli.main._find_stale_dashboard_pids",
return_value=[12345, 12346]), \
pytest.raises(SystemExit) as exc:
cmd_dashboard(_ns(status=True))
# Status is informational — always exits 0.
assert exc.value.code == 0
out = capsys.readouterr().out
assert "2 hermes dashboard process(es) running" in out
assert "PID 12345" in out
assert "PID 12346" in out
def test_status_does_not_try_to_import_fastapi(self):
"""`--status` must not require dashboard runtime deps — it's a
process-table scan only. We prove this by making fastapi import
fail and confirming --status still succeeds."""
orig_import = __import__
def fake_import(name, *a, **kw):
if name == "fastapi":
raise ImportError("fastapi missing")
return orig_import(name, *a, **kw)
with patch("hermes_cli.main._find_stale_dashboard_pids",
return_value=[]), \
patch("builtins.__import__", side_effect=fake_import), \
pytest.raises(SystemExit) as exc:
cmd_dashboard(_ns(status=True))
assert exc.value.code == 0
class TestDashboardStop:
def test_stop_when_nothing_running(self, capsys):
with patch("hermes_cli.main._find_stale_dashboard_pids",
return_value=[]), \
pytest.raises(SystemExit) as exc:
cmd_dashboard(_ns(stop=True))
assert exc.value.code == 0
out = capsys.readouterr().out
assert "No hermes dashboard processes running" in out
def test_stop_kills_and_exits_zero_when_all_killed(self, capsys):
"""After the kill, if the second scan returns empty we exit 0."""
# First scan: finds two processes. Second (verification) scan: empty.
scans = iter([[12345, 12346], []])
with patch("hermes_cli.main._find_stale_dashboard_pids",
side_effect=lambda: next(scans)), \
patch("hermes_cli.main._kill_stale_dashboard_processes") as mock_kill, \
pytest.raises(SystemExit) as exc:
cmd_dashboard(_ns(stop=True))
mock_kill.assert_called_once()
# --stop should pass a reason so the output doesn't say "running
# backend no longer matches the updated frontend" (that wording is
# for the post-`hermes update` path).
kwargs = mock_kill.call_args.kwargs
assert "reason" in kwargs
assert "stop" in kwargs["reason"].lower()
assert exc.value.code == 0
def test_stop_exits_nonzero_if_kill_leaves_survivors(self):
"""If the second scan still finds PIDs, we exit 1 so scripts can
detect that the stop didn't succeed (e.g. permission denied)."""
scans = iter([[12345], [12345]]) # both scans find the same PID
with patch("hermes_cli.main._find_stale_dashboard_pids",
side_effect=lambda: next(scans)), \
patch("hermes_cli.main._kill_stale_dashboard_processes"), \
pytest.raises(SystemExit) as exc:
cmd_dashboard(_ns(stop=True))
assert exc.value.code == 1
def test_stop_does_not_try_to_import_fastapi(self):
"""Like --status, --stop must work without dashboard runtime deps."""
orig_import = __import__
def fake_import(name, *a, **kw):
if name == "fastapi":
raise ImportError("fastapi missing")
return orig_import(name, *a, **kw)
with patch("hermes_cli.main._find_stale_dashboard_pids",
return_value=[]), \
patch("builtins.__import__", side_effect=fake_import), \
pytest.raises(SystemExit) as exc:
cmd_dashboard(_ns(stop=True))
assert exc.value.code == 0
class TestLifecycleFlagsTakePrecedence:
"""If both --stop and --status are set, --status wins (it's listed
first in cmd_dashboard). Neither is allowed to fall through to the
server-start path, which is the critical safety property a user
who typed ``hermes dashboard --stop`` must not end up ALSO starting
a new server."""
def test_status_wins_over_stop(self, capsys):
with patch("hermes_cli.main._find_stale_dashboard_pids",
return_value=[]), \
patch("hermes_cli.main._kill_stale_dashboard_processes") as mock_kill, \
pytest.raises(SystemExit):
cmd_dashboard(_ns(status=True, stop=True))
# Kill path must NOT run when --status is also set.
mock_kill.assert_not_called()
def test_stop_does_not_fall_through_to_server_start(self):
"""Covers the worst-case regression: if --stop ever stopped exiting
early, the user would start the dashboard they just asked to stop."""
called = {"start": False}
def fake_start_server(**kw):
called["start"] = True
# Provide a fake web_server module so the import doesn't matter.
fake_ws = MagicMock()
fake_ws.start_server = fake_start_server
with patch("hermes_cli.main._find_stale_dashboard_pids",
return_value=[]), \
patch.dict(sys.modules, {"hermes_cli.web_server": fake_ws}), \
pytest.raises(SystemExit):
cmd_dashboard(_ns(stop=True))
assert called["start"] is False
class TestArgparseWiring:
"""Confirm the flags are exposed via the real argparse tree so
``hermes dashboard --stop`` / ``--status`` actually parse."""
def test_flags_are_registered(self):
from hermes_cli.main import main as _cli_main # noqa: F401
# Rebuild the argparse tree by re-running the section of main()
# that builds it. Cheapest way: introspect via --help on the
# already-built parser would require refactoring; instead we
# parse the flags directly via a minimal replay.
import importlib
mod = importlib.import_module("hermes_cli.main")
# Find the dashboard_parser instance by running build logic would
# be too invasive. Instead parse args as if via the CLI by
# intercepting parse_args. This is overkill for a smoke test —
# we just want to know the flags don't KeyError.
with patch("hermes_cli.main._find_stale_dashboard_pids",
return_value=[]), \
pytest.raises(SystemExit) as exc:
mod.cmd_dashboard(_ns(status=True))
assert exc.value.code == 0
@@ -0,0 +1,12 @@
"""Static dashboard tests for the Profiles navigation copy."""
from pathlib import Path
def test_profiles_nav_label_uses_short_copy():
en_i18n = Path(__file__).resolve().parents[2] / "web" / "src" / "i18n" / "en.ts"
content = en_i18n.read_text(encoding="utf-8")
# Nav label should be the clean short form, not the old verbose string
assert 'profiles: "Profiles"' in content
assert "profiles : multi agents" not in content
File diff suppressed because it is too large Load Diff
+162
View File
@@ -0,0 +1,162 @@
from unittest.mock import patch
def test_ensure_dependency_skips_when_present():
"""ensure_dependency is a no-op when the dep is already available."""
from hermes_cli.dep_ensure import ensure_dependency
with patch("hermes_cli.dep_ensure.shutil") as mock_shutil:
mock_shutil.which.return_value = "/usr/bin/node"
result = ensure_dependency("node", interactive=False)
assert result is True
def test_ensure_dependency_returns_false_when_missing_noninteractive():
"""ensure_dependency returns False for missing dep in non-interactive mode."""
from hermes_cli.dep_ensure import ensure_dependency
with patch("hermes_cli.dep_ensure.shutil") as mock_shutil:
mock_shutil.which.return_value = None
with patch("hermes_cli.dep_ensure._find_install_script", return_value=(None, None)):
result = ensure_dependency("node", interactive=False)
assert result is False
def test_find_install_script_from_checkout(tmp_path):
"""_find_install_script finds scripts/install.sh in a git checkout."""
from hermes_cli.dep_ensure import _find_install_script
scripts_dir = tmp_path / "scripts"
scripts_dir.mkdir()
(scripts_dir / "install.sh").write_text("#!/bin/bash", encoding="utf-8")
with patch("hermes_cli.dep_ensure._IS_WINDOWS", False):
path, shell = _find_install_script(package_dir=tmp_path / "hermes_cli", repo_root=tmp_path)
assert path is not None
assert path.name == "install.sh"
assert shell == "bash"
def test_find_install_script_from_wheel(tmp_path):
"""_find_install_script finds bundled install.sh in a wheel."""
from hermes_cli.dep_ensure import _find_install_script
bundled = tmp_path / "hermes_cli" / "scripts"
bundled.mkdir(parents=True)
(bundled / "install.sh").write_text("#!/bin/bash", encoding="utf-8")
with patch("hermes_cli.dep_ensure._IS_WINDOWS", False):
path, shell = _find_install_script(package_dir=tmp_path / "hermes_cli", repo_root=tmp_path)
assert path is not None
assert path.name == "install.sh"
assert shell == "bash"
def test_find_install_script_prefers_ps1_on_windows(tmp_path):
"""On Windows, _find_install_script should find install.ps1."""
scripts_dir = tmp_path / "hermes_cli" / "scripts"
scripts_dir.mkdir(parents=True)
(scripts_dir / "install.ps1").write_text("# fake")
(scripts_dir / "install.sh").write_text("# fake")
from hermes_cli.dep_ensure import _find_install_script
with patch("hermes_cli.dep_ensure._IS_WINDOWS", True):
path, shell = _find_install_script(package_dir=tmp_path / "hermes_cli")
assert path == scripts_dir / "install.ps1"
assert shell == "powershell"
def test_find_install_script_returns_sh_on_posix(tmp_path):
"""On POSIX, _find_install_script should find install.sh."""
scripts_dir = tmp_path / "hermes_cli" / "scripts"
scripts_dir.mkdir(parents=True)
(scripts_dir / "install.ps1").write_text("# fake")
(scripts_dir / "install.sh").write_text("# fake")
from hermes_cli.dep_ensure import _find_install_script
with patch("hermes_cli.dep_ensure._IS_WINDOWS", False):
path, shell = _find_install_script(package_dir=tmp_path / "hermes_cli")
assert path == scripts_dir / "install.sh"
assert shell == "bash"
def test_find_install_script_falls_back_to_repo_root(tmp_path):
"""When no bundled script, check repo root."""
repo_root = tmp_path / "repo"
(repo_root / "scripts").mkdir(parents=True)
(repo_root / "scripts" / "install.sh").write_text("# fake")
from hermes_cli.dep_ensure import _find_install_script
with patch("hermes_cli.dep_ensure._IS_WINDOWS", False):
path, shell = _find_install_script(package_dir=tmp_path / "hermes_cli", repo_root=repo_root)
assert path == repo_root / "scripts" / "install.sh"
assert shell == "bash"
def test_find_install_script_returns_none_when_missing(tmp_path):
from hermes_cli.dep_ensure import _find_install_script
with patch("hermes_cli.dep_ensure._IS_WINDOWS", False):
result = _find_install_script(package_dir=tmp_path / "x", repo_root=tmp_path / "y")
assert result == (None, None)
def test_has_system_browser_checks_windows_names():
from hermes_cli.dep_ensure import _has_system_browser
with patch("hermes_cli.dep_ensure._IS_WINDOWS", True), \
patch("hermes_cli.dep_ensure.shutil") as mock_shutil:
mock_shutil.which.side_effect = lambda name: "/fake/msedge.exe" if name == "msedge" else None
assert _has_system_browser() is True
def test_has_system_browser_checks_posix_names():
from hermes_cli.dep_ensure import _has_system_browser
with patch("hermes_cli.dep_ensure._IS_WINDOWS", False), \
patch("hermes_cli.dep_ensure.shutil") as mock_shutil:
mock_shutil.which.return_value = None
assert _has_system_browser() is False
def test_has_hermes_agent_browser_windows_path(tmp_path):
node_dir = tmp_path / "node"
node_dir.mkdir(parents=True)
(node_dir / "agent-browser.cmd").write_text("@echo off")
from hermes_cli.dep_ensure import _has_hermes_agent_browser
with patch("hermes_cli.dep_ensure._IS_WINDOWS", True), \
patch("hermes_constants.get_hermes_home", return_value=tmp_path):
assert _has_hermes_agent_browser() is True
def test_has_hermes_agent_browser_posix_path(tmp_path):
bin_dir = tmp_path / "node" / "bin"
bin_dir.mkdir(parents=True)
(bin_dir / "agent-browser").write_text("#!/bin/sh")
from hermes_cli.dep_ensure import _has_hermes_agent_browser
with patch("hermes_cli.dep_ensure._IS_WINDOWS", False), \
patch("hermes_constants.get_hermes_home", return_value=tmp_path):
assert _has_hermes_agent_browser() is True
def test_has_hermes_agent_browser_legacy_node_modules_path(tmp_path):
"""Legacy git-clone installs put agent-browser in $HERMES_HOME/node_modules/.bin/."""
bin_dir = tmp_path / "node_modules" / ".bin"
bin_dir.mkdir(parents=True)
(bin_dir / "agent-browser").write_text("#!/bin/sh")
from hermes_cli.dep_ensure import _has_hermes_agent_browser
with patch("hermes_cli.dep_ensure._IS_WINDOWS", False), \
patch("hermes_constants.get_hermes_home", return_value=tmp_path):
assert _has_hermes_agent_browser() is True
def test_ensure_dependency_uses_powershell_on_windows(tmp_path):
from hermes_cli.dep_ensure import ensure_dependency
scripts_dir = tmp_path / "scripts"
scripts_dir.mkdir(parents=True)
(scripts_dir / "install.ps1").write_text("# fake")
with patch("hermes_cli.dep_ensure._IS_WINDOWS", True), \
patch("hermes_cli.dep_ensure._DEP_CHECKS", {"node": lambda: False}), \
patch("hermes_cli.dep_ensure._find_install_script", return_value=(scripts_dir / "install.ps1", "powershell")), \
patch("hermes_cli.dep_ensure.shutil") as mock_shutil, \
patch("hermes_constants.get_hermes_home", return_value=tmp_path / "fakehome"), \
patch("subprocess.run") as mock_run, \
patch("sys.stdin") as mock_stdin:
mock_shutil.which.side_effect = lambda name: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" if name == "powershell" else None
mock_stdin.isatty.return_value = False
mock_run.return_value = type("R", (), {"returncode": 0})()
ensure_dependency("node", interactive=False)
cmd = mock_run.call_args[0][0]
assert "powershell" in cmd[0].lower()
assert "-Ensure" in cmd
assert cmd[cmd.index("-Ensure") + 1] == "node"
assert "-HermesHome" in cmd
assert str(tmp_path / "fakehome") in cmd
@@ -0,0 +1,62 @@
"""Tests for warn_deprecated_cwd_env_vars() migration warning."""
class TestDeprecatedCwdWarning:
"""Warn when MESSAGING_CWD or TERMINAL_CWD is set in .env."""
def test_messaging_cwd_triggers_warning(self, monkeypatch, capsys):
monkeypatch.setenv("MESSAGING_CWD", "/some/path")
monkeypatch.delenv("TERMINAL_CWD", raising=False)
from hermes_cli.config import warn_deprecated_cwd_env_vars
warn_deprecated_cwd_env_vars(config={})
captured = capsys.readouterr()
assert "MESSAGING_CWD" in captured.err
assert "deprecated" in captured.err.lower()
assert "config.yaml" in captured.err
def test_terminal_cwd_triggers_warning_when_config_placeholder(self, monkeypatch, capsys):
monkeypatch.setenv("TERMINAL_CWD", "/project")
monkeypatch.delenv("MESSAGING_CWD", raising=False)
from hermes_cli.config import warn_deprecated_cwd_env_vars
# config has placeholder cwd → TERMINAL_CWD likely from .env
warn_deprecated_cwd_env_vars(config={"terminal": {"cwd": "."}})
captured = capsys.readouterr()
assert "TERMINAL_CWD" in captured.err
assert "deprecated" in captured.err.lower()
def test_no_warning_when_config_has_explicit_cwd(self, monkeypatch, capsys):
monkeypatch.setenv("TERMINAL_CWD", "/project")
monkeypatch.delenv("MESSAGING_CWD", raising=False)
from hermes_cli.config import warn_deprecated_cwd_env_vars
# config has explicit cwd → TERMINAL_CWD could be from config bridge
warn_deprecated_cwd_env_vars(config={"terminal": {"cwd": "/project"}})
captured = capsys.readouterr()
assert "TERMINAL_CWD" not in captured.err
def test_no_warning_when_env_clean(self, monkeypatch, capsys):
monkeypatch.delenv("MESSAGING_CWD", raising=False)
monkeypatch.delenv("TERMINAL_CWD", raising=False)
from hermes_cli.config import warn_deprecated_cwd_env_vars
warn_deprecated_cwd_env_vars(config={})
captured = capsys.readouterr()
assert captured.err == ""
def test_both_deprecated_vars_warn(self, monkeypatch, capsys):
monkeypatch.setenv("MESSAGING_CWD", "/msg/path")
monkeypatch.setenv("TERMINAL_CWD", "/term/path")
from hermes_cli.config import warn_deprecated_cwd_env_vars
warn_deprecated_cwd_env_vars(config={})
captured = capsys.readouterr()
assert "MESSAGING_CWD" in captured.err
assert "TERMINAL_CWD" in captured.err
@@ -0,0 +1,86 @@
"""Tests for the approvals.destructive_slash_confirm config gate.
Destructive session slash commands (/clear, /new, /reset, /undo) discard
conversation state. This config key (default True) gates a three-option
confirmation prompt "Always Approve" flips the key to False so future
destructive commands run silently.
See gateway/run.py::_maybe_confirm_destructive_slash and
cli.py::_confirm_destructive_slash for the runtime gate.
"""
from __future__ import annotations
from hermes_cli.config import DEFAULT_CONFIG
class TestDestructiveSlashConfirmDefault:
def test_default_config_has_the_key(self):
approvals = DEFAULT_CONFIG.get("approvals")
assert isinstance(approvals, dict)
assert "destructive_slash_confirm" in approvals
def test_default_is_true(self):
# New installs confirm by default — destructive commands must not
# silently wipe history without an explicit user "yes".
assert DEFAULT_CONFIG["approvals"]["destructive_slash_confirm"] is True
def test_shape_matches_other_approval_keys(self):
approvals = DEFAULT_CONFIG["approvals"]
assert isinstance(approvals.get("destructive_slash_confirm"), bool)
# Sibling key shape sanity — same flat dict level as mcp_reload_confirm.
assert isinstance(approvals.get("mcp_reload_confirm"), bool)
class TestUserConfigMerge:
"""If a user has a pre-existing config without this key, load_config
should fill it in from DEFAULT_CONFIG (deep merge preserves keys the
user didn't override)."""
def test_existing_user_config_without_key_gets_default(self, tmp_path, monkeypatch):
import yaml
home = tmp_path / ".hermes"
home.mkdir()
cfg_path = home / "config.yaml"
legacy = {
"approvals": {"mode": "manual", "timeout": 60, "cron_mode": "deny"},
}
cfg_path.write_text(yaml.safe_dump(legacy))
monkeypatch.setenv("HERMES_HOME", str(home))
import importlib
import hermes_cli.config as cfg_mod
importlib.reload(cfg_mod)
cfg = cfg_mod.load_config()
assert cfg["approvals"]["destructive_slash_confirm"] is True
def test_existing_user_config_with_false_key_survives_merge(
self, tmp_path, monkeypatch,
):
"""A user who clicked "Always Approve" (key=false) must keep that
setting the default-true value must not win on later loads.
"""
import yaml
home = tmp_path / ".hermes"
home.mkdir()
cfg_path = home / "config.yaml"
user_cfg = {
"approvals": {
"mode": "manual",
"timeout": 60,
"cron_mode": "deny",
"destructive_slash_confirm": False,
},
}
cfg_path.write_text(yaml.safe_dump(user_cfg))
monkeypatch.setenv("HERMES_HOME", str(home))
import importlib
import hermes_cli.config as cfg_mod
importlib.reload(cfg_mod)
cfg = cfg_mod.load_config()
assert cfg["approvals"]["destructive_slash_confirm"] is False
@@ -0,0 +1,79 @@
"""Tests for hermes_cli.runtime_provider._detect_api_mode_for_url.
The helper maps base URLs to api_modes for three cases:
* api.openai.com codex_responses
* api.x.ai codex_responses
* */anthropic anthropic_messages (third-party gateways like MiniMax,
Zhipu GLM, LiteLLM proxies)
Consolidating the /anthropic detection in this helper (instead of three
inline ``endswith`` checks spread across _resolve_runtime_from_pool_entry,
the explicit-provider path, and the api-key-provider path) means every
future update to the detection logic lives in one place.
"""
from __future__ import annotations
from hermes_cli.runtime_provider import _detect_api_mode_for_url
class TestCodexResponsesDetection:
def test_openai_api_returns_codex_responses(self):
assert _detect_api_mode_for_url("https://api.openai.com/v1") == "codex_responses"
def test_xai_api_returns_codex_responses(self):
assert _detect_api_mode_for_url("https://api.x.ai/v1") == "codex_responses"
def test_openrouter_is_not_codex_responses(self):
# api.openai.com check must exclude openrouter (which routes to openai-hosted models).
assert _detect_api_mode_for_url("https://openrouter.ai/api/v1") is None
def test_openai_host_suffix_does_not_match(self):
assert _detect_api_mode_for_url("https://api.openai.com.example/v1") is None
def test_openai_path_segment_does_not_match(self):
assert _detect_api_mode_for_url("https://proxy.example.test/api.openai.com/v1") is None
def test_xai_host_suffix_does_not_match(self):
assert _detect_api_mode_for_url("https://api.x.ai.example/v1") is None
class TestAnthropicMessagesDetection:
"""Third-party gateways that speak the Anthropic protocol under /anthropic."""
def test_minimax_anthropic_endpoint(self):
assert _detect_api_mode_for_url("https://api.minimax.io/anthropic") == "anthropic_messages"
def test_minimax_cn_anthropic_endpoint(self):
assert _detect_api_mode_for_url("https://api.minimaxi.com/anthropic") == "anthropic_messages"
def test_dashscope_anthropic_endpoint(self):
assert (
_detect_api_mode_for_url("https://dashscope.aliyuncs.com/api/v2/apps/anthropic")
== "anthropic_messages"
)
def test_trailing_slash_tolerated(self):
assert _detect_api_mode_for_url("https://api.minimax.io/anthropic/") == "anthropic_messages"
def test_uppercase_path_tolerated(self):
assert _detect_api_mode_for_url("https://API.MINIMAX.IO/Anthropic") == "anthropic_messages"
def test_anthropic_in_middle_of_path_does_not_match(self):
# The helper requires ``/anthropic`` as the path SUFFIX, not anywhere.
# Protects against false positives on e.g. /anthropic/v1/models.
assert _detect_api_mode_for_url("https://api.example.com/anthropic/v1") is None
class TestDefaultCase:
def test_generic_url_returns_none(self):
assert _detect_api_mode_for_url("https://api.together.xyz/v1") is None
def test_empty_string_returns_none(self):
assert _detect_api_mode_for_url("") is None
def test_none_returns_none(self):
assert _detect_api_mode_for_url(None) is None
def test_localhost_returns_none(self):
assert _detect_api_mode_for_url("http://localhost:11434/v1") is None
@@ -0,0 +1,43 @@
"""Regression tests for ``determine_api_mode`` hostname handling.
Companion to tests/hermes_cli/test_detect_api_mode_for_url.py the same
false-positive class (custom URLs containing ``api.openai.com`` /
``api.anthropic.com`` as a path segment or host suffix) must be rejected
by ``determine_api_mode`` as well, since it's the code path used by
custom/unknown providers in ``resolve_custom_provider``.
"""
from __future__ import annotations
from hermes_cli.providers import determine_api_mode
class TestOpenAIHostHardening:
def test_native_openai_url_is_codex_responses(self):
assert determine_api_mode("", "https://api.openai.com/v1") == "codex_responses"
def test_openai_host_suffix_is_not_codex(self):
assert determine_api_mode("", "https://api.openai.com.example/v1") == "chat_completions"
def test_openai_path_segment_is_not_codex(self):
assert determine_api_mode("", "https://proxy.example.test/api.openai.com/v1") == "chat_completions"
class TestAnthropicHostHardening:
def test_native_anthropic_url_is_anthropic_messages(self):
assert determine_api_mode("", "https://api.anthropic.com") == "anthropic_messages"
def test_anthropic_host_suffix_is_not_anthropic(self):
assert determine_api_mode("", "https://api.anthropic.com.example/v1") == "chat_completions"
def test_anthropic_path_segment_is_not_anthropic(self):
# A proxy whose path contains ``api.anthropic.com`` must not be misrouted.
# Note: the ``/anthropic`` convention for third-party gateways still wins
# via explicit path-suffix check — see test_anthropic_path_suffix_still_wins.
assert determine_api_mode("", "https://proxy.example.test/api.anthropic.com/v1") == "chat_completions"
def test_anthropic_path_suffix_still_wins(self):
# Third-party Anthropic-compatible gateways (MiniMax, Zhipu GLM, LiteLLM
# proxies) expose the Anthropic protocol under a ``/anthropic`` suffix.
# That convention must still resolve to anthropic_messages.
assert determine_api_mode("", "https://api.minimax.io/anthropic") == "anthropic_messages"
+217
View File
@@ -0,0 +1,217 @@
"""Unit tests for hermes_cli/dingtalk_auth.py (QR device-flow registration)."""
from __future__ import annotations
import sys
from unittest.mock import MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# API layer — _api_post + error mapping
# ---------------------------------------------------------------------------
class TestApiPost:
def test_raises_on_network_error(self):
import requests
from hermes_cli.dingtalk_auth import _api_post, RegistrationError
with patch("hermes_cli.dingtalk_auth.requests.post",
side_effect=requests.ConnectionError("nope")):
with pytest.raises(RegistrationError, match="Network error"):
_api_post("/app/registration/init", {"source": "hermes"})
def test_raises_on_nonzero_errcode(self):
from hermes_cli.dingtalk_auth import _api_post, RegistrationError
mock_resp = MagicMock()
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = {"errcode": 42, "errmsg": "boom"}
with patch("hermes_cli.dingtalk_auth.requests.post", return_value=mock_resp):
with pytest.raises(RegistrationError, match=r"boom \(errcode=42\)"):
_api_post("/app/registration/init", {"source": "hermes"})
def test_returns_data_on_success(self):
from hermes_cli.dingtalk_auth import _api_post
mock_resp = MagicMock()
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = {"errcode": 0, "nonce": "abc"}
with patch("hermes_cli.dingtalk_auth.requests.post", return_value=mock_resp):
result = _api_post("/app/registration/init", {"source": "hermes"})
assert result["nonce"] == "abc"
# ---------------------------------------------------------------------------
# begin_registration — 2-step nonce → device_code chain
# ---------------------------------------------------------------------------
class TestBeginRegistration:
def test_chains_init_then_begin(self):
from hermes_cli.dingtalk_auth import begin_registration
responses = [
{"errcode": 0, "nonce": "nonce123"},
{
"errcode": 0,
"device_code": "dev-xyz",
"verification_uri_complete": "https://open-dev.dingtalk.com/openapp/registration/openClaw?user_code=ABCD",
"expires_in": 7200,
"interval": 2,
},
]
with patch("hermes_cli.dingtalk_auth._api_post", side_effect=responses):
result = begin_registration()
assert result["device_code"] == "dev-xyz"
assert "verification_uri_complete" in result
assert result["interval"] == 2
assert result["expires_in"] == 7200
def test_missing_nonce_raises(self):
from hermes_cli.dingtalk_auth import begin_registration, RegistrationError
with patch("hermes_cli.dingtalk_auth._api_post",
return_value={"errcode": 0, "nonce": ""}):
with pytest.raises(RegistrationError, match="missing nonce"):
begin_registration()
def test_missing_device_code_raises(self):
from hermes_cli.dingtalk_auth import begin_registration, RegistrationError
responses = [
{"errcode": 0, "nonce": "n1"},
{"errcode": 0, "verification_uri_complete": "http://x"}, # no device_code
]
with patch("hermes_cli.dingtalk_auth._api_post", side_effect=responses):
with pytest.raises(RegistrationError, match="missing device_code"):
begin_registration()
def test_missing_verification_uri_raises(self):
from hermes_cli.dingtalk_auth import begin_registration, RegistrationError
responses = [
{"errcode": 0, "nonce": "n1"},
{"errcode": 0, "device_code": "dev"}, # no verification_uri_complete
]
with patch("hermes_cli.dingtalk_auth._api_post", side_effect=responses):
with pytest.raises(RegistrationError,
match="missing verification_uri_complete"):
begin_registration()
# ---------------------------------------------------------------------------
# wait_for_registration_success — polling loop
# ---------------------------------------------------------------------------
class TestWaitForSuccess:
def test_returns_credentials_on_success(self):
from hermes_cli.dingtalk_auth import wait_for_registration_success
responses = [
{"status": "WAITING"},
{"status": "WAITING"},
{"status": "SUCCESS", "client_id": "cid-1", "client_secret": "sec-1"},
]
with patch("hermes_cli.dingtalk_auth.poll_registration", side_effect=responses), \
patch("hermes_cli.dingtalk_auth.time.sleep"):
cid, secret = wait_for_registration_success(
device_code="dev", interval=0, expires_in=60
)
assert cid == "cid-1"
assert secret == "sec-1"
def test_success_without_credentials_raises(self):
from hermes_cli.dingtalk_auth import wait_for_registration_success, RegistrationError
with patch("hermes_cli.dingtalk_auth.poll_registration",
return_value={"status": "SUCCESS", "client_id": "", "client_secret": ""}), \
patch("hermes_cli.dingtalk_auth.time.sleep"):
with pytest.raises(RegistrationError, match="credentials are missing"):
wait_for_registration_success(
device_code="dev", interval=0, expires_in=60
)
def test_invokes_waiting_callback(self):
from hermes_cli.dingtalk_auth import wait_for_registration_success
callback = MagicMock()
responses = [
{"status": "WAITING"},
{"status": "WAITING"},
{"status": "SUCCESS", "client_id": "cid", "client_secret": "sec"},
]
with patch("hermes_cli.dingtalk_auth.poll_registration", side_effect=responses), \
patch("hermes_cli.dingtalk_auth.time.sleep"):
wait_for_registration_success(
device_code="dev", interval=0, expires_in=60, on_waiting=callback
)
assert callback.call_count == 2
# ---------------------------------------------------------------------------
# QR rendering — terminal output
# ---------------------------------------------------------------------------
class TestRenderQR:
def test_returns_false_when_qrcode_missing(self, monkeypatch):
from hermes_cli import dingtalk_auth
# Simulate qrcode import failure
monkeypatch.setitem(sys.modules, "qrcode", None)
assert dingtalk_auth.render_qr_to_terminal("https://example.com") is False
def test_prints_when_qrcode_available(self, capsys):
"""End-to-end: render a real QR and verify SOMETHING got printed."""
try:
import qrcode # noqa: F401
except ImportError:
pytest.skip("qrcode library not available")
from hermes_cli.dingtalk_auth import render_qr_to_terminal
result = render_qr_to_terminal("https://example.com/test")
captured = capsys.readouterr()
assert result is True
assert len(captured.out) > 100 # rendered matrix is non-trivial
# ---------------------------------------------------------------------------
# Configuration — env var overrides
# ---------------------------------------------------------------------------
class TestConfigOverrides:
def test_base_url_default(self, monkeypatch):
monkeypatch.delenv("DINGTALK_REGISTRATION_BASE_URL", raising=False)
# Force module reload to pick up current env
import importlib
import hermes_cli.dingtalk_auth as mod
importlib.reload(mod)
assert mod.REGISTRATION_BASE_URL == "https://oapi.dingtalk.com"
def test_base_url_override_via_env(self, monkeypatch):
monkeypatch.setenv("DINGTALK_REGISTRATION_BASE_URL",
"https://test.example.com/")
import importlib
import hermes_cli.dingtalk_auth as mod
importlib.reload(mod)
# Trailing slash stripped
assert mod.REGISTRATION_BASE_URL == "https://test.example.com"
def test_source_default(self, monkeypatch):
monkeypatch.delenv("DINGTALK_REGISTRATION_SOURCE", raising=False)
import importlib
import hermes_cli.dingtalk_auth as mod
importlib.reload(mod)
assert mod.REGISTRATION_SOURCE == "openClaw"
@@ -0,0 +1,246 @@
"""Tests for Discord /skill 32-char clamp collision warnings.
Discord's per-command name limit is 32 chars, so
``discord_skill_commands_by_category`` clamps skill slugs to that width
before deduping. When two skills share the same 32-char prefix, only
the first (alphabetical) wins; the second is dropped. Previously the
drop was silent the ``hidden`` count incremented but nothing named
which skills collided, so authors had no way to discover the drop
short of noticing that their skill was missing from the autocomplete.
This module pins the upgraded behavior: a WARNING log with both full
cmd_keys + the clamped name, so whoever named the skills sees the
collision and can rename one.
"""
from __future__ import annotations
import logging
from pathlib import Path
from unittest.mock import patch
def test_clamp_collision_emits_warning_naming_both_skills(
tmp_path: Path, caplog
) -> None:
"""Two skills with identical first 32 chars — warning names both."""
from hermes_cli.commands import discord_skill_commands_by_category
# Craft cmd_keys that share the first 32 chars.
# 40-char prefix 'skill-collision-prefix-identical-first-32'
# -> clamped to 'skill-collision-prefix-identical'
prefix = "skill-collision-prefix-identical" # exactly 32 chars
name_a = prefix + "-alpha" # /skill-collision-prefix-identical-alpha
name_b = prefix + "-bravo" # /skill-collision-prefix-identical-bravo
assert name_a[:32] == name_b[:32] == prefix
skills_dir = tmp_path / "skills"
for nm in (name_a, name_b):
d = skills_dir / "creative" / nm
d.mkdir(parents=True)
(d / "SKILL.md").write_text("---\nname: x\n---\n")
fake_cmds = {
f"/{name_a}": {
"name": name_a,
"description": "Alpha",
"skill_md_path": str(skills_dir / "creative" / name_a / "SKILL.md"),
},
f"/{name_b}": {
"name": name_b,
"description": "Bravo",
"skill_md_path": str(skills_dir / "creative" / name_b / "SKILL.md"),
},
}
with caplog.at_level(logging.WARNING, logger="hermes_cli.commands"), (
patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds)
), patch("tools.skills_tool.SKILLS_DIR", skills_dir):
categories, uncategorized, hidden = discord_skill_commands_by_category(
reserved_names=set(),
)
# One skill made it through, one was dropped (hidden counted).
assert hidden == 1
kept_names = [n for n, _d, _k in categories.get("creative", [])]
assert len(kept_names) == 1
# Alphabetical iteration means the -alpha variant wins the slot.
assert kept_names[0] == prefix # clamped
# Exactly one warning, naming BOTH full cmd_keys and the clamped name.
warnings = [
r for r in caplog.records
if r.levelno == logging.WARNING and "clamp" in r.getMessage()
]
assert len(warnings) == 1, (
f"expected exactly one clamp-collision warning, got {len(warnings)}: "
f"{[r.getMessage() for r in warnings]}"
)
msg = warnings[0].getMessage()
assert f"/{name_a}" in msg, f"winner not named in warning: {msg!r}"
assert f"/{name_b}" in msg, f"loser not named in warning: {msg!r}"
assert prefix in msg, f"clamped name not in warning: {msg!r}"
def test_clamp_collision_with_reserved_name_emits_distinct_warning(
tmp_path: Path, caplog
) -> None:
"""A skill clashing with a reserved gateway command gets its own phrasing.
The reserved-vs-skill case is operationally different the fix is
still "rename the skill," but there's no second skill to also
rename. The warning should say so explicitly.
"""
from hermes_cli.commands import discord_skill_commands_by_category
# Reserved name 'help' is 4 chars — make a skill whose slug
# clamps to 'help' (so, exactly 'help').
reserved = "help"
skills_dir = tmp_path / "skills"
d = skills_dir / "creative" / reserved
d.mkdir(parents=True)
(d / "SKILL.md").write_text("---\nname: x\n---\n")
fake_cmds = {
f"/{reserved}": {
"name": reserved,
"description": "desc",
"skill_md_path": str(d / "SKILL.md"),
},
}
with caplog.at_level(logging.WARNING, logger="hermes_cli.commands"), (
patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds)
), patch("tools.skills_tool.SKILLS_DIR", skills_dir):
categories, uncategorized, hidden = discord_skill_commands_by_category(
reserved_names={"help"},
)
# Skill dropped in favor of the reserved command.
assert hidden == 1
assert categories == {}
assert uncategorized == []
warnings = [
r for r in caplog.records
if r.levelno == logging.WARNING and "reserved" in r.getMessage()
]
assert len(warnings) == 1, (
f"expected one reserved-name collision warning, got "
f"{[r.getMessage() for r in warnings]}"
)
msg = warnings[0].getMessage()
assert f"/{reserved}" in msg
assert "reserved" in msg.lower()
def test_no_collision_no_warning(tmp_path: Path, caplog) -> None:
"""Sanity: two distinct-prefix skills produce zero warnings."""
from hermes_cli.commands import discord_skill_commands_by_category
skills_dir = tmp_path / "skills"
for nm in ("alpha", "bravo"):
d = skills_dir / "creative" / nm
d.mkdir(parents=True)
(d / "SKILL.md").write_text("---\nname: x\n---\n")
fake_cmds = {
"/alpha": {
"name": "alpha", "description": "",
"skill_md_path": str(skills_dir / "creative" / "alpha" / "SKILL.md"),
},
"/bravo": {
"name": "bravo", "description": "",
"skill_md_path": str(skills_dir / "creative" / "bravo" / "SKILL.md"),
},
}
with caplog.at_level(logging.WARNING, logger="hermes_cli.commands"), (
patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds)
), patch("tools.skills_tool.SKILLS_DIR", skills_dir):
categories, uncategorized, hidden = discord_skill_commands_by_category(
reserved_names=set(),
)
assert hidden == 0
assert {n for n, _d, _k in categories["creative"]} == {"alpha", "bravo"}
clamp_warnings = [
r for r in caplog.records
if r.levelno == logging.WARNING
and ("clamp" in r.getMessage() or "reserved" in r.getMessage())
]
assert clamp_warnings == []
def test_long_skill_name_preserves_cmd_key_through_by_category(
tmp_path: Path,
) -> None:
"""Skills with names > 32 chars must keep their original cmd_key.
``discord_skill_commands_by_category`` clamps the display name to 32
chars but the third tuple element (cmd_key) must stay as the original
``/full-skill-name`` so that ``_skill_handler`` dispatches via
``_run_simple_slash`` with the full command, not the truncated one.
This is the actual runtime path used by the Discord adapter via
``_refresh_skill_catalog_state``.
"""
from hermes_cli.commands import discord_skill_commands_by_category
skills_dir = tmp_path / "skills"
skills_dir.mkdir()
resolved = str(skills_dir.resolve())
long_name = "generate-ascii-art-from-text-description-detailed"
cmd_key = f"/{long_name}"
fake_cmds = {
cmd_key: {
"name": long_name,
"description": "Generate ASCII art from a text description",
"skill_md_path": f"{resolved}/creative/{long_name}/SKILL.md",
"skill_dir": f"{resolved}/creative/{long_name}",
},
"/short-skill": {
"name": "short-skill",
"description": "A short skill",
"skill_md_path": f"{resolved}/creative/short-skill/SKILL.md",
"skill_dir": f"{resolved}/creative/short-skill",
},
}
with patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds), \
patch("tools.skills_tool.SKILLS_DIR", skills_dir):
categories, uncategorized, hidden = discord_skill_commands_by_category(
reserved_names=set(),
)
# Flatten (same as _refresh_skill_catalog_state does)
entries = list(uncategorized)
for cat_skills in categories.values():
entries.extend(cat_skills)
# Build lookup (same as _refresh_skill_catalog_state does)
skill_lookup = {n: (d, k) for n, d, k in entries}
# Find the long skill
long_entry = [e for e in entries if e[2] == cmd_key]
assert len(long_entry) == 1, f"Long skill should appear once, got: {long_entry}"
display_name, desc, key = long_entry[0]
assert len(display_name) <= 32, (
f"Display name should be clamped to 32 chars, got {len(display_name)}"
)
assert key == cmd_key, (
f"cmd_key must be the original /{long_name}, got {key!r}"
)
# Verify lookup works: clamped display name -> original cmd_key
assert display_name in skill_lookup
_desc, looked_up_key = skill_lookup[display_name]
assert looked_up_key == cmd_key, (
f"Lookup must map clamped name to original cmd_key, got {looked_up_key!r}"
)
# Short skill should also be present and correct
short_entry = [e for e in entries if e[2] == "/short-skill"]
assert len(short_entry) == 1
assert short_entry[0][0] == "short-skill"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,274 @@
"""Tests for the Command Installation check in hermes doctor."""
import sys
import types
from argparse import Namespace
from pathlib import Path
import pytest
import hermes_cli.doctor as doctor_mod
def _setup_doctor_env(monkeypatch, tmp_path, venv_name="venv"):
"""Create a minimal HERMES_HOME + PROJECT_ROOT for doctor tests."""
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
project = tmp_path / "project"
project.mkdir(exist_ok=True)
# Create a fake venv entry point
venv_bin_dir = project / venv_name / "bin"
venv_bin_dir.mkdir(parents=True, exist_ok=True)
hermes_bin = venv_bin_dir / "hermes"
hermes_bin.write_text("#!/usr/bin/env python\n# entry point\n")
hermes_bin.chmod(0o755)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
# Stub model_tools so doctor doesn't fail on import
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
# Stub auth checks
try:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
except Exception:
pass
# Stub httpx.get to avoid network calls
try:
import httpx
monkeypatch.setattr(httpx, "get", lambda *a, **kw: types.SimpleNamespace(status_code=200))
except Exception:
pass
return home, project, hermes_bin
def _run_doctor(fix=False):
"""Run doctor and capture stdout."""
import io
import contextlib
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=fix))
return buf.getvalue()
class TestDoctorCommandInstallation:
"""Tests for the ◆ Command Installation section."""
@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only")
def test_correct_symlink_shows_ok(self, monkeypatch, tmp_path):
home, project, hermes_bin = _setup_doctor_env(monkeypatch, tmp_path)
# Create the command link dir with correct symlink
cmd_link_dir = tmp_path / ".local" / "bin"
cmd_link_dir.mkdir(parents=True)
cmd_link = cmd_link_dir / "hermes"
cmd_link.symlink_to(hermes_bin)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
out = _run_doctor(fix=False)
assert "Command Installation" in out
assert "Venv entry point exists" in out
assert "correct target" in out
@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only")
def test_missing_symlink_shows_fail(self, monkeypatch, tmp_path):
home, project, hermes_bin = _setup_doctor_env(monkeypatch, tmp_path)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
# Don't create the symlink — it should be missing
out = _run_doctor(fix=False)
assert "Command Installation" in out
assert "Venv entry point exists" in out
assert "not found" in out
assert "hermes doctor --fix" in out
@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only")
def test_fix_creates_missing_symlink(self, monkeypatch, tmp_path):
home, project, hermes_bin = _setup_doctor_env(monkeypatch, tmp_path)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
out = _run_doctor(fix=True)
assert "Command Installation" in out
assert "Created symlink" in out
# Verify the symlink was actually created
cmd_link = tmp_path / ".local" / "bin" / "hermes"
assert cmd_link.is_symlink()
assert cmd_link.resolve() == hermes_bin.resolve()
@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only")
def test_wrong_target_symlink_shows_warn(self, monkeypatch, tmp_path):
home, project, hermes_bin = _setup_doctor_env(monkeypatch, tmp_path)
# Create a symlink pointing to the wrong target
cmd_link_dir = tmp_path / ".local" / "bin"
cmd_link_dir.mkdir(parents=True)
cmd_link = cmd_link_dir / "hermes"
wrong_target = tmp_path / "wrong_hermes"
wrong_target.write_text("#!/usr/bin/env python\n")
cmd_link.symlink_to(wrong_target)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
out = _run_doctor(fix=False)
assert "Command Installation" in out
assert "wrong target" in out
@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only")
def test_fix_repairs_wrong_symlink(self, monkeypatch, tmp_path):
home, project, hermes_bin = _setup_doctor_env(monkeypatch, tmp_path)
# Create a symlink pointing to wrong target
cmd_link_dir = tmp_path / ".local" / "bin"
cmd_link_dir.mkdir(parents=True)
cmd_link = cmd_link_dir / "hermes"
wrong_target = tmp_path / "wrong_hermes"
wrong_target.write_text("#!/usr/bin/env python\n")
cmd_link.symlink_to(wrong_target)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
out = _run_doctor(fix=True)
assert "Fixed symlink" in out
# Verify the symlink now points to the correct target
assert cmd_link.is_symlink()
assert cmd_link.resolve() == hermes_bin.resolve()
@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only")
def test_missing_venv_entry_point_shows_warn(self, monkeypatch, tmp_path):
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
project = tmp_path / "project"
project.mkdir(exist_ok=True)
# Do NOT create any venv entry point
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
try:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
except Exception:
pass
try:
import httpx
monkeypatch.setattr(httpx, "get", lambda *a, **kw: types.SimpleNamespace(status_code=200))
except Exception:
pass
out = _run_doctor(fix=False)
assert "Command Installation" in out
assert "Venv entry point not found" in out
@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only")
def test_dot_venv_dir_is_found(self, monkeypatch, tmp_path):
"""The check finds entry points in .venv/ as well as venv/."""
home, project, _ = _setup_doctor_env(monkeypatch, tmp_path, venv_name=".venv")
# Create the command link with correct symlink
hermes_bin = project / ".venv" / "bin" / "hermes"
cmd_link_dir = tmp_path / ".local" / "bin"
cmd_link_dir.mkdir(parents=True)
cmd_link = cmd_link_dir / "hermes"
cmd_link.symlink_to(hermes_bin)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
out = _run_doctor(fix=False)
assert "Venv entry point exists" in out
assert ".venv/bin/hermes" in out
@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only")
def test_non_symlink_regular_file_shows_ok(self, monkeypatch, tmp_path):
"""If ~/.local/bin/hermes is a regular file (not symlink), accept it."""
home, project, hermes_bin = _setup_doctor_env(monkeypatch, tmp_path)
cmd_link_dir = tmp_path / ".local" / "bin"
cmd_link_dir.mkdir(parents=True)
cmd_link = cmd_link_dir / "hermes"
cmd_link.write_text("#!/bin/sh\nexec python -m hermes_cli.main \"$@\"\n")
monkeypatch.setattr(Path, "home", lambda: tmp_path)
out = _run_doctor(fix=False)
assert "non-symlink" in out
@pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only")
def test_termux_uses_prefix_bin(self, monkeypatch, tmp_path):
"""On Termux, the command link dir is $PREFIX/bin."""
prefix_dir = tmp_path / "termux_prefix"
prefix_bin = prefix_dir / "bin"
prefix_bin.mkdir(parents=True)
home, project, hermes_bin = _setup_doctor_env(monkeypatch, tmp_path)
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
monkeypatch.setenv("PREFIX", str(prefix_dir))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
out = _run_doctor(fix=False)
assert "Command Installation" in out
assert "$PREFIX/bin" in out
def test_windows_skips_check(self, monkeypatch, tmp_path):
"""On Windows, the Command Installation section is skipped."""
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
project = tmp_path / "project"
project.mkdir(exist_ok=True)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
monkeypatch.setattr(sys, "platform", "win32")
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
try:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
except Exception:
pass
try:
import httpx
monkeypatch.setattr(httpx, "get", lambda *a, **kw: types.SimpleNamespace(status_code=200))
except Exception:
pass
out = _run_doctor(fix=False)
assert "Command Installation" not in out
@@ -0,0 +1,50 @@
"""Regression: hermes doctor must not run a generic Bearer-auth health
check for providers that already have a dedicated check (Anthropic,
OpenRouter, Bedrock).
Anthropic's native API requires `x-api-key` + `anthropic-version` headers;
the generic loop sends `Authorization: Bearer ...` which Anthropic answers
with HTTP 404. The dedicated check at hermes_cli/doctor.py already covers
Anthropic with the right headers, so the pluggable profile must be
skipped by `_build_apikey_providers_list()`.
See: NousResearch/hermes-agent#22346
"""
from __future__ import annotations
def test_build_apikey_providers_list_skips_dedicated_check_providers():
from hermes_cli import doctor
# Force a rebuild — the module caches the list on first call.
doctor._APIKEY_PROVIDERS_CACHE = None
entries = doctor._build_apikey_providers_list()
# Tuple shape: (display_name, env_vars, default_url, base_env, supports_health_check)
names = {entry[0].lower() for entry in entries}
assert not any("anthropic" in name for name in names), (
f"Anthropic provider profile leaked into generic Bearer-auth health "
f"check loop. Dedicated check above already covers it with "
f"x-api-key headers. Got entries: {sorted(names)}"
)
assert not any("openrouter" in name for name in names), (
f"OpenRouter has a dedicated check; generic loop must skip it. "
f"Got: {sorted(names)}"
)
assert not any("bedrock" in name for name in names), (
f"Bedrock uses AWS SDK creds, not Bearer auth; generic loop must skip. "
f"Got: {sorted(names)}"
)
def test_build_apikey_providers_list_includes_non_dedicated_providers():
"""Sanity guard: the skip-set must not strip every provider."""
from hermes_cli import doctor
doctor._APIKEY_PROVIDERS_CACHE = None
entries = doctor._build_apikey_providers_list()
names = {entry[0] for entry in entries}
assert "DeepSeek" in names
assert "Z.AI / GLM" in names
+118
View File
@@ -0,0 +1,118 @@
"""Tests for hermes_cli.dump._get_git_commit — git SHA resolution for ``hermes dump``.
``hermes dump`` prints the running commit so support bug reports identify the
exact version. Source installs resolve it live via ``git rev-parse``; the
published Docker image excludes ``.git`` and falls back to the baked SHA
written by the Dockerfile's ``HERMES_GIT_SHA`` build-arg.
These tests cover both paths plus the failure modes (no git, no baked file).
"""
from unittest.mock import MagicMock, patch
def test_get_git_commit_uses_live_git_when_available(tmp_path):
"""Source install: ``git rev-parse --short=8 HEAD`` wins; no fallback."""
from hermes_cli import dump
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
git_result = MagicMock(returncode=0, stdout="deadbeef\n")
# build_info should NOT be consulted when live git succeeds.
with patch("hermes_cli.dump.subprocess.run", return_value=git_result) as mock_run, \
patch("hermes_cli.build_info.get_build_sha") as mock_build:
commit = dump._get_git_commit(repo_dir)
assert commit == "deadbeef"
mock_run.assert_called_once()
mock_build.assert_not_called()
def test_get_git_commit_falls_back_to_build_sha_when_live_git_fails(tmp_path):
"""Docker image case: live git returns non-zero → use baked SHA."""
from hermes_cli import dump
repo_dir = tmp_path / "no-git-here"
repo_dir.mkdir()
failed = MagicMock(returncode=128, stdout="")
with patch("hermes_cli.dump.subprocess.run", return_value=failed), \
patch("hermes_cli.build_info.get_build_sha", return_value="cafef00d"):
commit = dump._get_git_commit(repo_dir)
assert commit == "cafef00d"
def test_get_git_commit_falls_back_when_git_returns_empty_stdout(tmp_path):
"""Edge case: git exits 0 but prints nothing — still try the baked SHA."""
from hermes_cli import dump
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
empty = MagicMock(returncode=0, stdout="\n")
with patch("hermes_cli.dump.subprocess.run", return_value=empty), \
patch("hermes_cli.build_info.get_build_sha", return_value="abcdef12"):
commit = dump._get_git_commit(repo_dir)
assert commit == "abcdef12"
def test_get_git_commit_falls_back_when_git_raises(tmp_path):
"""git binary missing (e.g. minimal container w/o git) → baked SHA path."""
from hermes_cli import dump
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
with patch("hermes_cli.dump.subprocess.run", side_effect=FileNotFoundError("git")), \
patch("hermes_cli.build_info.get_build_sha", return_value="feedface"):
commit = dump._get_git_commit(repo_dir)
assert commit == "feedface"
def test_get_git_commit_returns_unknown_when_neither_source_available(tmp_path):
"""Pip-installed wheel: no git, no baked SHA → '(unknown)' (legacy contract)."""
from hermes_cli import dump
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
failed = MagicMock(returncode=128, stdout="")
with patch("hermes_cli.dump.subprocess.run", return_value=failed), \
patch("hermes_cli.build_info.get_build_sha", return_value=None):
commit = dump._get_git_commit(repo_dir)
assert commit == "(unknown)"
def test_get_git_commit_output_format_identical_between_sources(tmp_path):
"""Regression guard: live-git and baked-SHA outputs share the same shape.
Ben explicitly asked for identical output between Docker and source installs
so support tooling that parses ``hermes dump`` doesn't have to special-case
container builds. Both paths must return a bare 8-char SHA no prefix,
no suffix, no annotation.
"""
from hermes_cli import dump
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
# Live-git path.
git_result = MagicMock(returncode=0, stdout="b2f477a3\n")
with patch("hermes_cli.dump.subprocess.run", return_value=git_result):
live = dump._get_git_commit(repo_dir)
# Baked-SHA path.
failed = MagicMock(returncode=128, stdout="")
with patch("hermes_cli.dump.subprocess.run", return_value=failed), \
patch("hermes_cli.build_info.get_build_sha", return_value="b2f477a3"):
baked = dump._get_git_commit(repo_dir)
assert live == baked == "b2f477a3"
# Same length, same charset — no decoration in either branch.
assert len(live) == 8
assert all(c in "0123456789abcdef" for c in live)
+193
View File
@@ -0,0 +1,193 @@
"""Tests for the load_env() process-level cache.
The cache exists to keep `hermes tools` "All Platforms" fast: every
`get_env_value()` lookup used to re-read and re-sanitise the entire
.env file, racking up hundreds of ms across one menu render. The
cache is keyed on (path, mtime, size); writers (save_env_value /
remove_env_value / sanitise_env_file) call invalidate_env_cache().
"""
from __future__ import annotations
import os
import tempfile
from pathlib import Path
from unittest.mock import patch
def _write_env(path: Path, contents: str) -> None:
path.write_text(contents, encoding="utf-8")
def test_load_env_caches_on_repeat_calls():
"""Repeated load_env() calls on the same file return the cached dict."""
from hermes_cli.config import invalidate_env_cache, load_env
invalidate_env_cache()
with tempfile.NamedTemporaryFile(
mode="w", suffix=".env", delete=False, encoding="utf-8"
) as f:
f.write("OPENAI_API_KEY=sk-first\n")
env_path = Path(f.name)
try:
with patch("hermes_cli.config.get_env_path", return_value=env_path):
first = load_env()
# Even if a writer outside our cache mutates the file, an
# mtime/size match means the cache still wins. We simulate that
# by writing identical bytes back — sanity check that the cache
# is keyed structurally, not on a counter.
second = load_env()
assert first == second
assert first.get("OPENAI_API_KEY") == "sk-first"
finally:
env_path.unlink(missing_ok=True)
invalidate_env_cache()
def test_load_env_invalidates_on_mtime_bump():
"""Editing the file (mtime changes) invalidates the cache."""
from hermes_cli.config import invalidate_env_cache, load_env
invalidate_env_cache()
with tempfile.NamedTemporaryFile(
mode="w", suffix=".env", delete=False, encoding="utf-8"
) as f:
f.write("OPENAI_API_KEY=sk-old\n")
env_path = Path(f.name)
try:
with patch("hermes_cli.config.get_env_path", return_value=env_path):
first = load_env()
assert first.get("OPENAI_API_KEY") == "sk-old"
# Rewrite file with new contents and bump mtime to make sure
# the FS records the change even on coarse-mtime filesystems.
_write_env(env_path, "OPENAI_API_KEY=sk-new\n")
future = env_path.stat().st_mtime + 5.0
os.utime(env_path, (future, future))
second = load_env()
assert second.get("OPENAI_API_KEY") == "sk-new", (
"load_env() returned stale value after file change"
)
finally:
env_path.unlink(missing_ok=True)
invalidate_env_cache()
def test_invalidate_env_cache_forces_reread():
"""invalidate_env_cache() forces the next load_env() to hit the disk.
This is the belt-and-braces knob for writers (save_env_value, etc.)
on filesystems where mtime resolution might miss a same-second write.
"""
from hermes_cli.config import invalidate_env_cache, load_env
invalidate_env_cache()
with tempfile.NamedTemporaryFile(
mode="w", suffix=".env", delete=False, encoding="utf-8"
) as f:
f.write("OPENAI_API_KEY=sk-old\n")
env_path = Path(f.name)
try:
with patch("hermes_cli.config.get_env_path", return_value=env_path):
assert load_env().get("OPENAI_API_KEY") == "sk-old"
# Rewrite WITHOUT bumping mtime — simulates same-second write.
mtime_before = env_path.stat().st_mtime
_write_env(env_path, "OPENAI_API_KEY=sk-new\n")
os.utime(env_path, (mtime_before, mtime_before))
# Without invalidation, cache hit might return stale.
invalidate_env_cache()
assert load_env().get("OPENAI_API_KEY") == "sk-new"
finally:
env_path.unlink(missing_ok=True)
invalidate_env_cache()
def test_save_env_value_invalidates_cache(tmp_path, monkeypatch):
"""save_env_value() invalidates the cache so subsequent reads see the update."""
from hermes_cli import config as config_mod
from hermes_cli.config import invalidate_env_cache, load_env, save_env_value
invalidate_env_cache()
env_path = tmp_path / ".env"
env_path.write_text("EXISTING_KEY=old\n", encoding="utf-8")
monkeypatch.setattr(config_mod, "get_env_path", lambda: env_path)
monkeypatch.setattr(config_mod, "ensure_hermes_home", lambda: None)
monkeypatch.setattr(config_mod, "_secure_file", lambda _p: None)
monkeypatch.setattr(config_mod, "is_managed", lambda: False)
try:
# Prime the cache.
first = load_env()
assert first.get("EXISTING_KEY") == "old"
save_env_value("NEW_KEY", "shiny")
# Same-second writes on coarse-mtime filesystems would normally
# let stale cache survive; invalidate_env_cache() inside the
# writer makes the next read see the new key.
result = load_env()
assert result.get("NEW_KEY") == "shiny"
assert result.get("EXISTING_KEY") == "old"
finally:
monkeypatch.delenv("NEW_KEY", raising=False)
invalidate_env_cache()
def test_remove_env_value_invalidates_cache(tmp_path, monkeypatch):
"""remove_env_value() invalidates the cache so the removed key disappears."""
from hermes_cli import config as config_mod
from hermes_cli.config import (
invalidate_env_cache,
load_env,
remove_env_value,
save_env_value,
)
invalidate_env_cache()
env_path = tmp_path / ".env"
monkeypatch.setattr(config_mod, "get_env_path", lambda: env_path)
monkeypatch.setattr(config_mod, "ensure_hermes_home", lambda: None)
monkeypatch.setattr(config_mod, "_secure_file", lambda _p: None)
monkeypatch.setattr(config_mod, "is_managed", lambda: False)
save_env_value("DOOMED_KEY", "value")
assert load_env().get("DOOMED_KEY") == "value"
try:
removed = remove_env_value("DOOMED_KEY")
assert removed is True
assert "DOOMED_KEY" not in load_env()
finally:
monkeypatch.delenv("DOOMED_KEY", raising=False)
invalidate_env_cache()
def test_load_env_handles_missing_file():
"""A nonexistent .env returns {} and caches the empty result."""
from hermes_cli.config import invalidate_env_cache, load_env
invalidate_env_cache()
nonexistent = Path(tempfile.gettempdir()) / "hermes-test-no-such-env-xyz123.env"
nonexistent.unlink(missing_ok=True)
try:
with patch("hermes_cli.config.get_env_path", return_value=nonexistent):
assert load_env() == {}
assert load_env() == {} # cached
finally:
invalidate_env_cache()
+105
View File
@@ -0,0 +1,105 @@
import importlib
import os
import sys
from hermes_cli.env_loader import load_hermes_dotenv
def test_user_env_overrides_stale_shell_values(tmp_path, monkeypatch):
home = tmp_path / "hermes"
home.mkdir()
env_file = home / ".env"
env_file.write_text("OPENAI_BASE_URL=https://new.example/v1\n", encoding="utf-8")
monkeypatch.setenv("OPENAI_BASE_URL", "https://old.example/v1")
loaded = load_hermes_dotenv(hermes_home=home)
assert loaded == [env_file]
assert os.getenv("OPENAI_BASE_URL") == "https://new.example/v1"
def test_project_env_overrides_stale_shell_values_when_user_env_missing(tmp_path, monkeypatch):
home = tmp_path / "hermes"
project_env = tmp_path / ".env"
project_env.write_text("OPENAI_BASE_URL=https://project.example/v1\n", encoding="utf-8")
monkeypatch.setenv("OPENAI_BASE_URL", "https://old.example/v1")
loaded = load_hermes_dotenv(hermes_home=home, project_env=project_env)
assert loaded == [project_env]
assert os.getenv("OPENAI_BASE_URL") == "https://project.example/v1"
def test_project_env_is_sanitized_before_loading(tmp_path, monkeypatch):
home = tmp_path / "hermes"
project_env = tmp_path / ".env"
project_env.write_text(
"TELEGRAM_BOT_TOKEN=0123456789:test"
"ANTHROPIC_API_KEY=sk-ant-test123\n",
encoding="utf-8",
)
monkeypatch.delenv("TELEGRAM_BOT_TOKEN", raising=False)
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
loaded = load_hermes_dotenv(hermes_home=home, project_env=project_env)
assert loaded == [project_env]
assert os.getenv("TELEGRAM_BOT_TOKEN") == "0123456789:test"
assert os.getenv("ANTHROPIC_API_KEY") == "sk-ant-test123"
def test_user_env_takes_precedence_over_project_env(tmp_path, monkeypatch):
home = tmp_path / "hermes"
home.mkdir()
user_env = home / ".env"
project_env = tmp_path / ".env"
user_env.write_text("OPENAI_BASE_URL=https://user.example/v1\n", encoding="utf-8")
project_env.write_text("OPENAI_BASE_URL=https://project.example/v1\nOPENAI_API_KEY=project-key\n", encoding="utf-8")
monkeypatch.setenv("OPENAI_BASE_URL", "https://old.example/v1")
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
loaded = load_hermes_dotenv(hermes_home=home, project_env=project_env)
assert loaded == [user_env, project_env]
assert os.getenv("OPENAI_BASE_URL") == "https://user.example/v1"
assert os.getenv("OPENAI_API_KEY") == "project-key"
def test_null_bytes_in_user_env_are_stripped(tmp_path, monkeypatch):
home = tmp_path / "hermes"
home.mkdir()
env_file = home / ".env"
# Null bytes can be introduced when copy-pasting API keys.
env_file.write_text("GLM_API_KEY=abc\x00\x00\nOPENAI_API_KEY=sk-123\n", encoding="utf-8")
monkeypatch.delenv("GLM_API_KEY", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
loaded = load_hermes_dotenv(hermes_home=home)
assert loaded == [env_file]
assert os.getenv("GLM_API_KEY") == "abc"
assert os.getenv("OPENAI_API_KEY") == "sk-123"
def test_main_import_applies_user_env_over_shell_values(tmp_path, monkeypatch):
home = tmp_path / "hermes"
home.mkdir()
(home / ".env").write_text(
"OPENAI_BASE_URL=https://new.example/v1\nHERMES_INFERENCE_PROVIDER=custom\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setenv("OPENAI_BASE_URL", "https://old.example/v1")
monkeypatch.setenv("HERMES_INFERENCE_PROVIDER", "openrouter")
sys.modules.pop("hermes_cli.main", None)
importlib.import_module("hermes_cli.main")
assert os.getenv("OPENAI_BASE_URL") == "https://new.example/v1"
assert os.getenv("HERMES_INFERENCE_PROVIDER") == "custom"
@@ -0,0 +1,91 @@
"""Tests for .env sanitization during load to prevent token duplication (#8908)."""
import tempfile
from pathlib import Path
from unittest.mock import patch
def test_load_env_sanitizes_concatenated_lines():
"""Verify load_env() splits concatenated KEY=VALUE pairs.
Reproduces the scenario from #8908 where a corrupted .env file
contained multiple tokens on a single line, causing the bot token
to be duplicated 8 times.
"""
from hermes_cli.config import load_env
token = "0123456789:test"
# Simulate concatenated line: TOKEN=xxx followed immediately by another key
corrupted = f"TELEGRAM_BOT_TOKEN={token}ANTHROPIC_API_KEY=sk-ant-test123\n"
with tempfile.NamedTemporaryFile(
mode="w", suffix=".env", delete=False, encoding="utf-8"
) as f:
f.write(corrupted)
env_path = Path(f.name)
try:
with patch("hermes_cli.config.get_env_path", return_value=env_path):
result = load_env()
assert result.get("TELEGRAM_BOT_TOKEN") == token, (
f"Token should be exactly '{token}', got '{result.get('TELEGRAM_BOT_TOKEN')}'"
)
assert result.get("ANTHROPIC_API_KEY") == "sk-ant-test123"
finally:
env_path.unlink(missing_ok=True)
def test_load_env_normal_file_unchanged():
"""A well-formed .env file should be parsed identically."""
from hermes_cli.config import load_env
content = (
"TELEGRAM_BOT_TOKEN=mytoken123\n"
"ANTHROPIC_API_KEY=sk-ant-key\n"
"# comment\n"
"\n"
"OPENAI_API_KEY=sk-openai\n"
)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".env", delete=False, encoding="utf-8"
) as f:
f.write(content)
env_path = Path(f.name)
try:
with patch("hermes_cli.config.get_env_path", return_value=env_path):
result = load_env()
assert result["TELEGRAM_BOT_TOKEN"] == "mytoken123"
assert result["ANTHROPIC_API_KEY"] == "sk-ant-key"
assert result["OPENAI_API_KEY"] == "sk-openai"
finally:
env_path.unlink(missing_ok=True)
def test_env_loader_sanitizes_before_dotenv():
"""Verify env_loader._sanitize_env_file_if_needed fixes corrupted files."""
from hermes_cli.env_loader import _sanitize_env_file_if_needed
token = "0123456789:test"
corrupted = f"TELEGRAM_BOT_TOKEN={token}ANTHROPIC_API_KEY=sk-ant-test\n"
with tempfile.NamedTemporaryFile(
mode="w", suffix=".env", delete=False, encoding="utf-8"
) as f:
f.write(corrupted)
env_path = Path(f.name)
try:
_sanitize_env_file_if_needed(env_path)
with open(env_path, encoding="utf-8") as f:
lines = f.readlines()
# Should be split into two separate lines
assert len(lines) == 2, f"Expected 2 lines, got {len(lines)}: {lines}"
assert lines[0].startswith("TELEGRAM_BOT_TOKEN=")
assert lines[1].startswith("ANTHROPIC_API_KEY=")
# Token should not contain the second key
parsed_token = lines[0].strip().split("=", 1)[1]
assert parsed_token == token
finally:
env_path.unlink(missing_ok=True)

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