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
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:
@@ -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: []
|
||||
)
|
||||
@@ -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
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Tests for Arcee AI provider support — standard direct API provider."""
|
||||
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.auth import (
|
||||
PROVIDER_REGISTRY,
|
||||
resolve_provider,
|
||||
get_api_key_provider_status,
|
||||
resolve_api_key_provider_credentials,
|
||||
)
|
||||
|
||||
|
||||
_OTHER_PROVIDER_KEYS = (
|
||||
"OPENAI_API_KEY", "ANTHROPIC_API_KEY", "DEEPSEEK_API_KEY",
|
||||
"GOOGLE_API_KEY", "GEMINI_API_KEY", "DASHSCOPE_API_KEY",
|
||||
"XAI_API_KEY", "KIMI_API_KEY", "KIMI_CN_API_KEY",
|
||||
"MINIMAX_API_KEY", "MINIMAX_CN_API_KEY",
|
||||
"KILOCODE_API_KEY", "HF_TOKEN", "GLM_API_KEY", "ZAI_API_KEY",
|
||||
"XIAOMI_API_KEY", "TOKENHUB_API_KEY", "COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Provider Registry
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestArceeProviderRegistry:
|
||||
def test_registered(self):
|
||||
assert "arcee" in PROVIDER_REGISTRY
|
||||
|
||||
def test_name(self):
|
||||
assert PROVIDER_REGISTRY["arcee"].name == "Arcee AI"
|
||||
|
||||
def test_auth_type(self):
|
||||
assert PROVIDER_REGISTRY["arcee"].auth_type == "api_key"
|
||||
|
||||
def test_inference_base_url(self):
|
||||
assert PROVIDER_REGISTRY["arcee"].inference_base_url == "https://api.arcee.ai/api/v1"
|
||||
|
||||
def test_api_key_env_vars(self):
|
||||
assert PROVIDER_REGISTRY["arcee"].api_key_env_vars == ("ARCEEAI_API_KEY",)
|
||||
|
||||
def test_base_url_env_var(self):
|
||||
assert PROVIDER_REGISTRY["arcee"].base_url_env_var == "ARCEE_BASE_URL"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Aliases
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestArceeAliases:
|
||||
@pytest.mark.parametrize("alias", ["arcee", "arcee-ai", "arceeai"])
|
||||
def test_alias_resolves(self, alias, monkeypatch):
|
||||
for key in _OTHER_PROVIDER_KEYS + ("OPENROUTER_API_KEY",):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
monkeypatch.setenv("ARCEEAI_API_KEY", "arc-test-12345")
|
||||
assert resolve_provider(alias) == "arcee"
|
||||
|
||||
def test_normalize_provider_models_py(self):
|
||||
from hermes_cli.models import normalize_provider
|
||||
assert normalize_provider("arcee-ai") == "arcee"
|
||||
assert normalize_provider("arceeai") == "arcee"
|
||||
|
||||
def test_normalize_provider_providers_py(self):
|
||||
from hermes_cli.providers import normalize_provider
|
||||
assert normalize_provider("arcee-ai") == "arcee"
|
||||
assert normalize_provider("arceeai") == "arcee"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Credentials
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestArceeCredentials:
|
||||
def test_status_configured(self, monkeypatch):
|
||||
monkeypatch.setenv("ARCEEAI_API_KEY", "arc-test")
|
||||
status = get_api_key_provider_status("arcee")
|
||||
assert status["configured"]
|
||||
|
||||
def test_status_not_configured(self, monkeypatch):
|
||||
monkeypatch.delenv("ARCEEAI_API_KEY", raising=False)
|
||||
status = get_api_key_provider_status("arcee")
|
||||
assert not status["configured"]
|
||||
|
||||
def test_openrouter_key_does_not_make_arcee_configured(self, monkeypatch):
|
||||
"""OpenRouter users should NOT see arcee as configured."""
|
||||
monkeypatch.delenv("ARCEEAI_API_KEY", raising=False)
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test")
|
||||
status = get_api_key_provider_status("arcee")
|
||||
assert not status["configured"]
|
||||
|
||||
def test_resolve_credentials(self, monkeypatch):
|
||||
monkeypatch.setenv("ARCEEAI_API_KEY", "arc-direct-key")
|
||||
monkeypatch.delenv("ARCEE_BASE_URL", raising=False)
|
||||
creds = resolve_api_key_provider_credentials("arcee")
|
||||
assert creds["api_key"] == "arc-direct-key"
|
||||
assert creds["base_url"] == "https://api.arcee.ai/api/v1"
|
||||
|
||||
def test_custom_base_url_override(self, monkeypatch):
|
||||
monkeypatch.setenv("ARCEEAI_API_KEY", "arc-x")
|
||||
monkeypatch.setenv("ARCEE_BASE_URL", "https://custom.arcee.example/v1")
|
||||
creds = resolve_api_key_provider_credentials("arcee")
|
||||
assert creds["base_url"] == "https://custom.arcee.example/v1"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Model catalog
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestArceeModelCatalog:
|
||||
def test_static_model_list(self):
|
||||
"""Arcee has a static _PROVIDER_MODELS catalog entry. Specific model
|
||||
names change with releases and don't belong in tests.
|
||||
"""
|
||||
from hermes_cli.models import _PROVIDER_MODELS
|
||||
assert "arcee" in _PROVIDER_MODELS
|
||||
assert len(_PROVIDER_MODELS["arcee"]) >= 1
|
||||
|
||||
def test_canonical_provider_entry(self):
|
||||
from hermes_cli.models import CANONICAL_PROVIDERS
|
||||
slugs = [p.slug for p in CANONICAL_PROVIDERS]
|
||||
assert "arcee" in slugs
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Model normalization
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestArceeNormalization:
|
||||
def test_in_matching_prefix_strip_set(self):
|
||||
from hermes_cli.model_normalize import _MATCHING_PREFIX_STRIP_PROVIDERS
|
||||
assert "arcee" in _MATCHING_PREFIX_STRIP_PROVIDERS
|
||||
|
||||
def test_strips_prefix(self):
|
||||
from hermes_cli.model_normalize import normalize_model_for_provider
|
||||
assert normalize_model_for_provider("arcee/trinity-mini", "arcee") == "trinity-mini"
|
||||
|
||||
def test_bare_name_unchanged(self):
|
||||
from hermes_cli.model_normalize import normalize_model_for_provider
|
||||
assert normalize_model_for_provider("trinity-mini", "arcee") == "trinity-mini"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# URL mapping
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestArceeURLMapping:
|
||||
def test_url_to_provider(self):
|
||||
from agent.model_metadata import _URL_TO_PROVIDER
|
||||
assert _URL_TO_PROVIDER.get("api.arcee.ai") == "arcee"
|
||||
|
||||
def test_provider_prefixes(self):
|
||||
from agent.model_metadata import _PROVIDER_PREFIXES
|
||||
assert "arcee" in _PROVIDER_PREFIXES
|
||||
assert "arcee-ai" in _PROVIDER_PREFIXES
|
||||
assert "arceeai" in _PROVIDER_PREFIXES
|
||||
|
||||
def test_trajectory_compressor_detects_arcee(self):
|
||||
import trajectory_compressor as tc
|
||||
comp = tc.TrajectoryCompressor.__new__(tc.TrajectoryCompressor)
|
||||
comp.config = types.SimpleNamespace(base_url="https://api.arcee.ai/api/v1")
|
||||
assert comp._detect_provider() == "arcee"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# providers.py overlay + aliases
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestArceeProvidersModule:
|
||||
def test_overlay_exists(self):
|
||||
from hermes_cli.providers import HERMES_OVERLAYS
|
||||
assert "arcee" in HERMES_OVERLAYS
|
||||
overlay = HERMES_OVERLAYS["arcee"]
|
||||
assert overlay.transport == "openai_chat"
|
||||
assert overlay.base_url_env_var == "ARCEE_BASE_URL"
|
||||
assert not overlay.is_aggregator
|
||||
|
||||
def test_label(self):
|
||||
from hermes_cli.models import _PROVIDER_LABELS
|
||||
assert _PROVIDER_LABELS["arcee"] == "Arcee AI"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Auxiliary client — main-model-first design
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestArceeAuxiliary:
|
||||
def test_main_model_first_design(self):
|
||||
"""Arcee uses main-model-first — no entry in _API_KEY_PROVIDER_AUX_MODELS."""
|
||||
from agent.auxiliary_client import _API_KEY_PROVIDER_AUX_MODELS
|
||||
assert "arcee" not in _API_KEY_PROVIDER_AUX_MODELS
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Tests for parent→subparser flag propagation.
|
||||
|
||||
When flags like --yolo, -w, -s exist on both the parent parser and the 'chat'
|
||||
subparser, placing the flag BEFORE the subcommand (e.g. 'hermes --yolo chat')
|
||||
must not silently drop the flag value.
|
||||
|
||||
Regression test for: argparse subparser default=False overwriting parent's
|
||||
parsed True when the same argument is defined on both parsers.
|
||||
|
||||
Fix: chat subparser uses default=argparse.SUPPRESS for all duplicated flags,
|
||||
so the subparser only sets the attribute when the user explicitly provides it.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _build_parser():
|
||||
"""Build the hermes argument parser from the real code.
|
||||
|
||||
We import the real main() and extract the parser it builds.
|
||||
Since main() is a large function that does much more than parse args,
|
||||
we replicate just the parser structure here to avoid side effects.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(prog="hermes")
|
||||
parser.add_argument("--resume", "-r", metavar="SESSION", default=None)
|
||||
parser.add_argument(
|
||||
"--continue", "-c", dest="continue_last", nargs="?",
|
||||
const=True, default=None, metavar="SESSION_NAME",
|
||||
)
|
||||
parser.add_argument("--worktree", "-w", action="store_true", default=False)
|
||||
parser.add_argument("--skills", "-s", action="append", default=None)
|
||||
parser.add_argument("--yolo", action="store_true", default=False)
|
||||
parser.add_argument("--pass-session-id", action="store_true", default=False)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
chat = subparsers.add_parser("chat")
|
||||
# These MUST use argparse.SUPPRESS to avoid overwriting parent values
|
||||
chat.add_argument("--yolo", action="store_true",
|
||||
default=argparse.SUPPRESS)
|
||||
chat.add_argument("--worktree", "-w", action="store_true",
|
||||
default=argparse.SUPPRESS)
|
||||
chat.add_argument("--skills", "-s", action="append",
|
||||
default=argparse.SUPPRESS)
|
||||
chat.add_argument("--pass-session-id", action="store_true",
|
||||
default=argparse.SUPPRESS)
|
||||
chat.add_argument("--resume", "-r", metavar="SESSION_ID",
|
||||
default=argparse.SUPPRESS)
|
||||
chat.add_argument(
|
||||
"--continue", "-c", dest="continue_last", nargs="?",
|
||||
const=True, default=argparse.SUPPRESS, metavar="SESSION_NAME",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
class TestChatVerboseArg:
|
||||
"""Verify chat --verbose preserves config fallback when absent."""
|
||||
|
||||
def test_chat_without_verbose_leaves_attribute_unset(self):
|
||||
from hermes_cli._parser import build_top_level_parser
|
||||
|
||||
parser, _subparsers, _chat_parser = build_top_level_parser()
|
||||
args = parser.parse_args(["chat"])
|
||||
|
||||
assert not hasattr(args, "verbose")
|
||||
|
||||
def test_chat_verbose_sets_attribute_true(self):
|
||||
from hermes_cli._parser import build_top_level_parser
|
||||
|
||||
parser, _subparsers, _chat_parser = build_top_level_parser()
|
||||
args = parser.parse_args(["chat", "--verbose"])
|
||||
|
||||
assert args.verbose is True
|
||||
|
||||
def test_cmd_chat_forwards_none_when_verbose_is_absent(self, monkeypatch):
|
||||
import types
|
||||
import sys
|
||||
|
||||
import hermes_cli.main as main_mod
|
||||
from hermes_cli._parser import build_top_level_parser
|
||||
|
||||
parser, _subparsers, chat_parser = build_top_level_parser()
|
||||
chat_parser.set_defaults(func=main_mod.cmd_chat)
|
||||
args = parser.parse_args(["chat"])
|
||||
captured = {}
|
||||
fake_cli = types.ModuleType("cli")
|
||||
|
||||
def fake_main(**kwargs):
|
||||
captured.update(kwargs)
|
||||
|
||||
setattr(fake_cli, "main", fake_main)
|
||||
fake_banner = types.ModuleType("hermes_cli.banner")
|
||||
setattr(fake_banner, "prefetch_update_check", lambda: None)
|
||||
fake_skills_sync = types.ModuleType("tools.skills_sync")
|
||||
setattr(fake_skills_sync, "sync_skills", lambda quiet=True: None)
|
||||
|
||||
monkeypatch.setitem(sys.modules, "cli", fake_cli)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli.banner", fake_banner)
|
||||
monkeypatch.setitem(sys.modules, "tools.skills_sync", fake_skills_sync)
|
||||
monkeypatch.setattr(main_mod, "_has_any_provider_configured", lambda: True)
|
||||
monkeypatch.setattr(main_mod, "_pin_kanban_board_env", lambda: None)
|
||||
|
||||
main_mod.cmd_chat(args)
|
||||
|
||||
assert captured["quiet"] is False
|
||||
assert "verbose" not in captured
|
||||
|
||||
|
||||
class TestYoloEnvVar:
|
||||
"""Verify --yolo sets HERMES_YOLO_MODE regardless of flag position.
|
||||
|
||||
This tests the actual cmd_chat logic pattern (getattr → os.environ).
|
||||
"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_env(self):
|
||||
os.environ.pop("HERMES_YOLO_MODE", None)
|
||||
yield
|
||||
os.environ.pop("HERMES_YOLO_MODE", None)
|
||||
|
||||
def _simulate_cmd_chat_yolo_check(self, args):
|
||||
"""Replicate the exact check from cmd_chat in main.py."""
|
||||
if getattr(args, "yolo", False):
|
||||
os.environ["HERMES_YOLO_MODE"] = "1"
|
||||
|
||||
def test_yolo_before_chat_sets_env(self):
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["--yolo", "chat"])
|
||||
self._simulate_cmd_chat_yolo_check(args)
|
||||
assert os.environ.get("HERMES_YOLO_MODE") == "1"
|
||||
|
||||
def test_yolo_after_chat_sets_env(self):
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["chat", "--yolo"])
|
||||
self._simulate_cmd_chat_yolo_check(args)
|
||||
assert os.environ.get("HERMES_YOLO_MODE") == "1"
|
||||
|
||||
def test_no_yolo_no_env(self):
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["chat"])
|
||||
self._simulate_cmd_chat_yolo_check(args)
|
||||
assert os.environ.get("HERMES_YOLO_MODE") is None
|
||||
|
||||
|
||||
class TestAcceptHooksOnAgentSubparsers:
|
||||
"""Verify --accept-hooks is accepted at every agent-subcommand
|
||||
position (before the subcommand, between group/subcommand, and
|
||||
after the leaf subcommand) for gateway/cron/mcp/acp. Regression
|
||||
against prior behaviour where the flag only worked on the root
|
||||
parser and `chat`, so `hermes gateway run --accept-hooks` failed
|
||||
with `unrecognized arguments`."""
|
||||
|
||||
@pytest.mark.parametrize("argv", [
|
||||
["--accept-hooks", "gateway", "run", "--help"],
|
||||
["gateway", "--accept-hooks", "run", "--help"],
|
||||
["gateway", "run", "--accept-hooks", "--help"],
|
||||
["--accept-hooks", "cron", "tick", "--help"],
|
||||
["cron", "--accept-hooks", "tick", "--help"],
|
||||
["cron", "tick", "--accept-hooks", "--help"],
|
||||
["cron", "run", "--accept-hooks", "dummy-id", "--help"],
|
||||
["--accept-hooks", "mcp", "serve", "--help"],
|
||||
["mcp", "--accept-hooks", "serve", "--help"],
|
||||
["mcp", "serve", "--accept-hooks", "--help"],
|
||||
["acp", "--accept-hooks", "--help"],
|
||||
])
|
||||
def test_accepted_at_every_position(self, argv):
|
||||
"""Invoking `hermes <argv>` must exit 0 (help) rather than
|
||||
failing with `unrecognized arguments`."""
|
||||
import subprocess
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "hermes_cli.main", *argv],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
assert result.returncode == 0, (
|
||||
f"argv={argv!r} returned {result.returncode}\n"
|
||||
f"stdout: {result.stdout[:300]}\n"
|
||||
f"stderr: {result.stderr[:300]}"
|
||||
)
|
||||
assert "unrecognized arguments" not in result.stderr
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Regression test: `@folder:` completion must only surface directories and
|
||||
`@file:` must only surface regular files.
|
||||
|
||||
Reported during TUI v2 blitz testing: typing `@folder:` showed .dockerignore,
|
||||
.env, .gitignore, etc. alongside the actual directories because the path-
|
||||
completion branch yielded every entry regardless of the explicit prefix, and
|
||||
auto-switched the completion kind based on `is_dir`. That defeated the user's
|
||||
explicit choice and rendered the `@folder:` / `@file:` prefixes useless for
|
||||
filtering.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
from hermes_cli.commands import SlashCommandCompleter
|
||||
|
||||
|
||||
def _run(tmp_path: Path, word: str) -> list[tuple[str, str]]:
|
||||
(tmp_path / "readme.md").write_text("x")
|
||||
(tmp_path / ".env").write_text("x")
|
||||
(tmp_path / "src").mkdir()
|
||||
(tmp_path / "docs").mkdir()
|
||||
|
||||
completer = SlashCommandCompleter.__new__(SlashCommandCompleter)
|
||||
completions: Iterable = completer._context_completions(word)
|
||||
|
||||
return [(c.text, c.display_meta) for c in completions if c.text.startswith(("@file:", "@folder:"))]
|
||||
|
||||
|
||||
def test_at_folder_only_yields_directories(tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
texts = [t for t, _ in _run(tmp_path, "@folder:")]
|
||||
|
||||
assert all(t.startswith("@folder:") for t in texts), texts
|
||||
assert any(t == "@folder:src/" for t in texts)
|
||||
assert any(t == "@folder:docs/" for t in texts)
|
||||
assert not any(t == "@folder:readme.md" for t in texts)
|
||||
assert not any(t == "@folder:.env" for t in texts)
|
||||
|
||||
|
||||
def test_at_file_only_yields_files(tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
texts = [t for t, _ in _run(tmp_path, "@file:")]
|
||||
|
||||
assert all(t.startswith("@file:") for t in texts), texts
|
||||
assert any(t == "@file:readme.md" for t in texts)
|
||||
assert any(t == "@file:.env" for t in texts)
|
||||
assert not any(t == "@file:src/" for t in texts)
|
||||
assert not any(t == "@file:docs/" for t in texts)
|
||||
|
||||
|
||||
def test_at_folder_preserves_prefix_on_empty_match(tmp_path, monkeypatch):
|
||||
"""User typed `@folder:` (no partial) — completion text must keep the
|
||||
`@folder:` prefix even though the previous implementation auto-rewrote
|
||||
it to `@file:` for non-dir entries.
|
||||
"""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
texts = [t for t, _ in _run(tmp_path, "@folder:")]
|
||||
|
||||
assert texts, "expected at least one directory completion"
|
||||
for t in texts:
|
||||
assert t.startswith("@folder:"), f"prefix leaked: {t}"
|
||||
|
||||
|
||||
def test_at_folder_bare_without_colon_lists_directories(tmp_path, monkeypatch):
|
||||
"""Typing `@folder` alone (no colon yet) should surface directories so
|
||||
users don't need to first accept the static `@folder:` hint before
|
||||
seeing what they're picking from.
|
||||
"""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
texts = [t for t, _ in _run(tmp_path, "@folder")]
|
||||
|
||||
assert any(t == "@folder:src/" for t in texts), texts
|
||||
assert any(t == "@folder:docs/" for t in texts), texts
|
||||
assert not any(t == "@folder:readme.md" for t in texts)
|
||||
|
||||
|
||||
def test_at_file_bare_without_colon_lists_files(tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
texts = [t for t, _ in _run(tmp_path, "@file")]
|
||||
|
||||
assert any(t == "@file:readme.md" for t in texts), texts
|
||||
assert not any(t == "@file:src/" for t in texts)
|
||||
@@ -0,0 +1,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>"
|
||||
@@ -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
|
||||
@@ -0,0 +1,474 @@
|
||||
"""Tests for Qwen OAuth provider authentication (hermes_cli/auth.py).
|
||||
|
||||
Covers: _qwen_cli_auth_path, _read_qwen_cli_tokens, _save_qwen_cli_tokens,
|
||||
_qwen_access_token_is_expiring, _refresh_qwen_cli_tokens,
|
||||
resolve_qwen_runtime_credentials, get_qwen_auth_status.
|
||||
"""
|
||||
|
||||
import json
|
||||
import stat
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.auth import (
|
||||
AuthError,
|
||||
DEFAULT_QWEN_BASE_URL,
|
||||
QWEN_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
|
||||
_qwen_cli_auth_path,
|
||||
_read_qwen_cli_tokens,
|
||||
_save_qwen_cli_tokens,
|
||||
_qwen_access_token_is_expiring,
|
||||
_refresh_qwen_cli_tokens,
|
||||
resolve_qwen_runtime_credentials,
|
||||
get_qwen_auth_status,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_qwen_tokens(
|
||||
access_token="test-access-token",
|
||||
refresh_token="test-refresh-token",
|
||||
expiry_date=None,
|
||||
**extra,
|
||||
):
|
||||
"""Create a minimal Qwen CLI OAuth credential dict."""
|
||||
if expiry_date is None:
|
||||
# 1 hour from now in milliseconds
|
||||
expiry_date = int((time.time() + 3600) * 1000)
|
||||
data = {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": "Bearer",
|
||||
"expiry_date": expiry_date,
|
||||
"resource_url": "portal.qwen.ai",
|
||||
}
|
||||
data.update(extra)
|
||||
return data
|
||||
|
||||
|
||||
def _write_qwen_creds(tmp_path, tokens=None):
|
||||
"""Write tokens to the Qwen CLI credentials file and return the path."""
|
||||
qwen_dir = tmp_path / ".qwen"
|
||||
qwen_dir.mkdir(parents=True, exist_ok=True)
|
||||
creds_path = qwen_dir / "oauth_creds.json"
|
||||
if tokens is None:
|
||||
tokens = _make_qwen_tokens()
|
||||
creds_path.write_text(json.dumps(tokens), encoding="utf-8")
|
||||
return creds_path
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def qwen_env(tmp_path, monkeypatch):
|
||||
"""Redirect _qwen_cli_auth_path to tmp_path/.qwen/oauth_creds.json."""
|
||||
creds_path = tmp_path / ".qwen" / "oauth_creds.json"
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth._qwen_cli_auth_path", lambda: creds_path
|
||||
)
|
||||
return tmp_path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _qwen_cli_auth_path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_qwen_cli_auth_path_returns_expected_location():
|
||||
path = _qwen_cli_auth_path()
|
||||
assert path == Path.home() / ".qwen" / "oauth_creds.json"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _read_qwen_cli_tokens
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_read_qwen_cli_tokens_success(qwen_env):
|
||||
tokens = _make_qwen_tokens(access_token="my-access")
|
||||
_write_qwen_creds(qwen_env, tokens)
|
||||
result = _read_qwen_cli_tokens()
|
||||
assert result["access_token"] == "my-access"
|
||||
assert result["refresh_token"] == "test-refresh-token"
|
||||
|
||||
|
||||
def test_read_qwen_cli_tokens_missing_file(qwen_env):
|
||||
with pytest.raises(AuthError) as exc:
|
||||
_read_qwen_cli_tokens()
|
||||
assert exc.value.code == "qwen_auth_missing"
|
||||
|
||||
|
||||
def test_read_qwen_cli_tokens_invalid_json(qwen_env):
|
||||
creds_path = qwen_env / ".qwen" / "oauth_creds.json"
|
||||
creds_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
creds_path.write_text("not json{{{", encoding="utf-8")
|
||||
with pytest.raises(AuthError) as exc:
|
||||
_read_qwen_cli_tokens()
|
||||
assert exc.value.code == "qwen_auth_read_failed"
|
||||
|
||||
|
||||
def test_read_qwen_cli_tokens_non_dict(qwen_env):
|
||||
creds_path = qwen_env / ".qwen" / "oauth_creds.json"
|
||||
creds_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
creds_path.write_text(json.dumps(["a", "b"]), encoding="utf-8")
|
||||
with pytest.raises(AuthError) as exc:
|
||||
_read_qwen_cli_tokens()
|
||||
assert exc.value.code == "qwen_auth_invalid"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _save_qwen_cli_tokens
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_save_qwen_cli_tokens_roundtrip(qwen_env):
|
||||
tokens = _make_qwen_tokens(access_token="saved-token")
|
||||
saved_path = _save_qwen_cli_tokens(tokens)
|
||||
assert saved_path.exists()
|
||||
loaded = json.loads(saved_path.read_text(encoding="utf-8"))
|
||||
assert loaded["access_token"] == "saved-token"
|
||||
|
||||
|
||||
def test_save_qwen_cli_tokens_creates_parent(qwen_env):
|
||||
tokens = _make_qwen_tokens()
|
||||
saved_path = _save_qwen_cli_tokens(tokens)
|
||||
assert saved_path.parent.exists()
|
||||
|
||||
|
||||
def test_save_qwen_cli_tokens_permissions(qwen_env):
|
||||
tokens = _make_qwen_tokens()
|
||||
saved_path = _save_qwen_cli_tokens(tokens)
|
||||
mode = saved_path.stat().st_mode
|
||||
assert mode & stat.S_IRUSR # owner read
|
||||
assert mode & stat.S_IWUSR # owner write
|
||||
assert not (mode & stat.S_IRGRP) # no group read
|
||||
assert not (mode & stat.S_IROTH) # no other read
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _qwen_access_token_is_expiring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_expiring_token_not_expired():
|
||||
# 1 hour from now in milliseconds
|
||||
future_ms = int((time.time() + 3600) * 1000)
|
||||
assert not _qwen_access_token_is_expiring(future_ms)
|
||||
|
||||
|
||||
def test_expiring_token_already_expired():
|
||||
# 1 hour ago in milliseconds
|
||||
past_ms = int((time.time() - 3600) * 1000)
|
||||
assert _qwen_access_token_is_expiring(past_ms)
|
||||
|
||||
|
||||
def test_expiring_token_within_skew():
|
||||
# Just inside the default skew window
|
||||
near_ms = int((time.time() + QWEN_ACCESS_TOKEN_REFRESH_SKEW_SECONDS - 5) * 1000)
|
||||
assert _qwen_access_token_is_expiring(near_ms)
|
||||
|
||||
|
||||
def test_expiring_token_none_returns_true():
|
||||
assert _qwen_access_token_is_expiring(None)
|
||||
|
||||
|
||||
def test_expiring_token_non_numeric_returns_true():
|
||||
assert _qwen_access_token_is_expiring("not-a-number")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _refresh_qwen_cli_tokens
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_refresh_qwen_cli_tokens_success(qwen_env):
|
||||
tokens = _make_qwen_tokens(refresh_token="old-refresh")
|
||||
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.json.return_value = {
|
||||
"access_token": "new-access",
|
||||
"refresh_token": "new-refresh",
|
||||
"expires_in": 7200,
|
||||
}
|
||||
|
||||
with patch("hermes_cli.auth.httpx") as mock_httpx:
|
||||
mock_httpx.post.return_value = resp
|
||||
result = _refresh_qwen_cli_tokens(tokens)
|
||||
|
||||
assert result["access_token"] == "new-access"
|
||||
assert result["refresh_token"] == "new-refresh"
|
||||
assert "expiry_date" in result
|
||||
|
||||
|
||||
def test_refresh_qwen_cli_tokens_preserves_old_refresh_if_not_in_response(qwen_env):
|
||||
tokens = _make_qwen_tokens(refresh_token="keep-me")
|
||||
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.json.return_value = {
|
||||
"access_token": "new-access",
|
||||
# No refresh_token in response — should keep old one
|
||||
"expires_in": 3600,
|
||||
}
|
||||
|
||||
with patch("hermes_cli.auth.httpx") as mock_httpx:
|
||||
mock_httpx.post.return_value = resp
|
||||
result = _refresh_qwen_cli_tokens(tokens)
|
||||
|
||||
assert result["refresh_token"] == "keep-me"
|
||||
|
||||
|
||||
def test_refresh_qwen_cli_tokens_missing_refresh_token():
|
||||
tokens = {"access_token": "at", "refresh_token": ""}
|
||||
with pytest.raises(AuthError) as exc:
|
||||
_refresh_qwen_cli_tokens(tokens)
|
||||
assert exc.value.code == "qwen_refresh_token_missing"
|
||||
|
||||
|
||||
def test_refresh_qwen_cli_tokens_http_error(qwen_env):
|
||||
tokens = _make_qwen_tokens()
|
||||
|
||||
resp = MagicMock()
|
||||
resp.status_code = 401
|
||||
resp.text = "unauthorized"
|
||||
|
||||
with patch("hermes_cli.auth.httpx") as mock_httpx:
|
||||
mock_httpx.post.return_value = resp
|
||||
with pytest.raises(AuthError) as exc:
|
||||
_refresh_qwen_cli_tokens(tokens)
|
||||
assert exc.value.code == "qwen_refresh_failed"
|
||||
|
||||
|
||||
def test_refresh_qwen_cli_tokens_network_error(qwen_env):
|
||||
tokens = _make_qwen_tokens()
|
||||
|
||||
with patch("hermes_cli.auth.httpx") as mock_httpx:
|
||||
mock_httpx.post.side_effect = ConnectionError("timeout")
|
||||
with pytest.raises(AuthError) as exc:
|
||||
_refresh_qwen_cli_tokens(tokens)
|
||||
assert exc.value.code == "qwen_refresh_failed"
|
||||
|
||||
|
||||
def test_refresh_qwen_cli_tokens_invalid_json_response(qwen_env):
|
||||
tokens = _make_qwen_tokens()
|
||||
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.json.side_effect = ValueError("bad json")
|
||||
|
||||
with patch("hermes_cli.auth.httpx") as mock_httpx:
|
||||
mock_httpx.post.return_value = resp
|
||||
with pytest.raises(AuthError) as exc:
|
||||
_refresh_qwen_cli_tokens(tokens)
|
||||
assert exc.value.code == "qwen_refresh_invalid_json"
|
||||
|
||||
|
||||
def test_refresh_qwen_cli_tokens_missing_access_token_in_response(qwen_env):
|
||||
tokens = _make_qwen_tokens()
|
||||
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.json.return_value = {"something": "but no access_token"}
|
||||
|
||||
with patch("hermes_cli.auth.httpx") as mock_httpx:
|
||||
mock_httpx.post.return_value = resp
|
||||
with pytest.raises(AuthError) as exc:
|
||||
_refresh_qwen_cli_tokens(tokens)
|
||||
assert exc.value.code == "qwen_refresh_invalid_response"
|
||||
|
||||
|
||||
def test_refresh_qwen_cli_tokens_default_expires_in(qwen_env):
|
||||
"""When expires_in is missing, default to 6 hours."""
|
||||
tokens = _make_qwen_tokens()
|
||||
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.json.return_value = {"access_token": "new"}
|
||||
|
||||
with patch("hermes_cli.auth.httpx") as mock_httpx:
|
||||
mock_httpx.post.return_value = resp
|
||||
result = _refresh_qwen_cli_tokens(tokens)
|
||||
|
||||
# Verify expiry_date is roughly now + 6h (within 60s tolerance)
|
||||
expected_ms = int(time.time() * 1000) + 6 * 60 * 60 * 1000
|
||||
assert abs(result["expiry_date"] - expected_ms) < 60_000
|
||||
|
||||
|
||||
def test_refresh_qwen_cli_tokens_saves_to_disk(qwen_env):
|
||||
tokens = _make_qwen_tokens()
|
||||
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.json.return_value = {
|
||||
"access_token": "disk-check",
|
||||
"expires_in": 3600,
|
||||
}
|
||||
|
||||
with patch("hermes_cli.auth.httpx") as mock_httpx:
|
||||
mock_httpx.post.return_value = resp
|
||||
_refresh_qwen_cli_tokens(tokens)
|
||||
|
||||
# Verify it was persisted
|
||||
creds_path = qwen_env / ".qwen" / "oauth_creds.json"
|
||||
assert creds_path.exists()
|
||||
saved = json.loads(creds_path.read_text(encoding="utf-8"))
|
||||
assert saved["access_token"] == "disk-check"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_qwen_runtime_credentials
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_resolve_qwen_runtime_credentials_fresh_token(qwen_env):
|
||||
tokens = _make_qwen_tokens(access_token="fresh-at")
|
||||
_write_qwen_creds(qwen_env, tokens)
|
||||
|
||||
creds = resolve_qwen_runtime_credentials(refresh_if_expiring=False)
|
||||
assert creds["provider"] == "qwen-oauth"
|
||||
assert creds["api_key"] == "fresh-at"
|
||||
assert creds["base_url"] == DEFAULT_QWEN_BASE_URL
|
||||
assert creds["source"] == "qwen-cli"
|
||||
|
||||
|
||||
def test_resolve_qwen_runtime_credentials_triggers_refresh(qwen_env):
|
||||
# Write an expired token
|
||||
expired_ms = int((time.time() - 3600) * 1000)
|
||||
tokens = _make_qwen_tokens(access_token="old", expiry_date=expired_ms)
|
||||
_write_qwen_creds(qwen_env, tokens)
|
||||
|
||||
refreshed = _make_qwen_tokens(access_token="refreshed-at")
|
||||
|
||||
with patch(
|
||||
"hermes_cli.auth._refresh_qwen_cli_tokens", return_value=refreshed
|
||||
) as mock_refresh:
|
||||
creds = resolve_qwen_runtime_credentials()
|
||||
mock_refresh.assert_called_once()
|
||||
assert creds["api_key"] == "refreshed-at"
|
||||
|
||||
|
||||
def test_resolve_qwen_runtime_credentials_force_refresh(qwen_env):
|
||||
tokens = _make_qwen_tokens(access_token="old-at")
|
||||
_write_qwen_creds(qwen_env, tokens)
|
||||
|
||||
refreshed = _make_qwen_tokens(access_token="force-refreshed")
|
||||
|
||||
with patch(
|
||||
"hermes_cli.auth._refresh_qwen_cli_tokens", return_value=refreshed
|
||||
) as mock_refresh:
|
||||
creds = resolve_qwen_runtime_credentials(force_refresh=True)
|
||||
mock_refresh.assert_called_once()
|
||||
assert creds["api_key"] == "force-refreshed"
|
||||
|
||||
|
||||
def test_resolve_qwen_runtime_credentials_missing_access_token(qwen_env):
|
||||
tokens = _make_qwen_tokens(access_token="")
|
||||
_write_qwen_creds(qwen_env, tokens)
|
||||
|
||||
with pytest.raises(AuthError) as exc:
|
||||
resolve_qwen_runtime_credentials(refresh_if_expiring=False)
|
||||
assert exc.value.code == "qwen_access_token_missing"
|
||||
|
||||
|
||||
def test_resolve_qwen_runtime_credentials_base_url_env_override(qwen_env, monkeypatch):
|
||||
tokens = _make_qwen_tokens(access_token="at")
|
||||
_write_qwen_creds(qwen_env, tokens)
|
||||
monkeypatch.setenv("HERMES_QWEN_BASE_URL", "https://custom.qwen.ai/v1")
|
||||
|
||||
creds = resolve_qwen_runtime_credentials(refresh_if_expiring=False)
|
||||
assert creds["base_url"] == "https://custom.qwen.ai/v1"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_qwen_auth_status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_get_qwen_auth_status_logged_in(qwen_env):
|
||||
tokens = _make_qwen_tokens(access_token="status-at")
|
||||
_write_qwen_creds(qwen_env, tokens)
|
||||
|
||||
status = get_qwen_auth_status()
|
||||
assert status["logged_in"] is True
|
||||
assert status["api_key"] == "status-at"
|
||||
|
||||
|
||||
def test_get_qwen_auth_status_refreshes_expired_token(qwen_env):
|
||||
expired_ms = int((time.time() - 3600) * 1000)
|
||||
tokens = _make_qwen_tokens(access_token="old-at", expiry_date=expired_ms)
|
||||
_write_qwen_creds(qwen_env, tokens)
|
||||
|
||||
refreshed = _make_qwen_tokens(access_token="refreshed-at")
|
||||
|
||||
with patch(
|
||||
"hermes_cli.auth._refresh_qwen_cli_tokens", return_value=refreshed
|
||||
) as mock_refresh:
|
||||
status = get_qwen_auth_status()
|
||||
|
||||
mock_refresh.assert_called_once()
|
||||
assert status["logged_in"] is True
|
||||
assert status["api_key"] == "refreshed-at"
|
||||
|
||||
|
||||
def test_get_qwen_auth_status_expired_unrefreshable_token_is_not_logged_in(qwen_env):
|
||||
expired_ms = int((time.time() - 3600) * 1000)
|
||||
tokens = _make_qwen_tokens(access_token="dead-at", expiry_date=expired_ms)
|
||||
_write_qwen_creds(qwen_env, tokens)
|
||||
|
||||
with patch(
|
||||
"hermes_cli.auth._refresh_qwen_cli_tokens",
|
||||
side_effect=AuthError(
|
||||
"Qwen refresh rejected. Re-run 'qwen auth qwen-oauth'.",
|
||||
provider="qwen-oauth",
|
||||
code="qwen_refresh_failed",
|
||||
),
|
||||
) as mock_refresh:
|
||||
status = get_qwen_auth_status()
|
||||
|
||||
mock_refresh.assert_called_once()
|
||||
assert status["logged_in"] is False
|
||||
assert "qwen auth qwen-oauth" in status["error"]
|
||||
|
||||
|
||||
def test_get_qwen_auth_status_not_logged_in(qwen_env):
|
||||
# No credentials file
|
||||
status = get_qwen_auth_status()
|
||||
assert status["logged_in"] is False
|
||||
assert "error" in status
|
||||
|
||||
|
||||
def test_model_flow_qwen_oauth_stale_token_shows_reauth_guidance(qwen_env, monkeypatch, capsys):
|
||||
from hermes_cli.main import _model_flow_qwen_oauth
|
||||
|
||||
expired_ms = int((time.time() - 3600) * 1000)
|
||||
tokens = _make_qwen_tokens(access_token="dead-at", expiry_date=expired_ms)
|
||||
_write_qwen_creds(qwen_env, tokens)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth._refresh_qwen_cli_tokens",
|
||||
lambda *args, **kwargs: (_ for _ in ()).throw(
|
||||
AuthError(
|
||||
"Qwen refresh rejected. Re-run 'qwen auth qwen-oauth'.",
|
||||
provider="qwen-oauth",
|
||||
code="qwen_refresh_failed",
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
prompt_called = {"value": False}
|
||||
update_called = {"value": False}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth._prompt_model_selection",
|
||||
lambda *args, **kwargs: prompt_called.__setitem__("value", True),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth._update_config_for_provider",
|
||||
lambda *args, **kwargs: update_called.__setitem__("value", True),
|
||||
)
|
||||
|
||||
_model_flow_qwen_oauth({}, current_model="qwen3-coder-plus")
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Run: qwen auth qwen-oauth" in out
|
||||
assert "Qwen refresh rejected" in out
|
||||
assert prompt_called["value"] is False
|
||||
assert update_called["value"] is False
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Tests for hermes_cli.auth._default_verify platform-aware fallback.
|
||||
|
||||
On macOS with Homebrew Python, the system OpenSSL cannot locate the
|
||||
system trust store, so we explicitly load certifi's bundle. On other
|
||||
platforms we defer to httpx's own default (which itself uses certifi).
|
||||
|
||||
Most tests use monkeypatching — no real SSL handshakes. A handful use
|
||||
an openssl-generated self-signed cert via the `real_bundle_file`
|
||||
fixture because `ssl.create_default_context(cafile=...)` parses the
|
||||
bundle and refuses stubs.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from hermes_cli.auth import _default_verify, _resolve_verify
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def real_bundle_file(tmp_path: Path) -> str:
|
||||
"""Return a path to a real openssl-generated self-signed cert.
|
||||
|
||||
Skips the test when the `openssl` binary isn't on PATH, so CI images
|
||||
without it degrade gracefully instead of erroring out.
|
||||
"""
|
||||
if shutil.which("openssl") is None:
|
||||
pytest.skip("openssl binary not available")
|
||||
cert = tmp_path / "ca.pem"
|
||||
key = tmp_path / "key.pem"
|
||||
result = subprocess.run(
|
||||
[
|
||||
"openssl", "req", "-x509", "-newkey", "rsa:2048",
|
||||
"-keyout", str(key), "-out", str(cert),
|
||||
"-sha256", "-days", "1", "-nodes",
|
||||
"-subj", "/CN=test",
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
pytest.skip(f"openssl failed: {result.stderr.decode('utf-8', 'ignore')[:200]}")
|
||||
return str(cert)
|
||||
|
||||
|
||||
class TestDefaultVerify:
|
||||
def test_returns_ssl_context_on_darwin(self, monkeypatch):
|
||||
monkeypatch.setattr(sys, "platform", "darwin")
|
||||
result = _default_verify()
|
||||
assert isinstance(result, ssl.SSLContext)
|
||||
|
||||
def test_returns_true_on_linux(self, monkeypatch):
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
assert _default_verify() is True
|
||||
|
||||
def test_returns_true_on_windows(self, monkeypatch):
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
assert _default_verify() is True
|
||||
|
||||
def test_darwin_falls_back_to_true_when_certifi_missing(self, monkeypatch):
|
||||
monkeypatch.setattr(sys, "platform", "darwin")
|
||||
|
||||
real_import = __import__
|
||||
|
||||
def fake_import(name, *args, **kwargs):
|
||||
if name == "certifi":
|
||||
raise ImportError("simulated missing certifi")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr("builtins.__import__", fake_import)
|
||||
assert _default_verify() is True
|
||||
|
||||
|
||||
class TestResolveVerifyIntegration:
|
||||
"""_resolve_verify should defer to _default_verify in the no-CA path."""
|
||||
|
||||
def test_no_ca_uses_default_verify_on_darwin(self, monkeypatch):
|
||||
monkeypatch.setattr(sys, "platform", "darwin")
|
||||
for var in ("HERMES_CA_BUNDLE", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
result = _resolve_verify()
|
||||
assert isinstance(result, ssl.SSLContext)
|
||||
|
||||
def test_no_ca_uses_default_verify_on_linux(self, monkeypatch):
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
for var in ("HERMES_CA_BUNDLE", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
assert _resolve_verify() is True
|
||||
|
||||
def test_requests_ca_bundle_respected(self, monkeypatch, real_bundle_file):
|
||||
for var in ("HERMES_CA_BUNDLE", "SSL_CERT_FILE"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
monkeypatch.setenv("REQUESTS_CA_BUNDLE", real_bundle_file)
|
||||
result = _resolve_verify()
|
||||
assert isinstance(result, ssl.SSLContext)
|
||||
|
||||
def test_missing_ca_path_falls_back_to_default_verify(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
monkeypatch.setenv("HERMES_CA_BUNDLE", str(tmp_path / "missing.pem"))
|
||||
for var in ("SSL_CERT_FILE", "REQUESTS_CA_BUNDLE"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
assert _resolve_verify() is True
|
||||
|
||||
def test_insecure_wins_over_everything(self, monkeypatch, tmp_path):
|
||||
bundle = tmp_path / "ca.pem"
|
||||
bundle.write_text("stub")
|
||||
monkeypatch.setenv("HERMES_CA_BUNDLE", str(bundle))
|
||||
assert _resolve_verify(insecure=True) is False
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Regression tests for TOCTOU-safe credential file writers in ``hermes_cli.auth``.
|
||||
|
||||
Background
|
||||
==========
|
||||
The three writers below used to create a temp file via ``Path.write_text`` /
|
||||
``Path.open('w')`` and only ``chmod``'d it to ``0o600`` afterward. Between
|
||||
create and chmod the file existed at the process umask (typically ``0o644``),
|
||||
briefly exposing OAuth tokens to other local users on multi-user hosts. The
|
||||
fix switches them to ``os.open(O_EXCL, mode=0o600)`` + ``os.fdopen`` +
|
||||
``fsync`` so the file is atomic at ``0o600`` on creation. Mirrors the fixes
|
||||
shipped for ``agent/google_oauth.py`` (#19673) and ``tools/mcp_oauth.py``
|
||||
(#21148).
|
||||
|
||||
These tests stay green only while the token file and its parent directory
|
||||
end up at ``0o600`` / ``0o700`` after every write. POSIX-only — the mode-bit
|
||||
enforcement does not exist on Windows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
sys.platform.startswith("win"),
|
||||
reason="POSIX mode bits not enforced on Windows",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _save_auth_store (~/.hermes/auth.json — every native OAuth provider)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_save_auth_store_writes_0o600_with_0o700_parent(tmp_path, monkeypatch):
|
||||
"""``_save_auth_store`` must land ``auth.json`` at 0o600 and parent at 0o700."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
old_umask = os.umask(0o022) # make the race observable if it regresses
|
||||
try:
|
||||
from hermes_cli import auth as auth_mod
|
||||
|
||||
auth_store = {
|
||||
"version": auth_mod.AUTH_STORE_VERSION,
|
||||
"providers": {"openai-codex": {"tokens": {"access_token": "secret-x"}}},
|
||||
"active_provider": "openai-codex",
|
||||
}
|
||||
auth_path = auth_mod._save_auth_store(auth_store)
|
||||
finally:
|
||||
os.umask(old_umask)
|
||||
|
||||
mode = stat.S_IMODE(auth_path.stat().st_mode)
|
||||
parent_mode = stat.S_IMODE(auth_path.parent.stat().st_mode)
|
||||
|
||||
assert mode == 0o600, (
|
||||
f"auth.json mode 0o{mode:o} != 0o600 — TOCTOU race regressed"
|
||||
)
|
||||
assert parent_mode == 0o700, (
|
||||
f"auth.json parent dir mode 0o{parent_mode:o} != 0o700 — siblings can traverse"
|
||||
)
|
||||
|
||||
# Content survived the rewrite
|
||||
data = json.loads(auth_path.read_text())
|
||||
assert data["providers"]["openai-codex"]["tokens"]["access_token"] == "secret-x"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _save_qwen_cli_tokens (Qwen CLI OAuth tokens)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_save_qwen_cli_tokens_writes_0o600_with_0o700_parent(tmp_path, monkeypatch):
|
||||
"""``_save_qwen_cli_tokens`` must land the token file at 0o600 and parent at 0o700."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
# The Qwen CLI auth path lives under $HOME/.qwen by default — isolate it.
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
old_umask = os.umask(0o022)
|
||||
try:
|
||||
from hermes_cli import auth as auth_mod
|
||||
|
||||
tokens = {
|
||||
"access_token": "qwen-secret",
|
||||
"refresh_token": "qwen-refresh",
|
||||
"token_type": "Bearer",
|
||||
"expiry_date": 123,
|
||||
}
|
||||
auth_path = auth_mod._save_qwen_cli_tokens(tokens)
|
||||
finally:
|
||||
os.umask(old_umask)
|
||||
|
||||
mode = stat.S_IMODE(auth_path.stat().st_mode)
|
||||
parent_mode = stat.S_IMODE(auth_path.parent.stat().st_mode)
|
||||
|
||||
assert mode == 0o600, (
|
||||
f"Qwen token file mode 0o{mode:o} != 0o600 — TOCTOU race regressed"
|
||||
)
|
||||
assert parent_mode == 0o700, (
|
||||
f"Qwen token parent dir mode 0o{parent_mode:o} != 0o700"
|
||||
)
|
||||
|
||||
data = json.loads(auth_path.read_text())
|
||||
assert data["access_token"] == "qwen-secret"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Nous shared-credential store write (inside _write_shared_nous_state)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_shared_nous_store_writes_0o600_with_0o700_parent(tmp_path, monkeypatch):
|
||||
"""The Nous shared-credential store must land at 0o600 / parent 0o700."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
# _nous_shared_store_path() refuses to touch the real shared store during
|
||||
# pytest runs; redirect it into tmp_path explicitly. Use a distinct
|
||||
# subdirectory name (``shared_override``) so the guard's "real user
|
||||
# home" reference — which currently tracks HERMES_HOME via
|
||||
# get_default_hermes_root() — can't collide with our override and
|
||||
# falsely claim we're writing to the real user's shared store.
|
||||
monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared_override"))
|
||||
old_umask = os.umask(0o022)
|
||||
try:
|
||||
from hermes_cli import auth as auth_mod
|
||||
|
||||
state = {
|
||||
"access_token": "nous-access-xxx",
|
||||
"refresh_token": "nous-refresh-xxx",
|
||||
"token_type": "Bearer",
|
||||
"scope": "openid profile",
|
||||
"client_id": "test-client",
|
||||
"obtained_at": "2026-01-01T00:00:00Z",
|
||||
"expires_at": "2026-01-01T01:00:00Z",
|
||||
}
|
||||
auth_mod._write_shared_nous_state(state)
|
||||
path = auth_mod._nous_shared_store_path()
|
||||
finally:
|
||||
os.umask(old_umask)
|
||||
|
||||
assert path.exists(), "shared Nous store was not written"
|
||||
mode = stat.S_IMODE(path.stat().st_mode)
|
||||
parent_mode = stat.S_IMODE(path.parent.stat().st_mode)
|
||||
|
||||
assert mode == 0o600, (
|
||||
f"Nous shared store mode 0o{mode:o} != 0o600 — TOCTOU race regressed"
|
||||
)
|
||||
assert parent_mode == 0o700, (
|
||||
f"Nous shared store parent dir mode 0o{parent_mode:o} != 0o700"
|
||||
)
|
||||
|
||||
data = json.loads(path.read_text())
|
||||
assert data["refresh_token"] == "nous-refresh-xxx"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Atomicity: verify ``os.open`` is called with an explicit 0o600 mode.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_save_auth_store_uses_os_open_with_0o600_mode(tmp_path, monkeypatch):
|
||||
"""Regression: the writer must call ``os.open`` with an explicit restricted
|
||||
mode so the file is created at 0o600 atomically — closing the TOCTOU
|
||||
window the previous ``Path.open('w')`` left open (fd inherited process
|
||||
umask and was briefly 0o644 before post-write chmod)."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
observed_opens: list[tuple[str, int, int]] = []
|
||||
real_os_open = os.open
|
||||
|
||||
def spying_os_open(path, flags, mode=0o777, *args, **kwargs):
|
||||
observed_opens.append((str(path), flags, mode))
|
||||
return real_os_open(path, flags, mode, *args, **kwargs)
|
||||
|
||||
with patch.object(os, "open", spying_os_open):
|
||||
from hermes_cli import auth as auth_mod
|
||||
|
||||
auth_mod._save_auth_store(
|
||||
{"version": auth_mod.AUTH_STORE_VERSION, "providers": {}}
|
||||
)
|
||||
|
||||
auth_tmp_opens = [
|
||||
(p, fl, m) for (p, fl, m) in observed_opens if "auth.json.tmp" in p
|
||||
]
|
||||
assert auth_tmp_opens, (
|
||||
f"os.open was never called for the auth.json temp file; "
|
||||
f"observed={observed_opens!r}"
|
||||
)
|
||||
for path, flags, mode in auth_tmp_opens:
|
||||
assert flags & os.O_CREAT, f"auth.json temp open missing O_CREAT: path={path}"
|
||||
assert flags & os.O_EXCL, (
|
||||
f"auth.json temp open missing O_EXCL — TOCTOU-safe pattern regressed: "
|
||||
f"path={path}, flags={flags}"
|
||||
)
|
||||
# Must be exactly S_IRUSR | S_IWUSR (0o600) — no group/other bits.
|
||||
expected = stat.S_IRUSR | stat.S_IWUSR
|
||||
assert mode == expected, (
|
||||
f"auth.json temp open mode 0o{mode:o} != 0o{expected:o} — "
|
||||
f"umask would apply and potentially expose tokens"
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Tests for placeholder API key detection in hermes_cli.auth."""
|
||||
|
||||
from hermes_cli.auth import has_usable_secret
|
||||
|
||||
|
||||
def test_has_usable_secret_rejects_documented_placeholder_key() -> None:
|
||||
"""Network-exposed API server key must reject static documentation placeholders."""
|
||||
assert not has_usable_secret("your_api_key_here", min_length=8)
|
||||
|
||||
|
||||
def test_has_usable_secret_accepts_generated_key() -> None:
|
||||
"""Random-looking keys should still be accepted."""
|
||||
assert has_usable_secret("b4d59f7fe8b857d0b367ef0f5710b6a4", min_length=8)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,301 @@
|
||||
"""Tests for the auxiliary-model configuration UI in ``hermes model``.
|
||||
|
||||
Covers the helper functions:
|
||||
- ``_save_aux_choice`` writes to config.yaml without touching main model config
|
||||
- ``_reset_aux_to_auto`` clears routing fields but preserves timeouts
|
||||
- ``_format_aux_current`` renders current task config for the menu
|
||||
- ``_AUX_TASKS`` stays in sync with ``DEFAULT_CONFIG["auxiliary"]``
|
||||
|
||||
These are pure-function tests — the interactive menu loops are not covered
|
||||
here (they're stdin-driven curses prompts).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.config import DEFAULT_CONFIG, load_config
|
||||
from hermes_cli.main import (
|
||||
_AUX_TASKS,
|
||||
_format_aux_current,
|
||||
_reset_aux_to_auto,
|
||||
_save_aux_choice,
|
||||
)
|
||||
|
||||
|
||||
# ── Default config ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_title_generation_present_in_default_config():
|
||||
"""`title_generation` task must be defined in DEFAULT_CONFIG.
|
||||
|
||||
Regression for an existing gap: title_generator.py calls
|
||||
``call_llm(task="title_generation", ...)`` but the task was missing
|
||||
from DEFAULT_CONFIG["auxiliary"], so the config-backed timeout/provider
|
||||
overrides never worked for that task.
|
||||
"""
|
||||
assert "title_generation" in DEFAULT_CONFIG["auxiliary"]
|
||||
tg = DEFAULT_CONFIG["auxiliary"]["title_generation"]
|
||||
assert tg["provider"] == "auto"
|
||||
assert tg["model"] == ""
|
||||
assert tg["timeout"] > 0
|
||||
assert tg["extra_body"] == {}
|
||||
|
||||
|
||||
def test_session_search_no_longer_appears_in_auxiliary_model_config():
|
||||
"""session_search is a direct DB-backed tool, not an auxiliary LLM task."""
|
||||
assert "session_search" not in DEFAULT_CONFIG["auxiliary"]
|
||||
assert "session_search" not in {key for key, _name, _desc in _AUX_TASKS}
|
||||
|
||||
|
||||
def test_aux_tasks_keys_all_exist_in_default_config():
|
||||
"""Every task the menu offers must be defined in DEFAULT_CONFIG."""
|
||||
aux_keys = {k for k, _name, _desc in _AUX_TASKS}
|
||||
default_keys = set(DEFAULT_CONFIG["auxiliary"].keys())
|
||||
missing = aux_keys - default_keys
|
||||
assert not missing, (
|
||||
f"_AUX_TASKS references tasks not in DEFAULT_CONFIG.auxiliary: {missing}"
|
||||
)
|
||||
|
||||
|
||||
# ── _format_aux_current ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"task_cfg,expected",
|
||||
[
|
||||
({}, "auto"),
|
||||
({"provider": "", "model": ""}, "auto"),
|
||||
({"provider": "auto", "model": ""}, "auto"),
|
||||
({"provider": "auto", "model": "gpt-4o"}, "auto · gpt-4o"),
|
||||
({"provider": "openrouter", "model": ""}, "openrouter"),
|
||||
(
|
||||
{"provider": "openrouter", "model": "google/gemini-2.5-flash"},
|
||||
"openrouter · google/gemini-2.5-flash",
|
||||
),
|
||||
({"provider": "nous", "model": "gemini-3-flash"}, "nous · gemini-3-flash"),
|
||||
(
|
||||
{"provider": "custom", "base_url": "http://localhost:11434/v1", "model": ""},
|
||||
"custom (localhost:11434/v1)",
|
||||
),
|
||||
(
|
||||
{
|
||||
"provider": "custom",
|
||||
"base_url": "http://localhost:11434/v1/",
|
||||
"model": "qwen2.5:32b",
|
||||
},
|
||||
"custom (localhost:11434/v1) · qwen2.5:32b",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_format_aux_current(task_cfg, expected):
|
||||
assert _format_aux_current(task_cfg) == expected
|
||||
|
||||
|
||||
def test_format_aux_current_handles_non_dict():
|
||||
assert _format_aux_current(None) == "auto"
|
||||
assert _format_aux_current("string") == "auto"
|
||||
|
||||
|
||||
# ── _save_aux_choice ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_save_aux_choice_persists_to_config_yaml(tmp_path, monkeypatch):
|
||||
"""Saving a task writes provider/model/base_url/api_key to auxiliary.<task>."""
|
||||
from pathlib import Path
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
(tmp_path / ".hermes").mkdir(exist_ok=True)
|
||||
|
||||
_save_aux_choice(
|
||||
"vision", provider="openrouter", model="google/gemini-2.5-flash",
|
||||
)
|
||||
cfg = load_config()
|
||||
v = cfg["auxiliary"]["vision"]
|
||||
assert v["provider"] == "openrouter"
|
||||
assert v["model"] == "google/gemini-2.5-flash"
|
||||
assert v["base_url"] == ""
|
||||
assert v["api_key"] == ""
|
||||
|
||||
|
||||
def test_save_aux_choice_preserves_timeout(tmp_path, monkeypatch):
|
||||
"""Saving must NOT clobber user-tuned timeout values."""
|
||||
from pathlib import Path
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
(tmp_path / ".hermes").mkdir(exist_ok=True)
|
||||
|
||||
# Default vision timeout is 120
|
||||
cfg_before = load_config()
|
||||
default_timeout = cfg_before["auxiliary"]["vision"]["timeout"]
|
||||
assert default_timeout == 120
|
||||
|
||||
_save_aux_choice("vision", provider="nous", model="gemini-3-flash")
|
||||
cfg_after = load_config()
|
||||
assert cfg_after["auxiliary"]["vision"]["timeout"] == default_timeout
|
||||
# download_timeout also preserved for vision
|
||||
assert cfg_after["auxiliary"]["vision"].get("download_timeout") == 30
|
||||
|
||||
|
||||
def test_save_aux_choice_does_not_touch_main_model(tmp_path, monkeypatch):
|
||||
"""Aux config must never mutate model.default / model.provider / model.base_url."""
|
||||
from pathlib import Path
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
(tmp_path / ".hermes").mkdir(exist_ok=True)
|
||||
|
||||
# Simulate a configured main model
|
||||
from hermes_cli.config import save_config
|
||||
|
||||
cfg = load_config()
|
||||
cfg["model"] = {
|
||||
"default": "claude-sonnet-4.6",
|
||||
"provider": "anthropic",
|
||||
"base_url": "",
|
||||
}
|
||||
save_config(cfg)
|
||||
|
||||
_save_aux_choice(
|
||||
"compression", provider="custom",
|
||||
base_url="http://localhost:11434/v1", model="qwen2.5:32b",
|
||||
)
|
||||
|
||||
cfg = load_config()
|
||||
# Main model untouched
|
||||
assert cfg["model"]["default"] == "claude-sonnet-4.6"
|
||||
assert cfg["model"]["provider"] == "anthropic"
|
||||
# Aux saved correctly
|
||||
c = cfg["auxiliary"]["compression"]
|
||||
assert c["provider"] == "custom"
|
||||
assert c["model"] == "qwen2.5:32b"
|
||||
assert c["base_url"] == "http://localhost:11434/v1"
|
||||
|
||||
|
||||
def test_save_aux_choice_creates_missing_task_entry(tmp_path, monkeypatch):
|
||||
"""Saving a task that was wiped from config.yaml should recreate it."""
|
||||
from pathlib import Path
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
(tmp_path / ".hermes").mkdir(exist_ok=True)
|
||||
|
||||
# Remove vision from config entirely
|
||||
from hermes_cli.config import save_config
|
||||
|
||||
cfg = load_config()
|
||||
cfg.setdefault("auxiliary", {}).pop("vision", None)
|
||||
save_config(cfg)
|
||||
|
||||
_save_aux_choice("vision", provider="nous", model="gemini-3-flash")
|
||||
cfg = load_config()
|
||||
assert cfg["auxiliary"]["vision"]["provider"] == "nous"
|
||||
assert cfg["auxiliary"]["vision"]["model"] == "gemini-3-flash"
|
||||
|
||||
|
||||
# ── _reset_aux_to_auto ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_reset_aux_to_auto_clears_routing_preserves_timeouts(tmp_path, monkeypatch):
|
||||
from pathlib import Path
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
(tmp_path / ".hermes").mkdir(exist_ok=True)
|
||||
|
||||
# Configure two tasks non-auto, and bump a timeout
|
||||
_save_aux_choice("vision", provider="openrouter", model="gpt-4o")
|
||||
_save_aux_choice("compression", provider="nous", model="gemini-3-flash")
|
||||
from hermes_cli.config import save_config
|
||||
|
||||
cfg = load_config()
|
||||
cfg["auxiliary"]["vision"]["timeout"] = 300 # user-tuned
|
||||
save_config(cfg)
|
||||
|
||||
n = _reset_aux_to_auto()
|
||||
assert n == 2 # both changed
|
||||
|
||||
cfg = load_config()
|
||||
for task in ("vision", "compression"):
|
||||
v = cfg["auxiliary"][task]
|
||||
assert v["provider"] == "auto"
|
||||
assert v["model"] == ""
|
||||
assert v["base_url"] == ""
|
||||
assert v["api_key"] == ""
|
||||
# User-tuned timeout survives reset
|
||||
assert cfg["auxiliary"]["vision"]["timeout"] == 300
|
||||
# Default compression timeout preserved
|
||||
assert cfg["auxiliary"]["compression"]["timeout"] == 120
|
||||
|
||||
|
||||
def test_reset_aux_to_auto_idempotent(tmp_path, monkeypatch):
|
||||
"""Second reset on already-auto config returns 0 without errors."""
|
||||
from pathlib import Path
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
(tmp_path / ".hermes").mkdir(exist_ok=True)
|
||||
|
||||
assert _reset_aux_to_auto() == 0
|
||||
_save_aux_choice("vision", provider="nous", model="gemini-3-flash")
|
||||
assert _reset_aux_to_auto() == 1
|
||||
assert _reset_aux_to_auto() == 0
|
||||
|
||||
|
||||
# ── Menu dispatch ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_select_provider_and_model_dispatches_to_aux_menu(tmp_path, monkeypatch):
|
||||
"""Picking 'Configure auxiliary models...' in the provider list calls _aux_config_menu."""
|
||||
from pathlib import Path
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
(tmp_path / ".hermes").mkdir(exist_ok=True)
|
||||
|
||||
from hermes_cli import main as main_mod
|
||||
|
||||
called = {"aux": 0, "flow": 0}
|
||||
|
||||
def fake_prompt(choices, *, default=0):
|
||||
# Find the aux-config entry by its label text and return its index
|
||||
for i, label in enumerate(choices):
|
||||
if "Configure auxiliary models" in label:
|
||||
return i
|
||||
raise AssertionError("aux entry not in provider list")
|
||||
|
||||
monkeypatch.setattr(main_mod, "_prompt_provider_choice", fake_prompt)
|
||||
monkeypatch.setattr(main_mod, "_aux_config_menu", lambda: called.__setitem__("aux", called["aux"] + 1))
|
||||
# Guard against any main flow accidentally running
|
||||
monkeypatch.setattr(main_mod, "_model_flow_openrouter",
|
||||
lambda *a, **kw: called.__setitem__("flow", called["flow"] + 1))
|
||||
|
||||
main_mod.select_provider_and_model()
|
||||
|
||||
assert called["aux"] == 1, "aux menu not invoked"
|
||||
assert called["flow"] == 0, "main provider flow should not run"
|
||||
|
||||
|
||||
def test_leave_unchanged_replaces_cancel_label(tmp_path, monkeypatch):
|
||||
"""The bottom cancel entry now reads 'Leave unchanged' (UX polish)."""
|
||||
from pathlib import Path
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
(tmp_path / ".hermes").mkdir(exist_ok=True)
|
||||
|
||||
from hermes_cli import main as main_mod
|
||||
|
||||
captured: list[list[str]] = []
|
||||
|
||||
def fake_prompt(choices, *, default=0):
|
||||
captured.append(list(choices))
|
||||
# Pick 'Leave unchanged' (last item) to exit cleanly
|
||||
for i, label in enumerate(choices):
|
||||
if label == "Leave unchanged":
|
||||
return i
|
||||
raise AssertionError("Leave unchanged not in provider list")
|
||||
|
||||
monkeypatch.setattr(main_mod, "_prompt_provider_choice", fake_prompt)
|
||||
|
||||
main_mod.select_provider_and_model()
|
||||
|
||||
assert captured, "provider menu never rendered"
|
||||
labels = captured[0]
|
||||
assert "Leave unchanged" in labels
|
||||
assert "Cancel" not in labels, "Cancel label should be replaced"
|
||||
assert any("Configure auxiliary models" in label for label in labels)
|
||||
@@ -0,0 +1,237 @@
|
||||
"""Tests for hermes_cli.azure_detect — transport & model auto-detection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import azure_detect
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
class _FakeHTTPResponse:
|
||||
"""Minimal stand-in for urllib.request.urlopen's context manager."""
|
||||
|
||||
def __init__(self, status: int, body: bytes):
|
||||
self.status = status
|
||||
self._body = body
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def read(self) -> bytes:
|
||||
return self._body
|
||||
|
||||
|
||||
def _openai_models_body(*ids: str) -> bytes:
|
||||
return json.dumps({
|
||||
"object": "list",
|
||||
"data": [{"id": i, "object": "model"} for i in ids],
|
||||
}).encode()
|
||||
|
||||
|
||||
def _anthropic_error_body(msg: str = "model not found") -> bytes:
|
||||
return json.dumps({
|
||||
"type": "error",
|
||||
"error": {"type": "invalid_request_error", "message": msg},
|
||||
}).encode()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# _looks_like_anthropic_path
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("url, expected", [
|
||||
("https://foo.services.ai.azure.com/anthropic", True),
|
||||
("https://foo.services.ai.azure.com/anthropic/", True),
|
||||
("https://foo.services.ai.azure.com/anthropic/v1", True),
|
||||
("https://foo.openai.azure.com/openai/v1", False),
|
||||
("https://foo.openai.azure.com/", False),
|
||||
("https://openrouter.ai/api/v1", False),
|
||||
])
|
||||
def test_looks_like_anthropic_path(url, expected):
|
||||
assert azure_detect._looks_like_anthropic_path(url) is expected
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# _extract_model_ids
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def test_extract_model_ids_openai_shape():
|
||||
body = {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{"id": "gpt-4.1-mini", "object": "model"},
|
||||
{"id": "claude-sonnet-4-6", "object": "model"},
|
||||
],
|
||||
}
|
||||
assert azure_detect._extract_model_ids(body) == ["gpt-4.1-mini", "claude-sonnet-4-6"]
|
||||
|
||||
|
||||
def test_extract_model_ids_bad_shape_returns_empty():
|
||||
assert azure_detect._extract_model_ids({}) == []
|
||||
assert azure_detect._extract_model_ids({"data": "not-a-list"}) == []
|
||||
assert azure_detect._extract_model_ids({"data": [{"no-id": True}]}) == []
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# detect() integration
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def test_detect_anthropic_path_wins_without_http():
|
||||
"""URL path sniff short-circuits — no HTTP call happens."""
|
||||
with patch.object(azure_detect, "_http_get_json") as fake_get, \
|
||||
patch.object(azure_detect, "_probe_anthropic_messages") as fake_probe:
|
||||
result = azure_detect.detect(
|
||||
"https://foo.services.ai.azure.com/anthropic", "key-abc",
|
||||
)
|
||||
assert result.api_mode == "anthropic_messages"
|
||||
assert result.is_anthropic is True
|
||||
assert "path" in result.reason.lower()
|
||||
fake_get.assert_not_called()
|
||||
fake_probe.assert_not_called()
|
||||
|
||||
|
||||
def test_detect_openai_models_probe_success():
|
||||
"""/models probe returning a model list → chat_completions."""
|
||||
def _fake_get(url, api_key, timeout=6.0, **kwargs):
|
||||
assert "key-abc" == api_key
|
||||
return 200, json.loads(_openai_models_body("gpt-5.4", "claude-opus-4-6"))
|
||||
|
||||
with patch.object(azure_detect, "_http_get_json", side_effect=_fake_get):
|
||||
result = azure_detect.detect(
|
||||
"https://my.openai.azure.com/openai/v1", "key-abc",
|
||||
)
|
||||
assert result.api_mode == "chat_completions"
|
||||
assert result.models_probe_ok is True
|
||||
assert result.models == ["gpt-5.4", "claude-opus-4-6"]
|
||||
assert "/models" in result.reason
|
||||
|
||||
|
||||
def test_detect_openai_models_probe_empty_list_still_counts():
|
||||
"""Endpoint returned OpenAI shape but no models → still chat_completions."""
|
||||
def _fake_get(url, api_key, timeout=6.0, **kwargs):
|
||||
return 200, {"object": "list", "data": []}
|
||||
|
||||
with patch.object(azure_detect, "_http_get_json", side_effect=_fake_get):
|
||||
result = azure_detect.detect(
|
||||
"https://my.openai.azure.com/openai/v1", "key-abc",
|
||||
)
|
||||
assert result.api_mode == "chat_completions"
|
||||
assert result.models == []
|
||||
assert result.models_probe_ok is True
|
||||
|
||||
|
||||
def test_detect_falls_back_to_anthropic_probe():
|
||||
"""/models fails but Anthropic Messages probe succeeds."""
|
||||
def _fake_get(url, api_key, timeout=6.0, **kwargs):
|
||||
return 401, None # /models forbidden
|
||||
|
||||
with patch.object(azure_detect, "_http_get_json", side_effect=_fake_get), \
|
||||
patch.object(azure_detect, "_probe_anthropic_messages", return_value=True):
|
||||
result = azure_detect.detect(
|
||||
"https://my.services.ai.azure.com/v1", "key-abc",
|
||||
)
|
||||
assert result.api_mode == "anthropic_messages"
|
||||
assert result.is_anthropic is True
|
||||
|
||||
|
||||
def test_detect_all_probes_fail_returns_none():
|
||||
"""Every probe fails → api_mode is None and caller falls back to manual."""
|
||||
with patch.object(azure_detect, "_http_get_json", return_value=(500, None)), \
|
||||
patch.object(azure_detect, "_probe_anthropic_messages", return_value=False):
|
||||
result = azure_detect.detect(
|
||||
"https://some-private.example.com/", "key-abc",
|
||||
)
|
||||
assert result.api_mode is None
|
||||
assert result.models == []
|
||||
assert "manual" in result.reason.lower()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# _probe_openai_models URL list (Azure vs v1 api-version)
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def test_probe_openai_models_tries_multiple_api_versions():
|
||||
"""First call (no api-version) fails, api-version fallback succeeds."""
|
||||
calls = []
|
||||
|
||||
def _fake_get(url, api_key, timeout=6.0, **kwargs):
|
||||
calls.append(url)
|
||||
if "api-version" not in url:
|
||||
return 404, None
|
||||
return 200, json.loads(_openai_models_body("gpt-4.1"))
|
||||
|
||||
with patch.object(azure_detect, "_http_get_json", side_effect=_fake_get):
|
||||
ok, models = azure_detect._probe_openai_models(
|
||||
"https://my.openai.azure.com/openai/v1", "k",
|
||||
)
|
||||
assert ok is True
|
||||
assert models == ["gpt-4.1"]
|
||||
# Should have tried without api-version first, then with at least one
|
||||
assert any("api-version" not in u for u in calls)
|
||||
assert any("api-version" in u for u in calls)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# _http_get_json error handling
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def test_http_get_json_on_urlerror_returns_zero_none():
|
||||
"""Network failure returns (0, None), never raises."""
|
||||
import urllib.error
|
||||
with patch("hermes_cli.azure_detect.urllib_request.urlopen",
|
||||
side_effect=urllib.error.URLError("dns fail")):
|
||||
status, body = azure_detect._http_get_json("https://bad.example/", "k")
|
||||
assert status == 0
|
||||
assert body is None
|
||||
|
||||
|
||||
def test_http_get_json_on_http_error_returns_code_none():
|
||||
"""HTTP 4xx/5xx returns (code, None)."""
|
||||
import urllib.error
|
||||
err = urllib.error.HTTPError("https://x/", 403, "Forbidden", {}, None)
|
||||
with patch("hermes_cli.azure_detect.urllib_request.urlopen", side_effect=err):
|
||||
status, body = azure_detect._http_get_json("https://x/", "k")
|
||||
assert status == 403
|
||||
assert body is None
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# lookup_context_length
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def test_lookup_context_length_returns_known():
|
||||
"""When model_metadata returns a non-fallback value, we pass it through."""
|
||||
fake = MagicMock(return_value=400000)
|
||||
with patch("agent.model_metadata.get_model_context_length", fake), \
|
||||
patch("agent.model_metadata.DEFAULT_FALLBACK_CONTEXT", 128000):
|
||||
n = azure_detect.lookup_context_length(
|
||||
"gpt-5.4", "https://x.openai.azure.com/openai/v1", "k",
|
||||
)
|
||||
assert n == 400000
|
||||
|
||||
|
||||
def test_lookup_context_length_returns_none_on_fallback():
|
||||
"""When resolver falls through to DEFAULT_FALLBACK_CONTEXT, we return None."""
|
||||
with patch("agent.model_metadata.get_model_context_length", return_value=128000), \
|
||||
patch("agent.model_metadata.DEFAULT_FALLBACK_CONTEXT", 128000):
|
||||
n = azure_detect.lookup_context_length(
|
||||
"totally-unknown-model", "https://x.openai.azure.com/openai/v1", "k",
|
||||
)
|
||||
assert n is None
|
||||
|
||||
|
||||
def test_lookup_context_length_swallows_exceptions():
|
||||
"""Resolver raising must not crash the wizard."""
|
||||
with patch("agent.model_metadata.get_model_context_length",
|
||||
side_effect=RuntimeError("boom")):
|
||||
assert azure_detect.lookup_context_length("m", "https://x/", "k") is None
|
||||
@@ -0,0 +1,403 @@
|
||||
"""Tests for Azure Foundry Entra ID runtime resolution.
|
||||
|
||||
Covers the contract introduced in PR for Microsoft Entra ID auth on
|
||||
``azure-foundry``:
|
||||
|
||||
* ``_resolve_azure_foundry_runtime`` returns a callable ``api_key`` for
|
||||
``model.auth_mode = entra_id`` (OpenAI-style only).
|
||||
* Anthropic-style endpoints with ``auth_mode = entra_id`` return the same
|
||||
callable runtime credential as OpenAI-style endpoints.
|
||||
* The legacy ``api_key`` path is unchanged when ``auth_mode`` is absent
|
||||
or set to ``api_key``.
|
||||
* Explicit ``--api-key`` overrides at runtime still work in entra mode
|
||||
(escape hatch for one-off testing).
|
||||
* ``model.entra.scope`` propagates to the token-provider config; Azure
|
||||
identity selection stays in standard AZURE_* env vars.
|
||||
* ``_get_azure_foundry_auth_status`` is structural — never mints a
|
||||
token (verified by checking the credential cache untouched).
|
||||
* ``has_usable_secret`` for ``AZURE_FOUNDRY_API_KEY`` is irrelevant
|
||||
when ``auth_mode == entra_id``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_credential_cache():
|
||||
from agent.azure_identity_adapter import reset_credential_cache
|
||||
reset_credential_cache()
|
||||
yield
|
||||
reset_credential_cache()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_azure_identity(monkeypatch):
|
||||
"""Identical fake to test_azure_identity_adapter — keeps Azure SDK
|
||||
out of these tests so they run in CI without the package installed."""
|
||||
from agent import azure_identity_adapter as _adapter
|
||||
|
||||
last = {"scope": None, "kwargs": None, "credential_count": 0}
|
||||
|
||||
def _provider(scope):
|
||||
return lambda: f"jwt-for-{scope}"
|
||||
|
||||
fake_module = SimpleNamespace(
|
||||
DefaultAzureCredential=lambda **kw: SimpleNamespace(
|
||||
kwargs=kw,
|
||||
get_token=lambda scope: SimpleNamespace(token="fake", expires_on=9999999999),
|
||||
),
|
||||
get_bearer_token_provider=lambda credential, scope: (
|
||||
last.__setitem__("scope", scope),
|
||||
last.__setitem__("kwargs", credential.kwargs),
|
||||
last.__setitem__("credential_count", cast(int, last["credential_count"]) + 1),
|
||||
_provider(scope),
|
||||
)[-1],
|
||||
)
|
||||
monkeypatch.setattr(_adapter, "_require_azure_identity", lambda: fake_module)
|
||||
monkeypatch.setitem(sys.modules, "azure.identity", fake_module)
|
||||
return last
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _resolve_azure_foundry_runtime: entra_id branch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolveAzureFoundryRuntimeEntra:
|
||||
def test_returns_callable_api_key_for_entra(self, fake_azure_identity):
|
||||
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
|
||||
runtime = _resolve_azure_foundry_runtime(
|
||||
requested_provider="azure-foundry",
|
||||
model_cfg={
|
||||
"provider": "azure-foundry",
|
||||
"base_url": "https://my-resource.openai.azure.com/openai/v1",
|
||||
"api_mode": "chat_completions",
|
||||
"auth_mode": "entra_id",
|
||||
"default": "gpt-4o", # stays on chat_completions (no codex auto-upgrade)
|
||||
},
|
||||
)
|
||||
assert runtime["provider"] == "azure-foundry"
|
||||
assert runtime["auth_mode"] == "entra_id"
|
||||
assert runtime["api_mode"] == "chat_completions"
|
||||
assert callable(runtime["api_key"])
|
||||
assert runtime["source"] == "entra_id"
|
||||
|
||||
def test_entra_inherits_codex_responses_for_gpt5_family(self, fake_azure_identity):
|
||||
"""GPT-5.x / o-series / codex models on Azure are Responses-API-only.
|
||||
The runtime auto-upgrades api_mode regardless of auth mode — this is
|
||||
the same behaviour as the static-key path (see
|
||||
``hermes_cli/models.py::azure_foundry_model_api_mode``)."""
|
||||
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
|
||||
runtime = _resolve_azure_foundry_runtime(
|
||||
requested_provider="azure-foundry",
|
||||
model_cfg={
|
||||
"provider": "azure-foundry",
|
||||
"base_url": "https://my-resource.openai.azure.com/openai/v1",
|
||||
"api_mode": "chat_completions",
|
||||
"auth_mode": "entra_id",
|
||||
"default": "gpt-5.4",
|
||||
},
|
||||
)
|
||||
# GPT-5.x is upgraded to codex_responses — Entra path inherits.
|
||||
assert runtime["api_mode"] == "codex_responses"
|
||||
assert callable(runtime["api_key"])
|
||||
assert runtime["auth_mode"] == "entra_id"
|
||||
|
||||
def test_entra_propagates_scope_only(self, fake_azure_identity):
|
||||
"""``model.entra.scope`` is the only Hermes-managed Azure SDK
|
||||
setting. Identity selection (client ID, tenant, authority,
|
||||
service principal secret, federated token file) flows through
|
||||
standard ``AZURE_*`` env vars read by azure-identity directly.
|
||||
Legacy ``model.entra.client_id`` / ``tenant_id`` / ``authority``
|
||||
keys in config.yaml are silently ignored."""
|
||||
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
|
||||
_resolve_azure_foundry_runtime(
|
||||
requested_provider="azure-foundry",
|
||||
model_cfg={
|
||||
"provider": "azure-foundry",
|
||||
"base_url": "https://my-resource.services.ai.azure.com/v1",
|
||||
"api_mode": "chat_completions",
|
||||
"auth_mode": "entra_id",
|
||||
"entra": {
|
||||
"scope": "https://custom.example/.default",
|
||||
"client_id": "client-uuid",
|
||||
# Legacy keys must not crash — they are accepted in
|
||||
# from_dict but never propagated to the SDK.
|
||||
"tenant_id": "legacy-tenant",
|
||||
"authority": "https://login.microsoftonline.us",
|
||||
},
|
||||
},
|
||||
)
|
||||
assert fake_azure_identity["scope"] == "https://custom.example/.default"
|
||||
kw = fake_azure_identity["kwargs"]
|
||||
assert "managed_identity_client_id" not in kw
|
||||
assert "workload_identity_client_id" not in kw
|
||||
assert "interactive_browser_tenant_id" not in kw
|
||||
assert "authority" not in kw
|
||||
|
||||
def test_entra_default_scope_when_unset(self, fake_azure_identity):
|
||||
"""When ``model.entra.scope`` is not set, the runtime resolves
|
||||
Microsoft's documented inference scope —
|
||||
``https://ai.azure.com/.default`` — regardless of whether the
|
||||
endpoint is ``*.openai.azure.com`` or ``*.services.ai.azure.com``.
|
||||
Both shapes use the SAME scope per Microsoft's docs; the
|
||||
``cognitiveservices.azure.com`` scope is the control-plane
|
||||
audience and is rejected for inference by newer resources."""
|
||||
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
|
||||
from agent.azure_identity_adapter import SCOPE_AI_AZURE_DEFAULT
|
||||
_resolve_azure_foundry_runtime(
|
||||
requested_provider="azure-foundry",
|
||||
model_cfg={
|
||||
"provider": "azure-foundry",
|
||||
"base_url": "https://r.openai.azure.com/openai/v1",
|
||||
"api_mode": "chat_completions",
|
||||
"auth_mode": "entra_id",
|
||||
},
|
||||
)
|
||||
assert fake_azure_identity["scope"] == SCOPE_AI_AZURE_DEFAULT
|
||||
|
||||
def test_entra_scope_override_wins(self, fake_azure_identity):
|
||||
"""Users on sovereign clouds / unusual tenants can set
|
||||
``model.entra.scope`` to override the default."""
|
||||
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
|
||||
_resolve_azure_foundry_runtime(
|
||||
requested_provider="azure-foundry",
|
||||
model_cfg={
|
||||
"provider": "azure-foundry",
|
||||
"base_url": "https://r.openai.azure.com/openai/v1",
|
||||
"api_mode": "chat_completions",
|
||||
"auth_mode": "entra_id",
|
||||
"entra": {
|
||||
"scope": "https://cognitiveservices.azure.com/.default",
|
||||
},
|
||||
},
|
||||
)
|
||||
assert (
|
||||
fake_azure_identity["scope"]
|
||||
== "https://cognitiveservices.azure.com/.default"
|
||||
)
|
||||
|
||||
def test_entra_with_anthropic_messages_is_supported(self, fake_azure_identity):
|
||||
"""Entra ID now works for both OpenAI-style and Anthropic-style
|
||||
Azure Foundry endpoints. The runtime returns a callable
|
||||
``api_key``; downstream
|
||||
:func:`agent.anthropic_adapter.build_anthropic_client` detects
|
||||
the callable and installs an httpx event hook that mints a
|
||||
fresh bearer JWT per request (the Anthropic SDK does not
|
||||
accept callable auth_token natively)."""
|
||||
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
|
||||
runtime = _resolve_azure_foundry_runtime(
|
||||
requested_provider="azure-foundry",
|
||||
model_cfg={
|
||||
"provider": "azure-foundry",
|
||||
"base_url": "https://r.services.ai.azure.com/anthropic",
|
||||
"api_mode": "anthropic_messages",
|
||||
"auth_mode": "entra_id",
|
||||
"default": "claude-sonnet-4-5",
|
||||
},
|
||||
)
|
||||
assert runtime["provider"] == "azure-foundry"
|
||||
assert runtime["auth_mode"] == "entra_id"
|
||||
assert runtime["api_mode"] == "anthropic_messages"
|
||||
# Callable api_key — the anthropic_adapter detects this and
|
||||
# plumbs through an httpx event hook.
|
||||
assert callable(runtime["api_key"])
|
||||
assert not isinstance(runtime["api_key"], str)
|
||||
|
||||
def test_entra_with_explicit_api_key_uses_string_escape_hatch(self, fake_azure_identity):
|
||||
"""Passing --api-key on the CLI overrides the entra path so a
|
||||
user can debug a single request with a static key without
|
||||
editing config.yaml."""
|
||||
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
|
||||
runtime = _resolve_azure_foundry_runtime(
|
||||
requested_provider="azure-foundry",
|
||||
model_cfg={
|
||||
"provider": "azure-foundry",
|
||||
"base_url": "https://r.openai.azure.com/openai/v1",
|
||||
"api_mode": "chat_completions",
|
||||
"auth_mode": "entra_id",
|
||||
},
|
||||
explicit_api_key="explicit-string-key",
|
||||
)
|
||||
assert runtime["api_key"] == "explicit-string-key"
|
||||
assert runtime["auth_mode"] == "api_key"
|
||||
assert runtime["source"] == "explicit"
|
||||
|
||||
def test_entra_runtime_dict_keeps_only_scope_override(self, fake_azure_identity):
|
||||
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
|
||||
runtime = _resolve_azure_foundry_runtime(
|
||||
requested_provider="azure-foundry",
|
||||
model_cfg={
|
||||
"provider": "azure-foundry",
|
||||
"base_url": "https://r.openai.azure.com/openai/v1",
|
||||
"api_mode": "chat_completions",
|
||||
"auth_mode": "entra_id",
|
||||
"entra": {
|
||||
"scope": "https://custom.example/.default",
|
||||
"client_id": "legacy-client",
|
||||
},
|
||||
},
|
||||
)
|
||||
assert runtime["entra"] == {"scope": "https://custom.example/.default"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _resolve_azure_foundry_runtime: legacy api_key branch (regression)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolveAzureFoundryRuntimeApiKey:
|
||||
def test_default_auth_mode_uses_static_key(self, monkeypatch):
|
||||
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
|
||||
monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "sk-azure-static-key")
|
||||
runtime = _resolve_azure_foundry_runtime(
|
||||
requested_provider="azure-foundry",
|
||||
model_cfg={
|
||||
"provider": "azure-foundry",
|
||||
"base_url": "https://r.openai.azure.com/openai/v1",
|
||||
"api_mode": "chat_completions",
|
||||
},
|
||||
)
|
||||
assert runtime["api_key"] == "sk-azure-static-key"
|
||||
assert runtime["auth_mode"] == "api_key"
|
||||
assert "entra" not in runtime # only present in entra mode
|
||||
|
||||
def test_explicit_auth_mode_api_key(self, monkeypatch):
|
||||
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
|
||||
monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "sk-static")
|
||||
runtime = _resolve_azure_foundry_runtime(
|
||||
requested_provider="azure-foundry",
|
||||
model_cfg={
|
||||
"provider": "azure-foundry",
|
||||
"base_url": "https://r.openai.azure.com/openai/v1",
|
||||
"api_mode": "chat_completions",
|
||||
"auth_mode": "api_key",
|
||||
},
|
||||
)
|
||||
assert runtime["api_key"] == "sk-static"
|
||||
assert runtime["auth_mode"] == "api_key"
|
||||
|
||||
def test_anthropic_messages_strips_v1_suffix(self, monkeypatch):
|
||||
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
|
||||
monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "k")
|
||||
runtime = _resolve_azure_foundry_runtime(
|
||||
requested_provider="azure-foundry",
|
||||
model_cfg={
|
||||
"provider": "azure-foundry",
|
||||
"base_url": "https://r.services.ai.azure.com/anthropic/v1",
|
||||
"api_mode": "anthropic_messages",
|
||||
},
|
||||
)
|
||||
assert runtime["base_url"] == "https://r.services.ai.azure.com/anthropic"
|
||||
|
||||
def test_missing_api_key_raises_with_entra_hint(self, monkeypatch):
|
||||
from hermes_cli.auth import AuthError
|
||||
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
|
||||
monkeypatch.delenv("AZURE_FOUNDRY_API_KEY", raising=False)
|
||||
with pytest.raises(AuthError) as exc_info:
|
||||
_resolve_azure_foundry_runtime(
|
||||
requested_provider="azure-foundry",
|
||||
model_cfg={
|
||||
"provider": "azure-foundry",
|
||||
"base_url": "https://r.openai.azure.com/openai/v1",
|
||||
"api_mode": "chat_completions",
|
||||
},
|
||||
)
|
||||
msg = str(exc_info.value)
|
||||
assert "AZURE_FOUNDRY_API_KEY" in msg
|
||||
# Surface the Entra alternative so users discover the keyless path.
|
||||
assert "entra_id" in msg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _get_azure_foundry_auth_status (auth.py) — never mints a token
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAzureFoundryAuthStatus:
|
||||
def test_entra_status_does_not_mint_token(self, monkeypatch, tmp_path):
|
||||
"""Structural check — must return logged_in=True based on
|
||||
importable + config, never call get_bearer_token_provider."""
|
||||
from hermes_cli import auth as _auth
|
||||
# Force load_config to return our entra config.
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: {
|
||||
"model": {
|
||||
"provider": "azure-foundry",
|
||||
"auth_mode": "entra_id",
|
||||
"base_url": "https://r.openai.azure.com/openai/v1",
|
||||
},
|
||||
},
|
||||
)
|
||||
# Patch has_azure_identity_installed to True; do NOT patch the
|
||||
# token provider — if the code path tried to mint, the SDK
|
||||
# missing would raise.
|
||||
monkeypatch.setattr(
|
||||
"agent.azure_identity_adapter.has_azure_identity_installed",
|
||||
lambda: True,
|
||||
)
|
||||
info = _auth._get_azure_foundry_auth_status()
|
||||
assert info["logged_in"] is True
|
||||
assert info["auth_mode"] == "entra_id"
|
||||
assert info["azure_identity_installed"] is True
|
||||
assert info["scope"].endswith("/.default")
|
||||
|
||||
def test_entra_status_reports_missing_package(self, monkeypatch):
|
||||
from hermes_cli import auth as _auth
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: {
|
||||
"model": {
|
||||
"provider": "azure-foundry",
|
||||
"auth_mode": "entra_id",
|
||||
"base_url": "https://r.openai.azure.com/openai/v1",
|
||||
},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"agent.azure_identity_adapter.has_azure_identity_installed",
|
||||
lambda: False,
|
||||
)
|
||||
info = _auth._get_azure_foundry_auth_status()
|
||||
assert info["logged_in"] is False
|
||||
assert info["azure_identity_installed"] is False
|
||||
assert "azure-identity" in info["hint"]
|
||||
|
||||
def test_api_key_status_uses_env_var(self, monkeypatch):
|
||||
from hermes_cli import auth as _auth
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: {
|
||||
"model": {
|
||||
"provider": "azure-foundry",
|
||||
"auth_mode": "api_key",
|
||||
"base_url": "https://r.openai.azure.com/openai/v1",
|
||||
},
|
||||
},
|
||||
)
|
||||
monkeypatch.setenv("AZURE_FOUNDRY_API_KEY", "sk-real-key-xxx")
|
||||
info = _auth._get_azure_foundry_auth_status()
|
||||
assert info["auth_mode"] == "api_key"
|
||||
assert info["logged_in"] is True
|
||||
|
||||
def test_api_key_status_false_when_missing(self, monkeypatch):
|
||||
from hermes_cli import auth as _auth
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: {
|
||||
"model": {
|
||||
"provider": "azure-foundry",
|
||||
"auth_mode": "api_key",
|
||||
},
|
||||
},
|
||||
)
|
||||
monkeypatch.delenv("AZURE_FOUNDRY_API_KEY", raising=False)
|
||||
info = _auth._get_azure_foundry_auth_status()
|
||||
assert info["logged_in"] is False
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,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"
|
||||
@@ -0,0 +1,116 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
def test_format_banner_version_label_without_git_state():
|
||||
from hermes_cli import banner
|
||||
|
||||
with patch.object(banner, "get_git_banner_state", return_value=None):
|
||||
value = banner.format_banner_version_label()
|
||||
|
||||
assert value == f"Hermes Agent v{banner.VERSION} ({banner.RELEASE_DATE})"
|
||||
|
||||
|
||||
def test_format_banner_version_label_on_upstream_main():
|
||||
from hermes_cli import banner
|
||||
|
||||
with patch.object(
|
||||
banner,
|
||||
"get_git_banner_state",
|
||||
return_value={"upstream": "b2f477a3", "local": "b2f477a3", "ahead": 0},
|
||||
):
|
||||
value = banner.format_banner_version_label()
|
||||
|
||||
assert value.endswith("· upstream b2f477a3")
|
||||
assert "local" not in value
|
||||
|
||||
|
||||
def test_format_banner_version_label_with_carried_commits():
|
||||
from hermes_cli import banner
|
||||
|
||||
with patch.object(
|
||||
banner,
|
||||
"get_git_banner_state",
|
||||
return_value={"upstream": "b2f477a3", "local": "af8aad31", "ahead": 3},
|
||||
):
|
||||
value = banner.format_banner_version_label()
|
||||
|
||||
assert "upstream b2f477a3" in value
|
||||
assert "local af8aad31" in value
|
||||
assert "+3 carried commits" in value
|
||||
|
||||
|
||||
def test_get_git_banner_state_reads_origin_and_head(tmp_path):
|
||||
from hermes_cli import banner
|
||||
|
||||
repo_dir = tmp_path / "repo"
|
||||
(repo_dir / ".git").mkdir(parents=True)
|
||||
|
||||
results = {
|
||||
("git", "rev-parse", "--short=8", "origin/main"): MagicMock(returncode=0, stdout="b2f477a3\n"),
|
||||
("git", "rev-parse", "--short=8", "HEAD"): MagicMock(returncode=0, stdout="af8aad31\n"),
|
||||
("git", "rev-list", "--count", "origin/main..HEAD"): MagicMock(returncode=0, stdout="3\n"),
|
||||
}
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
key = tuple(cmd)
|
||||
if key not in results:
|
||||
raise AssertionError(f"unexpected command: {cmd}")
|
||||
return results[key]
|
||||
|
||||
with patch("hermes_cli.banner.subprocess.run", side_effect=fake_run):
|
||||
state = banner.get_git_banner_state(repo_dir)
|
||||
|
||||
assert state == {"upstream": "b2f477a3", "local": "af8aad31", "ahead": 3}
|
||||
|
||||
|
||||
def test_get_git_banner_state_falls_back_to_build_sha_when_no_repo():
|
||||
"""Docker image case: no .git checkout — baked build SHA fills the gap.
|
||||
|
||||
``_resolve_repo_dir`` returns None when neither the running code's
|
||||
parent nor ``$HERMES_HOME/hermes-agent/`` is a git repo (the canonical
|
||||
case inside the published container, where .git is dockerignored).
|
||||
The banner should still report the build SHA so support bug reports
|
||||
can identify the running commit.
|
||||
"""
|
||||
from hermes_cli import banner
|
||||
|
||||
with patch.object(banner, "_resolve_repo_dir", return_value=None), \
|
||||
patch("hermes_cli.build_info.get_build_sha", return_value="abcdef12"):
|
||||
state = banner.get_git_banner_state()
|
||||
|
||||
assert state == {"upstream": "abcdef12", "local": "abcdef12", "ahead": 0}
|
||||
|
||||
|
||||
def test_get_git_banner_state_returns_none_when_no_repo_and_no_build_sha():
|
||||
"""Pip-installed wheel with neither git checkout nor baked SHA → None.
|
||||
|
||||
Banner correctly omits the upstream/local suffix in this case.
|
||||
"""
|
||||
from hermes_cli import banner
|
||||
|
||||
with patch.object(banner, "_resolve_repo_dir", return_value=None), \
|
||||
patch("hermes_cli.build_info.get_build_sha", return_value=None):
|
||||
state = banner.get_git_banner_state()
|
||||
|
||||
assert state is None
|
||||
|
||||
|
||||
def test_get_git_banner_state_falls_back_when_live_git_returns_nothing(tmp_path):
|
||||
"""Shallow clone without origin/main → still surface build SHA if baked.
|
||||
|
||||
Some install paths (e.g. ``git clone --depth 1`` without a remote) have
|
||||
a ``.git`` directory but ``git rev-parse origin/main`` fails. When that
|
||||
happens AND a baked SHA exists, return the baked one instead of None.
|
||||
"""
|
||||
from hermes_cli import banner
|
||||
|
||||
repo_dir = tmp_path / "repo"
|
||||
(repo_dir / ".git").mkdir(parents=True)
|
||||
|
||||
# All git invocations fail (returncode=1, empty stdout).
|
||||
failed = MagicMock(returncode=1, stdout="")
|
||||
with patch("hermes_cli.banner.subprocess.run", return_value=failed), \
|
||||
patch("hermes_cli.build_info.get_build_sha", return_value="cafef00d"):
|
||||
state = banner.get_git_banner_state(repo_dir)
|
||||
|
||||
assert state == {"upstream": "cafef00d", "local": "cafef00d", "ahead": 0}
|
||||
@@ -0,0 +1,35 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def testcheck_via_pypi_detects_update():
|
||||
"""check_via_pypi returns 1 when PyPI has newer version."""
|
||||
from hermes_cli.banner import check_via_pypi
|
||||
with patch("hermes_cli.banner.VERSION", "0.12.0"):
|
||||
with patch("hermes_cli.banner._fetch_pypi_latest", return_value="0.13.0"):
|
||||
result = check_via_pypi()
|
||||
assert result == 1
|
||||
|
||||
|
||||
def testcheck_via_pypi_up_to_date():
|
||||
"""check_via_pypi returns 0 when versions match."""
|
||||
from hermes_cli.banner import check_via_pypi
|
||||
with patch("hermes_cli.banner.VERSION", "0.13.0"):
|
||||
with patch("hermes_cli.banner._fetch_pypi_latest", return_value="0.13.0"):
|
||||
result = check_via_pypi()
|
||||
assert result == 0
|
||||
|
||||
|
||||
def testcheck_via_pypi_network_failure():
|
||||
"""check_via_pypi returns None on network error."""
|
||||
from hermes_cli.banner import check_via_pypi
|
||||
with patch("hermes_cli.banner._fetch_pypi_latest", return_value=None):
|
||||
result = check_via_pypi()
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_version_tuple_comparison():
|
||||
"""Version comparison works with multi-segment versions."""
|
||||
from hermes_cli.banner import _version_tuple
|
||||
assert _version_tuple("0.13.0") > _version_tuple("0.12.0")
|
||||
assert _version_tuple("0.13.0") == _version_tuple("0.13.0")
|
||||
assert _version_tuple("1.0.0") > _version_tuple("0.99.99")
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Tests for banner get_available_skills() — disabled and platform filtering."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
|
||||
_MOCK_SKILLS = [
|
||||
{"name": "skill-a", "description": "A skill", "category": "tools"},
|
||||
{"name": "skill-b", "description": "B skill", "category": "tools"},
|
||||
{"name": "skill-c", "description": "C skill", "category": "creative"},
|
||||
]
|
||||
|
||||
|
||||
def test_get_available_skills_delegates_to_find_all_skills():
|
||||
"""get_available_skills should call _find_all_skills (which handles filtering)."""
|
||||
with patch("tools.skills_tool._find_all_skills", return_value=list(_MOCK_SKILLS)):
|
||||
from hermes_cli.banner import get_available_skills
|
||||
result = get_available_skills()
|
||||
|
||||
assert "tools" in result
|
||||
assert "creative" in result
|
||||
assert sorted(result["tools"]) == ["skill-a", "skill-b"]
|
||||
assert result["creative"] == ["skill-c"]
|
||||
|
||||
|
||||
def test_get_available_skills_excludes_disabled():
|
||||
"""Disabled skills should not appear in the banner count."""
|
||||
# _find_all_skills already filters disabled skills, so if we give it
|
||||
# a filtered list, get_available_skills should reflect that.
|
||||
filtered = [s for s in _MOCK_SKILLS if s["name"] != "skill-b"]
|
||||
with patch("tools.skills_tool._find_all_skills", return_value=filtered):
|
||||
from hermes_cli.banner import get_available_skills
|
||||
result = get_available_skills()
|
||||
|
||||
all_names = [n for names in result.values() for n in names]
|
||||
assert "skill-b" not in all_names
|
||||
assert "skill-a" in all_names
|
||||
assert len(all_names) == 2
|
||||
|
||||
|
||||
def test_get_available_skills_empty_when_no_skills():
|
||||
"""No skills installed returns empty dict."""
|
||||
with patch("tools.skills_tool._find_all_skills", return_value=[]):
|
||||
from hermes_cli.banner import get_available_skills
|
||||
result = get_available_skills()
|
||||
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_get_available_skills_handles_import_failure():
|
||||
"""If _find_all_skills import fails, return empty dict gracefully."""
|
||||
with patch("tools.skills_tool._find_all_skills", side_effect=ImportError("boom")):
|
||||
from hermes_cli.banner import get_available_skills
|
||||
result = get_available_skills()
|
||||
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_get_available_skills_null_category_becomes_general():
|
||||
"""Skills with None category should be grouped under 'general'."""
|
||||
skills = [{"name": "orphan-skill", "description": "No cat", "category": None}]
|
||||
with patch("tools.skills_tool._find_all_skills", return_value=skills):
|
||||
from hermes_cli.banner import get_available_skills
|
||||
result = get_available_skills()
|
||||
|
||||
assert "general" in result
|
||||
assert result["general"] == ["orphan-skill"]
|
||||
@@ -0,0 +1,361 @@
|
||||
"""Tests for AWS Bedrock integration in the model picker and provider catalog.
|
||||
|
||||
Covers the three paths changed by fix/bedrock-provider-model-ids-live-discovery:
|
||||
|
||||
1. provider_model_ids("bedrock") — uses live discover_bedrock_models() instead
|
||||
of the static _PROVIDER_MODELS table, with curated fallback.
|
||||
|
||||
2. list_authenticated_providers() Section 2 (HERMES_OVERLAYS) — bedrock
|
||||
appears when AWS credentials are present; model list comes from live
|
||||
discovery keyed by the resolved region, NOT the static us.* table.
|
||||
|
||||
3. Region resolution — resolve_bedrock_region() reads from botocore profile
|
||||
when no AWS_REGION / AWS_DEFAULT_REGION env vars are set, so EU/AP users
|
||||
in eu-central-1 get eu.* profile IDs, not us.* ones.
|
||||
|
||||
All Bedrock API calls are mocked — no real AWS credentials needed.
|
||||
"""
|
||||
|
||||
from contextlib import contextmanager
|
||||
from types import ModuleType
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared helpers / fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _mock_botocore_session(*, return_value=None):
|
||||
"""Patch botocore.session even when botocore is not installed."""
|
||||
botocore_mod = ModuleType("botocore")
|
||||
session_mod = ModuleType("botocore.session")
|
||||
session_mod.get_session = MagicMock(return_value=return_value)
|
||||
botocore_mod.session = session_mod
|
||||
with patch.dict("sys.modules", {"botocore": botocore_mod, "botocore.session": session_mod}):
|
||||
yield session_mod.get_session
|
||||
|
||||
|
||||
_EU_MODELS = [
|
||||
{"id": "eu.anthropic.claude-sonnet-4-6-20250514-v1:0", "name": "Claude Sonnet 4.6 (EU)", "provider": "inference-profile"},
|
||||
{"id": "eu.anthropic.claude-haiku-4-5-20251015-v1:0", "name": "Claude Haiku 4.5 (EU)", "provider": "inference-profile"},
|
||||
{"id": "eu.amazon.nova-pro-v1:0", "name": "Nova Pro (EU)", "provider": "inference-profile"},
|
||||
]
|
||||
|
||||
_US_MODELS = [
|
||||
{"id": "us.anthropic.claude-sonnet-4-6-20250514-v1:0", "name": "Claude Sonnet 4.6 (US)", "provider": "inference-profile"},
|
||||
{"id": "us.amazon.nova-pro-v1:0", "name": "Nova Pro (US)", "provider": "inference-profile"},
|
||||
]
|
||||
|
||||
|
||||
def _mock_discover(region: str):
|
||||
"""Return EU models for eu-* regions, US models otherwise."""
|
||||
return _EU_MODELS if region.startswith("eu-") else _US_MODELS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. provider_model_ids("bedrock")
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestProviderModelIdsBedrock:
|
||||
"""provider_model_ids("bedrock") should use live Bedrock discovery."""
|
||||
|
||||
def test_returns_live_discovered_model_ids(self, monkeypatch):
|
||||
"""Live discovery result is returned as a flat list of model ID strings."""
|
||||
from hermes_cli.models import provider_model_ids
|
||||
|
||||
monkeypatch.setenv("AWS_REGION", "eu-central-1")
|
||||
|
||||
with patch("agent.bedrock_adapter.discover_bedrock_models", side_effect=_mock_discover), \
|
||||
patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
|
||||
result = provider_model_ids("bedrock")
|
||||
|
||||
assert "eu.anthropic.claude-sonnet-4-6-20250514-v1:0" in result
|
||||
assert "eu.anthropic.claude-haiku-4-5-20251015-v1:0" in result
|
||||
assert len(result) == len(_EU_MODELS)
|
||||
|
||||
def test_region_determines_model_ids(self, monkeypatch):
|
||||
"""Different regions produce different model ID prefixes (eu.* vs us.*)."""
|
||||
from hermes_cli.models import provider_model_ids
|
||||
|
||||
with patch("agent.bedrock_adapter.discover_bedrock_models", side_effect=_mock_discover):
|
||||
with patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
|
||||
eu_result = provider_model_ids("bedrock")
|
||||
with patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="us-east-1"):
|
||||
us_result = provider_model_ids("bedrock")
|
||||
|
||||
assert all(m.startswith("eu.") for m in eu_result)
|
||||
assert all(m.startswith("us.") for m in us_result)
|
||||
assert eu_result != us_result
|
||||
|
||||
def test_falls_back_to_static_list_when_discovery_empty(self, monkeypatch):
|
||||
"""When discover_bedrock_models() returns [], fall back to curated static list."""
|
||||
from hermes_cli.models import provider_model_ids
|
||||
|
||||
with patch("agent.bedrock_adapter.discover_bedrock_models", return_value=[]), \
|
||||
patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
|
||||
result = provider_model_ids("bedrock")
|
||||
|
||||
# Should fall back to static table (may be empty or populated depending on
|
||||
# the current static list, but must not crash and must be a list).
|
||||
assert isinstance(result, list)
|
||||
|
||||
def test_falls_back_to_static_list_on_exception(self, monkeypatch):
|
||||
"""When discover_bedrock_models() raises, fall back gracefully."""
|
||||
from hermes_cli.models import provider_model_ids
|
||||
|
||||
with patch("agent.bedrock_adapter.discover_bedrock_models",
|
||||
side_effect=Exception("boto3 not installed")), \
|
||||
patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
|
||||
result = provider_model_ids("bedrock")
|
||||
|
||||
assert isinstance(result, list) # no crash
|
||||
|
||||
def test_accepts_bedrock_aliases(self, monkeypatch):
|
||||
"""Provider aliases (aws, aws-bedrock, amazon) should also trigger live discovery."""
|
||||
from hermes_cli.models import provider_model_ids
|
||||
|
||||
_expected_ids = [m["id"] for m in _US_MODELS]
|
||||
|
||||
with patch("agent.bedrock_adapter.discover_bedrock_models", side_effect=_mock_discover), \
|
||||
patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="us-east-1"):
|
||||
for alias in ("aws", "aws-bedrock", "amazon-bedrock"):
|
||||
result = provider_model_ids(alias)
|
||||
assert result == _expected_ids, \
|
||||
f"alias {alias!r} should return live-discovered US model IDs, got {result!r}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. list_authenticated_providers() — bedrock via HERMES_OVERLAYS (Section 2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestListAuthenticatedProvidersBedrock:
|
||||
"""Bedrock should appear in the /model picker when AWS creds are present."""
|
||||
|
||||
def test_bedrock_appears_with_aws_profile(self, monkeypatch):
|
||||
"""Bedrock shows up when AWS_PROFILE is set."""
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
monkeypatch.setenv("AWS_PROFILE", "my-sso-profile")
|
||||
monkeypatch.setenv("AWS_REGION", "eu-central-1")
|
||||
|
||||
with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \
|
||||
patch("agent.bedrock_adapter.discover_bedrock_models", side_effect=_mock_discover), \
|
||||
patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
|
||||
providers = list_authenticated_providers(current_provider="bedrock")
|
||||
|
||||
bedrock = next((p for p in providers if p["slug"] == "bedrock"), None)
|
||||
assert bedrock is not None, "bedrock should appear when AWS credentials are present"
|
||||
|
||||
def test_bedrock_uses_live_discovery_not_static_list(self, monkeypatch):
|
||||
"""Model IDs come from discover_bedrock_models(), not the static _PROVIDER_MODELS table."""
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
monkeypatch.setenv("AWS_PROFILE", "my-sso-profile")
|
||||
|
||||
with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \
|
||||
patch("agent.bedrock_adapter.discover_bedrock_models", side_effect=_mock_discover), \
|
||||
patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
|
||||
providers = list_authenticated_providers(current_provider="bedrock")
|
||||
|
||||
bedrock = next((p for p in providers if p["slug"] == "bedrock"), None)
|
||||
assert bedrock is not None
|
||||
|
||||
# All returned model IDs should have eu.* prefix — live discovery result
|
||||
for model_id in bedrock["models"]:
|
||||
assert model_id.startswith("eu."), \
|
||||
f"Expected eu.* model ID from live discovery, got {model_id!r}"
|
||||
|
||||
def test_bedrock_total_models_matches_discovery(self, monkeypatch):
|
||||
"""total_models reflects the actual discovered count."""
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
monkeypatch.setenv("AWS_PROFILE", "my-sso-profile")
|
||||
|
||||
with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \
|
||||
patch("agent.bedrock_adapter.discover_bedrock_models", return_value=_EU_MODELS), \
|
||||
patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
|
||||
providers = list_authenticated_providers(current_provider="openai")
|
||||
|
||||
bedrock = next((p for p in providers if p["slug"] == "bedrock"), None)
|
||||
assert bedrock is not None
|
||||
assert bedrock["total_models"] == len(_EU_MODELS)
|
||||
|
||||
def test_bedrock_is_current_when_selected(self, monkeypatch):
|
||||
"""is_current=True when current_provider matches bedrock."""
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
monkeypatch.setenv("AWS_PROFILE", "my-sso-profile")
|
||||
|
||||
with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \
|
||||
patch("agent.bedrock_adapter.discover_bedrock_models", return_value=_EU_MODELS), \
|
||||
patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
|
||||
providers = list_authenticated_providers(current_provider="bedrock")
|
||||
|
||||
bedrock = next((p for p in providers if p["slug"] == "bedrock"), None)
|
||||
assert bedrock is not None
|
||||
assert bedrock["is_current"] is True
|
||||
|
||||
def test_bedrock_not_shown_without_credentials(self, monkeypatch):
|
||||
"""Bedrock must not appear when no AWS credentials are present."""
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
monkeypatch.delenv("AWS_PROFILE", raising=False)
|
||||
monkeypatch.delenv("AWS_ACCESS_KEY_ID", raising=False)
|
||||
monkeypatch.delenv("AWS_SECRET_ACCESS_KEY", raising=False)
|
||||
monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False)
|
||||
monkeypatch.delenv("AWS_WEB_IDENTITY_TOKEN_FILE", raising=False)
|
||||
monkeypatch.delenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", raising=False)
|
||||
|
||||
with patch("agent.bedrock_adapter.has_aws_credentials", return_value=False):
|
||||
providers = list_authenticated_providers(current_provider="openai")
|
||||
|
||||
bedrock = next((p for p in providers if p["slug"] == "bedrock"), None)
|
||||
assert bedrock is None, "bedrock should NOT appear when AWS credentials are absent"
|
||||
|
||||
def test_non_bedrock_picker_does_not_probe_full_aws_chain(self, monkeypatch):
|
||||
"""Non-Bedrock provider discovery must not touch boto3's full credential chain."""
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
monkeypatch.delenv("AWS_PROFILE", raising=False)
|
||||
monkeypatch.delenv("AWS_ACCESS_KEY_ID", raising=False)
|
||||
monkeypatch.delenv("AWS_SECRET_ACCESS_KEY", raising=False)
|
||||
monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False)
|
||||
monkeypatch.delenv("AWS_WEB_IDENTITY_TOKEN_FILE", raising=False)
|
||||
monkeypatch.delenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", raising=False)
|
||||
monkeypatch.delenv("AWS_CONTAINER_CREDENTIALS_FULL_URI", raising=False)
|
||||
|
||||
calls = {"has_aws_credentials": 0}
|
||||
|
||||
def _has_aws_credentials():
|
||||
calls["has_aws_credentials"] += 1
|
||||
return False
|
||||
|
||||
with patch("agent.bedrock_adapter.has_aws_credentials", side_effect=_has_aws_credentials):
|
||||
providers = list_authenticated_providers(current_provider="openrouter", max_models=0)
|
||||
|
||||
assert calls["has_aws_credentials"] == 0
|
||||
assert all(p["slug"] != "bedrock" for p in providers)
|
||||
|
||||
def test_bedrock_falls_back_to_curated_when_discovery_fails(self, monkeypatch):
|
||||
"""When discover_bedrock_models() raises, fall back to curated list without crashing."""
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
monkeypatch.setenv("AWS_PROFILE", "my-sso-profile")
|
||||
|
||||
with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \
|
||||
patch("agent.bedrock_adapter.discover_bedrock_models",
|
||||
side_effect=Exception("API call failed")), \
|
||||
patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
|
||||
providers = list_authenticated_providers(current_provider="bedrock")
|
||||
|
||||
# Should not raise — bedrock entry may or may not appear depending on
|
||||
# whether the curated fallback has entries, but the call must succeed.
|
||||
assert isinstance(providers, list)
|
||||
|
||||
def test_bedrock_no_duplicate_entries(self, monkeypatch):
|
||||
"""Bedrock must appear at most once — not in both Section 1 and Section 2."""
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
monkeypatch.setenv("AWS_PROFILE", "my-sso-profile")
|
||||
|
||||
with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \
|
||||
patch("agent.bedrock_adapter.discover_bedrock_models", return_value=_EU_MODELS), \
|
||||
patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"):
|
||||
providers = list_authenticated_providers(current_provider="bedrock")
|
||||
|
||||
bedrock_entries = [p for p in providers if p["slug"] == "bedrock"]
|
||||
assert len(bedrock_entries) <= 1, \
|
||||
f"bedrock should appear at most once, got {len(bedrock_entries)} entries"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Region routing: EU/AP users see regional model IDs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBedrockRegionRouting:
|
||||
"""End-to-end: region from botocore profile is used for discovery, so EU/AP
|
||||
users get eu.*/ap.* model IDs rather than the hardcoded us-east-1 list."""
|
||||
|
||||
def test_eu_region_from_botocore_profile_yields_eu_models(self):
|
||||
"""When botocore resolves eu-central-1, picker shows eu.* model IDs."""
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.get_config_variable.return_value = "eu-central-1"
|
||||
|
||||
with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \
|
||||
patch("agent.bedrock_adapter.discover_bedrock_models", side_effect=_mock_discover), \
|
||||
_mock_botocore_session(return_value=mock_session):
|
||||
providers = list_authenticated_providers(current_provider="bedrock")
|
||||
|
||||
bedrock = next((p for p in providers if p["slug"] == "bedrock"), None)
|
||||
assert bedrock is not None
|
||||
for model_id in bedrock["models"]:
|
||||
assert model_id.startswith("eu."), \
|
||||
f"Expected eu.* model ID from eu-central-1 profile, got {model_id!r}"
|
||||
|
||||
def test_us_region_from_env_var_yields_us_models(self, monkeypatch):
|
||||
"""Explicit AWS_REGION=us-east-1 returns us.* model IDs."""
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
monkeypatch.setenv("AWS_REGION", "us-east-1")
|
||||
|
||||
with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \
|
||||
patch("agent.bedrock_adapter.discover_bedrock_models", side_effect=_mock_discover):
|
||||
providers = list_authenticated_providers(current_provider="bedrock")
|
||||
|
||||
bedrock = next((p for p in providers if p["slug"] == "bedrock"), None)
|
||||
assert bedrock is not None
|
||||
for model_id in bedrock["models"]:
|
||||
assert model_id.startswith("us."), \
|
||||
f"Expected us.* model ID from us-east-1, got {model_id!r}"
|
||||
|
||||
def test_env_var_takes_priority_over_botocore_profile(self, monkeypatch):
|
||||
"""AWS_REGION env var wins over botocore profile region."""
|
||||
from agent.bedrock_adapter import resolve_bedrock_region
|
||||
|
||||
monkeypatch.setenv("AWS_REGION", "us-west-2")
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.get_config_variable.return_value = "eu-central-1"
|
||||
|
||||
with _mock_botocore_session(return_value=mock_session):
|
||||
region = resolve_bedrock_region()
|
||||
|
||||
assert region == "us-west-2", "env var should override botocore profile"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. providers.py overlay registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBedrockOverlayRegistration:
|
||||
"""bedrock entry in HERMES_OVERLAYS is correctly configured."""
|
||||
|
||||
def test_bedrock_overlay_exists(self):
|
||||
from hermes_cli.providers import HERMES_OVERLAYS
|
||||
assert "bedrock" in HERMES_OVERLAYS
|
||||
|
||||
def test_bedrock_overlay_transport(self):
|
||||
from hermes_cli.providers import HERMES_OVERLAYS
|
||||
assert HERMES_OVERLAYS["bedrock"].transport == "bedrock_converse"
|
||||
|
||||
def test_bedrock_overlay_auth_type(self):
|
||||
from hermes_cli.providers import HERMES_OVERLAYS
|
||||
assert HERMES_OVERLAYS["bedrock"].auth_type == "aws_sdk"
|
||||
|
||||
def test_bedrock_label(self):
|
||||
from hermes_cli.providers import get_label
|
||||
label = get_label("bedrock")
|
||||
assert label # non-empty
|
||||
assert "bedrock" in label.lower() or "aws" in label.lower()
|
||||
|
||||
def test_bedrock_aliases_resolve(self):
|
||||
from hermes_cli.providers import normalize_provider
|
||||
for alias in ("aws", "aws-bedrock", "amazon-bedrock", "amazon"):
|
||||
assert normalize_provider(alias) == "bedrock", \
|
||||
f"alias {alias!r} should normalize to 'bedrock'"
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Tests for hermes_cli.build_info — baked-in build SHA resolution.
|
||||
|
||||
The build SHA is written by the Dockerfile's ``HERMES_GIT_SHA`` build-arg
|
||||
into ``<project_root>/.hermes_build_sha``. These tests cover the read-side
|
||||
helper: missing file, malformed file, truncation, and error tolerance.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def test_get_build_sha_returns_none_when_file_absent(tmp_path):
|
||||
"""Source installs: no file present → None, callers fall back to git."""
|
||||
from hermes_cli import build_info
|
||||
|
||||
missing = tmp_path / ".hermes_build_sha" # never created
|
||||
|
||||
with patch.object(build_info, "_BUILD_SHA_FILE", missing):
|
||||
assert build_info.get_build_sha() is None
|
||||
|
||||
|
||||
def test_get_build_sha_reads_baked_file(tmp_path):
|
||||
"""Docker image case: file exists with full 40-char SHA → truncated to 8."""
|
||||
from hermes_cli import build_info
|
||||
|
||||
sha_file = tmp_path / ".hermes_build_sha"
|
||||
sha_file.write_text("abcdef1234567890abcdef1234567890abcdef12\n")
|
||||
|
||||
with patch.object(build_info, "_BUILD_SHA_FILE", sha_file):
|
||||
assert build_info.get_build_sha() == "abcdef12"
|
||||
|
||||
|
||||
def test_get_build_sha_respects_short_argument(tmp_path):
|
||||
"""``short=N`` truncates to N chars; ``short<=0`` returns full SHA."""
|
||||
from hermes_cli import build_info
|
||||
|
||||
sha_file = tmp_path / ".hermes_build_sha"
|
||||
full_sha = "abcdef1234567890abcdef1234567890abcdef12"
|
||||
sha_file.write_text(full_sha + "\n")
|
||||
|
||||
with patch.object(build_info, "_BUILD_SHA_FILE", sha_file):
|
||||
assert build_info.get_build_sha(short=12) == "abcdef123456"
|
||||
assert build_info.get_build_sha(short=0) == full_sha
|
||||
assert build_info.get_build_sha(short=-1) == full_sha
|
||||
|
||||
|
||||
def test_get_build_sha_strips_whitespace(tmp_path):
|
||||
"""The Dockerfile uses ``printf '%s\\n'`` — strip the trailing newline."""
|
||||
from hermes_cli import build_info
|
||||
|
||||
sha_file = tmp_path / ".hermes_build_sha"
|
||||
sha_file.write_text(" abcdef1234567890\n\n")
|
||||
|
||||
with patch.object(build_info, "_BUILD_SHA_FILE", sha_file):
|
||||
assert build_info.get_build_sha() == "abcdef12"
|
||||
|
||||
|
||||
def test_get_build_sha_returns_none_for_empty_file(tmp_path):
|
||||
"""A whitespace-only file is treated as absent."""
|
||||
from hermes_cli import build_info
|
||||
|
||||
sha_file = tmp_path / ".hermes_build_sha"
|
||||
sha_file.write_text(" \n\n")
|
||||
|
||||
with patch.object(build_info, "_BUILD_SHA_FILE", sha_file):
|
||||
assert build_info.get_build_sha() is None
|
||||
|
||||
|
||||
def test_get_build_sha_swallows_read_errors(tmp_path):
|
||||
"""Any IO exception from the read returns None — never raises."""
|
||||
from hermes_cli import build_info
|
||||
|
||||
sha_file = tmp_path / ".hermes_build_sha"
|
||||
sha_file.write_text("abcdef1234567890\n")
|
||||
|
||||
with patch.object(build_info, "_BUILD_SHA_FILE", sha_file), \
|
||||
patch.object(Path, "read_text", side_effect=OSError("boom")):
|
||||
assert build_info.get_build_sha() is None
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Tests for hermes_cli/bundles.py — the `hermes bundles` CLI subcommand."""
|
||||
|
||||
import argparse
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.bundles import (
|
||||
bundles_command,
|
||||
register_cli,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bundles_env(tmp_path, monkeypatch):
|
||||
bundles_dir = tmp_path / "skill-bundles"
|
||||
monkeypatch.setenv("HERMES_BUNDLES_DIR", str(bundles_dir))
|
||||
# Reset module-level cache between tests.
|
||||
import agent.skill_bundles as mod
|
||||
mod._bundles_cache = {}
|
||||
mod._bundles_cache_mtime = None
|
||||
return bundles_dir
|
||||
|
||||
|
||||
def _parse(argv):
|
||||
parser = argparse.ArgumentParser()
|
||||
register_cli(parser)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
class TestBundlesCli:
|
||||
def test_create_and_list(self, bundles_env, capsys):
|
||||
args = _parse(["create", "my-bundle", "--skill", "a", "--skill", "b", "-d", "desc"])
|
||||
bundles_command(args)
|
||||
out = capsys.readouterr().out
|
||||
assert "Created bundle" in out
|
||||
# File should exist
|
||||
assert (bundles_env / "my-bundle.yaml").exists()
|
||||
|
||||
args = _parse(["list"])
|
||||
bundles_command(args)
|
||||
out = capsys.readouterr().out
|
||||
assert "my-bundle" in out
|
||||
|
||||
def test_show(self, bundles_env, capsys):
|
||||
bundles_command(_parse(["create", "x", "--skill", "s1", "--skill", "s2"]))
|
||||
capsys.readouterr() # clear
|
||||
bundles_command(_parse(["show", "x"]))
|
||||
out = capsys.readouterr().out
|
||||
assert "/x" in out
|
||||
assert "s1" in out
|
||||
assert "s2" in out
|
||||
|
||||
def test_delete(self, bundles_env, capsys):
|
||||
bundles_command(_parse(["create", "doomed", "--skill", "s1"]))
|
||||
capsys.readouterr()
|
||||
bundles_command(_parse(["delete", "doomed"]))
|
||||
out = capsys.readouterr().out
|
||||
assert "Deleted bundle" in out
|
||||
assert not (bundles_env / "doomed.yaml").exists()
|
||||
|
||||
def test_create_refuses_overwrite(self, bundles_env, capsys):
|
||||
bundles_command(_parse(["create", "dup", "--skill", "s1"]))
|
||||
capsys.readouterr()
|
||||
with pytest.raises(SystemExit) as ei:
|
||||
bundles_command(_parse(["create", "dup", "--skill", "s2"]))
|
||||
assert ei.value.code == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "already exists" in out.lower() or "--force" in out.lower()
|
||||
|
||||
def test_create_force_overwrites(self, bundles_env, capsys):
|
||||
bundles_command(_parse(["create", "dup", "--skill", "s1"]))
|
||||
capsys.readouterr()
|
||||
bundles_command(_parse(["create", "dup", "--skill", "s2", "--force"]))
|
||||
out = capsys.readouterr().out
|
||||
assert "Created bundle" in out
|
||||
|
||||
def test_create_requires_skills(self, bundles_env, capsys, monkeypatch):
|
||||
# Simulate user pressing Ctrl-D immediately at the interactive prompt.
|
||||
monkeypatch.setattr("builtins.input", lambda *_a, **_kw: (_ for _ in ()).throw(EOFError()))
|
||||
with pytest.raises(SystemExit):
|
||||
bundles_command(_parse(["create", "empty"]))
|
||||
|
||||
def test_show_missing(self, bundles_env, capsys):
|
||||
with pytest.raises(SystemExit) as ei:
|
||||
bundles_command(_parse(["show", "ghost"]))
|
||||
assert ei.value.code == 1
|
||||
|
||||
def test_reload(self, bundles_env, capsys):
|
||||
# Reload on an empty dir reports no changes.
|
||||
bundles_command(_parse(["reload"]))
|
||||
out = capsys.readouterr().out
|
||||
assert "No changes" in out or "0" in out
|
||||
@@ -0,0 +1,101 @@
|
||||
import sys
|
||||
|
||||
|
||||
def test_top_level_skills_flag_defaults_to_chat(monkeypatch):
|
||||
import hermes_cli.main as main_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_cmd_chat(args):
|
||||
captured["skills"] = args.skills
|
||||
captured["command"] = args.command
|
||||
|
||||
monkeypatch.setattr(main_mod, "cmd_chat", fake_cmd_chat)
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
["hermes", "-s", "hermes-agent-dev,github-auth"],
|
||||
)
|
||||
|
||||
main_mod.main()
|
||||
|
||||
assert captured == {
|
||||
"skills": ["hermes-agent-dev,github-auth"],
|
||||
"command": None,
|
||||
}
|
||||
|
||||
|
||||
def test_chat_subcommand_accepts_skills_flag(monkeypatch):
|
||||
import hermes_cli.main as main_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_cmd_chat(args):
|
||||
captured["skills"] = args.skills
|
||||
captured["query"] = args.query
|
||||
|
||||
monkeypatch.setattr(main_mod, "cmd_chat", fake_cmd_chat)
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
["hermes", "chat", "-s", "github-auth", "-q", "hello"],
|
||||
)
|
||||
|
||||
main_mod.main()
|
||||
|
||||
assert captured == {
|
||||
"skills": ["github-auth"],
|
||||
"query": "hello",
|
||||
}
|
||||
|
||||
|
||||
def test_chat_subcommand_accepts_image_flag(monkeypatch):
|
||||
import hermes_cli.main as main_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_cmd_chat(args):
|
||||
captured["query"] = args.query
|
||||
captured["image"] = args.image
|
||||
|
||||
monkeypatch.setattr(main_mod, "cmd_chat", fake_cmd_chat)
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
["hermes", "chat", "-q", "hello", "--image", "~/storage/shared/Pictures/cat.png"],
|
||||
)
|
||||
|
||||
main_mod.main()
|
||||
|
||||
assert captured == {
|
||||
"query": "hello",
|
||||
"image": "~/storage/shared/Pictures/cat.png",
|
||||
}
|
||||
|
||||
|
||||
def test_continue_worktree_and_skills_flags_work_together(monkeypatch):
|
||||
import hermes_cli.main as main_mod
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_cmd_chat(args):
|
||||
captured["continue_last"] = args.continue_last
|
||||
captured["worktree"] = args.worktree
|
||||
captured["skills"] = args.skills
|
||||
captured["command"] = args.command
|
||||
|
||||
monkeypatch.setattr(main_mod, "cmd_chat", fake_cmd_chat)
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
["hermes", "-c", "-w", "-s", "hermes-agent-dev"],
|
||||
)
|
||||
|
||||
main_mod.main()
|
||||
|
||||
assert captured == {
|
||||
"continue_last": True,
|
||||
"worktree": True,
|
||||
"skills": ["hermes-agent-dev"],
|
||||
"command": "chat",
|
||||
}
|
||||
@@ -0,0 +1,799 @@
|
||||
"""Tests for hermes claw commands."""
|
||||
|
||||
from argparse import Namespace
|
||||
import subprocess
|
||||
from types import ModuleType
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import claw as claw_mod
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _find_migration_script
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFindMigrationScript:
|
||||
"""Test script discovery in known locations."""
|
||||
|
||||
def test_finds_project_root_script(self, tmp_path):
|
||||
script = tmp_path / "openclaw_to_hermes.py"
|
||||
script.write_text("# placeholder")
|
||||
with patch.object(claw_mod, "_OPENCLAW_SCRIPT", script):
|
||||
assert claw_mod._find_migration_script() == script
|
||||
|
||||
def test_finds_installed_script(self, tmp_path):
|
||||
installed = tmp_path / "installed.py"
|
||||
installed.write_text("# placeholder")
|
||||
with (
|
||||
patch.object(claw_mod, "_OPENCLAW_SCRIPT", tmp_path / "nonexistent.py"),
|
||||
patch.object(claw_mod, "_OPENCLAW_SCRIPT_INSTALLED", installed),
|
||||
):
|
||||
assert claw_mod._find_migration_script() == installed
|
||||
|
||||
def test_returns_none_when_missing(self, tmp_path):
|
||||
with (
|
||||
patch.object(claw_mod, "_OPENCLAW_SCRIPT", tmp_path / "a.py"),
|
||||
patch.object(claw_mod, "_OPENCLAW_SCRIPT_INSTALLED", tmp_path / "b.py"),
|
||||
):
|
||||
assert claw_mod._find_migration_script() is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _find_openclaw_dirs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFindOpenclawDirs:
|
||||
"""Test discovery of OpenClaw directories."""
|
||||
|
||||
def test_finds_openclaw_dir(self, tmp_path):
|
||||
openclaw = tmp_path / ".openclaw"
|
||||
openclaw.mkdir()
|
||||
with patch("pathlib.Path.home", return_value=tmp_path):
|
||||
found = claw_mod._find_openclaw_dirs()
|
||||
assert openclaw in found
|
||||
|
||||
def test_finds_legacy_dirs(self, tmp_path):
|
||||
clawdbot = tmp_path / ".clawdbot"
|
||||
clawdbot.mkdir()
|
||||
moltbot = tmp_path / ".moltbot"
|
||||
moltbot.mkdir()
|
||||
with patch("pathlib.Path.home", return_value=tmp_path):
|
||||
found = claw_mod._find_openclaw_dirs()
|
||||
assert len(found) == 2
|
||||
assert clawdbot in found
|
||||
assert moltbot in found
|
||||
|
||||
def test_returns_empty_when_none_exist(self, tmp_path):
|
||||
with patch("pathlib.Path.home", return_value=tmp_path):
|
||||
found = claw_mod._find_openclaw_dirs()
|
||||
assert found == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _scan_workspace_state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestScanWorkspaceState:
|
||||
"""Test scanning for workspace state files."""
|
||||
|
||||
def test_finds_root_state_files(self, tmp_path):
|
||||
(tmp_path / "todo.json").write_text("{}")
|
||||
(tmp_path / "sessions").mkdir()
|
||||
findings = claw_mod._scan_workspace_state(tmp_path)
|
||||
descs = [desc for _, desc in findings]
|
||||
assert any("todo.json" in d for d in descs)
|
||||
assert any("sessions" in d for d in descs)
|
||||
|
||||
def test_finds_workspace_state_files(self, tmp_path):
|
||||
ws = tmp_path / "workspace"
|
||||
ws.mkdir()
|
||||
(ws / "todo.json").write_text("{}")
|
||||
(ws / "sessions").mkdir()
|
||||
findings = claw_mod._scan_workspace_state(tmp_path)
|
||||
descs = [desc for _, desc in findings]
|
||||
assert any("workspace/todo.json" in d for d in descs)
|
||||
assert any("workspace/sessions" in d for d in descs)
|
||||
|
||||
def test_ignores_hidden_dirs(self, tmp_path):
|
||||
scan_dir = tmp_path / "scan_target"
|
||||
scan_dir.mkdir()
|
||||
hidden = scan_dir / ".git"
|
||||
hidden.mkdir()
|
||||
(hidden / "todo.json").write_text("{}")
|
||||
findings = claw_mod._scan_workspace_state(scan_dir)
|
||||
assert len(findings) == 0
|
||||
|
||||
def test_empty_dir_returns_empty(self, tmp_path):
|
||||
scan_dir = tmp_path / "scan_target"
|
||||
scan_dir.mkdir()
|
||||
findings = claw_mod._scan_workspace_state(scan_dir)
|
||||
assert findings == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _archive_directory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestArchiveDirectory:
|
||||
"""Test directory archival (rename)."""
|
||||
|
||||
def test_renames_to_pre_migration(self, tmp_path):
|
||||
source = tmp_path / ".openclaw"
|
||||
source.mkdir()
|
||||
(source / "test.txt").write_text("data")
|
||||
|
||||
archive_path = claw_mod._archive_directory(source)
|
||||
assert archive_path == tmp_path / ".openclaw.pre-migration"
|
||||
assert archive_path.is_dir()
|
||||
assert not source.exists()
|
||||
assert (archive_path / "test.txt").read_text() == "data"
|
||||
|
||||
def test_adds_timestamp_when_archive_exists(self, tmp_path):
|
||||
source = tmp_path / ".openclaw"
|
||||
source.mkdir()
|
||||
# Pre-existing archive
|
||||
(tmp_path / ".openclaw.pre-migration").mkdir()
|
||||
|
||||
archive_path = claw_mod._archive_directory(source)
|
||||
assert ".pre-migration-" in archive_path.name
|
||||
assert archive_path.is_dir()
|
||||
assert not source.exists()
|
||||
|
||||
def test_dry_run_does_not_rename(self, tmp_path):
|
||||
source = tmp_path / ".openclaw"
|
||||
source.mkdir()
|
||||
|
||||
archive_path = claw_mod._archive_directory(source, dry_run=True)
|
||||
assert archive_path == tmp_path / ".openclaw.pre-migration"
|
||||
assert source.is_dir() # Still exists
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# claw_command routing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestClawCommand:
|
||||
"""Test the claw_command router."""
|
||||
|
||||
def test_routes_to_migrate(self):
|
||||
args = Namespace(claw_action="migrate", source=None, dry_run=True,
|
||||
preset="full", overwrite=False, migrate_secrets=False,
|
||||
workspace_target=None, skill_conflict="skip", yes=False)
|
||||
with patch.object(claw_mod, "_cmd_migrate") as mock:
|
||||
claw_mod.claw_command(args)
|
||||
mock.assert_called_once_with(args)
|
||||
|
||||
def test_routes_to_cleanup(self):
|
||||
args = Namespace(claw_action="cleanup", source=None, dry_run=False, yes=False)
|
||||
with patch.object(claw_mod, "_cmd_cleanup") as mock:
|
||||
claw_mod.claw_command(args)
|
||||
mock.assert_called_once_with(args)
|
||||
|
||||
def test_routes_clean_alias(self):
|
||||
args = Namespace(claw_action="clean", source=None, dry_run=False, yes=False)
|
||||
with patch.object(claw_mod, "_cmd_cleanup") as mock:
|
||||
claw_mod.claw_command(args)
|
||||
mock.assert_called_once_with(args)
|
||||
|
||||
def test_shows_help_for_no_action(self, capsys):
|
||||
args = Namespace(claw_action=None)
|
||||
claw_mod.claw_command(args)
|
||||
captured = capsys.readouterr()
|
||||
assert "migrate" in captured.out
|
||||
assert "cleanup" in captured.out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _cmd_migrate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCmdMigrate:
|
||||
"""Test the migrate command handler."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_openclaw_running(self):
|
||||
with patch.object(claw_mod, "_detect_openclaw_processes", return_value=[]):
|
||||
yield
|
||||
|
||||
def test_error_when_source_missing(self, tmp_path, capsys):
|
||||
args = Namespace(
|
||||
source=str(tmp_path / "nonexistent"),
|
||||
dry_run=True, preset="full", overwrite=False,
|
||||
migrate_secrets=False, workspace_target=None,
|
||||
skill_conflict="skip", yes=False,
|
||||
)
|
||||
claw_mod._cmd_migrate(args)
|
||||
captured = capsys.readouterr()
|
||||
assert "not found" in captured.out
|
||||
|
||||
def test_error_when_script_missing(self, tmp_path, capsys):
|
||||
openclaw_dir = tmp_path / ".openclaw"
|
||||
openclaw_dir.mkdir()
|
||||
args = Namespace(
|
||||
source=str(openclaw_dir),
|
||||
dry_run=True, preset="full", overwrite=False,
|
||||
migrate_secrets=False, workspace_target=None,
|
||||
skill_conflict="skip", yes=False,
|
||||
)
|
||||
with (
|
||||
patch.object(claw_mod, "_OPENCLAW_SCRIPT", tmp_path / "a.py"),
|
||||
patch.object(claw_mod, "_OPENCLAW_SCRIPT_INSTALLED", tmp_path / "b.py"),
|
||||
):
|
||||
claw_mod._cmd_migrate(args)
|
||||
captured = capsys.readouterr()
|
||||
assert "Migration script not found" in captured.out
|
||||
|
||||
def test_dry_run_succeeds(self, tmp_path, capsys):
|
||||
openclaw_dir = tmp_path / ".openclaw"
|
||||
openclaw_dir.mkdir()
|
||||
script = tmp_path / "script.py"
|
||||
script.write_text("# placeholder")
|
||||
|
||||
# Build a fake migration module
|
||||
fake_mod = ModuleType("openclaw_to_hermes")
|
||||
fake_mod.resolve_selected_options = MagicMock(return_value={"soul", "memory"})
|
||||
fake_migrator = MagicMock()
|
||||
fake_migrator.migrate.return_value = {
|
||||
"summary": {"migrated": 0, "skipped": 5, "conflict": 0, "error": 0},
|
||||
"items": [
|
||||
{"kind": "soul", "status": "skipped", "reason": "Not found"},
|
||||
],
|
||||
"preset": "full",
|
||||
}
|
||||
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
|
||||
|
||||
args = Namespace(
|
||||
source=str(openclaw_dir),
|
||||
dry_run=True, preset="full", overwrite=False,
|
||||
migrate_secrets=False, workspace_target=None,
|
||||
skill_conflict="skip", yes=False,
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(claw_mod, "_find_migration_script", return_value=script),
|
||||
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
|
||||
patch.object(claw_mod, "get_config_path", return_value=tmp_path / "config.yaml"),
|
||||
patch.object(claw_mod, "save_config"),
|
||||
patch.object(claw_mod, "load_config", return_value={}),
|
||||
):
|
||||
claw_mod._cmd_migrate(args)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Dry Run Results" in captured.out
|
||||
assert "5 skipped" in captured.out
|
||||
|
||||
def test_execute_with_confirmation(self, tmp_path, capsys):
|
||||
openclaw_dir = tmp_path / ".openclaw"
|
||||
openclaw_dir.mkdir()
|
||||
config_path = tmp_path / "config.yaml"
|
||||
config_path.write_text("agent:\n max_turns: 90\n")
|
||||
|
||||
fake_mod = ModuleType("openclaw_to_hermes")
|
||||
fake_mod.resolve_selected_options = MagicMock(return_value={"soul"})
|
||||
fake_migrator = MagicMock()
|
||||
fake_migrator.migrate.return_value = {
|
||||
"summary": {"migrated": 2, "skipped": 1, "conflict": 0, "error": 0},
|
||||
"items": [
|
||||
{"kind": "soul", "status": "migrated", "destination": str(tmp_path / "SOUL.md")},
|
||||
{"kind": "memory", "status": "migrated", "destination": str(tmp_path / "memories/MEMORY.md")},
|
||||
],
|
||||
}
|
||||
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
|
||||
|
||||
args = Namespace(
|
||||
source=str(openclaw_dir),
|
||||
dry_run=False, preset="user-data", overwrite=False,
|
||||
migrate_secrets=False, workspace_target=None,
|
||||
skill_conflict="skip", yes=False,
|
||||
)
|
||||
|
||||
mock_stdin = MagicMock()
|
||||
mock_stdin.isatty.return_value = True
|
||||
|
||||
with (
|
||||
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
|
||||
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
|
||||
patch.object(claw_mod, "get_config_path", return_value=config_path),
|
||||
patch.object(claw_mod, "prompt_yes_no", return_value=True),
|
||||
patch("sys.stdin", mock_stdin),
|
||||
):
|
||||
claw_mod._cmd_migrate(args)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Migration Results" in captured.out
|
||||
assert "Migration complete!" in captured.out
|
||||
|
||||
def test_dry_run_does_not_touch_source(self, tmp_path, capsys):
|
||||
"""Dry run should not modify the source directory."""
|
||||
openclaw_dir = tmp_path / ".openclaw"
|
||||
openclaw_dir.mkdir()
|
||||
|
||||
fake_mod = ModuleType("openclaw_to_hermes")
|
||||
fake_mod.resolve_selected_options = MagicMock(return_value=set())
|
||||
fake_migrator = MagicMock()
|
||||
fake_migrator.migrate.return_value = {
|
||||
"summary": {"migrated": 2, "skipped": 0, "conflict": 0, "error": 0},
|
||||
"items": [],
|
||||
"preset": "full",
|
||||
}
|
||||
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
|
||||
|
||||
args = Namespace(
|
||||
source=str(openclaw_dir),
|
||||
dry_run=True, preset="full", overwrite=False,
|
||||
migrate_secrets=False, workspace_target=None,
|
||||
skill_conflict="skip", yes=False,
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
|
||||
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
|
||||
patch.object(claw_mod, "get_config_path", return_value=tmp_path / "config.yaml"),
|
||||
patch.object(claw_mod, "save_config"),
|
||||
patch.object(claw_mod, "load_config", return_value={}),
|
||||
):
|
||||
claw_mod._cmd_migrate(args)
|
||||
|
||||
assert openclaw_dir.is_dir() # Source untouched
|
||||
|
||||
def test_execute_cancelled_by_user(self, tmp_path, capsys):
|
||||
openclaw_dir = tmp_path / ".openclaw"
|
||||
openclaw_dir.mkdir()
|
||||
config_path = tmp_path / "config.yaml"
|
||||
config_path.write_text("")
|
||||
|
||||
# Preview must succeed before the confirmation prompt is shown
|
||||
fake_mod = ModuleType("openclaw_to_hermes")
|
||||
fake_mod.resolve_selected_options = MagicMock(return_value=set())
|
||||
fake_migrator = MagicMock()
|
||||
fake_migrator.migrate.return_value = {
|
||||
"summary": {"migrated": 1, "skipped": 0, "conflict": 0, "error": 0},
|
||||
"items": [{"kind": "soul", "status": "migrated", "source": "s", "destination": "d", "reason": ""}],
|
||||
}
|
||||
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
|
||||
|
||||
args = Namespace(
|
||||
source=str(openclaw_dir),
|
||||
dry_run=False, preset="full", overwrite=False,
|
||||
migrate_secrets=False, workspace_target=None,
|
||||
skill_conflict="skip", yes=False,
|
||||
)
|
||||
|
||||
mock_stdin = MagicMock()
|
||||
mock_stdin.isatty.return_value = True
|
||||
|
||||
with (
|
||||
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
|
||||
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
|
||||
patch.object(claw_mod, "get_config_path", return_value=config_path),
|
||||
patch.object(claw_mod, "prompt_yes_no", return_value=False),
|
||||
patch("sys.stdin", mock_stdin),
|
||||
):
|
||||
claw_mod._cmd_migrate(args)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Migration cancelled" in captured.out
|
||||
|
||||
def test_execute_with_yes_skips_confirmation(self, tmp_path, capsys):
|
||||
openclaw_dir = tmp_path / ".openclaw"
|
||||
openclaw_dir.mkdir()
|
||||
config_path = tmp_path / "config.yaml"
|
||||
config_path.write_text("")
|
||||
|
||||
fake_mod = ModuleType("openclaw_to_hermes")
|
||||
fake_mod.resolve_selected_options = MagicMock(return_value=set())
|
||||
fake_migrator = MagicMock()
|
||||
fake_migrator.migrate.return_value = {
|
||||
"summary": {"migrated": 0, "skipped": 0, "conflict": 0, "error": 0},
|
||||
"items": [],
|
||||
}
|
||||
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
|
||||
|
||||
args = Namespace(
|
||||
source=str(openclaw_dir),
|
||||
dry_run=False, preset="full", overwrite=False,
|
||||
migrate_secrets=False, workspace_target=None,
|
||||
skill_conflict="skip", yes=True,
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
|
||||
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
|
||||
patch.object(claw_mod, "get_config_path", return_value=config_path),
|
||||
patch.object(claw_mod, "prompt_yes_no") as mock_prompt,
|
||||
):
|
||||
claw_mod._cmd_migrate(args)
|
||||
|
||||
mock_prompt.assert_not_called()
|
||||
|
||||
def test_handles_migration_error(self, tmp_path, capsys):
|
||||
openclaw_dir = tmp_path / ".openclaw"
|
||||
openclaw_dir.mkdir()
|
||||
config_path = tmp_path / "config.yaml"
|
||||
config_path.write_text("")
|
||||
|
||||
args = Namespace(
|
||||
source=str(openclaw_dir),
|
||||
dry_run=True, preset="full", overwrite=False,
|
||||
migrate_secrets=False, workspace_target=None,
|
||||
skill_conflict="skip", yes=False,
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
|
||||
patch.object(claw_mod, "_load_migration_module", side_effect=RuntimeError("boom")),
|
||||
patch.object(claw_mod, "get_config_path", return_value=config_path),
|
||||
patch.object(claw_mod, "save_config"),
|
||||
patch.object(claw_mod, "load_config", return_value={}),
|
||||
):
|
||||
claw_mod._cmd_migrate(args)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Could not load migration script" in captured.out
|
||||
|
||||
def test_full_preset_does_not_enable_secrets_silently(self, tmp_path, capsys):
|
||||
"""The 'full' preset must NOT auto-enable migrate_secrets.
|
||||
|
||||
Users have to opt in to secret import explicitly via --migrate-secrets,
|
||||
even under the 'full' preset. This mirrors OpenClaw's migrate-hermes
|
||||
posture (two-phase import) and prevents a 'full' run from silently
|
||||
copying API keys.
|
||||
"""
|
||||
openclaw_dir = tmp_path / ".openclaw"
|
||||
openclaw_dir.mkdir()
|
||||
|
||||
fake_mod = ModuleType("openclaw_to_hermes")
|
||||
fake_mod.resolve_selected_options = MagicMock(return_value=set())
|
||||
fake_migrator = MagicMock()
|
||||
fake_migrator.migrate.return_value = {
|
||||
"summary": {"migrated": 0, "skipped": 0, "conflict": 0, "error": 0},
|
||||
"items": [],
|
||||
}
|
||||
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
|
||||
|
||||
args = Namespace(
|
||||
source=str(openclaw_dir),
|
||||
dry_run=True, preset="full", overwrite=False,
|
||||
migrate_secrets=False, # Not explicitly set by user
|
||||
workspace_target=None,
|
||||
skill_conflict="skip", yes=False,
|
||||
no_backup=False,
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
|
||||
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
|
||||
patch.object(claw_mod, "get_config_path", return_value=tmp_path / "config.yaml"),
|
||||
patch.object(claw_mod, "save_config"),
|
||||
patch.object(claw_mod, "load_config", return_value={}),
|
||||
):
|
||||
claw_mod._cmd_migrate(args)
|
||||
|
||||
# Migrator should have been called with migrate_secrets=False — the
|
||||
# 'full' preset on its own no longer opts the user into secret import.
|
||||
call_kwargs = fake_mod.Migrator.call_args[1]
|
||||
assert call_kwargs["migrate_secrets"] is False
|
||||
|
||||
def test_full_preset_with_explicit_migrate_secrets_passes_through(self, tmp_path, capsys):
|
||||
"""Explicit --migrate-secrets still works under --preset full."""
|
||||
openclaw_dir = tmp_path / ".openclaw"
|
||||
openclaw_dir.mkdir()
|
||||
|
||||
fake_mod = ModuleType("openclaw_to_hermes")
|
||||
fake_mod.resolve_selected_options = MagicMock(return_value=set())
|
||||
fake_migrator = MagicMock()
|
||||
fake_migrator.migrate.return_value = {
|
||||
"summary": {"migrated": 0, "skipped": 0, "conflict": 0, "error": 0},
|
||||
"items": [],
|
||||
}
|
||||
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
|
||||
|
||||
args = Namespace(
|
||||
source=str(openclaw_dir),
|
||||
dry_run=True, preset="full", overwrite=False,
|
||||
migrate_secrets=True, # Explicitly requested
|
||||
workspace_target=None,
|
||||
skill_conflict="skip", yes=False,
|
||||
no_backup=False,
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
|
||||
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
|
||||
patch.object(claw_mod, "get_config_path", return_value=tmp_path / "config.yaml"),
|
||||
patch.object(claw_mod, "save_config"),
|
||||
patch.object(claw_mod, "load_config", return_value={}),
|
||||
):
|
||||
claw_mod._cmd_migrate(args)
|
||||
|
||||
call_kwargs = fake_mod.Migrator.call_args[1]
|
||||
assert call_kwargs["migrate_secrets"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _cmd_cleanup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCmdCleanup:
|
||||
"""Test the cleanup command handler."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_openclaw_running(self):
|
||||
with patch.object(claw_mod, "_detect_openclaw_processes", return_value=[]):
|
||||
yield
|
||||
|
||||
def test_no_dirs_found(self, tmp_path, capsys):
|
||||
args = Namespace(source=None, dry_run=False, yes=False)
|
||||
with patch.object(claw_mod, "_find_openclaw_dirs", return_value=[]):
|
||||
claw_mod._cmd_cleanup(args)
|
||||
captured = capsys.readouterr()
|
||||
assert "No OpenClaw directories found" in captured.out
|
||||
|
||||
def test_dry_run_lists_dirs(self, tmp_path, capsys):
|
||||
openclaw = tmp_path / ".openclaw"
|
||||
openclaw.mkdir()
|
||||
ws = openclaw / "workspace"
|
||||
ws.mkdir()
|
||||
(ws / "todo.json").write_text("{}")
|
||||
|
||||
args = Namespace(source=None, dry_run=True, yes=False)
|
||||
with patch.object(claw_mod, "_find_openclaw_dirs", return_value=[openclaw]):
|
||||
claw_mod._cmd_cleanup(args)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Would archive" in captured.out
|
||||
assert openclaw.is_dir() # Not actually archived
|
||||
|
||||
def test_archives_with_yes(self, tmp_path, capsys):
|
||||
openclaw = tmp_path / ".openclaw"
|
||||
openclaw.mkdir()
|
||||
(openclaw / "workspace").mkdir()
|
||||
(openclaw / "workspace" / "todo.json").write_text("{}")
|
||||
|
||||
args = Namespace(source=None, dry_run=False, yes=True)
|
||||
with patch.object(claw_mod, "_find_openclaw_dirs", return_value=[openclaw]):
|
||||
claw_mod._cmd_cleanup(args)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Archived" in captured.out
|
||||
assert "Cleaned up 1" in captured.out
|
||||
assert not openclaw.exists()
|
||||
assert (tmp_path / ".openclaw.pre-migration").is_dir()
|
||||
|
||||
def test_skips_when_user_declines(self, tmp_path, capsys):
|
||||
openclaw = tmp_path / ".openclaw"
|
||||
openclaw.mkdir()
|
||||
|
||||
mock_stdin = MagicMock()
|
||||
mock_stdin.isatty.return_value = True
|
||||
|
||||
args = Namespace(source=None, dry_run=False, yes=False)
|
||||
with (
|
||||
patch.object(claw_mod, "_find_openclaw_dirs", return_value=[openclaw]),
|
||||
patch.object(claw_mod, "prompt_yes_no", return_value=False),
|
||||
patch("sys.stdin", mock_stdin),
|
||||
):
|
||||
claw_mod._cmd_cleanup(args)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Skipped" in captured.out
|
||||
assert openclaw.is_dir()
|
||||
|
||||
def test_explicit_source(self, tmp_path, capsys):
|
||||
custom_dir = tmp_path / "my-openclaw"
|
||||
custom_dir.mkdir()
|
||||
(custom_dir / "todo.json").write_text("{}")
|
||||
|
||||
args = Namespace(source=str(custom_dir), dry_run=False, yes=True)
|
||||
claw_mod._cmd_cleanup(args)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Archived" in captured.out
|
||||
assert not custom_dir.exists()
|
||||
|
||||
def test_shows_workspace_details(self, tmp_path, capsys):
|
||||
openclaw = tmp_path / ".openclaw"
|
||||
openclaw.mkdir()
|
||||
ws = openclaw / "workspace"
|
||||
ws.mkdir()
|
||||
(ws / "todo.json").write_text("{}")
|
||||
(ws / "SOUL.md").write_text("# Soul")
|
||||
|
||||
args = Namespace(source=None, dry_run=True, yes=False)
|
||||
with patch.object(claw_mod, "_find_openclaw_dirs", return_value=[openclaw]):
|
||||
claw_mod._cmd_cleanup(args)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "workspace/" in captured.out
|
||||
assert "todo.json" in captured.out
|
||||
|
||||
def test_handles_multiple_dirs(self, tmp_path, capsys):
|
||||
openclaw = tmp_path / ".openclaw"
|
||||
openclaw.mkdir()
|
||||
clawdbot = tmp_path / ".clawdbot"
|
||||
clawdbot.mkdir()
|
||||
|
||||
args = Namespace(source=None, dry_run=False, yes=True)
|
||||
with patch.object(claw_mod, "_find_openclaw_dirs", return_value=[openclaw, clawdbot]):
|
||||
claw_mod._cmd_cleanup(args)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Cleaned up 2" in captured.out
|
||||
assert not openclaw.exists()
|
||||
assert not clawdbot.exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _print_migration_report
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPrintMigrationReport:
|
||||
"""Test the report formatting function."""
|
||||
|
||||
def test_dry_run_report(self, capsys):
|
||||
report = {
|
||||
"summary": {"migrated": 2, "skipped": 1, "conflict": 1, "error": 0},
|
||||
"items": [
|
||||
{"kind": "soul", "status": "migrated", "destination": "/home/user/.hermes/SOUL.md"},
|
||||
{"kind": "memory", "status": "migrated", "destination": "/home/user/.hermes/memories/MEMORY.md"},
|
||||
{"kind": "skills", "status": "conflict", "reason": "already exists"},
|
||||
{"kind": "tts-assets", "status": "skipped", "reason": "not found"},
|
||||
],
|
||||
"preset": "full",
|
||||
}
|
||||
claw_mod._print_migration_report(report, dry_run=True)
|
||||
captured = capsys.readouterr()
|
||||
assert "Dry Run Results" in captured.out
|
||||
assert "Would migrate" in captured.out
|
||||
assert "2 would migrate" in captured.out
|
||||
assert "--dry-run" in captured.out
|
||||
|
||||
def test_execute_report(self, capsys):
|
||||
report = {
|
||||
"summary": {"migrated": 3, "skipped": 0, "conflict": 0, "error": 0},
|
||||
"items": [
|
||||
{"kind": "soul", "status": "migrated", "destination": "/home/user/.hermes/SOUL.md"},
|
||||
],
|
||||
"output_dir": "/home/user/.hermes/migration/openclaw/20250312T120000",
|
||||
}
|
||||
claw_mod._print_migration_report(report, dry_run=False)
|
||||
captured = capsys.readouterr()
|
||||
assert "Migration Results" in captured.out
|
||||
assert "Migrated" in captured.out
|
||||
assert "Full report saved to" in captured.out
|
||||
|
||||
def test_empty_report(self, capsys):
|
||||
report = {
|
||||
"summary": {"migrated": 0, "skipped": 0, "conflict": 0, "error": 0},
|
||||
"items": [],
|
||||
}
|
||||
claw_mod._print_migration_report(report, dry_run=False)
|
||||
captured = capsys.readouterr()
|
||||
assert "Nothing to migrate" in captured.out
|
||||
|
||||
|
||||
class TestDetectOpenclawProcesses:
|
||||
def test_returns_match_when_pgrep_finds_openclaw(self):
|
||||
with patch.object(claw_mod, "sys") as mock_sys:
|
||||
mock_sys.platform = "linux"
|
||||
with patch.object(claw_mod, "subprocess") as mock_subprocess:
|
||||
# systemd check misses, pgrep finds openclaw
|
||||
mock_subprocess.run.side_effect = [
|
||||
MagicMock(returncode=1, stdout=""), # systemctl
|
||||
MagicMock(returncode=0, stdout="1234\n"), # pgrep
|
||||
]
|
||||
mock_subprocess.TimeoutExpired = subprocess.TimeoutExpired
|
||||
result = claw_mod._detect_openclaw_processes()
|
||||
assert len(result) == 1
|
||||
assert "1234" in result[0]
|
||||
|
||||
def test_returns_empty_when_pgrep_finds_nothing(self):
|
||||
with patch.object(claw_mod, "sys") as mock_sys:
|
||||
mock_sys.platform = "darwin"
|
||||
with patch.object(claw_mod, "subprocess") as mock_subprocess:
|
||||
mock_subprocess.run.side_effect = [
|
||||
MagicMock(returncode=1, stdout=""), # systemctl (not found)
|
||||
MagicMock(returncode=1, stdout=""), # pgrep
|
||||
]
|
||||
mock_subprocess.TimeoutExpired = subprocess.TimeoutExpired
|
||||
result = claw_mod._detect_openclaw_processes()
|
||||
assert result == []
|
||||
|
||||
def test_detects_systemd_service(self):
|
||||
with patch.object(claw_mod, "sys") as mock_sys:
|
||||
mock_sys.platform = "linux"
|
||||
with patch.object(claw_mod, "subprocess") as mock_subprocess:
|
||||
mock_subprocess.run.side_effect = [
|
||||
MagicMock(returncode=0, stdout="active\n"), # systemctl
|
||||
MagicMock(returncode=1, stdout=""), # pgrep
|
||||
]
|
||||
mock_subprocess.TimeoutExpired = subprocess.TimeoutExpired
|
||||
result = claw_mod._detect_openclaw_processes()
|
||||
assert len(result) == 1
|
||||
assert "systemd" in result[0]
|
||||
|
||||
def test_returns_match_on_windows_when_openclaw_exe_running(self):
|
||||
with patch.object(claw_mod, "sys") as mock_sys:
|
||||
mock_sys.platform = "win32"
|
||||
with patch.object(claw_mod, "subprocess") as mock_subprocess:
|
||||
mock_subprocess.run.side_effect = [
|
||||
MagicMock(returncode=0, stdout="openclaw.exe 1234 Console 1 45,056 K\n"),
|
||||
]
|
||||
result = claw_mod._detect_openclaw_processes()
|
||||
assert len(result) >= 1
|
||||
assert any("openclaw.exe" in r for r in result)
|
||||
|
||||
def test_returns_match_on_windows_when_node_exe_has_openclaw_in_cmdline(self):
|
||||
with patch.object(claw_mod, "sys") as mock_sys:
|
||||
mock_sys.platform = "win32"
|
||||
with patch.object(claw_mod, "subprocess") as mock_subprocess:
|
||||
mock_subprocess.run.side_effect = [
|
||||
MagicMock(returncode=0, stdout=""), # tasklist openclaw.exe
|
||||
MagicMock(returncode=0, stdout=""), # tasklist clawd.exe
|
||||
MagicMock(returncode=0, stdout="1234\n"), # PowerShell
|
||||
]
|
||||
result = claw_mod._detect_openclaw_processes()
|
||||
assert len(result) >= 1
|
||||
assert any("node.exe" in r for r in result)
|
||||
|
||||
def test_returns_empty_on_windows_when_nothing_found(self):
|
||||
with patch.object(claw_mod, "sys") as mock_sys:
|
||||
mock_sys.platform = "win32"
|
||||
with patch.object(claw_mod, "subprocess") as mock_subprocess:
|
||||
mock_subprocess.run.side_effect = [
|
||||
MagicMock(returncode=0, stdout=""),
|
||||
MagicMock(returncode=0, stdout=""),
|
||||
MagicMock(returncode=0, stdout=""),
|
||||
]
|
||||
result = claw_mod._detect_openclaw_processes()
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestWarnIfOpenclawRunning:
|
||||
def test_noop_when_not_running(self, capsys):
|
||||
with patch.object(claw_mod, "_detect_openclaw_processes", return_value=[]):
|
||||
claw_mod._warn_if_openclaw_running(auto_yes=False)
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == ""
|
||||
|
||||
def test_warns_and_exits_when_running_and_user_declines(self, capsys):
|
||||
with patch.object(claw_mod, "_detect_openclaw_processes", return_value=["openclaw process(es) (PIDs: 1234)"]):
|
||||
with patch.object(claw_mod, "prompt_yes_no", return_value=False):
|
||||
with patch.object(claw_mod.sys.stdin, "isatty", return_value=True):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
claw_mod._warn_if_openclaw_running(auto_yes=False)
|
||||
assert exc_info.value.code == 0
|
||||
captured = capsys.readouterr()
|
||||
assert "OpenClaw appears to be running" in captured.out
|
||||
|
||||
def test_warns_and_continues_when_running_and_user_accepts(self, capsys):
|
||||
with patch.object(claw_mod, "_detect_openclaw_processes", return_value=["openclaw process(es) (PIDs: 1234)"]):
|
||||
with patch.object(claw_mod, "prompt_yes_no", return_value=True):
|
||||
with patch.object(claw_mod.sys.stdin, "isatty", return_value=True):
|
||||
claw_mod._warn_if_openclaw_running(auto_yes=False)
|
||||
captured = capsys.readouterr()
|
||||
assert "OpenClaw appears to be running" in captured.out
|
||||
|
||||
def test_warns_and_continues_in_auto_yes_mode(self, capsys):
|
||||
with patch.object(claw_mod, "_detect_openclaw_processes", return_value=["openclaw process(es) (PIDs: 1234)"]):
|
||||
claw_mod._warn_if_openclaw_running(auto_yes=True)
|
||||
captured = capsys.readouterr()
|
||||
assert "OpenClaw appears to be running" in captured.out
|
||||
|
||||
def test_warns_and_continues_in_non_interactive_session(self, capsys):
|
||||
with patch.object(claw_mod, "_detect_openclaw_processes", return_value=["openclaw process(es) (PIDs: 1234)"]):
|
||||
with patch.object(claw_mod.sys.stdin, "isatty", return_value=False):
|
||||
claw_mod._warn_if_openclaw_running(auto_yes=False)
|
||||
captured = capsys.readouterr()
|
||||
assert "OpenClaw appears to be running" in captured.out
|
||||
assert "Non-interactive session" in captured.out
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Tests for _clear_stale_openai_base_url() cleanup after provider switch (#5161)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from hermes_cli.config import load_config, save_config, save_env_value, get_env_value
|
||||
|
||||
|
||||
def _write_provider(provider: str, model: str = "test-model"):
|
||||
"""Helper: write a provider + model to config.yaml."""
|
||||
cfg = load_config()
|
||||
model_cfg = cfg.get("model", {})
|
||||
if not isinstance(model_cfg, dict):
|
||||
model_cfg = {}
|
||||
model_cfg["provider"] = provider
|
||||
model_cfg["default"] = model
|
||||
cfg["model"] = model_cfg
|
||||
save_config(cfg)
|
||||
|
||||
|
||||
class TestClearStaleOpenaiBaseUrl:
|
||||
"""_clear_stale_openai_base_url() removes OPENAI_BASE_URL when provider is not custom."""
|
||||
|
||||
def test_clears_when_provider_is_named(self, monkeypatch):
|
||||
"""OPENAI_BASE_URL is cleared when config provider is a named provider."""
|
||||
from hermes_cli.main import _clear_stale_openai_base_url
|
||||
|
||||
_write_provider("openrouter")
|
||||
save_env_value("OPENAI_BASE_URL", "http://localhost:11434/v1")
|
||||
|
||||
_clear_stale_openai_base_url()
|
||||
|
||||
result = get_env_value("OPENAI_BASE_URL")
|
||||
assert not result, f"Expected OPENAI_BASE_URL to be cleared, got: {result!r}"
|
||||
|
||||
def test_preserves_when_provider_is_custom(self, monkeypatch):
|
||||
"""OPENAI_BASE_URL is NOT cleared when config provider is 'custom'."""
|
||||
from hermes_cli.main import _clear_stale_openai_base_url
|
||||
|
||||
_write_provider("custom")
|
||||
save_env_value("OPENAI_BASE_URL", "http://localhost:11434/v1")
|
||||
|
||||
_clear_stale_openai_base_url()
|
||||
|
||||
result = get_env_value("OPENAI_BASE_URL")
|
||||
assert result == "http://localhost:11434/v1", \
|
||||
f"Expected OPENAI_BASE_URL to be preserved, got: {result!r}"
|
||||
|
||||
def test_noop_when_no_openai_base_url(self, monkeypatch):
|
||||
"""No error when OPENAI_BASE_URL is not set."""
|
||||
from hermes_cli.main import _clear_stale_openai_base_url
|
||||
|
||||
_write_provider("openrouter")
|
||||
# Ensure it's not set
|
||||
save_env_value("OPENAI_BASE_URL", "")
|
||||
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
||||
|
||||
# Should not raise
|
||||
_clear_stale_openai_base_url()
|
||||
|
||||
def test_noop_when_provider_empty(self, monkeypatch):
|
||||
"""No cleanup when provider is not set in config."""
|
||||
from hermes_cli.main import _clear_stale_openai_base_url
|
||||
|
||||
cfg = load_config()
|
||||
cfg.pop("model", None)
|
||||
save_config(cfg)
|
||||
save_env_value("OPENAI_BASE_URL", "http://localhost:11434/v1")
|
||||
|
||||
_clear_stale_openai_base_url()
|
||||
|
||||
result = get_env_value("OPENAI_BASE_URL")
|
||||
assert result == "http://localhost:11434/v1", \
|
||||
"Should not clear when provider is not configured"
|
||||
@@ -0,0 +1,20 @@
|
||||
from hermes_cli import cli_output
|
||||
|
||||
|
||||
def test_password_prompt_uses_masked_secret_prompt(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def fake_masked_secret_prompt(display):
|
||||
seen["display"] = display
|
||||
return " secret "
|
||||
|
||||
monkeypatch.setattr(cli_output, "masked_secret_prompt", fake_masked_secret_prompt)
|
||||
|
||||
assert cli_output.prompt("API key", default="old", password=True) == "secret"
|
||||
assert "API key [old]" in seen["display"]
|
||||
|
||||
|
||||
def test_empty_password_prompt_returns_default(monkeypatch):
|
||||
monkeypatch.setattr(cli_output, "masked_secret_prompt", lambda _display: "")
|
||||
|
||||
assert cli_output.prompt("API key", default="old", password=True) == "old"
|
||||
@@ -0,0 +1,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"]
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
@@ -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}"
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Regression tests for removed dead config keys.
|
||||
|
||||
This file guards against accidental re-introduction of config keys that were
|
||||
documented or declared at some point but never actually wired up to read code.
|
||||
Future dead-config regressions can accumulate here.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
|
||||
|
||||
def test_delegation_default_toolsets_removed_from_cli_config():
|
||||
"""delegation.default_toolsets was dead config — never read by
|
||||
_load_config() or anywhere else. Removed.
|
||||
|
||||
Guards against accidental re-introduction in cli.py's CLI_CONFIG default
|
||||
dict. If this test fails, someone re-added the key without wiring it up
|
||||
to _load_config() in tools/delegate_tool.py.
|
||||
|
||||
We inspect the source of load_cli_config() instead of asserting on the
|
||||
runtime CLI_CONFIG dict because CLI_CONFIG is populated by deep-merging
|
||||
the user's ~/.hermes/config.yaml over the defaults (cli.py:359-366).
|
||||
A contributor who still has the legacy key set in their own config
|
||||
would cause a false failure, and HERMES_HOME patching via conftest
|
||||
doesn't help because cli._hermes_home is frozen at module import time
|
||||
(cli.py:76) — before any autouse fixture can fire. Source inspection
|
||||
sidesteps all of that: it tests the defaults literal directly.
|
||||
"""
|
||||
from cli import load_cli_config
|
||||
|
||||
source = inspect.getsource(load_cli_config)
|
||||
assert '"default_toolsets"' not in source, (
|
||||
"delegation.default_toolsets was removed because it was never read. "
|
||||
"Do not re-add it to cli.py's CLI_CONFIG default dict; "
|
||||
"use tools/delegate_tool.py's DEFAULT_TOOLSETS module constant or "
|
||||
"wire a new config key through _load_config()."
|
||||
)
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Tests for ${ENV_VAR} substitution in config.yaml values."""
|
||||
|
||||
import pytest
|
||||
from hermes_cli.config import _expand_env_vars, load_config
|
||||
|
||||
|
||||
class TestExpandEnvVars:
|
||||
def test_simple_substitution(self):
|
||||
with pytest.MonkeyPatch().context() as mp:
|
||||
mp.setenv("MY_KEY", "secret123")
|
||||
assert _expand_env_vars("${MY_KEY}") == "secret123"
|
||||
|
||||
def test_missing_var_kept_verbatim(self):
|
||||
with pytest.MonkeyPatch().context() as mp:
|
||||
mp.delenv("UNDEFINED_VAR_XYZ", raising=False)
|
||||
assert _expand_env_vars("${UNDEFINED_VAR_XYZ}") == "${UNDEFINED_VAR_XYZ}"
|
||||
|
||||
def test_no_placeholder_unchanged(self):
|
||||
assert _expand_env_vars("plain-value") == "plain-value"
|
||||
|
||||
def test_dict_recursive(self):
|
||||
with pytest.MonkeyPatch().context() as mp:
|
||||
mp.setenv("TOKEN", "tok-abc")
|
||||
result = _expand_env_vars({"key": "${TOKEN}", "other": "literal"})
|
||||
assert result == {"key": "tok-abc", "other": "literal"}
|
||||
|
||||
def test_nested_dict(self):
|
||||
with pytest.MonkeyPatch().context() as mp:
|
||||
mp.setenv("API_KEY", "sk-xyz")
|
||||
result = _expand_env_vars({"model": {"api_key": "${API_KEY}"}})
|
||||
assert result["model"]["api_key"] == "sk-xyz"
|
||||
|
||||
def test_list_items(self):
|
||||
with pytest.MonkeyPatch().context() as mp:
|
||||
mp.setenv("VAL", "hello")
|
||||
result = _expand_env_vars(["${VAL}", "literal", 42])
|
||||
assert result == ["hello", "literal", 42]
|
||||
|
||||
def test_non_string_values_untouched(self):
|
||||
assert _expand_env_vars(42) == 42
|
||||
assert _expand_env_vars(3.14) == 3.14
|
||||
assert _expand_env_vars(True) is True
|
||||
assert _expand_env_vars(None) is None
|
||||
|
||||
def test_multiple_placeholders_in_one_string(self):
|
||||
with pytest.MonkeyPatch().context() as mp:
|
||||
mp.setenv("HOST", "localhost")
|
||||
mp.setenv("PORT", "5432")
|
||||
assert _expand_env_vars("${HOST}:${PORT}") == "localhost:5432"
|
||||
|
||||
def test_dict_keys_not_expanded(self):
|
||||
with pytest.MonkeyPatch().context() as mp:
|
||||
mp.setenv("KEY", "value")
|
||||
result = _expand_env_vars({"${KEY}": "no-expand-key"})
|
||||
assert "${KEY}" in result
|
||||
|
||||
|
||||
class TestLoadConfigExpansion:
|
||||
def test_load_config_expands_env_vars(self, tmp_path, monkeypatch):
|
||||
config_yaml = (
|
||||
"model:\n"
|
||||
" api_key: ${GOOGLE_API_KEY}\n"
|
||||
"platforms:\n"
|
||||
" telegram:\n"
|
||||
" token: ${TELEGRAM_BOT_TOKEN}\n"
|
||||
"plain: no-substitution\n"
|
||||
)
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text(config_yaml)
|
||||
|
||||
monkeypatch.setenv("GOOGLE_API_KEY", "gsk-test-key")
|
||||
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "1234567:ABC-token")
|
||||
# Patch the imported function's own globals. Other tests may reload
|
||||
# hermes_cli.config, making string-target monkeypatches hit a different
|
||||
# module object than this collection-time imported load_config().
|
||||
monkeypatch.setitem(load_config.__globals__, "get_config_path", lambda: config_file)
|
||||
|
||||
config = load_config()
|
||||
|
||||
assert config["model"]["api_key"] == "gsk-test-key"
|
||||
assert config["platforms"]["telegram"]["token"] == "1234567:ABC-token"
|
||||
assert config["plain"] == "no-substitution"
|
||||
|
||||
def test_load_config_unresolved_kept_verbatim(self, tmp_path, monkeypatch):
|
||||
config_yaml = "model:\n api_key: ${NOT_SET_XYZ_123}\n"
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text(config_yaml)
|
||||
|
||||
monkeypatch.delenv("NOT_SET_XYZ_123", raising=False)
|
||||
monkeypatch.setitem(load_config.__globals__, "get_config_path", lambda: config_file)
|
||||
|
||||
config = load_config()
|
||||
|
||||
assert config["model"]["api_key"] == "${NOT_SET_XYZ_123}"
|
||||
|
||||
|
||||
class TestLoadCliConfigExpansion:
|
||||
"""Verify that load_cli_config() also expands ${VAR} references."""
|
||||
|
||||
def test_cli_config_expands_auxiliary_api_key(self, tmp_path, monkeypatch):
|
||||
config_yaml = (
|
||||
"auxiliary:\n"
|
||||
" vision:\n"
|
||||
" api_key: ${TEST_VISION_KEY_XYZ}\n"
|
||||
)
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text(config_yaml)
|
||||
|
||||
monkeypatch.setenv("TEST_VISION_KEY_XYZ", "vis-key-123")
|
||||
# Patch the hermes home so load_cli_config finds our test config
|
||||
monkeypatch.setattr("cli._hermes_home", tmp_path)
|
||||
|
||||
from cli import load_cli_config
|
||||
config = load_cli_config()
|
||||
|
||||
assert config["auxiliary"]["vision"]["api_key"] == "vis-key-123"
|
||||
|
||||
def test_cli_config_unresolved_kept_verbatim(self, tmp_path, monkeypatch):
|
||||
config_yaml = (
|
||||
"auxiliary:\n"
|
||||
" vision:\n"
|
||||
" api_key: ${UNSET_CLI_VAR_ABC}\n"
|
||||
)
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text(config_yaml)
|
||||
|
||||
monkeypatch.delenv("UNSET_CLI_VAR_ABC", raising=False)
|
||||
monkeypatch.setattr("cli._hermes_home", tmp_path)
|
||||
|
||||
from cli import load_cli_config
|
||||
config = load_cli_config()
|
||||
|
||||
assert config["auxiliary"]["vision"]["api_key"] == "${UNSET_CLI_VAR_ABC}"
|
||||
@@ -0,0 +1,169 @@
|
||||
import textwrap
|
||||
|
||||
from hermes_cli.config import load_config, save_config
|
||||
|
||||
|
||||
def _write_config(tmp_path, body: str):
|
||||
(tmp_path / "config.yaml").write_text(textwrap.dedent(body), encoding="utf-8")
|
||||
|
||||
|
||||
def _read_config(tmp_path) -> str:
|
||||
return (tmp_path / "config.yaml").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_save_config_preserves_env_refs_on_unrelated_change(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("TU_ZI_API_KEY", "sk-realsecret")
|
||||
monkeypatch.setenv("ALT_SECRET", "alt-secret")
|
||||
_write_config(
|
||||
tmp_path,
|
||||
"""\
|
||||
custom_providers:
|
||||
- name: tuzi
|
||||
base_url: https://api.tu-zi.com
|
||||
api_key: ${TU_ZI_API_KEY}
|
||||
headers:
|
||||
Authorization: Bearer ${ALT_SECRET}
|
||||
model: claude-opus-4-6
|
||||
model:
|
||||
default: claude-opus-4-6
|
||||
""",
|
||||
)
|
||||
|
||||
config = load_config()
|
||||
config["model"]["default"] = "doubao-pro"
|
||||
save_config(config)
|
||||
|
||||
saved = _read_config(tmp_path)
|
||||
assert "api_key: ${TU_ZI_API_KEY}" in saved
|
||||
assert "Authorization: Bearer ${ALT_SECRET}" in saved
|
||||
assert "sk-realsecret" not in saved
|
||||
assert "alt-secret" not in saved
|
||||
|
||||
|
||||
def test_save_config_preserves_unresolved_env_refs(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.delenv("MISSING_SECRET", raising=False)
|
||||
_write_config(
|
||||
tmp_path,
|
||||
"""\
|
||||
custom_providers:
|
||||
- name: unresolved
|
||||
api_key: ${MISSING_SECRET}
|
||||
model: claude-opus-4-6
|
||||
model:
|
||||
default: claude-opus-4-6
|
||||
""",
|
||||
)
|
||||
|
||||
config = load_config()
|
||||
config["display"]["compact"] = True
|
||||
save_config(config)
|
||||
|
||||
assert "api_key: ${MISSING_SECRET}" in _read_config(tmp_path)
|
||||
|
||||
|
||||
def test_save_config_allows_intentional_secret_value_change(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("TU_ZI_API_KEY", "sk-old-secret")
|
||||
_write_config(
|
||||
tmp_path,
|
||||
"""\
|
||||
custom_providers:
|
||||
- name: tuzi
|
||||
api_key: ${TU_ZI_API_KEY}
|
||||
model: claude-opus-4-6
|
||||
model:
|
||||
default: claude-opus-4-6
|
||||
""",
|
||||
)
|
||||
|
||||
config = load_config()
|
||||
config["custom_providers"][0]["api_key"] = "sk-new-secret"
|
||||
save_config(config)
|
||||
|
||||
saved = _read_config(tmp_path)
|
||||
assert "api_key: sk-new-secret" in saved
|
||||
assert "${TU_ZI_API_KEY}" not in saved
|
||||
|
||||
|
||||
def test_save_config_preserves_template_when_env_rotates_after_load(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("TU_ZI_API_KEY", "sk-old-secret")
|
||||
_write_config(
|
||||
tmp_path,
|
||||
"""\
|
||||
custom_providers:
|
||||
- name: tuzi
|
||||
api_key: ${TU_ZI_API_KEY}
|
||||
model: claude-opus-4-6
|
||||
model:
|
||||
default: claude-opus-4-6
|
||||
""",
|
||||
)
|
||||
|
||||
config = load_config()
|
||||
monkeypatch.setenv("TU_ZI_API_KEY", "sk-rotated-secret")
|
||||
config["model"]["default"] = "doubao-pro"
|
||||
save_config(config)
|
||||
|
||||
saved = _read_config(tmp_path)
|
||||
assert "api_key: ${TU_ZI_API_KEY}" in saved
|
||||
assert "sk-old-secret" not in saved
|
||||
assert "sk-rotated-secret" not in saved
|
||||
|
||||
|
||||
def test_save_config_keeps_edited_partial_template_strings_literal(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("ALT_SECRET", "alt-secret")
|
||||
_write_config(
|
||||
tmp_path,
|
||||
"""\
|
||||
custom_providers:
|
||||
- name: tuzi
|
||||
headers:
|
||||
Authorization: Bearer ${ALT_SECRET}
|
||||
model: claude-opus-4-6
|
||||
model:
|
||||
default: claude-opus-4-6
|
||||
""",
|
||||
)
|
||||
|
||||
config = load_config()
|
||||
config["custom_providers"][0]["headers"]["Authorization"] = "Token alt-secret"
|
||||
save_config(config)
|
||||
|
||||
saved = _read_config(tmp_path)
|
||||
assert "Authorization: Token alt-secret" in saved
|
||||
assert "Authorization: Bearer ${ALT_SECRET}" not in saved
|
||||
|
||||
|
||||
def test_save_config_falls_back_to_positional_matching_for_duplicate_names(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("FIRST_SECRET", "first-secret")
|
||||
monkeypatch.setenv("SECOND_SECRET", "second-secret")
|
||||
_write_config(
|
||||
tmp_path,
|
||||
"""\
|
||||
custom_providers:
|
||||
- name: duplicate
|
||||
api_key: ${FIRST_SECRET}
|
||||
model: claude-opus-4-6
|
||||
- name: duplicate
|
||||
api_key: ${SECOND_SECRET}
|
||||
model: doubao-pro
|
||||
model:
|
||||
default: claude-opus-4-6
|
||||
""",
|
||||
)
|
||||
|
||||
config = load_config()
|
||||
config["display"]["compact"] = True
|
||||
save_config(config)
|
||||
|
||||
saved = _read_config(tmp_path)
|
||||
assert saved.count("name: duplicate") == 2
|
||||
assert "api_key: ${FIRST_SECRET}" in saved
|
||||
assert "api_key: ${SECOND_SECRET}" in saved
|
||||
assert "first-secret" not in saved
|
||||
assert "second-secret" not in saved
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Tests for config.yaml structure validation (validate_config_structure)."""
|
||||
|
||||
|
||||
from hermes_cli.config import validate_config_structure, ConfigIssue
|
||||
|
||||
|
||||
class TestCustomProvidersValidation:
|
||||
"""custom_providers must be a YAML list, not a dict."""
|
||||
|
||||
def test_dict_instead_of_list(self):
|
||||
"""The exact Discord user scenario — custom_providers as flat dict."""
|
||||
issues = validate_config_structure({
|
||||
"custom_providers": {
|
||||
"name": "Generativelanguage.googleapis.com",
|
||||
"base_url": "https://generativelanguage.googleapis.com/v1beta",
|
||||
"api_key": "xxx",
|
||||
"model": "models/gemini-2.5-flash",
|
||||
"rate_limit_delay": 2.0,
|
||||
"fallback_model": {
|
||||
"provider": "openrouter",
|
||||
"model": "qwen/qwen3.6-plus:free",
|
||||
},
|
||||
},
|
||||
"fallback_providers": [],
|
||||
})
|
||||
errors = [i for i in issues if i.severity == "error"]
|
||||
assert any("dict" in i.message and "list" in i.message for i in errors), (
|
||||
"Should detect custom_providers as dict instead of list"
|
||||
)
|
||||
|
||||
def test_dict_detects_misplaced_fields(self):
|
||||
"""When custom_providers is a dict, detect fields that look misplaced."""
|
||||
issues = validate_config_structure({
|
||||
"custom_providers": {
|
||||
"name": "test",
|
||||
"base_url": "https://example.com",
|
||||
"api_key": "xxx",
|
||||
},
|
||||
})
|
||||
warnings = [i for i in issues if i.severity == "warning"]
|
||||
# Should flag base_url, api_key as looking like custom_providers entry fields
|
||||
misplaced = [i for i in warnings if "custom_providers entry fields" in i.message]
|
||||
assert len(misplaced) == 1
|
||||
|
||||
def test_dict_detects_nested_fallback(self):
|
||||
"""When fallback_model gets swallowed into custom_providers dict."""
|
||||
issues = validate_config_structure({
|
||||
"custom_providers": {
|
||||
"name": "test",
|
||||
"fallback_model": {"provider": "openrouter", "model": "test"},
|
||||
},
|
||||
})
|
||||
errors = [i for i in issues if i.severity == "error"]
|
||||
assert any("fallback_model" in i.message and "inside" in i.message for i in errors)
|
||||
|
||||
def test_valid_list_no_issues(self):
|
||||
"""Properly formatted custom_providers should produce no issues."""
|
||||
issues = validate_config_structure({
|
||||
"custom_providers": [
|
||||
{"name": "gemini", "base_url": "https://example.com/v1"},
|
||||
],
|
||||
"model": {"provider": "custom", "default": "test"},
|
||||
})
|
||||
assert len(issues) == 0
|
||||
|
||||
def test_list_entry_missing_name(self):
|
||||
"""List entry without name should warn."""
|
||||
issues = validate_config_structure({
|
||||
"custom_providers": [{"base_url": "https://example.com/v1"}],
|
||||
"model": {"provider": "custom"},
|
||||
})
|
||||
assert any("missing 'name'" in i.message for i in issues)
|
||||
|
||||
def test_list_entry_missing_base_url(self):
|
||||
"""List entry without base_url should warn."""
|
||||
issues = validate_config_structure({
|
||||
"custom_providers": [{"name": "test"}],
|
||||
"model": {"provider": "custom"},
|
||||
})
|
||||
assert any("missing 'base_url'" in i.message for i in issues)
|
||||
|
||||
def test_list_entry_not_dict(self):
|
||||
"""Non-dict list entries should warn."""
|
||||
issues = validate_config_structure({
|
||||
"custom_providers": ["not-a-dict"],
|
||||
"model": {"provider": "custom"},
|
||||
})
|
||||
assert any("not a dict" in i.message for i in issues)
|
||||
|
||||
def test_none_custom_providers_no_issues(self):
|
||||
"""No custom_providers at all should be fine."""
|
||||
issues = validate_config_structure({
|
||||
"model": {"provider": "openrouter"},
|
||||
})
|
||||
assert len(issues) == 0
|
||||
|
||||
|
||||
class TestFallbackModelValidation:
|
||||
"""fallback_model should be a top-level dict with provider + model."""
|
||||
|
||||
def test_missing_provider(self):
|
||||
issues = validate_config_structure({
|
||||
"fallback_model": {"model": "anthropic/claude-sonnet-4"},
|
||||
})
|
||||
assert any("missing 'provider'" in i.message for i in issues)
|
||||
|
||||
def test_missing_model(self):
|
||||
issues = validate_config_structure({
|
||||
"fallback_model": {"provider": "openrouter"},
|
||||
})
|
||||
assert any("missing 'model'" in i.message for i in issues)
|
||||
|
||||
def test_valid_fallback(self):
|
||||
issues = validate_config_structure({
|
||||
"fallback_model": {
|
||||
"provider": "openrouter",
|
||||
"model": "anthropic/claude-sonnet-4",
|
||||
},
|
||||
})
|
||||
# Only fallback-related issues should be absent
|
||||
fb_issues = [i for i in issues if "fallback" in i.message.lower()]
|
||||
assert len(fb_issues) == 0
|
||||
|
||||
def test_non_dict_fallback(self):
|
||||
issues = validate_config_structure({
|
||||
"fallback_model": "openrouter:anthropic/claude-sonnet-4",
|
||||
})
|
||||
assert any("should be a dict" in i.message for i in issues)
|
||||
|
||||
def test_empty_fallback_dict_no_issues(self):
|
||||
"""Empty fallback_model dict means disabled — no warnings needed."""
|
||||
issues = validate_config_structure({
|
||||
"fallback_model": {},
|
||||
})
|
||||
fb_issues = [i for i in issues if "fallback" in i.message.lower()]
|
||||
assert len(fb_issues) == 0
|
||||
|
||||
def test_valid_fallback_list(self):
|
||||
"""List-form fallback_model (chain) should validate when every entry has provider+model."""
|
||||
issues = validate_config_structure({
|
||||
"fallback_model": [
|
||||
{"provider": "openrouter", "model": "anthropic/claude-sonnet-4"},
|
||||
{"provider": "anthropic", "model": "claude-sonnet-4-6"},
|
||||
],
|
||||
})
|
||||
fb_issues = [i for i in issues if "fallback" in i.message.lower()]
|
||||
assert len(fb_issues) == 0
|
||||
|
||||
def test_fallback_list_entry_missing_provider(self):
|
||||
issues = validate_config_structure({
|
||||
"fallback_model": [
|
||||
{"provider": "openrouter", "model": "anthropic/claude-sonnet-4"},
|
||||
{"model": "claude-sonnet-4-6"},
|
||||
],
|
||||
})
|
||||
assert any("fallback_model[1]" in i.message and "provider" in i.message for i in issues)
|
||||
|
||||
def test_fallback_list_entry_missing_model(self):
|
||||
issues = validate_config_structure({
|
||||
"fallback_model": [
|
||||
{"provider": "openrouter"},
|
||||
],
|
||||
})
|
||||
assert any("fallback_model[0]" in i.message and "model" in i.message for i in issues)
|
||||
|
||||
def test_fallback_list_entry_not_a_dict(self):
|
||||
issues = validate_config_structure({
|
||||
"fallback_model": ["openrouter:anthropic/claude-sonnet-4"],
|
||||
})
|
||||
assert any("fallback_model[0]" in i.message and "should be a dict" in i.message for i in issues)
|
||||
|
||||
|
||||
class TestMissingModelSection:
|
||||
"""Warn when custom_providers exists but model section is missing."""
|
||||
|
||||
def test_custom_providers_without_model(self):
|
||||
issues = validate_config_structure({
|
||||
"custom_providers": [
|
||||
{"name": "test", "base_url": "https://example.com/v1"},
|
||||
],
|
||||
})
|
||||
assert any("no 'model' section" in i.message for i in issues)
|
||||
|
||||
def test_custom_providers_with_model(self):
|
||||
issues = validate_config_structure({
|
||||
"custom_providers": [
|
||||
{"name": "test", "base_url": "https://example.com/v1"},
|
||||
],
|
||||
"model": {"provider": "custom", "default": "test-model"},
|
||||
})
|
||||
# Should not warn about missing model section
|
||||
assert not any("no 'model' section" in i.message for i in issues)
|
||||
|
||||
|
||||
class TestConfigIssueDataclass:
|
||||
"""ConfigIssue should be a proper dataclass."""
|
||||
|
||||
def test_fields(self):
|
||||
issue = ConfigIssue(severity="error", message="test msg", hint="test hint")
|
||||
assert issue.severity == "error"
|
||||
assert issue.message == "test msg"
|
||||
assert issue.hint == "test hint"
|
||||
|
||||
def test_equality(self):
|
||||
a = ConfigIssue("error", "msg", "hint")
|
||||
b = ConfigIssue("error", "msg", "hint")
|
||||
assert a == b
|
||||
@@ -0,0 +1,303 @@
|
||||
"""Tests for container-aware CLI routing (NixOS container mode).
|
||||
|
||||
When container.enable = true in the NixOS module, the activation script
|
||||
writes a .container-mode metadata file. The host CLI detects this and
|
||||
execs into the container instead of running locally.
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.config import (
|
||||
get_container_exec_info,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# get_container_exec_info
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def container_env(tmp_path, monkeypatch):
|
||||
"""Set up a fake HERMES_HOME with .container-mode file."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.delenv("HERMES_DEV", raising=False)
|
||||
|
||||
container_mode = hermes_home / ".container-mode"
|
||||
container_mode.write_text(
|
||||
"# Written by NixOS activation script. Do not edit manually.\n"
|
||||
"backend=podman\n"
|
||||
"container_name=hermes-agent\n"
|
||||
"exec_user=hermes\n"
|
||||
"hermes_bin=/data/current-package/bin/hermes\n"
|
||||
)
|
||||
return hermes_home
|
||||
|
||||
|
||||
def test_get_container_exec_info_returns_metadata(container_env):
|
||||
"""Reads .container-mode and returns all fields including exec_user."""
|
||||
with patch("hermes_constants.is_container", return_value=False):
|
||||
info = get_container_exec_info()
|
||||
|
||||
assert info is not None
|
||||
assert info["backend"] == "podman"
|
||||
assert info["container_name"] == "hermes-agent"
|
||||
assert info["exec_user"] == "hermes"
|
||||
assert info["hermes_bin"] == "/data/current-package/bin/hermes"
|
||||
|
||||
|
||||
def test_get_container_exec_info_none_inside_container(container_env):
|
||||
"""Returns None when we're already inside a container."""
|
||||
with patch("hermes_constants.is_container", return_value=True):
|
||||
info = get_container_exec_info()
|
||||
|
||||
assert info is None
|
||||
|
||||
|
||||
def test_get_container_exec_info_none_without_file(tmp_path, monkeypatch):
|
||||
"""Returns None when .container-mode doesn't exist (native mode)."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.delenv("HERMES_DEV", raising=False)
|
||||
|
||||
with patch("hermes_constants.is_container", return_value=False):
|
||||
info = get_container_exec_info()
|
||||
|
||||
assert info is None
|
||||
|
||||
|
||||
def test_get_container_exec_info_skipped_when_hermes_dev(container_env, monkeypatch):
|
||||
"""Returns None when HERMES_DEV=1 is set (dev mode bypass)."""
|
||||
monkeypatch.setenv("HERMES_DEV", "1")
|
||||
|
||||
with patch("hermes_constants.is_container", return_value=False):
|
||||
info = get_container_exec_info()
|
||||
|
||||
assert info is None
|
||||
|
||||
|
||||
def test_get_container_exec_info_not_skipped_when_hermes_dev_zero(container_env, monkeypatch):
|
||||
"""HERMES_DEV=0 does NOT trigger bypass — only '1' does."""
|
||||
monkeypatch.setenv("HERMES_DEV", "0")
|
||||
|
||||
with patch("hermes_constants.is_container", return_value=False):
|
||||
info = get_container_exec_info()
|
||||
|
||||
assert info is not None
|
||||
|
||||
|
||||
def test_get_container_exec_info_defaults():
|
||||
"""Falls back to defaults for missing keys."""
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
hermes_home = Path(tmpdir) / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
(hermes_home / ".container-mode").write_text(
|
||||
"# minimal file with no keys\n"
|
||||
)
|
||||
|
||||
with patch("hermes_constants.is_container", return_value=False), \
|
||||
patch.dict(get_container_exec_info.__globals__, {"get_hermes_home": lambda: hermes_home}), \
|
||||
patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("HERMES_DEV", None)
|
||||
info = get_container_exec_info()
|
||||
|
||||
assert info is not None
|
||||
assert info["backend"] == "docker"
|
||||
assert info["container_name"] == "hermes-agent"
|
||||
assert info["exec_user"] == "hermes"
|
||||
assert info["hermes_bin"] == "/data/current-package/bin/hermes"
|
||||
|
||||
|
||||
def test_get_container_exec_info_docker_backend(container_env):
|
||||
"""Correctly reads docker backend with custom exec_user."""
|
||||
(container_env / ".container-mode").write_text(
|
||||
"backend=docker\n"
|
||||
"container_name=hermes-custom\n"
|
||||
"exec_user=myuser\n"
|
||||
"hermes_bin=/opt/hermes/bin/hermes\n"
|
||||
)
|
||||
|
||||
with patch("hermes_constants.is_container", return_value=False):
|
||||
info = get_container_exec_info()
|
||||
|
||||
assert info["backend"] == "docker"
|
||||
assert info["container_name"] == "hermes-custom"
|
||||
assert info["exec_user"] == "myuser"
|
||||
assert info["hermes_bin"] == "/opt/hermes/bin/hermes"
|
||||
|
||||
|
||||
def test_get_container_exec_info_crashes_on_permission_error(container_env):
|
||||
"""PermissionError propagates instead of being silently swallowed."""
|
||||
with patch("hermes_constants.is_container", return_value=False), \
|
||||
patch("builtins.open", side_effect=PermissionError("permission denied")):
|
||||
with pytest.raises(PermissionError):
|
||||
get_container_exec_info()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# _exec_in_container
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def docker_container_info():
|
||||
return {
|
||||
"backend": "docker",
|
||||
"container_name": "hermes-agent",
|
||||
"exec_user": "hermes",
|
||||
"hermes_bin": "/data/current-package/bin/hermes",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def podman_container_info():
|
||||
return {
|
||||
"backend": "podman",
|
||||
"container_name": "hermes-agent",
|
||||
"exec_user": "hermes",
|
||||
"hermes_bin": "/data/current-package/bin/hermes",
|
||||
}
|
||||
|
||||
|
||||
def test_exec_in_container_calls_execvp(docker_container_info):
|
||||
"""Verifies os.execvp is called with correct args: runtime, tty flags,
|
||||
user, env vars, container name, binary, and CLI args."""
|
||||
from hermes_cli.main import _exec_in_container
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/docker"), \
|
||||
patch("subprocess.run") as mock_run, \
|
||||
patch("sys.stdin") as mock_stdin, \
|
||||
patch("os.execvp") as mock_execvp, \
|
||||
patch.dict(os.environ, {"TERM": "xterm-256color", "LANG": "en_US.UTF-8"},
|
||||
clear=False):
|
||||
mock_stdin.isatty.return_value = True
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
|
||||
_exec_in_container(docker_container_info, ["chat", "-m", "opus"])
|
||||
|
||||
mock_execvp.assert_called_once()
|
||||
cmd = mock_execvp.call_args[0][1]
|
||||
assert cmd[0] == "/usr/bin/docker"
|
||||
assert cmd[1] == "exec"
|
||||
assert "-it" in cmd
|
||||
idx_u = cmd.index("-u")
|
||||
assert cmd[idx_u + 1] == "hermes"
|
||||
e_indices = [i for i, v in enumerate(cmd) if v == "-e"]
|
||||
e_values = [cmd[i + 1] for i in e_indices]
|
||||
assert "TERM=xterm-256color" in e_values
|
||||
assert "LANG=en_US.UTF-8" in e_values
|
||||
assert "hermes-agent" in cmd
|
||||
assert "/data/current-package/bin/hermes" in cmd
|
||||
assert "chat" in cmd
|
||||
|
||||
|
||||
def test_exec_in_container_non_tty_uses_i_only(docker_container_info):
|
||||
"""Non-TTY mode uses -i instead of -it."""
|
||||
from hermes_cli.main import _exec_in_container
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/docker"), \
|
||||
patch("subprocess.run") as mock_run, \
|
||||
patch("sys.stdin") as mock_stdin, \
|
||||
patch("os.execvp") as mock_execvp:
|
||||
mock_stdin.isatty.return_value = False
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
|
||||
_exec_in_container(docker_container_info, ["sessions", "list"])
|
||||
|
||||
cmd = mock_execvp.call_args[0][1]
|
||||
assert "-i" in cmd
|
||||
assert "-it" not in cmd
|
||||
|
||||
|
||||
def test_exec_in_container_no_runtime_hard_fails(podman_container_info):
|
||||
"""Hard fails when runtime not found (no fallback)."""
|
||||
from hermes_cli.main import _exec_in_container
|
||||
|
||||
with patch("shutil.which", return_value=None), \
|
||||
patch("subprocess.run") as mock_run, \
|
||||
patch("os.execvp") as mock_execvp, \
|
||||
pytest.raises(SystemExit) as exc_info:
|
||||
_exec_in_container(podman_container_info, ["chat"])
|
||||
|
||||
mock_run.assert_not_called()
|
||||
mock_execvp.assert_not_called()
|
||||
assert exc_info.value.code != 0
|
||||
|
||||
|
||||
def test_exec_in_container_sudo_probe_sets_prefix(podman_container_info):
|
||||
"""When first probe fails and sudo probe succeeds, execvp is called
|
||||
with sudo -n prefix."""
|
||||
from hermes_cli.main import _exec_in_container
|
||||
|
||||
def which_side_effect(name):
|
||||
if name == "podman":
|
||||
return "/usr/bin/podman"
|
||||
if name == "sudo":
|
||||
return "/usr/bin/sudo"
|
||||
return None
|
||||
|
||||
with patch("shutil.which", side_effect=which_side_effect), \
|
||||
patch("subprocess.run") as mock_run, \
|
||||
patch("sys.stdin") as mock_stdin, \
|
||||
patch("os.execvp") as mock_execvp:
|
||||
mock_stdin.isatty.return_value = True
|
||||
mock_run.side_effect = [
|
||||
MagicMock(returncode=1), # direct probe fails
|
||||
MagicMock(returncode=0), # sudo probe succeeds
|
||||
]
|
||||
|
||||
_exec_in_container(podman_container_info, ["chat"])
|
||||
|
||||
mock_execvp.assert_called_once()
|
||||
cmd = mock_execvp.call_args[0][1]
|
||||
assert cmd[0] == "/usr/bin/sudo"
|
||||
assert cmd[1] == "-n"
|
||||
assert cmd[2] == "/usr/bin/podman"
|
||||
assert cmd[3] == "exec"
|
||||
|
||||
|
||||
def test_exec_in_container_probe_timeout_prints_message(docker_container_info):
|
||||
"""TimeoutExpired from probe produces a human-readable error, not a
|
||||
raw traceback."""
|
||||
from hermes_cli.main import _exec_in_container
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/docker"), \
|
||||
patch("subprocess.run", side_effect=subprocess.TimeoutExpired(
|
||||
cmd=["docker", "inspect"], timeout=15)), \
|
||||
patch("os.execvp") as mock_execvp, \
|
||||
pytest.raises(SystemExit) as exc_info:
|
||||
_exec_in_container(docker_container_info, ["chat"])
|
||||
|
||||
mock_execvp.assert_not_called()
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
|
||||
def test_exec_in_container_container_not_running_no_sudo(docker_container_info):
|
||||
"""When runtime exists but container not found and no sudo available,
|
||||
prints helpful error about root containers."""
|
||||
from hermes_cli.main import _exec_in_container
|
||||
|
||||
def which_side_effect(name):
|
||||
if name == "docker":
|
||||
return "/usr/bin/docker"
|
||||
return None
|
||||
|
||||
with patch("shutil.which", side_effect=which_side_effect), \
|
||||
patch("subprocess.run") as mock_run, \
|
||||
patch("os.execvp") as mock_execvp, \
|
||||
pytest.raises(SystemExit) as exc_info:
|
||||
mock_run.return_value = MagicMock(returncode=1)
|
||||
|
||||
_exec_in_container(docker_container_info, ["chat"])
|
||||
|
||||
mock_execvp.assert_not_called()
|
||||
assert exc_info.value.code == 1
|
||||
@@ -0,0 +1,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
|
||||
)
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Tests for hermes_cli.copilot_auth — Copilot token validation and resolution."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class TestTokenValidation:
|
||||
"""Token type validation."""
|
||||
|
||||
def test_classic_pat_rejected(self):
|
||||
from hermes_cli.copilot_auth import validate_copilot_token
|
||||
valid, msg = validate_copilot_token("ghp_abcdefghijklmnop1234")
|
||||
assert valid is False
|
||||
assert "Classic Personal Access Tokens" in msg
|
||||
assert "ghp_" in msg
|
||||
|
||||
def test_oauth_token_accepted(self):
|
||||
from hermes_cli.copilot_auth import validate_copilot_token
|
||||
valid, msg = validate_copilot_token("gho_abcdefghijklmnop1234")
|
||||
assert valid is True
|
||||
|
||||
def test_fine_grained_pat_accepted(self):
|
||||
from hermes_cli.copilot_auth import validate_copilot_token
|
||||
valid, msg = validate_copilot_token("github_pat_abcdefghijklmnop1234")
|
||||
assert valid is True
|
||||
|
||||
def test_github_app_token_accepted(self):
|
||||
from hermes_cli.copilot_auth import validate_copilot_token
|
||||
valid, msg = validate_copilot_token("ghu_abcdefghijklmnop1234")
|
||||
assert valid is True
|
||||
|
||||
def test_empty_token_rejected(self):
|
||||
from hermes_cli.copilot_auth import validate_copilot_token
|
||||
valid, msg = validate_copilot_token("")
|
||||
assert valid is False
|
||||
|
||||
|
||||
|
||||
class TestResolveToken:
|
||||
"""Token resolution with env var priority."""
|
||||
|
||||
def test_copilot_github_token_first_priority(self, monkeypatch):
|
||||
from hermes_cli.copilot_auth import resolve_copilot_token
|
||||
monkeypatch.setenv("COPILOT_GITHUB_TOKEN", "gho_copilot_first")
|
||||
monkeypatch.setenv("GH_TOKEN", "gho_gh_second")
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "gho_github_third")
|
||||
token, source = resolve_copilot_token()
|
||||
assert token == "gho_copilot_first"
|
||||
assert source == "COPILOT_GITHUB_TOKEN"
|
||||
|
||||
def test_gh_token_second_priority(self, monkeypatch):
|
||||
from hermes_cli.copilot_auth import resolve_copilot_token
|
||||
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.setenv("GH_TOKEN", "gho_gh_second")
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "gho_github_third")
|
||||
token, source = resolve_copilot_token()
|
||||
assert token == "gho_gh_second"
|
||||
assert source == "GH_TOKEN"
|
||||
|
||||
def test_github_token_third_priority(self, monkeypatch):
|
||||
from hermes_cli.copilot_auth import resolve_copilot_token
|
||||
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "gho_github_third")
|
||||
token, source = resolve_copilot_token()
|
||||
assert token == "gho_github_third"
|
||||
assert source == "GITHUB_TOKEN"
|
||||
|
||||
def test_classic_pat_in_env_skipped(self, monkeypatch):
|
||||
"""Classic PATs in env vars should be skipped, not returned."""
|
||||
from hermes_cli.copilot_auth import resolve_copilot_token
|
||||
monkeypatch.setenv("COPILOT_GITHUB_TOKEN", "ghp_classic_pat_nope")
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "gho_valid_oauth")
|
||||
token, source = resolve_copilot_token()
|
||||
# Should skip the ghp_ token and find the gho_ one
|
||||
assert token == "gho_valid_oauth"
|
||||
assert source == "GITHUB_TOKEN"
|
||||
|
||||
def test_gh_cli_fallback(self, monkeypatch):
|
||||
from hermes_cli.copilot_auth import resolve_copilot_token
|
||||
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
with patch("hermes_cli.copilot_auth._try_gh_cli_token", return_value="gho_from_cli"):
|
||||
token, source = resolve_copilot_token()
|
||||
assert token == "gho_from_cli"
|
||||
assert source == "gh auth token"
|
||||
|
||||
def test_gh_cli_classic_pat_raises(self, monkeypatch):
|
||||
from hermes_cli.copilot_auth import resolve_copilot_token
|
||||
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
with patch("hermes_cli.copilot_auth._try_gh_cli_token", return_value="ghp_classic"):
|
||||
with pytest.raises(ValueError, match="classic PAT"):
|
||||
resolve_copilot_token()
|
||||
|
||||
def test_no_token_returns_empty(self, monkeypatch):
|
||||
from hermes_cli.copilot_auth import resolve_copilot_token
|
||||
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
with patch("hermes_cli.copilot_auth._try_gh_cli_token", return_value=None):
|
||||
token, source = resolve_copilot_token()
|
||||
assert token == ""
|
||||
assert source == ""
|
||||
|
||||
|
||||
class TestRequestHeaders:
|
||||
"""Copilot API header generation."""
|
||||
|
||||
def test_default_headers_include_openai_intent(self):
|
||||
from hermes_cli.copilot_auth import copilot_request_headers
|
||||
headers = copilot_request_headers()
|
||||
assert headers["Openai-Intent"] == "conversation-edits"
|
||||
assert headers["User-Agent"] == "HermesAgent/1.0"
|
||||
assert "Editor-Version" in headers
|
||||
|
||||
def test_agent_turn_sets_initiator(self):
|
||||
from hermes_cli.copilot_auth import copilot_request_headers
|
||||
headers = copilot_request_headers(is_agent_turn=True)
|
||||
assert headers["x-initiator"] == "agent"
|
||||
|
||||
def test_user_turn_sets_initiator(self):
|
||||
from hermes_cli.copilot_auth import copilot_request_headers
|
||||
headers = copilot_request_headers(is_agent_turn=False)
|
||||
assert headers["x-initiator"] == "user"
|
||||
|
||||
def test_vision_header(self):
|
||||
from hermes_cli.copilot_auth import copilot_request_headers
|
||||
headers = copilot_request_headers(is_vision=True)
|
||||
assert headers["Copilot-Vision-Request"] == "true"
|
||||
|
||||
def test_no_vision_header_by_default(self):
|
||||
from hermes_cli.copilot_auth import copilot_request_headers
|
||||
headers = copilot_request_headers()
|
||||
assert "Copilot-Vision-Request" not in headers
|
||||
|
||||
|
||||
class TestCopilotDefaultHeaders:
|
||||
"""The models.py copilot_default_headers uses copilot_auth."""
|
||||
|
||||
def test_includes_openai_intent(self):
|
||||
from hermes_cli.models import copilot_default_headers
|
||||
headers = copilot_default_headers()
|
||||
assert "Openai-Intent" in headers
|
||||
assert headers["Openai-Intent"] == "conversation-edits"
|
||||
|
||||
def test_includes_x_initiator(self):
|
||||
from hermes_cli.models import copilot_default_headers
|
||||
headers = copilot_default_headers()
|
||||
assert "x-initiator" in headers
|
||||
|
||||
|
||||
class TestApiModeSelection:
|
||||
"""API mode selection matching opencode's shouldUseCopilotResponsesApi."""
|
||||
|
||||
def test_gpt5_uses_responses(self):
|
||||
from hermes_cli.models import _should_use_copilot_responses_api
|
||||
assert _should_use_copilot_responses_api("gpt-5.4") is True
|
||||
assert _should_use_copilot_responses_api("gpt-5.4-mini") is True
|
||||
assert _should_use_copilot_responses_api("gpt-5.3-codex") is True
|
||||
assert _should_use_copilot_responses_api("gpt-5.2-codex") is True
|
||||
assert _should_use_copilot_responses_api("gpt-5.2") is True
|
||||
assert _should_use_copilot_responses_api("gpt-5.1-codex-max") is True
|
||||
|
||||
def test_gpt5_mini_excluded(self):
|
||||
from hermes_cli.models import _should_use_copilot_responses_api
|
||||
assert _should_use_copilot_responses_api("gpt-5-mini") is False
|
||||
|
||||
def test_gpt4_uses_chat(self):
|
||||
from hermes_cli.models import _should_use_copilot_responses_api
|
||||
assert _should_use_copilot_responses_api("gpt-4.1") is False
|
||||
assert _should_use_copilot_responses_api("gpt-4o") is False
|
||||
assert _should_use_copilot_responses_api("gpt-4o-mini") is False
|
||||
|
||||
def test_non_gpt_uses_chat(self):
|
||||
from hermes_cli.models import _should_use_copilot_responses_api
|
||||
assert _should_use_copilot_responses_api("claude-sonnet-4.6") is False
|
||||
assert _should_use_copilot_responses_api("claude-opus-4.6") is False
|
||||
assert _should_use_copilot_responses_api("gemini-2.5-pro") is False
|
||||
assert _should_use_copilot_responses_api("grok-code-fast-1") is False
|
||||
|
||||
|
||||
class TestEnvVarOrder:
|
||||
"""PROVIDER_REGISTRY has correct env var order."""
|
||||
|
||||
def test_copilot_env_vars_include_copilot_github_token(self):
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
copilot = PROVIDER_REGISTRY["copilot"]
|
||||
assert "COPILOT_GITHUB_TOKEN" in copilot.api_key_env_vars
|
||||
# COPILOT_GITHUB_TOKEN should be first
|
||||
assert copilot.api_key_env_vars[0] == "COPILOT_GITHUB_TOKEN"
|
||||
|
||||
def test_copilot_env_vars_order_matches_docs(self):
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
copilot = PROVIDER_REGISTRY["copilot"]
|
||||
assert copilot.api_key_env_vars == (
|
||||
"COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"
|
||||
)
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Catalog-API-key fallback for the Copilot ``/model`` picker.
|
||||
|
||||
Regression for #16708: when the user's only Copilot credential is a
|
||||
``gho_*`` token (typically obtained via device-code login) stored in
|
||||
``auth.json`` under ``credential_pool.copilot[]`` — placed there by
|
||||
``hermes auth add copilot`` or by ``_seed_from_env`` when the env var
|
||||
is set in ``~/.hermes/.env`` — the picker was silently dropping back to
|
||||
a stale hardcoded list because ``_resolve_copilot_catalog_api_key``
|
||||
only consulted env vars / ``gh auth token`` and never read the
|
||||
credential pool.
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from hermes_cli.models import _resolve_copilot_catalog_api_key
|
||||
|
||||
|
||||
class TestCopilotCatalogApiKeyResolution:
|
||||
def test_env_var_token_wins_over_pool(self):
|
||||
"""Env-resolved token still short-circuits the pool fallback."""
|
||||
with patch(
|
||||
"hermes_cli.auth.resolve_api_key_provider_credentials",
|
||||
return_value={"api_key": "env-token"},
|
||||
), patch(
|
||||
"hermes_cli.auth.read_credential_pool",
|
||||
) as mock_pool:
|
||||
assert _resolve_copilot_catalog_api_key() == "env-token"
|
||||
mock_pool.assert_not_called()
|
||||
|
||||
def test_falls_back_to_pool_oauth_token(self):
|
||||
"""Empty env → walk credential_pool.copilot[] for an OAuth access_token."""
|
||||
with patch(
|
||||
"hermes_cli.auth.resolve_api_key_provider_credentials",
|
||||
return_value={"api_key": ""},
|
||||
), patch(
|
||||
"hermes_cli.auth.read_credential_pool",
|
||||
return_value=[{"access_token": "gho_abc123"}],
|
||||
), patch(
|
||||
"hermes_cli.copilot_auth.exchange_copilot_token",
|
||||
return_value=("tid_exchanged_xyz", 1234567890.0),
|
||||
):
|
||||
assert _resolve_copilot_catalog_api_key() == "tid_exchanged_xyz"
|
||||
|
||||
def test_falls_back_when_env_resolution_raises(self):
|
||||
"""Env path raising an exception still falls through to the pool."""
|
||||
with patch(
|
||||
"hermes_cli.auth.resolve_api_key_provider_credentials",
|
||||
side_effect=RuntimeError("auth.json corrupt"),
|
||||
), patch(
|
||||
"hermes_cli.auth.read_credential_pool",
|
||||
return_value=[{"access_token": "gho_xyz"}],
|
||||
), patch(
|
||||
"hermes_cli.copilot_auth.exchange_copilot_token",
|
||||
return_value=("tid_exchanged_xyz", 1234567890.0),
|
||||
):
|
||||
assert _resolve_copilot_catalog_api_key() == "tid_exchanged_xyz"
|
||||
|
||||
def test_skips_classic_pat_in_pool(self):
|
||||
"""Classic PATs (``ghp_…``) are unsupported by the Copilot API — skip them."""
|
||||
with patch(
|
||||
"hermes_cli.auth.resolve_api_key_provider_credentials",
|
||||
return_value={"api_key": ""},
|
||||
), patch(
|
||||
"hermes_cli.auth.read_credential_pool",
|
||||
return_value=[{"access_token": "ghp_classic_pat"}],
|
||||
), patch(
|
||||
"hermes_cli.copilot_auth.exchange_copilot_token",
|
||||
) as mock_exchange:
|
||||
assert _resolve_copilot_catalog_api_key() == ""
|
||||
mock_exchange.assert_not_called()
|
||||
|
||||
def test_skips_invalid_pool_entries_until_first_exchangeable(self):
|
||||
"""Non-dict entries and entries without an ``access_token`` are skipped."""
|
||||
with patch(
|
||||
"hermes_cli.auth.resolve_api_key_provider_credentials",
|
||||
return_value={"api_key": ""},
|
||||
), patch(
|
||||
"hermes_cli.auth.read_credential_pool",
|
||||
return_value=[
|
||||
"not-a-dict",
|
||||
{"label": "no-token-here"},
|
||||
{"access_token": ""},
|
||||
{"access_token": "gho_first_real_token"},
|
||||
{"access_token": "gho_should_not_reach"},
|
||||
],
|
||||
), patch(
|
||||
"hermes_cli.copilot_auth.exchange_copilot_token",
|
||||
return_value=("tid_from_first", 1234567890.0),
|
||||
) as mock_exchange:
|
||||
assert _resolve_copilot_catalog_api_key() == "tid_from_first"
|
||||
mock_exchange.assert_called_once_with("gho_first_real_token")
|
||||
|
||||
def test_skips_pool_entry_that_fails_to_exchange(self):
|
||||
"""If the first entry won't exchange, try the next — an unsupported pool[0]
|
||||
must not wedge a later valid entry (Copilot review #16868 finding)."""
|
||||
attempts: list[str] = []
|
||||
|
||||
def fake_exchange(raw_token: str):
|
||||
attempts.append(raw_token)
|
||||
if raw_token == "gho_unsupported_account":
|
||||
raise ValueError("Copilot token exchange failed: HTTP 401")
|
||||
return ("tid_from_second", 1234567890.0)
|
||||
|
||||
with patch(
|
||||
"hermes_cli.auth.resolve_api_key_provider_credentials",
|
||||
return_value={"api_key": ""},
|
||||
), patch(
|
||||
"hermes_cli.auth.read_credential_pool",
|
||||
return_value=[
|
||||
{"access_token": "gho_unsupported_account"},
|
||||
{"access_token": "gho_valid_token"},
|
||||
],
|
||||
), patch(
|
||||
"hermes_cli.copilot_auth.exchange_copilot_token",
|
||||
side_effect=fake_exchange,
|
||||
):
|
||||
assert _resolve_copilot_catalog_api_key() == "tid_from_second"
|
||||
assert attempts == ["gho_unsupported_account", "gho_valid_token"]
|
||||
|
||||
def test_all_pool_entries_fail_exchange_returns_empty(self):
|
||||
"""All exchanges fail → return "" so the caller falls back to curated."""
|
||||
with patch(
|
||||
"hermes_cli.auth.resolve_api_key_provider_credentials",
|
||||
return_value={"api_key": ""},
|
||||
), patch(
|
||||
"hermes_cli.auth.read_credential_pool",
|
||||
return_value=[
|
||||
{"access_token": "gho_expired_a"},
|
||||
{"access_token": "gho_expired_b"},
|
||||
],
|
||||
), patch(
|
||||
"hermes_cli.copilot_auth.exchange_copilot_token",
|
||||
side_effect=ValueError("Copilot token exchange failed"),
|
||||
):
|
||||
assert _resolve_copilot_catalog_api_key() == ""
|
||||
|
||||
def test_returns_empty_string_when_no_credentials_anywhere(self):
|
||||
"""No env, no pool → empty string (caller falls back to curated list)."""
|
||||
with patch(
|
||||
"hermes_cli.auth.resolve_api_key_provider_credentials",
|
||||
return_value={"api_key": ""},
|
||||
), patch(
|
||||
"hermes_cli.auth.read_credential_pool",
|
||||
return_value=[],
|
||||
):
|
||||
assert _resolve_copilot_catalog_api_key() == ""
|
||||
|
||||
def test_pool_failure_returns_empty_string(self):
|
||||
"""If the pool read itself raises, swallow and return ""."""
|
||||
with patch(
|
||||
"hermes_cli.auth.resolve_api_key_provider_credentials",
|
||||
return_value={"api_key": ""},
|
||||
), patch(
|
||||
"hermes_cli.auth.read_credential_pool",
|
||||
side_effect=RuntimeError("auth.json locked"),
|
||||
):
|
||||
assert _resolve_copilot_catalog_api_key() == ""
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Tests for Copilot live /models context-window resolution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.models import get_copilot_model_context
|
||||
|
||||
|
||||
# Sample catalog items mimicking the Copilot /models API response
|
||||
_SAMPLE_CATALOG = [
|
||||
{
|
||||
"id": "claude-opus-4.6-1m",
|
||||
"capabilities": {
|
||||
"type": "chat",
|
||||
"limits": {"max_prompt_tokens": 1000000, "max_output_tokens": 64000},
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "gpt-4.1",
|
||||
"capabilities": {
|
||||
"type": "chat",
|
||||
"limits": {"max_prompt_tokens": 128000, "max_output_tokens": 32768},
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "claude-sonnet-4",
|
||||
"capabilities": {
|
||||
"type": "chat",
|
||||
"limits": {"max_prompt_tokens": 200000, "max_output_tokens": 64000},
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "model-without-limits",
|
||||
"capabilities": {"type": "chat"},
|
||||
},
|
||||
{
|
||||
"id": "model-zero-limit",
|
||||
"capabilities": {
|
||||
"type": "chat",
|
||||
"limits": {"max_prompt_tokens": 0},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_cache():
|
||||
"""Reset module-level cache before each test."""
|
||||
import hermes_cli.models as mod
|
||||
|
||||
mod._copilot_context_cache = {}
|
||||
mod._copilot_context_cache_time = 0.0
|
||||
yield
|
||||
mod._copilot_context_cache = {}
|
||||
mod._copilot_context_cache_time = 0.0
|
||||
|
||||
|
||||
class TestGetCopilotModelContext:
|
||||
"""Tests for get_copilot_model_context()."""
|
||||
|
||||
@patch("hermes_cli.models.fetch_github_model_catalog", return_value=_SAMPLE_CATALOG)
|
||||
def test_returns_max_prompt_tokens(self, mock_fetch):
|
||||
assert get_copilot_model_context("claude-opus-4.6-1m") == 1_000_000
|
||||
assert get_copilot_model_context("gpt-4.1") == 128_000
|
||||
|
||||
@patch("hermes_cli.models.fetch_github_model_catalog", return_value=_SAMPLE_CATALOG)
|
||||
def test_returns_none_for_unknown_model(self, mock_fetch):
|
||||
assert get_copilot_model_context("nonexistent-model") is None
|
||||
|
||||
@patch("hermes_cli.models.fetch_github_model_catalog", return_value=_SAMPLE_CATALOG)
|
||||
def test_skips_models_without_limits(self, mock_fetch):
|
||||
assert get_copilot_model_context("model-without-limits") is None
|
||||
|
||||
@patch("hermes_cli.models.fetch_github_model_catalog", return_value=_SAMPLE_CATALOG)
|
||||
def test_skips_zero_limit(self, mock_fetch):
|
||||
assert get_copilot_model_context("model-zero-limit") is None
|
||||
|
||||
@patch("hermes_cli.models.fetch_github_model_catalog", return_value=_SAMPLE_CATALOG)
|
||||
def test_caches_results(self, mock_fetch):
|
||||
get_copilot_model_context("gpt-4.1")
|
||||
get_copilot_model_context("claude-sonnet-4")
|
||||
# Only one API call despite two lookups
|
||||
assert mock_fetch.call_count == 1
|
||||
|
||||
@patch("hermes_cli.models.fetch_github_model_catalog", return_value=_SAMPLE_CATALOG)
|
||||
def test_cache_expires(self, mock_fetch):
|
||||
import hermes_cli.models as mod
|
||||
|
||||
get_copilot_model_context("gpt-4.1")
|
||||
assert mock_fetch.call_count == 1
|
||||
|
||||
# Expire the cache
|
||||
mod._copilot_context_cache_time = time.time() - 7200
|
||||
get_copilot_model_context("gpt-4.1")
|
||||
assert mock_fetch.call_count == 2
|
||||
|
||||
@patch("hermes_cli.models.fetch_github_model_catalog", return_value=None)
|
||||
def test_returns_none_when_catalog_unavailable(self, mock_fetch):
|
||||
assert get_copilot_model_context("gpt-4.1") is None
|
||||
|
||||
@patch("hermes_cli.models.fetch_github_model_catalog", return_value=[])
|
||||
def test_returns_none_for_empty_catalog(self, mock_fetch):
|
||||
assert get_copilot_model_context("gpt-4.1") is None
|
||||
|
||||
|
||||
class TestModelMetadataCopilotIntegration:
|
||||
"""Test that get_model_context_length() uses Copilot live API for copilot provider."""
|
||||
|
||||
@patch("hermes_cli.models.fetch_github_model_catalog", return_value=_SAMPLE_CATALOG)
|
||||
def test_copilot_provider_uses_live_api(self, mock_fetch):
|
||||
from agent.model_metadata import get_model_context_length
|
||||
|
||||
ctx = get_model_context_length("claude-opus-4.6-1m", provider="copilot")
|
||||
assert ctx == 1_000_000
|
||||
|
||||
@patch("hermes_cli.models.fetch_github_model_catalog", return_value=_SAMPLE_CATALOG)
|
||||
def test_copilot_acp_provider_uses_live_api(self, mock_fetch):
|
||||
from agent.model_metadata import get_model_context_length
|
||||
|
||||
ctx = get_model_context_length("claude-sonnet-4", provider="copilot-acp")
|
||||
assert ctx == 200_000
|
||||
|
||||
@patch("hermes_cli.models.fetch_github_model_catalog", return_value=None)
|
||||
def test_falls_through_when_catalog_unavailable(self, mock_fetch):
|
||||
from agent.model_metadata import get_model_context_length
|
||||
|
||||
# Should not raise, should fall through to models.dev or defaults
|
||||
ctx = get_model_context_length("gpt-4.1", provider="copilot")
|
||||
assert isinstance(ctx, int)
|
||||
assert ctx > 0
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Tests for GitHub Copilot entries shown in the /model picker."""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
|
||||
@patch.dict(os.environ, {"GH_TOKEN": "test-key"}, clear=False)
|
||||
def test_copilot_picker_uses_live_catalog_when_available():
|
||||
live_models = ["gpt-5.4", "claude-sonnet-4.6", "gemini-3.1-pro-preview"]
|
||||
|
||||
with patch("agent.models_dev.fetch_models_dev", return_value={}), \
|
||||
patch("hermes_cli.models._resolve_copilot_catalog_api_key", return_value="gh-token"), \
|
||||
patch("hermes_cli.models._fetch_github_models", return_value=live_models):
|
||||
providers = list_authenticated_providers(current_provider="openrouter", max_models=50)
|
||||
|
||||
copilot = next((p for p in providers if p["slug"] == "copilot"), None)
|
||||
|
||||
assert copilot is not None
|
||||
assert copilot["models"] == live_models
|
||||
assert copilot["total_models"] == len(live_models)
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Tests for Copilot token exchange (raw GitHub token → Copilot API token)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_jwt_cache():
|
||||
"""Reset the module-level JWT cache before each test."""
|
||||
import hermes_cli.copilot_auth as mod
|
||||
mod._jwt_cache.clear()
|
||||
yield
|
||||
mod._jwt_cache.clear()
|
||||
|
||||
|
||||
class TestExchangeCopilotToken:
|
||||
"""Tests for exchange_copilot_token()."""
|
||||
|
||||
def _mock_urlopen(self, token="tid=abc;exp=123;sku=copilot_individual", expires_at=None):
|
||||
"""Create a mock urlopen context manager returning a token response."""
|
||||
if expires_at is None:
|
||||
expires_at = time.time() + 1800
|
||||
resp_data = json.dumps({"token": token, "expires_at": expires_at}).encode()
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = resp_data
|
||||
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
|
||||
mock_resp.__exit__ = MagicMock(return_value=False)
|
||||
return mock_resp
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_exchanges_token_successfully(self, mock_urlopen):
|
||||
from hermes_cli.copilot_auth import exchange_copilot_token
|
||||
|
||||
mock_urlopen.return_value = self._mock_urlopen(token="tid=abc;exp=999")
|
||||
api_token, expires_at = exchange_copilot_token("gho_test123")
|
||||
|
||||
assert api_token == "tid=abc;exp=999"
|
||||
assert isinstance(expires_at, float)
|
||||
|
||||
# Verify request was made with correct headers
|
||||
call_args = mock_urlopen.call_args
|
||||
req = call_args[0][0]
|
||||
assert req.get_header("Authorization") == "token gho_test123"
|
||||
assert "GitHubCopilotChat" in req.get_header("User-agent")
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_caches_result(self, mock_urlopen):
|
||||
from hermes_cli.copilot_auth import exchange_copilot_token
|
||||
|
||||
future = time.time() + 1800
|
||||
mock_urlopen.return_value = self._mock_urlopen(expires_at=future)
|
||||
|
||||
exchange_copilot_token("gho_test123")
|
||||
exchange_copilot_token("gho_test123")
|
||||
|
||||
assert mock_urlopen.call_count == 1
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_refreshes_expired_cache(self, mock_urlopen):
|
||||
from hermes_cli.copilot_auth import exchange_copilot_token, _jwt_cache, _token_fingerprint
|
||||
|
||||
# Seed cache with expired entry
|
||||
fp = _token_fingerprint("gho_test123")
|
||||
_jwt_cache[fp] = ("old_token", time.time() - 10)
|
||||
|
||||
mock_urlopen.return_value = self._mock_urlopen(
|
||||
token="new_token", expires_at=time.time() + 1800
|
||||
)
|
||||
api_token, _ = exchange_copilot_token("gho_test123")
|
||||
|
||||
assert api_token == "new_token"
|
||||
assert mock_urlopen.call_count == 1
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_raises_on_empty_token(self, mock_urlopen):
|
||||
from hermes_cli.copilot_auth import exchange_copilot_token
|
||||
|
||||
resp_data = json.dumps({"token": "", "expires_at": 0}).encode()
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = resp_data
|
||||
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
|
||||
mock_resp.__exit__ = MagicMock(return_value=False)
|
||||
mock_urlopen.return_value = mock_resp
|
||||
|
||||
with pytest.raises(ValueError, match="empty token"):
|
||||
exchange_copilot_token("gho_test123")
|
||||
|
||||
@patch("urllib.request.urlopen", side_effect=Exception("network error"))
|
||||
def test_raises_on_network_error(self, mock_urlopen):
|
||||
from hermes_cli.copilot_auth import exchange_copilot_token
|
||||
|
||||
with pytest.raises(ValueError, match="network error"):
|
||||
exchange_copilot_token("gho_test123")
|
||||
|
||||
|
||||
class TestGetCopilotApiToken:
|
||||
"""Tests for get_copilot_api_token() — the fallback wrapper."""
|
||||
|
||||
@patch("hermes_cli.copilot_auth.exchange_copilot_token")
|
||||
def test_returns_exchanged_token(self, mock_exchange):
|
||||
from hermes_cli.copilot_auth import get_copilot_api_token
|
||||
|
||||
mock_exchange.return_value = ("exchanged_jwt", time.time() + 1800)
|
||||
assert get_copilot_api_token("gho_raw") == "exchanged_jwt"
|
||||
|
||||
@patch("hermes_cli.copilot_auth.exchange_copilot_token", side_effect=ValueError("fail"))
|
||||
def test_falls_back_to_raw_token(self, mock_exchange):
|
||||
from hermes_cli.copilot_auth import get_copilot_api_token
|
||||
|
||||
assert get_copilot_api_token("gho_raw") == "gho_raw"
|
||||
|
||||
def test_empty_token_passthrough(self):
|
||||
from hermes_cli.copilot_auth import get_copilot_api_token
|
||||
|
||||
assert get_copilot_api_token("") == ""
|
||||
|
||||
|
||||
class TestTokenFingerprint:
|
||||
"""Tests for _token_fingerprint()."""
|
||||
|
||||
def test_consistent(self):
|
||||
from hermes_cli.copilot_auth import _token_fingerprint
|
||||
|
||||
fp1 = _token_fingerprint("gho_abc123")
|
||||
fp2 = _token_fingerprint("gho_abc123")
|
||||
assert fp1 == fp2
|
||||
|
||||
def test_different_tokens_different_fingerprints(self):
|
||||
from hermes_cli.copilot_auth import _token_fingerprint
|
||||
|
||||
fp1 = _token_fingerprint("gho_abc123")
|
||||
fp2 = _token_fingerprint("gho_xyz789")
|
||||
assert fp1 != fp2
|
||||
|
||||
def test_length(self):
|
||||
from hermes_cli.copilot_auth import _token_fingerprint
|
||||
|
||||
assert len(_token_fingerprint("gho_test")) == 16
|
||||
|
||||
|
||||
class TestCallerIntegration:
|
||||
"""Test that callers correctly use token exchange."""
|
||||
|
||||
@patch("hermes_cli.copilot_auth.resolve_copilot_token", return_value=("gho_raw", "GH_TOKEN"))
|
||||
@patch("hermes_cli.copilot_auth.get_copilot_api_token", return_value="exchanged_jwt")
|
||||
def test_auth_resolve_uses_exchange(self, mock_exchange, mock_resolve):
|
||||
from hermes_cli.auth import _resolve_api_key_provider_secret
|
||||
|
||||
# Create a minimal pconfig mock
|
||||
pconfig = MagicMock()
|
||||
token, source = _resolve_api_key_provider_secret("copilot", pconfig)
|
||||
assert token == "exchanged_jwt"
|
||||
assert source == "GH_TOKEN"
|
||||
mock_exchange.assert_called_once_with("gho_raw")
|
||||
@@ -0,0 +1,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"
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Tests for `hermes curator run` CLI behavior."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
def _args(**kwargs):
|
||||
values = {
|
||||
"dry_run": False,
|
||||
"synchronous": False,
|
||||
"background": False,
|
||||
}
|
||||
values.update(kwargs)
|
||||
return SimpleNamespace(**values)
|
||||
|
||||
|
||||
def test_run_defaults_to_synchronous(monkeypatch, capsys):
|
||||
import agent.curator as curator_state
|
||||
import hermes_cli.curator as curator_cli
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(curator_state, "is_enabled", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
curator_state,
|
||||
"run_curator_review",
|
||||
lambda **kwargs: calls.append(kwargs) or {"auto_transitions": {}},
|
||||
)
|
||||
|
||||
assert curator_cli._cmd_run(_args()) == 0
|
||||
|
||||
assert calls[0]["synchronous"] is True
|
||||
assert calls[0]["dry_run"] is False
|
||||
assert "background" not in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_run_background_opts_into_async(monkeypatch, capsys):
|
||||
import agent.curator as curator_state
|
||||
import hermes_cli.curator as curator_cli
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(curator_state, "is_enabled", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
curator_state,
|
||||
"run_curator_review",
|
||||
lambda **kwargs: calls.append(kwargs) or {"auto_transitions": {}},
|
||||
)
|
||||
|
||||
assert curator_cli._cmd_run(_args(background=True)) == 0
|
||||
|
||||
assert calls[0]["synchronous"] is False
|
||||
assert "llm pass running in background" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_run_sync_wins_over_background(monkeypatch):
|
||||
import agent.curator as curator_state
|
||||
import hermes_cli.curator as curator_cli
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(curator_state, "is_enabled", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
curator_state,
|
||||
"run_curator_review",
|
||||
lambda **kwargs: calls.append(kwargs) or {"auto_transitions": {}},
|
||||
)
|
||||
|
||||
assert curator_cli._cmd_run(_args(synchronous=True, background=True)) == 0
|
||||
|
||||
assert calls[0]["synchronous"] is True
|
||||
|
||||
|
||||
def test_dry_run_default_reports_synchronous_wording(monkeypatch, capsys):
|
||||
import agent.curator as curator_state
|
||||
import hermes_cli.curator as curator_cli
|
||||
|
||||
monkeypatch.setattr(curator_state, "is_enabled", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
curator_state,
|
||||
"run_curator_review",
|
||||
lambda **kwargs: {"auto_transitions": {}},
|
||||
)
|
||||
|
||||
assert curator_cli._cmd_run(_args(dry_run=True)) == 0
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "When the report lands" not in out
|
||||
assert "Read the report with `hermes curator status`" in out
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Tests for `hermes curator status` output.
|
||||
|
||||
Covers:
|
||||
- y0shualee's "least recently active" semantic (view/patch/use all count as activity).
|
||||
- The most-used / least-used rankings by activity_count so users can see which
|
||||
skills actually get exercised.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from argparse import Namespace
|
||||
from contextlib import redirect_stdout
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_status_uses_last_activity_not_only_last_used(monkeypatch, capsys):
|
||||
import agent.curator as curator_state
|
||||
import hermes_cli.curator as curator_cli
|
||||
import tools.skill_usage as skill_usage
|
||||
|
||||
monkeypatch.setattr(curator_state, "load_state", lambda: {
|
||||
"paused": False,
|
||||
"last_run_at": None,
|
||||
"last_run_summary": "(none)",
|
||||
"run_count": 0,
|
||||
})
|
||||
monkeypatch.setattr(curator_state, "is_enabled", lambda: True)
|
||||
monkeypatch.setattr(curator_state, "get_interval_hours", lambda: 168)
|
||||
monkeypatch.setattr(curator_state, "get_stale_after_days", lambda: 30)
|
||||
monkeypatch.setattr(curator_state, "get_archive_after_days", lambda: 90)
|
||||
monkeypatch.setattr(skill_usage, "agent_created_report", lambda: [
|
||||
{
|
||||
"name": "recently-viewed",
|
||||
"state": "active",
|
||||
"pinned": False,
|
||||
"use_count": 0,
|
||||
"view_count": 3,
|
||||
"patch_count": 1,
|
||||
"created_at": "2026-01-01T00:00:00+00:00",
|
||||
"last_used_at": None,
|
||||
"last_viewed_at": "2026-04-30T10:00:00+00:00",
|
||||
"last_patched_at": "2026-04-30T11:00:00+00:00",
|
||||
"last_activity_at": "2026-04-30T11:00:00+00:00",
|
||||
"activity_count": 4,
|
||||
}
|
||||
])
|
||||
|
||||
assert curator_cli._cmd_status(SimpleNamespace()) == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "least recently active" in out
|
||||
assert "activity= 4" in out
|
||||
assert "last_activity=never" not in out
|
||||
assert "last_used=never" not in out
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def curator_status_env(tmp_path, monkeypatch):
|
||||
"""Isolated HERMES_HOME with real agent-created skills on disk."""
|
||||
home = tmp_path / ".hermes"
|
||||
skills = home / "skills"
|
||||
skills.mkdir(parents=True)
|
||||
(home / "logs").mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
import importlib
|
||||
import hermes_constants
|
||||
importlib.reload(hermes_constants)
|
||||
from tools import skill_usage
|
||||
importlib.reload(skill_usage)
|
||||
from agent import curator
|
||||
importlib.reload(curator)
|
||||
from hermes_cli import curator as curator_cli
|
||||
importlib.reload(curator_cli)
|
||||
|
||||
def _write_skill(name: str) -> None:
|
||||
d = skills / name
|
||||
d.mkdir()
|
||||
(d / "SKILL.md").write_text(
|
||||
"---\n"
|
||||
f"name: {name}\n"
|
||||
"description: test\n"
|
||||
"version: 1.0.0\n"
|
||||
"metadata:\n"
|
||||
" hermes:\n"
|
||||
" agent_created: true\n"
|
||||
"---\n"
|
||||
f"# {name}\n"
|
||||
)
|
||||
|
||||
return {
|
||||
"home": home,
|
||||
"skills": skills,
|
||||
"make_skill": _write_skill,
|
||||
"skill_usage": skill_usage,
|
||||
"curator_cli": curator_cli,
|
||||
}
|
||||
|
||||
|
||||
def _capture_status(curator_cli) -> str:
|
||||
buf = io.StringIO()
|
||||
with redirect_stdout(buf):
|
||||
rc = curator_cli._cmd_status(Namespace())
|
||||
assert rc == 0
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_status_shows_most_and_least_used_sections(curator_status_env):
|
||||
env = curator_status_env
|
||||
env["make_skill"]("top-dog")
|
||||
env["make_skill"]("middling")
|
||||
env["make_skill"]("never-used")
|
||||
# Mark all three as agent-created so they enter the curator's catalog.
|
||||
# Under the provenance-marker semantics, skills must be explicitly opted
|
||||
# into curator management (normally via the background-review fork when
|
||||
# it creates a skill through skill_manage).
|
||||
for n in ("top-dog", "middling", "never-used"):
|
||||
env["skill_usage"].mark_agent_created(n)
|
||||
|
||||
# Bump use_count differentially. All three counters (use/view/patch) feed
|
||||
# into activity_count, so bumping use alone is enough to make activity
|
||||
# diverge between skills.
|
||||
for _ in range(10):
|
||||
env["skill_usage"].bump_use("top-dog")
|
||||
for _ in range(2):
|
||||
env["skill_usage"].bump_use("middling")
|
||||
|
||||
out = _capture_status(env["curator_cli"])
|
||||
|
||||
# Both new sections present
|
||||
assert "most active (top 5):" in out
|
||||
assert "least active (top 5):" in out
|
||||
# y0shualee's section preserved
|
||||
assert "least recently active (top 5):" in out
|
||||
|
||||
# most-active lists top-dog FIRST (highest activity_count)
|
||||
most_section = out.split("most active (top 5):")[1].split("\n\n")[0]
|
||||
top_line = most_section.strip().split("\n")[0]
|
||||
assert "top-dog" in top_line
|
||||
assert "activity= 10" in top_line
|
||||
|
||||
# least-active lists never-used FIRST (activity=0)
|
||||
least_section = out.split("least active (top 5):")[1].split("\n\n")[0]
|
||||
bottom_line = least_section.strip().split("\n")[0]
|
||||
assert "never-used" in bottom_line
|
||||
assert "activity= 0" in bottom_line
|
||||
|
||||
|
||||
def test_status_hides_most_active_when_all_zero(curator_status_env):
|
||||
"""If no skills have any activity, skip the most-active block — it's noise.
|
||||
Least-active still shows so the user sees their catalog."""
|
||||
env = curator_status_env
|
||||
env["make_skill"]("a")
|
||||
env["make_skill"]("b")
|
||||
# Mark both as agent-created so the catalog lists them. No bumps.
|
||||
env["skill_usage"].mark_agent_created("a")
|
||||
env["skill_usage"].mark_agent_created("b")
|
||||
|
||||
out = _capture_status(env["curator_cli"])
|
||||
|
||||
# most-active section is hidden because the top is 0
|
||||
assert "most active (top 5):" not in out
|
||||
# least-active still renders — it's part of the catalog overview
|
||||
assert "least active (top 5):" in out
|
||||
|
||||
|
||||
def test_status_no_skills_produces_clean_empty_output(curator_status_env):
|
||||
env = curator_status_env
|
||||
out = _capture_status(env["curator_cli"])
|
||||
assert "no agent-created skills" in out
|
||||
# None of the ranking sections render
|
||||
assert "most active" not in out
|
||||
assert "least active" not in out
|
||||
|
||||
|
||||
def test_status_marks_missing_last_report_path(monkeypatch, capsys, tmp_path):
|
||||
import agent.curator as curator_state
|
||||
import hermes_cli.curator as curator_cli
|
||||
import tools.skill_usage as skill_usage
|
||||
|
||||
missing_report = tmp_path / "stale-report"
|
||||
monkeypatch.setattr(curator_state, "load_state", lambda: {
|
||||
"paused": False,
|
||||
"last_run_at": None,
|
||||
"last_run_summary": "auto: no changes",
|
||||
"run_count": 1,
|
||||
"last_report_path": str(missing_report),
|
||||
})
|
||||
monkeypatch.setattr(curator_state, "is_enabled", lambda: True)
|
||||
monkeypatch.setattr(curator_state, "get_interval_hours", lambda: 168)
|
||||
monkeypatch.setattr(curator_state, "get_stale_after_days", lambda: 30)
|
||||
monkeypatch.setattr(curator_state, "get_archive_after_days", lambda: 90)
|
||||
monkeypatch.setattr(skill_usage, "agent_created_report", lambda: [])
|
||||
|
||||
assert curator_cli._cmd_status(SimpleNamespace()) == 0
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert f"last report: {missing_report} (missing)" in out
|
||||
@@ -0,0 +1,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
@@ -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"
|
||||
@@ -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
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Tests for hermes_cli.dump._get_git_commit — git SHA resolution for ``hermes dump``.
|
||||
|
||||
``hermes dump`` prints the running commit so support bug reports identify the
|
||||
exact version. Source installs resolve it live via ``git rev-parse``; the
|
||||
published Docker image excludes ``.git`` and falls back to the baked SHA
|
||||
written by the Dockerfile's ``HERMES_GIT_SHA`` build-arg.
|
||||
|
||||
These tests cover both paths plus the failure modes (no git, no baked file).
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
def test_get_git_commit_uses_live_git_when_available(tmp_path):
|
||||
"""Source install: ``git rev-parse --short=8 HEAD`` wins; no fallback."""
|
||||
from hermes_cli import dump
|
||||
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
|
||||
git_result = MagicMock(returncode=0, stdout="deadbeef\n")
|
||||
# build_info should NOT be consulted when live git succeeds.
|
||||
with patch("hermes_cli.dump.subprocess.run", return_value=git_result) as mock_run, \
|
||||
patch("hermes_cli.build_info.get_build_sha") as mock_build:
|
||||
commit = dump._get_git_commit(repo_dir)
|
||||
|
||||
assert commit == "deadbeef"
|
||||
mock_run.assert_called_once()
|
||||
mock_build.assert_not_called()
|
||||
|
||||
|
||||
def test_get_git_commit_falls_back_to_build_sha_when_live_git_fails(tmp_path):
|
||||
"""Docker image case: live git returns non-zero → use baked SHA."""
|
||||
from hermes_cli import dump
|
||||
|
||||
repo_dir = tmp_path / "no-git-here"
|
||||
repo_dir.mkdir()
|
||||
|
||||
failed = MagicMock(returncode=128, stdout="")
|
||||
with patch("hermes_cli.dump.subprocess.run", return_value=failed), \
|
||||
patch("hermes_cli.build_info.get_build_sha", return_value="cafef00d"):
|
||||
commit = dump._get_git_commit(repo_dir)
|
||||
|
||||
assert commit == "cafef00d"
|
||||
|
||||
|
||||
def test_get_git_commit_falls_back_when_git_returns_empty_stdout(tmp_path):
|
||||
"""Edge case: git exits 0 but prints nothing — still try the baked SHA."""
|
||||
from hermes_cli import dump
|
||||
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
|
||||
empty = MagicMock(returncode=0, stdout="\n")
|
||||
with patch("hermes_cli.dump.subprocess.run", return_value=empty), \
|
||||
patch("hermes_cli.build_info.get_build_sha", return_value="abcdef12"):
|
||||
commit = dump._get_git_commit(repo_dir)
|
||||
|
||||
assert commit == "abcdef12"
|
||||
|
||||
|
||||
def test_get_git_commit_falls_back_when_git_raises(tmp_path):
|
||||
"""git binary missing (e.g. minimal container w/o git) → baked SHA path."""
|
||||
from hermes_cli import dump
|
||||
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
|
||||
with patch("hermes_cli.dump.subprocess.run", side_effect=FileNotFoundError("git")), \
|
||||
patch("hermes_cli.build_info.get_build_sha", return_value="feedface"):
|
||||
commit = dump._get_git_commit(repo_dir)
|
||||
|
||||
assert commit == "feedface"
|
||||
|
||||
|
||||
def test_get_git_commit_returns_unknown_when_neither_source_available(tmp_path):
|
||||
"""Pip-installed wheel: no git, no baked SHA → '(unknown)' (legacy contract)."""
|
||||
from hermes_cli import dump
|
||||
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
|
||||
failed = MagicMock(returncode=128, stdout="")
|
||||
with patch("hermes_cli.dump.subprocess.run", return_value=failed), \
|
||||
patch("hermes_cli.build_info.get_build_sha", return_value=None):
|
||||
commit = dump._get_git_commit(repo_dir)
|
||||
|
||||
assert commit == "(unknown)"
|
||||
|
||||
|
||||
def test_get_git_commit_output_format_identical_between_sources(tmp_path):
|
||||
"""Regression guard: live-git and baked-SHA outputs share the same shape.
|
||||
|
||||
Ben explicitly asked for identical output between Docker and source installs
|
||||
so support tooling that parses ``hermes dump`` doesn't have to special-case
|
||||
container builds. Both paths must return a bare 8-char SHA — no prefix,
|
||||
no suffix, no annotation.
|
||||
"""
|
||||
from hermes_cli import dump
|
||||
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
|
||||
# Live-git path.
|
||||
git_result = MagicMock(returncode=0, stdout="b2f477a3\n")
|
||||
with patch("hermes_cli.dump.subprocess.run", return_value=git_result):
|
||||
live = dump._get_git_commit(repo_dir)
|
||||
|
||||
# Baked-SHA path.
|
||||
failed = MagicMock(returncode=128, stdout="")
|
||||
with patch("hermes_cli.dump.subprocess.run", return_value=failed), \
|
||||
patch("hermes_cli.build_info.get_build_sha", return_value="b2f477a3"):
|
||||
baked = dump._get_git_commit(repo_dir)
|
||||
|
||||
assert live == baked == "b2f477a3"
|
||||
# Same length, same charset — no decoration in either branch.
|
||||
assert len(live) == 8
|
||||
assert all(c in "0123456789abcdef" for c in live)
|
||||
@@ -0,0 +1,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()
|
||||
@@ -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
Reference in New Issue
Block a user