Merge remote-tracking branch 'origin/main' into jq/hermes-update-branch-flag

This commit is contained in:
emozilla 2026-05-27 00:48:25 -04:00
commit 3d9a26afad
1217 changed files with 178911 additions and 8214 deletions

View file

@ -54,7 +54,7 @@ class TestStaleOAuthTokenDetection:
# Simulate user types "3" (Cancel) when prompted for re-auth
monkeypatch.setattr("builtins.input", lambda _: "3")
monkeypatch.setattr("getpass.getpass", lambda _: "")
monkeypatch.setattr("hermes_cli.secret_prompt.masked_secret_prompt", lambda _: "")
from hermes_cli.main import _model_flow_anthropic
cfg = {}

View file

@ -40,7 +40,10 @@ def test_run_anthropic_oauth_flow_manual_token_still_persists(tmp_path, monkeypa
monkeypatch.setattr("agent.anthropic_adapter.read_claude_code_credentials", lambda: None)
monkeypatch.setattr("agent.anthropic_adapter.is_claude_code_token_valid", lambda creds: False)
monkeypatch.setattr("builtins.input", lambda _prompt="": "sk-ant-oat01-manual-token")
monkeypatch.setattr("getpass.getpass", lambda _prompt="": "sk-ant-oat01-manual-token")
monkeypatch.setattr(
"hermes_cli.secret_prompt.masked_secret_prompt",
lambda _prompt="": "sk-ant-oat01-manual-token",
)
from hermes_cli.main import _run_anthropic_oauth_flow

View file

@ -57,6 +57,59 @@ def _build_parser():
return parser
class TestChatVerboseArg:
"""Verify chat --verbose preserves config fallback when absent."""
def test_chat_without_verbose_leaves_attribute_unset(self):
from hermes_cli._parser import build_top_level_parser
parser, _subparsers, _chat_parser = build_top_level_parser()
args = parser.parse_args(["chat"])
assert not hasattr(args, "verbose")
def test_chat_verbose_sets_attribute_true(self):
from hermes_cli._parser import build_top_level_parser
parser, _subparsers, _chat_parser = build_top_level_parser()
args = parser.parse_args(["chat", "--verbose"])
assert args.verbose is True
def test_cmd_chat_forwards_none_when_verbose_is_absent(self, monkeypatch):
import types
import sys
import hermes_cli.main as main_mod
from hermes_cli._parser import build_top_level_parser
parser, _subparsers, chat_parser = build_top_level_parser()
chat_parser.set_defaults(func=main_mod.cmd_chat)
args = parser.parse_args(["chat"])
captured = {}
fake_cli = types.ModuleType("cli")
def fake_main(**kwargs):
captured.update(kwargs)
setattr(fake_cli, "main", fake_main)
fake_banner = types.ModuleType("hermes_cli.banner")
setattr(fake_banner, "prefetch_update_check", lambda: None)
fake_skills_sync = types.ModuleType("tools.skills_sync")
setattr(fake_skills_sync, "sync_skills", lambda quiet=True: None)
monkeypatch.setitem(sys.modules, "cli", fake_cli)
monkeypatch.setitem(sys.modules, "hermes_cli.banner", fake_banner)
monkeypatch.setitem(sys.modules, "tools.skills_sync", fake_skills_sync)
monkeypatch.setattr(main_mod, "_has_any_provider_configured", lambda: True)
monkeypatch.setattr(main_mod, "_pin_kanban_board_env", lambda: None)
main_mod.cmd_chat(args)
assert captured["quiet"] is False
assert "verbose" not in captured
class TestYoloEnvVar:
"""Verify --yolo sets HERMES_YOLO_MODE regardless of flag position.

View file

@ -1590,20 +1590,16 @@ def test_auth_remove_copilot_suppresses_all_variants(tmp_path, monkeypatch):
hermes_home.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
# The copilot pool entry is no longer persisted directly in auth.json —
# `(copilot, gh_cli)` is borrowed and stripped by
# sanitize_borrowed_credential_payload (PR #31416, May 2026). Tokens are
# hydrated at runtime via resolve_copilot_token(). Mock that path so the
# pool has an entry to remove.
_write_auth_store(
tmp_path,
{
"version": 1,
"credential_pool": {
"copilot": [{
"id": "c1",
"label": "gh auth token",
"auth_type": "api_key",
"priority": 0,
"source": "gh_cli",
"access_token": "ghp_fake",
}]
},
"credential_pool": {"copilot": []},
},
)
@ -1611,7 +1607,14 @@ def test_auth_remove_copilot_suppresses_all_variants(tmp_path, monkeypatch):
from hermes_cli.auth import is_source_suppressed
from hermes_cli.auth_commands import auth_remove_command
auth_remove_command(SimpleNamespace(provider="copilot", target="1"))
with patch(
"hermes_cli.copilot_auth.resolve_copilot_token",
return_value=("ghp_fake", "gh"),
), patch(
"hermes_cli.copilot_auth.get_copilot_api_token",
return_value="ghu_fake_api",
):
auth_remove_command(SimpleNamespace(provider="copilot", target="1"))
assert is_source_suppressed("copilot", "gh_cli")
assert is_source_suppressed("copilot", "env:COPILOT_GITHUB_TOKEN")

View file

@ -392,8 +392,84 @@ def test_get_qwen_auth_status_logged_in(qwen_env):
assert status["api_key"] == "status-at"
def test_get_qwen_auth_status_refreshes_expired_token(qwen_env):
expired_ms = int((time.time() - 3600) * 1000)
tokens = _make_qwen_tokens(access_token="old-at", expiry_date=expired_ms)
_write_qwen_creds(qwen_env, tokens)
refreshed = _make_qwen_tokens(access_token="refreshed-at")
with patch(
"hermes_cli.auth._refresh_qwen_cli_tokens", return_value=refreshed
) as mock_refresh:
status = get_qwen_auth_status()
mock_refresh.assert_called_once()
assert status["logged_in"] is True
assert status["api_key"] == "refreshed-at"
def test_get_qwen_auth_status_expired_unrefreshable_token_is_not_logged_in(qwen_env):
expired_ms = int((time.time() - 3600) * 1000)
tokens = _make_qwen_tokens(access_token="dead-at", expiry_date=expired_ms)
_write_qwen_creds(qwen_env, tokens)
with patch(
"hermes_cli.auth._refresh_qwen_cli_tokens",
side_effect=AuthError(
"Qwen refresh rejected. Re-run 'qwen auth qwen-oauth'.",
provider="qwen-oauth",
code="qwen_refresh_failed",
),
) as mock_refresh:
status = get_qwen_auth_status()
mock_refresh.assert_called_once()
assert status["logged_in"] is False
assert "qwen auth qwen-oauth" in status["error"]
def test_get_qwen_auth_status_not_logged_in(qwen_env):
# No credentials file
status = get_qwen_auth_status()
assert status["logged_in"] is False
assert "error" in status
def test_model_flow_qwen_oauth_stale_token_shows_reauth_guidance(qwen_env, monkeypatch, capsys):
from hermes_cli.main import _model_flow_qwen_oauth
expired_ms = int((time.time() - 3600) * 1000)
tokens = _make_qwen_tokens(access_token="dead-at", expiry_date=expired_ms)
_write_qwen_creds(qwen_env, tokens)
monkeypatch.setattr(
"hermes_cli.auth._refresh_qwen_cli_tokens",
lambda *args, **kwargs: (_ for _ in ()).throw(
AuthError(
"Qwen refresh rejected. Re-run 'qwen auth qwen-oauth'.",
provider="qwen-oauth",
code="qwen_refresh_failed",
)
),
)
prompt_called = {"value": False}
update_called = {"value": False}
monkeypatch.setattr(
"hermes_cli.auth._prompt_model_selection",
lambda *args, **kwargs: prompt_called.__setitem__("value", True),
)
monkeypatch.setattr(
"hermes_cli.auth._update_config_for_provider",
lambda *args, **kwargs: update_called.__setitem__("value", True),
)
_model_flow_qwen_oauth({}, current_model="qwen3-coder-plus")
out = capsys.readouterr().out
assert "Run: qwen auth qwen-oauth" in out
assert "Qwen refresh rejected" in out
assert prompt_called["value"] is False
assert update_called["value"] is False

View file

@ -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)

View file

@ -68,6 +68,13 @@ def _make_hermes_tree(root: Path) -> None:
(root / "logs" / "agent.log").write_text("log line\n")
def _symlink_file_or_skip(link: Path, target: Path) -> None:
try:
link.symlink_to(target)
except OSError as exc:
pytest.skip(f"symlinks unavailable in test environment: {exc}")
# ---------------------------------------------------------------------------
# _should_exclude tests
# ---------------------------------------------------------------------------
@ -257,6 +264,29 @@ class TestBackup:
zips = list(tmp_path.glob("hermes-backup-*.zip"))
assert len(zips) == 1
def test_skips_symlinked_files(self, tmp_path, monkeypatch):
"""Backup must not dereference symlinks and leak files outside HERMES_HOME."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
_make_hermes_tree(hermes_home)
outside = tmp_path / "outside-secret.txt"
outside.write_text("outside secret\n")
_symlink_file_or_skip(hermes_home / "skills" / "outside-link.txt", outside)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
out_zip = tmp_path / "backup.zip"
args = Namespace(output=str(out_zip))
from hermes_cli.backup import run_backup
run_backup(args)
with zipfile.ZipFile(out_zip, "r") as zf:
names = zf.namelist()
assert "skills/outside-link.txt" not in names
assert all(zf.read(name) != b"outside secret\n" for name in names)
# ---------------------------------------------------------------------------
# _validate_backup_zip tests
@ -1421,6 +1451,21 @@ class TestPreUpdateBackup:
f"remaining={remaining}"
)
def test_skips_symlinked_files(self, hermes_home, tmp_path):
"""Pre-update backups must not dereference symlinks outside HERMES_HOME."""
from hermes_cli.backup import create_pre_update_backup
outside = tmp_path / "outside-secret.txt"
outside.write_text("outside secret\n")
_symlink_file_or_skip(hermes_home / "skills" / "outside-link.txt", outside)
out = create_pre_update_backup(hermes_home=hermes_home)
assert out is not None
with zipfile.ZipFile(out) as zf:
names = zf.namelist()
assert "skills/outside-link.txt" not in names
assert all(zf.read(name) != b"outside secret\n" for name in names)
class TestRunPreUpdateBackup:
"""Tests for the ``_run_pre_update_backup`` wrapper in main.py —

View file

@ -0,0 +1,20 @@
from hermes_cli import cli_output
def test_password_prompt_uses_masked_secret_prompt(monkeypatch):
seen = {}
def fake_masked_secret_prompt(display):
seen["display"] = display
return " secret "
monkeypatch.setattr(cli_output, "masked_secret_prompt", fake_masked_secret_prompt)
assert cli_output.prompt("API key", default="old", password=True) == "secret"
assert "API key [old]" in seen["display"]
def test_empty_password_prompt_returns_default(monkeypatch):
monkeypatch.setattr(cli_output, "masked_secret_prompt", lambda _display: "")
assert cli_output.prompt("API key", default="old", password=True) == "old"

View file

@ -4,6 +4,7 @@ import os
from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
import yaml
from hermes_cli.config import (
@ -486,6 +487,49 @@ class TestOptionalEnvVarsRegistry:
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."""
@ -732,3 +776,120 @@ class TestUserMessagePreviewConfig:
preview = DEFAULT_CONFIG["display"]["user_message_preview"]
assert preview["first_lines"] == 2
assert preview["last_lines"] == 2
class TestEnvWriteDenylist:
"""``save_env_value`` refuses to persist env-var names that
influence how subprocesses execute ``LD_PRELOAD``, ``PYTHONPATH``,
``PATH``, ``EDITOR``, etc. or any ``HERMES_*`` runtime flag.
The dashboard exposes ``PUT /api/env`` to any authed caller (and
the session token lives in the SPA's HTML where any future plugin
XSS or local process could exfiltrate it). Without this gate, an
attacker who steals the token could plant
``LD_PRELOAD=/tmp/evil.so`` in ``.env`` and own the next Hermes
process on next startup via the dotenv ``os.environ`` chain in
``hermes_cli/env_loader.py``.
Regression test for the dashboard pentest finding filed alongside
the ``web-pentest`` skill (PR #32265 / issue #32267).
"""
@pytest.fixture(autouse=True)
def _hermes_home(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
ensure_hermes_home()
@pytest.mark.parametrize(
"denied_key",
[
"LD_PRELOAD",
"LD_LIBRARY_PATH",
"LD_AUDIT",
"DYLD_INSERT_LIBRARIES",
"DYLD_LIBRARY_PATH",
"PYTHONPATH",
"PYTHONHOME",
"PYTHONSTARTUP",
"NODE_OPTIONS",
"NODE_PATH",
"PATH",
"SHELL",
"EDITOR",
"VISUAL",
"PAGER",
"BROWSER",
"GIT_SSH_COMMAND",
"GIT_EXEC_PATH",
"HERMES_HOME",
"HERMES_PROFILE",
"HERMES_CONFIG",
"HERMES_ENV",
],
)
def test_denylisted_keys_rejected(self, denied_key):
"""Each denylisted name raises ``ValueError`` and never reaches
the on-disk ``.env`` file."""
with pytest.raises(ValueError, match="denylist"):
save_env_value(denied_key, "anything")
# And nothing landed on disk either.
env = load_env()
assert denied_key not in env
@pytest.mark.parametrize(
"allowed_key",
[
"HERMES_GEMINI_CLIENT_ID",
"HERMES_LANGFUSE_PUBLIC_KEY",
"HERMES_SPOTIFY_CLIENT_ID",
"HERMES_QWEN_BASE_URL",
"HERMES_MAX_ITERATIONS",
],
)
def test_hermes_integration_keys_still_writable(self, allowed_key):
"""``HERMES_*`` overall is NOT blocked — only the four runtime
location names (HOME/PROFILE/CONFIG/ENV) are. Integration
credentials following the ``HERMES_*`` convention must keep
working or we'd regress every provider setup wizard that
currently writes one of these (auth.py, Spotify, Langfuse, )."""
save_env_value(allowed_key, "test-value-123")
env = load_env()
assert env[allowed_key] == "test-value-123"
def test_legitimate_provider_key_still_works(self):
"""The denylist must not regress on real provider key writes."""
save_env_value("OPENROUTER_API_KEY", "sk-or-test-1234")
env = load_env()
assert env["OPENROUTER_API_KEY"] == "sk-or-test-1234"
def test_arbitrary_user_key_still_works(self):
"""Plugin / user-defined env vars (anything outside the
denylist and outside ``HERMES_*``) keep working. The denylist
is narrow on purpose."""
save_env_value("MY_PLUGIN_TOKEN", "plugin-secret-123")
env = load_env()
assert env["MY_PLUGIN_TOKEN"] == "plugin-secret-123"
def test_save_env_value_secure_inherits_denylist(self):
"""The ``_secure`` variant goes through ``save_env_value`` so
it inherits the gate verify, don't assume."""
with pytest.raises(ValueError, match="denylist"):
save_env_value_secure("LD_PRELOAD", "/tmp/evil.so")
def test_pre_existing_value_in_env_file_is_left_alone(self, tmp_path):
"""The gate is on *write*. If ``.env`` already contains
``LD_PRELOAD`` (set out-of-band by the operator before this
change shipped, or hand-edited), we don't blow up — we just
refuse to add or update it via the API."""
env_path = tmp_path / ".env"
env_path.write_text("LD_PRELOAD=/something/legit.so\n")
# load_env returns it (the read path is intentionally permissive)
env = load_env()
assert env["LD_PRELOAD"] == "/something/legit.so"
# But the write path still refuses to update it
with pytest.raises(ValueError, match="denylist"):
save_env_value("LD_PRELOAD", "/tmp/evil.so")

View file

@ -0,0 +1,578 @@
"""Tests for hermes_cli.container_boot — the cont-init.d-time
reconciliation that recreates per-profile gateway s6 service slots
from the persistent profiles directory.
These tests run against a fake $HERMES_HOME under tmp_path; no real
s6 supervision tree is required. The in-container integration test
covering end-to-end "docker restart" survival lives in
tests/docker/test_container_restart.py.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from hermes_cli.container_boot import (
ReconcileAction,
reconcile_profile_gateways,
)
# ---------------------------------------------------------------------------
# Fixtures + helpers
# ---------------------------------------------------------------------------
def _make_profile(
hermes_home: Path,
name: str,
*,
state: str | None,
with_pid: bool = False,
config: bool = True,
) -> Path:
"""Create a fake profile directory under hermes_home/profiles/<name>/."""
p = hermes_home / "profiles" / name
p.mkdir(parents=True)
if config:
# SOUL.md is what the reconciler keys on — it's always seeded by
# `hermes profile create`. See container_boot._render_run_script.
(p / "SOUL.md").write_text("# fake profile\n")
if state is not None:
(p / "gateway_state.json").write_text(json.dumps({
"gateway_state": state, "timestamp": 1234567890,
}))
if with_pid:
(p / "gateway.pid").write_text(json.dumps(
{"pid": 99999, "host": "old-container"},
))
(p / "processes.json").write_text("[]")
return p
def _seed_default_root(
hermes_home: Path,
*,
state: str | None = None,
with_pid: bool = False,
) -> None:
"""Populate gateway_state.json / stale runtime files at the
HERMES_HOME root (the implicit default profile)."""
if state is not None:
(hermes_home / "gateway_state.json").write_text(json.dumps({
"gateway_state": state, "timestamp": 1234567890,
}))
if with_pid:
(hermes_home / "gateway.pid").write_text(json.dumps(
{"pid": 99999, "host": "old-container"},
))
(hermes_home / "processes.json").write_text("[]")
def _named_actions(actions: list[ReconcileAction]) -> list[ReconcileAction]:
"""Drop the always-present default-profile action so tests that
only care about named profiles can assert against a clean list."""
return [a for a in actions if a.profile != "default"]
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_running_profile_is_registered_and_autostarted(tmp_path: Path) -> None:
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(tmp_path, "coder", state="running")
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
assert _named_actions(actions) == [ReconcileAction(
profile="coder", prior_state="running", action="started",
)]
svc = scandir / "gateway-coder"
assert (svc / "run").exists()
assert (svc / "run").stat().st_mode & 0o111 # executable
assert (svc / "type").read_text().strip() == "longrun"
# Auto-start means no down-marker.
assert not (svc / "down").exists()
def test_stopped_profile_is_registered_but_not_started(tmp_path: Path) -> None:
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(tmp_path, "writer", state="stopped")
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
assert _named_actions(actions) == [ReconcileAction(
profile="writer", prior_state="stopped", action="registered",
)]
# down marker tells s6-svscan to NOT start the service.
assert (scandir / "gateway-writer" / "down").exists()
def test_startup_failed_does_not_autostart(tmp_path: Path) -> None:
"""Avoid crash-loop on restart when the gateway was failing to boot."""
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(tmp_path, "broken", state="startup_failed")
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
named = _named_actions(actions)
assert named[0].action == "registered"
assert (scandir / "gateway-broken" / "down").exists()
def test_starting_state_does_not_autostart(tmp_path: Path) -> None:
"""`starting` means the gateway died mid-boot last time; treat as
failed, not as a candidate for auto-restart."""
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(tmp_path, "unlucky", state="starting")
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
named = _named_actions(actions)
assert named[0].action == "registered"
def test_stale_runtime_files_are_removed(tmp_path: Path) -> None:
scandir = tmp_path / "run-service"; scandir.mkdir()
profile = _make_profile(tmp_path, "coder", state="running", with_pid=True)
assert (profile / "gateway.pid").exists()
assert (profile / "processes.json").exists()
reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
assert not (profile / "gateway.pid").exists()
assert not (profile / "processes.json").exists()
def test_profile_without_state_file_is_registered_but_not_started(
tmp_path: Path,
) -> None:
"""A freshly-created profile that's never been started: register
its slot but don't auto-start."""
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(tmp_path, "fresh", state=None)
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
assert _named_actions(actions) == [ReconcileAction(
profile="fresh", prior_state=None, action="registered",
)]
assert (scandir / "gateway-fresh" / "down").exists()
def test_directory_without_marker_file_is_skipped(tmp_path: Path) -> None:
"""A stray dir under profiles/ that isn't actually a profile (no
SOUL.md the marker the reconciler keys on) should be skipped."""
scandir = tmp_path / "run-service"; scandir.mkdir()
# Create a profile dir but without SOUL.md
(tmp_path / "profiles" / "stray").mkdir(parents=True)
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
assert _named_actions(actions) == []
assert not (scandir / "gateway-stray").exists()
def test_corrupt_state_file_treated_as_no_prior_state(tmp_path: Path) -> None:
"""If gateway_state.json is malformed JSON, don't blow up the whole
reconciliation register the slot in the down state."""
scandir = tmp_path / "run-service"; scandir.mkdir()
profile = _make_profile(tmp_path, "junk", state="running")
(profile / "gateway_state.json").write_text("{ not valid json")
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
named = _named_actions(actions)
assert named[0].action == "registered" # not "started"
assert (scandir / "gateway-junk" / "down").exists()
def test_reconcile_log_is_written(tmp_path: Path) -> None:
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(tmp_path, "a", state="running")
_make_profile(tmp_path, "b", state="stopped")
reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
log = (tmp_path / "logs" / "container-boot.log").read_text()
assert "profile=a" in log
assert "action=started" in log
assert "profile=b" in log
assert "action=registered" in log
def test_reconcile_log_rotates_when_size_exceeded(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When container-boot.log exceeds _LOG_ROTATE_BYTES, the existing
file is rotated to .1 before the new entries are appended."""
from hermes_cli import container_boot
# Tighten the threshold so we don't have to write 256 KiB.
monkeypatch.setattr(container_boot, "_LOG_ROTATE_BYTES", 200)
log_path = tmp_path / "logs" / "container-boot.log"
log_path.parent.mkdir()
log_path.write_text("X" * 300) # already over the threshold
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(tmp_path, "coder", state="running")
reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
rotated = tmp_path / "logs" / "container-boot.log.1"
assert rotated.exists(), "expected previous log to be rotated to .1"
assert rotated.read_text().startswith("X" * 300)
# The new entries land in a fresh container-boot.log (no leftover Xs).
new_contents = log_path.read_text()
assert "X" not in new_contents
assert "profile=coder" in new_contents
def test_reconcile_log_does_not_rotate_below_threshold(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A small existing log is appended to in place; no .1 is created."""
from hermes_cli import container_boot
monkeypatch.setattr(container_boot, "_LOG_ROTATE_BYTES", 10_000_000)
log_path = tmp_path / "logs" / "container-boot.log"
log_path.parent.mkdir()
log_path.write_text("previous entry\n")
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(tmp_path, "coder", state="running")
reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
assert not (tmp_path / "logs" / "container-boot.log.1").exists()
contents = log_path.read_text()
assert contents.startswith("previous entry\n")
assert "profile=coder" in contents
def test_reconcile_log_rotation_overwrites_existing_dot1(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Rotating again replaces the prior .1 — we keep at most one
rotated file (soft cap of ~2 × threshold)."""
from hermes_cli import container_boot
monkeypatch.setattr(container_boot, "_LOG_ROTATE_BYTES", 200)
log_dir = tmp_path / "logs"; log_dir.mkdir()
(log_dir / "container-boot.log.1").write_text("OLD ROTATION")
(log_dir / "container-boot.log").write_text("Y" * 300)
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(tmp_path, "coder", state="running")
reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
# .1 now contains the previous .log (Ys), not OLD ROTATION.
rotated = (log_dir / "container-boot.log.1").read_text()
assert "OLD ROTATION" not in rotated
assert rotated.startswith("Y" * 300)
def test_dry_run_makes_no_filesystem_changes(tmp_path: Path) -> None:
scandir = tmp_path / "run-service"; scandir.mkdir()
profile = _make_profile(tmp_path, "coder", state="running", with_pid=True)
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=True,
)
# The action list is still produced...
assert _named_actions(actions) == [ReconcileAction(
profile="coder", prior_state="running", action="started",
)]
# ...but nothing on disk was touched.
assert (profile / "gateway.pid").exists() # not removed under dry_run
assert not (scandir / "gateway-coder").exists()
assert not (tmp_path / "logs" / "container-boot.log").exists()
def test_missing_profiles_root_still_registers_default_slot(
tmp_path: Path,
) -> None:
"""When $HERMES_HOME/profiles doesn't exist (fresh install), the
reconciliation should still register a gateway-default slot for
the root profile and return without raising. Previously this
returned an empty list; the default slot is now always present
so `hermes gateway start` (no -p) has somewhere to land."""
scandir = tmp_path / "run-service"; scandir.mkdir()
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
assert actions == [ReconcileAction(
profile="default", prior_state=None, action="registered",
)]
assert (scandir / "gateway-default").is_dir()
assert (scandir / "gateway-default" / "down").exists()
def test_invalid_profile_name_in_directory_raises(tmp_path: Path) -> None:
"""A profile dir whose name doesn't match validate_profile_name's
rules (uppercase, etc.) must surface as a hard error rather than
silently produce an invalid s6 service dir."""
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(tmp_path, "BadName", state="running")
with pytest.raises(ValueError):
reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
def test_register_service_publishes_atomically(tmp_path: Path) -> None:
"""The reconciler should build the new service dir in a sibling
tmp directory and rename it into place never leaving a half-
populated slot visible to a concurrent s6-svscan rescan.
We verify the invariant indirectly: after a clean reconcile, the
target directory exists with all required files, and no sibling
.tmp leftovers remain. (Atomic publication is the only way to
achieve both with mkdir + write.)
"""
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(tmp_path, "coder", state="running")
reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
# No leftover tmp dir.
leftover = list(scandir.glob("*.tmp"))
assert leftover == [], f"leftover tmp directories: {leftover}"
# Target is fully populated.
svc = scandir / "gateway-coder"
assert (svc / "type").exists()
assert (svc / "run").exists()
assert (svc / "log" / "run").exists()
def test_register_service_overwrites_existing_slot(tmp_path: Path) -> None:
"""A second reconciliation pass cleanly replaces an existing
slot (the tmp+rename publication overwrites the previous one)."""
scandir = tmp_path / "run-service"; scandir.mkdir()
profile = _make_profile(tmp_path, "coder", state="running")
# First pass.
reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
first_run = (scandir / "gateway-coder" / "run").read_text()
# Mutate the profile state so the run-script changes (extra_env
# rendering would differ if we wired profile config through, but
# for now just exercise the overwrite path).
(profile / "gateway_state.json").write_text(
'{"gateway_state": "stopped"}',
)
reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
# Slot still exists, no .tmp remnants.
assert (scandir / "gateway-coder" / "run").read_text() == first_run
assert list(scandir.glob("*.tmp")) == []
# Down marker now present (state went from running → stopped).
assert (scandir / "gateway-coder" / "down").exists()
def test_register_service_cleans_up_stale_tmp_dir(tmp_path: Path) -> None:
"""If a previous interrupted run left a .tmp sibling directory,
a fresh reconcile must clean it up rather than failing on mkdir."""
scandir = tmp_path / "run-service"; scandir.mkdir()
# Simulate a leftover from an interrupted run.
stale_tmp = scandir / "gateway-coder.tmp"
stale_tmp.mkdir()
(stale_tmp / "stale-file").write_text("garbage")
_make_profile(tmp_path, "coder", state="running")
reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
assert not stale_tmp.exists()
assert (scandir / "gateway-coder" / "run").exists()
# ---------------------------------------------------------------------------
# Default-profile slot — always registered (PR #30136 review item I1)
# ---------------------------------------------------------------------------
def test_default_slot_always_registered_on_empty_home(tmp_path: Path) -> None:
"""Bare HERMES_HOME with nothing under it still produces a
gateway-default slot (down state)."""
scandir = tmp_path / "run-service"; scandir.mkdir()
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
assert actions == [ReconcileAction(
profile="default", prior_state=None, action="registered",
)]
svc = scandir / "gateway-default"
assert svc.is_dir()
assert (svc / "run").exists()
assert (svc / "down").exists()
def test_default_slot_run_script_omits_profile_flag(tmp_path: Path) -> None:
"""The default slot's run script must NOT pass `-p default` —
that would resolve to $HERMES_HOME/profiles/default/ instead of
the root profile. It must call `hermes gateway run` directly."""
scandir = tmp_path / "run-service"; scandir.mkdir()
reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
run = (scandir / "gateway-default" / "run").read_text()
assert "hermes gateway run" in run
assert "-p default" not in run
assert "-p 'default'" not in run
def test_default_slot_autostarts_when_root_state_running(tmp_path: Path) -> None:
"""gateway_state.json at the HERMES_HOME root with state=running
means the default slot auto-starts on container boot."""
scandir = tmp_path / "run-service"; scandir.mkdir()
_seed_default_root(tmp_path, state="running")
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
default_action = next(a for a in actions if a.profile == "default")
assert default_action.prior_state == "running"
assert default_action.action == "started"
assert not (scandir / "gateway-default" / "down").exists()
def test_default_slot_does_not_autostart_when_root_state_stopped(
tmp_path: Path,
) -> None:
scandir = tmp_path / "run-service"; scandir.mkdir()
_seed_default_root(tmp_path, state="stopped")
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
default_action = next(a for a in actions if a.profile == "default")
assert default_action.action == "registered"
assert (scandir / "gateway-default" / "down").exists()
def test_default_slot_does_not_autostart_when_root_state_startup_failed(
tmp_path: Path,
) -> None:
"""Crash-loop guard applies to the default slot too."""
scandir = tmp_path / "run-service"; scandir.mkdir()
_seed_default_root(tmp_path, state="startup_failed")
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
default_action = next(a for a in actions if a.profile == "default")
assert default_action.action == "registered"
def test_default_slot_cleans_up_stale_runtime_files_at_root(
tmp_path: Path,
) -> None:
"""gateway.pid and processes.json at the HERMES_HOME root (left
over from the previous container's default gateway) must be
swept the same way as for named profiles."""
scandir = tmp_path / "run-service"; scandir.mkdir()
_seed_default_root(tmp_path, state="running", with_pid=True)
assert (tmp_path / "gateway.pid").exists()
reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
assert not (tmp_path / "gateway.pid").exists()
assert not (tmp_path / "processes.json").exists()
def test_default_slot_appears_before_named_profiles(tmp_path: Path) -> None:
"""The action list is ordered: default first, then named profiles
in directory order. Operators and the boot-log reader rely on
this ordering being stable."""
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(tmp_path, "z-last-alphabetically", state="stopped")
_make_profile(tmp_path, "a-first-alphabetically", state="stopped")
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
assert [a.profile for a in actions] == [
"default",
"a-first-alphabetically",
"z-last-alphabetically",
]
def test_profiles_default_subdir_is_skipped_with_warning(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A user-created profiles/default/ collides with the reserved
root-profile slot the named entry is skipped (with a warning)
so we don't double-register gateway-default."""
import logging
caplog.set_level(logging.WARNING)
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(tmp_path, "default", state="running")
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
# Only the root-profile default slot appears — not the colliding
# named profile.
default_actions = [a for a in actions if a.profile == "default"]
assert len(default_actions) == 1
# And the warning surfaces so operators know the named profile
# was ignored.
assert any(
"profiles/default/" in record.message for record in caplog.records
)

View file

@ -0,0 +1,131 @@
"""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, call
import pytest
# 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}"
)

View file

@ -353,6 +353,40 @@ class TestCaptureLogSnapshotRedaction:
assert snap.full_text is not None
assert _REDACT_FIXTURE_TOKEN not in snap.full_text
def test_default_redacts_email_addresses_for_public_share(
self, hermes_home_with_secret
):
from hermes_cli.debug import _capture_log_snapshot
log_path = hermes_home_with_secret / "logs" / "agent.log"
log_path.write_text(
"2026-04-12 17:00:00 INFO gateway.run: "
"inbound message: platform=bluebubbles "
"user=person@example.com chat=iMessage;-;person@example.com msg='hello'\n"
)
snap = _capture_log_snapshot("agent", tail_lines=10)
assert "person@example.com" not in snap.tail_text
assert "[REDACTED_EMAIL]" in snap.tail_text
assert snap.full_text is not None
assert "person@example.com" not in snap.full_text
def test_no_redact_preserves_email_addresses(self, hermes_home_with_secret):
from hermes_cli.debug import _capture_log_snapshot
log_path = hermes_home_with_secret / "logs" / "agent.log"
log_path.write_text(
"2026-04-12 17:00:00 INFO gateway.run: "
"inbound message: platform=bluebubbles "
"user=person@example.com chat=iMessage;-;person@example.com msg='hello'\n"
)
snap = _capture_log_snapshot("agent", tail_lines=10, redact=False)
assert "person@example.com" in snap.tail_text
assert "person@example.com" in (snap.full_text or "")
def test_capture_default_log_snapshots_threads_redact(
self, hermes_home_with_secret
):

View file

@ -70,6 +70,23 @@ def test_user_env_takes_precedence_over_project_env(tmp_path, monkeypatch):
assert os.getenv("OPENAI_API_KEY") == "project-key"
def test_null_bytes_in_user_env_are_stripped(tmp_path, monkeypatch):
home = tmp_path / "hermes"
home.mkdir()
env_file = home / ".env"
# Null bytes can be introduced when copy-pasting API keys.
env_file.write_text("GLM_API_KEY=abc\x00\x00\nOPENAI_API_KEY=sk-123\n", encoding="utf-8")
monkeypatch.delenv("GLM_API_KEY", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
loaded = load_hermes_dotenv(hermes_home=home)
assert loaded == [env_file]
assert os.getenv("GLM_API_KEY") == "abc"
assert os.getenv("OPENAI_API_KEY") == "sk-123"
def test_main_import_applies_user_env_over_shell_values(tmp_path, monkeypatch):
home = tmp_path / "hermes"
home.mkdir()

View file

@ -55,6 +55,31 @@ class TestReadChain:
{"provider": "nous", "model": "Hermes-4-Llama-3.1-405B"},
]
def test_merges_new_and_legacy_formats(self):
from hermes_cli.fallback_cmd import _read_chain
cfg = {
"fallback_providers": [
{"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"},
],
"fallback_model": {"provider": "nous", "model": "Hermes-4"},
}
assert _read_chain(cfg) == [
{"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"},
{"provider": "nous", "model": "Hermes-4"},
]
def test_legacy_duplicate_is_deduplicated_after_merge(self):
from hermes_cli.fallback_cmd import _read_chain
cfg = {
"fallback_providers": [
{"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"},
],
"fallback_model": {"provider": "OpenRouter", "model": "anthropic/claude-sonnet-4.6"},
}
assert _read_chain(cfg) == [
{"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"},
]
def test_migrates_legacy_single_dict(self):
from hermes_cli.fallback_cmd import _read_chain
cfg = {"fallback_model": {"provider": "openrouter", "model": "gpt-5.4"}}

View file

@ -0,0 +1,335 @@
"""Tests for the Phase 4 s6 dispatch helper in hermes_cli.gateway.
`_dispatch_via_service_manager_if_s6` decides whether a
`hermes gateway start/stop/restart` invocation should be routed to
the in-container S6ServiceManager instead of falling through to the
host systemd/launchd/windows code path.
"""
from __future__ import annotations
from typing import Any
import pytest
class _CallRecorder:
"""Minimal stand-in for S6ServiceManager."""
kind = "s6"
def __init__(self) -> None:
self.calls: list[tuple[str, str]] = []
def start(self, name: str) -> None:
self.calls.append(("start", name))
def stop(self, name: str) -> None:
self.calls.append(("stop", name))
def restart(self, name: str) -> None:
self.calls.append(("restart", name))
def test_dispatch_returns_false_on_host(monkeypatch: pytest.MonkeyPatch) -> None:
"""When the environment isn't s6 (host run), the helper must
return False and not invoke a manager callers continue with
their existing systemd/launchd/windows path."""
from hermes_cli import gateway as gw
monkeypatch.setattr(
"hermes_cli.service_manager.detect_service_manager", lambda: "systemd",
)
# Should not even attempt to construct a manager.
monkeypatch.setattr(
"hermes_cli.service_manager.get_service_manager",
lambda: pytest.fail("manager should not be constructed on host"),
)
assert gw._dispatch_via_service_manager_if_s6("start", profile="x") is False
def test_dispatch_returns_true_and_calls_start_on_s6(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from hermes_cli import gateway as gw
rec = _CallRecorder()
monkeypatch.setattr(
"hermes_cli.service_manager.detect_service_manager", lambda: "s6",
)
monkeypatch.setattr(
"hermes_cli.service_manager.get_service_manager", lambda: rec,
)
assert gw._dispatch_via_service_manager_if_s6("start", profile="coder") is True
assert rec.calls == [("start", "gateway-coder")]
@pytest.mark.parametrize("action,expected", [
("start", "start"),
("stop", "stop"),
("restart", "restart"),
])
def test_dispatch_translates_action_to_manager_method(
monkeypatch: pytest.MonkeyPatch, action: str, expected: str,
) -> None:
from hermes_cli import gateway as gw
rec = _CallRecorder()
monkeypatch.setattr(
"hermes_cli.service_manager.detect_service_manager", lambda: "s6",
)
monkeypatch.setattr(
"hermes_cli.service_manager.get_service_manager", lambda: rec,
)
assert gw._dispatch_via_service_manager_if_s6(action, profile="x") is True
assert rec.calls == [(expected, "gateway-x")]
def test_dispatch_unknown_action_returns_false(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An unrecognized action (e.g. 'install') must not silently
succeed return False so the host code path handles it."""
from hermes_cli import gateway as gw
rec = _CallRecorder()
monkeypatch.setattr(
"hermes_cli.service_manager.detect_service_manager", lambda: "s6",
)
monkeypatch.setattr(
"hermes_cli.service_manager.get_service_manager", lambda: rec,
)
assert gw._dispatch_via_service_manager_if_s6("install", profile="x") is False
assert rec.calls == []
def test_dispatch_defaults_profile_to_default(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When profile is None, the helper resolves it via _profile_arg().
With no profile context set anywhere, that resolves to "default"."""
from hermes_cli import gateway as gw
rec = _CallRecorder()
monkeypatch.setattr(
"hermes_cli.service_manager.detect_service_manager", lambda: "s6",
)
monkeypatch.setattr(
"hermes_cli.service_manager.get_service_manager", lambda: rec,
)
monkeypatch.setattr(
"hermes_cli.gateway._profile_suffix", lambda: "",
)
assert gw._dispatch_via_service_manager_if_s6("start") is True
assert rec.calls == [("start", "gateway-default")]
# ---------------------------------------------------------------------------
# _dispatch_all_via_service_manager_if_s6 — --all under s6
# ---------------------------------------------------------------------------
class _ListingRecorder(_CallRecorder):
"""_CallRecorder that also exposes a profile list."""
def __init__(self, profiles: list[str]) -> None:
super().__init__()
self._profiles = profiles
def list_profile_gateways(self) -> list[str]:
return list(self._profiles)
def test_dispatch_all_returns_false_on_host(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from hermes_cli import gateway as gw
monkeypatch.setattr(
"hermes_cli.service_manager.detect_service_manager", lambda: "systemd",
)
monkeypatch.setattr(
"hermes_cli.service_manager.get_service_manager",
lambda: pytest.fail("manager should not be constructed on host"),
)
assert gw._dispatch_all_via_service_manager_if_s6("stop") is False
def test_dispatch_all_iterates_every_profile_on_stop(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture,
) -> None:
from hermes_cli import gateway as gw
rec = _ListingRecorder(["coder", "writer", "assistant"])
monkeypatch.setattr(
"hermes_cli.service_manager.detect_service_manager", lambda: "s6",
)
monkeypatch.setattr(
"hermes_cli.service_manager.get_service_manager", lambda: rec,
)
assert gw._dispatch_all_via_service_manager_if_s6("stop") is True
assert rec.calls == [
("stop", "gateway-coder"),
("stop", "gateway-writer"),
("stop", "gateway-assistant"),
]
out = capsys.readouterr().out
assert "Stopped 3 profile gateway(s)" in out
def test_dispatch_all_iterates_every_profile_on_restart(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture,
) -> None:
from hermes_cli import gateway as gw
rec = _ListingRecorder(["coder", "writer"])
monkeypatch.setattr(
"hermes_cli.service_manager.detect_service_manager", lambda: "s6",
)
monkeypatch.setattr(
"hermes_cli.service_manager.get_service_manager", lambda: rec,
)
assert gw._dispatch_all_via_service_manager_if_s6("restart") is True
assert rec.calls == [
("restart", "gateway-coder"),
("restart", "gateway-writer"),
]
out = capsys.readouterr().out
assert "Restarted 2 profile gateway(s)" in out
def test_dispatch_all_handles_partial_failure(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture,
) -> None:
"""A failure on one profile must not skip the others; the helper
reports each failure and the success count."""
from hermes_cli import gateway as gw
class _FailOnWriter(_ListingRecorder):
def stop(self, name: str) -> None:
if name == "gateway-writer":
raise RuntimeError("supervise FIFO permission denied")
super().stop(name)
rec = _FailOnWriter(["coder", "writer", "assistant"])
monkeypatch.setattr(
"hermes_cli.service_manager.detect_service_manager", lambda: "s6",
)
monkeypatch.setattr(
"hermes_cli.service_manager.get_service_manager", lambda: rec,
)
assert gw._dispatch_all_via_service_manager_if_s6("stop") is True
# The two successful ones were called; writer raised before recording.
assert ("stop", "gateway-coder") in rec.calls
assert ("stop", "gateway-assistant") in rec.calls
assert ("stop", "gateway-writer") not in rec.calls
out = capsys.readouterr().out
assert "Stopped 2 profile gateway(s)" in out
assert "Could not stop gateway-writer" in out
assert "supervise FIFO permission denied" in out
def test_dispatch_all_empty_list_reports_and_returns_true(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture,
) -> None:
"""With no profile gateways registered the helper still claims the
dispatch (returns True) and prints a friendly message the host
fallback would just pkill nothing, which isn't useful inside a
container."""
from hermes_cli import gateway as gw
rec = _ListingRecorder([])
monkeypatch.setattr(
"hermes_cli.service_manager.detect_service_manager", lambda: "s6",
)
monkeypatch.setattr(
"hermes_cli.service_manager.get_service_manager", lambda: rec,
)
assert gw._dispatch_all_via_service_manager_if_s6("stop") is True
assert rec.calls == []
assert "No profile gateways" in capsys.readouterr().out
def test_dispatch_all_unknown_action_returns_false(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""`start --all` is not a supported CLI surface; the helper must
fall through to the host code path rather than no-op."""
from hermes_cli import gateway as gw
monkeypatch.setattr(
"hermes_cli.service_manager.detect_service_manager", lambda: "s6",
)
monkeypatch.setattr(
"hermes_cli.service_manager.get_service_manager",
lambda: pytest.fail(
"manager should not be constructed for unsupported --all action",
),
)
assert gw._dispatch_all_via_service_manager_if_s6("start") is False
# ---------------------------------------------------------------------------
# Friendly error rendering — GatewayNotRegisteredError / S6CommandError
# (PR #30136 review item I2)
# ---------------------------------------------------------------------------
def test_dispatch_renders_gateway_not_registered_friendly(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture,
) -> None:
"""`hermes -p typo gateway start` should print a clear message and
exit 1 not dump a traceback at the user."""
from hermes_cli import gateway as gw
from hermes_cli.service_manager import GatewayNotRegisteredError
class _RaisesMissing:
kind = "s6"
def start(self, name: str) -> None:
raise GatewayNotRegisteredError("typo")
monkeypatch.setattr(
"hermes_cli.service_manager.detect_service_manager", lambda: "s6",
)
monkeypatch.setattr(
"hermes_cli.service_manager.get_service_manager", lambda: _RaisesMissing(),
)
with pytest.raises(SystemExit) as excinfo:
gw._dispatch_via_service_manager_if_s6("start", profile="typo")
assert excinfo.value.code == 1
out = capsys.readouterr().out
assert "no such gateway 'typo'" in out
assert "hermes profile create typo" in out
# And critically: no traceback prefix.
assert "Traceback" not in out
def test_dispatch_renders_s6_command_error_friendly(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture,
) -> None:
"""An s6-svc failure (e.g. EACCES on the supervise FIFO) should
surface the stderr inline, not as an opaque traceback."""
from hermes_cli import gateway as gw
from hermes_cli.service_manager import S6CommandError
class _RaisesS6Error:
kind = "s6"
def start(self, name: str) -> None:
raise S6CommandError(
service=name,
action="start",
returncode=111,
stderr="s6-svc: fatal: Permission denied",
)
monkeypatch.setattr(
"hermes_cli.service_manager.detect_service_manager", lambda: "s6",
)
monkeypatch.setattr(
"hermes_cli.service_manager.get_service_manager", lambda: _RaisesS6Error(),
)
with pytest.raises(SystemExit) as excinfo:
gw._dispatch_via_service_manager_if_s6("start", profile="coder")
assert excinfo.value.code == 1
out = capsys.readouterr().out
assert "rc=111" in out
assert "Permission denied" in out
assert "Traceback" not in out

View file

@ -69,18 +69,19 @@ class TestPluginPickerInjection:
assert "Myimg" in names
assert "myimg" in plugin_names
def test_fal_skipped_to_avoid_duplicate(self, monkeypatch):
def test_fal_surfaced_alongside_other_plugins(self, monkeypatch):
from hermes_cli import tools_config
# Simulate a FAL plugin being registered — the picker already has
# hardcoded FAL rows in TOOL_CATEGORIES, so plugin-FAL must be
# skipped to avoid showing FAL twice.
# After #26241, FAL is itself a plugin (`plugins/image_gen/fal/`)
# and the hardcoded `TOOL_CATEGORIES["image_gen"]` FAL row is
# gone. The plugin-row builder therefore surfaces it like any
# other backend — no deduplication step needed.
image_gen_registry.register_provider(_FakeProvider("fal"))
image_gen_registry.register_provider(_FakeProvider("openai"))
rows = tools_config._plugin_image_gen_providers()
names = [r.get("image_gen_plugin_name") for r in rows]
assert "fal" not in names
assert "fal" in names
assert "openai" in names
def test_visible_providers_includes_plugins_for_image_gen(self, monkeypatch):

View file

@ -1,4 +1,4 @@
"""Tests for ``install_cua_driver`` upgrade semantics.
"""Tests for ``install_cua_driver`` upgrade semantics and architecture pre-check.
The cua-driver upstream installer always pulls the latest release tag, so
re-running it is the canonical upgrade path. ``install_cua_driver(upgrade=True)``
@ -10,18 +10,18 @@ must:
fix for the "we only pulled cua-driver once on enable" complaint).
* Preserve original ``upgrade=False`` behaviour for the toolset-enable flow:
skip if installed, install otherwise, warn on non-macOS.
* Pre-check architecture compatibility before downloading to avoid raw 404
errors on Intel macOS when the upstream release lacks x86_64 assets.
"""
from __future__ import annotations
from unittest.mock import patch
import json
from unittest.mock import MagicMock, patch
class TestInstallCuaDriverUpgrade:
def test_upgrade_on_non_macos_is_silent_noop(self):
"""``hermes update`` calls install_cua_driver(upgrade=True) for every
user. On Linux/Windows it must return False without printing the
"macOS-only; skipping" warning that the toolset-enable path emits."""
from hermes_cli import tools_config
with patch.object(tools_config, "_print_warning") as warn, \
@ -30,8 +30,6 @@ class TestInstallCuaDriverUpgrade:
warn.assert_not_called()
def test_non_upgrade_on_non_macos_warns(self):
"""The toolset-enable path (upgrade=False) should still warn loudly
when the user tries to enable Computer Use on a non-macOS host."""
from hermes_cli import tools_config
with patch.object(tools_config, "_print_warning") as warn, \
@ -40,43 +38,36 @@ class TestInstallCuaDriverUpgrade:
warn.assert_called()
def test_upgrade_on_macos_with_binary_runs_installer(self):
"""When cua-driver is already on PATH and upgrade=True, we must
re-run the upstream installer (this is the fix for the bug report).
"""
from hermes_cli import tools_config
with patch("platform.system", return_value="Darwin"), \
patch.object(tools_config.shutil, "which",
side_effect=lambda n: "/usr/local/bin/" + n
if n in {"cua-driver", "curl"} else None), \
patch.object(tools_config, "_check_cua_driver_asset_for_arch",
return_value=True), \
patch.object(tools_config, "_run_cua_driver_installer",
return_value=True) as runner, \
patch("subprocess.run"):
assert tools_config.install_cua_driver(upgrade=True) is True
runner.assert_called_once()
# Refresh path uses non-verbose mode so we don't re-print the
# "grant macOS permissions" block on every `hermes update`.
kwargs = runner.call_args.kwargs
assert kwargs.get("verbose") is False
def test_upgrade_on_macos_without_binary_runs_installer(self):
"""upgrade=True with cua-driver missing must still trigger an
install equivalent to a fresh install. (Don't silently no-op.)"""
from hermes_cli import tools_config
with patch("platform.system", return_value="Darwin"), \
patch.object(tools_config.shutil, "which",
side_effect=lambda n: "/usr/bin/curl" if n == "curl" else None), \
patch.object(tools_config, "_check_cua_driver_asset_for_arch",
return_value=True), \
patch.object(tools_config, "_run_cua_driver_installer",
return_value=True) as runner:
assert tools_config.install_cua_driver(upgrade=True) is True
runner.assert_called_once()
def test_non_upgrade_on_macos_with_binary_skips_install(self):
"""Original toolset-enable behaviour: cua-driver already installed
+ upgrade=False confirm and return without re-running installer.
This is the behaviour that ``hermes tools`` (re)enable depends on,
so the new helper must not regress it."""
from hermes_cli import tools_config
with patch("platform.system", return_value="Darwin"), \
@ -89,27 +80,133 @@ class TestInstallCuaDriverUpgrade:
runner.assert_not_called()
def test_non_upgrade_on_macos_without_binary_runs_installer(self):
"""Original fresh-install path must still work."""
from hermes_cli import tools_config
with patch("platform.system", return_value="Darwin"), \
patch.object(tools_config.shutil, "which",
side_effect=lambda n: "/usr/bin/curl" if n == "curl" else None), \
patch.object(tools_config, "_check_cua_driver_asset_for_arch",
return_value=True), \
patch.object(tools_config, "_run_cua_driver_installer",
return_value=True) as runner:
assert tools_config.install_cua_driver(upgrade=False) is True
runner.assert_called_once()
def test_upgrade_without_curl_does_not_crash(self):
"""If curl isn't on PATH we can't refresh — must warn and return
the current install state, not raise."""
class TestCheckCuaDriverAssetForArch:
def test_arm64_always_returns_true(self):
from hermes_cli import tools_config
# cua-driver present, curl missing.
def _which(name):
return "/usr/local/bin/cua-driver" if name == "cua-driver" else None
with patch("platform.machine", return_value="arm64"):
assert tools_config._check_cua_driver_asset_for_arch() is True
def test_x86_64_with_asset_returns_true(self):
from hermes_cli import tools_config
release = {
"tag_name": "cua-driver-v0.1.6",
"assets": [
{"name": "cua-driver-0.1.6-darwin-arm64.tar.gz"},
{"name": "cua-driver-0.1.6-darwin-x86_64.tar.gz"},
],
}
mock_resp = MagicMock()
mock_resp.read.return_value = json.dumps(release).encode()
mock_resp.__enter__ = lambda s: s
mock_resp.__exit__ = MagicMock(return_value=False)
with patch("platform.machine", return_value="x86_64"), \
patch("urllib.request.urlopen", return_value=mock_resp):
assert tools_config._check_cua_driver_asset_for_arch() is True
def test_x86_64_without_asset_returns_false(self):
from hermes_cli import tools_config
release = {
"tag_name": "cua-driver-v0.1.6",
"assets": [
{"name": "cua-driver-0.1.6-darwin-arm64.tar.gz"},
{"name": "cua-driver.tar.gz"},
],
}
mock_resp = MagicMock()
mock_resp.read.return_value = json.dumps(release).encode()
mock_resp.__enter__ = lambda s: s
mock_resp.__exit__ = MagicMock(return_value=False)
with patch("platform.machine", return_value="x86_64"), \
patch("urllib.request.urlopen", return_value=mock_resp), \
patch.object(tools_config, "_print_warning") as warn, \
patch.object(tools_config, "_print_info"):
assert tools_config._check_cua_driver_asset_for_arch() is False
warn.assert_called_once()
assert "no Intel" in warn.call_args[0][0].lower() or "x86_64" in warn.call_args[0][0]
def test_x86_64_api_failure_returns_true(self):
"""Network failure should fail open — let the installer handle it."""
from hermes_cli import tools_config
with patch("platform.machine", return_value="x86_64"), \
patch("urllib.request.urlopen", side_effect=Exception("timeout")):
assert tools_config._check_cua_driver_asset_for_arch() is True
def test_fresh_install_x86_64_no_asset_skips_installer(self):
"""When the latest release has no Intel asset, skip the installer."""
from hermes_cli import tools_config
release = {
"tag_name": "cua-driver-v0.1.6",
"assets": [{"name": "cua-driver-0.1.6-darwin-arm64.tar.gz"}],
}
mock_resp = MagicMock()
mock_resp.read.return_value = json.dumps(release).encode()
mock_resp.__enter__ = lambda s: s
mock_resp.__exit__ = MagicMock(return_value=False)
with patch("platform.system", return_value="Darwin"), \
patch.object(tools_config.shutil, "which", side_effect=_which), \
patch.object(tools_config, "_print_warning"):
patch.object(tools_config.shutil, "which",
side_effect=lambda n: "/usr/bin/curl" if n == "curl" else None), \
patch("platform.machine", return_value="x86_64"), \
patch("urllib.request.urlopen", return_value=mock_resp), \
patch.object(tools_config, "_print_warning"), \
patch.object(tools_config, "_print_info"), \
patch.object(tools_config, "_run_cua_driver_installer") as runner:
assert tools_config.install_cua_driver(upgrade=False) is False
runner.assert_not_called()
def test_upgrade_x86_64_no_asset_returns_existing_status(self):
"""On upgrade with no Intel asset, return whether binary existed."""
from hermes_cli import tools_config
release = {
"tag_name": "cua-driver-v0.1.6",
"assets": [{"name": "cua-driver-0.1.6-darwin-arm64.tar.gz"}],
}
mock_resp = MagicMock()
mock_resp.read.return_value = json.dumps(release).encode()
mock_resp.__enter__ = lambda s: s
mock_resp.__exit__ = MagicMock(return_value=False)
# With binary installed — returns True (binary exists)
with patch("platform.system", return_value="Darwin"), \
patch.object(tools_config.shutil, "which",
side_effect=lambda n: "/usr/local/bin/" + n
if n in ("cua-driver", "curl") else None), \
patch("platform.machine", return_value="x86_64"), \
patch("urllib.request.urlopen", return_value=mock_resp), \
patch.object(tools_config, "_print_warning"), \
patch.object(tools_config, "_print_info"), \
patch.object(tools_config, "_run_cua_driver_installer") as runner:
assert tools_config.install_cua_driver(upgrade=True) is True
runner.assert_not_called()
# Without binary — returns False
with patch("platform.system", return_value="Darwin"), \
patch.object(tools_config.shutil, "which",
side_effect=lambda n: "/usr/bin/curl" if n == "curl" else None), \
patch("platform.machine", return_value="x86_64"), \
patch("urllib.request.urlopen", return_value=mock_resp), \
patch.object(tools_config, "_print_warning"), \
patch.object(tools_config, "_print_info"), \
patch.object(tools_config, "_run_cua_driver_installer") as runner:
assert tools_config.install_cua_driver(upgrade=True) is False
runner.assert_not_called()

View file

@ -1470,6 +1470,138 @@ def test_worktree_workspace_returns_intended_path(kanban_home, tmp_path):
assert str(ws) == target
# ---------------------------------------------------------------------------
# Scratch cleanup containment (#28818)
# ---------------------------------------------------------------------------
def test_cleanup_workspace_removes_managed_scratch_dir(kanban_home):
"""A scratch workspace under the kanban workspaces root is removed."""
with kb.connect() as conn:
t = kb.create_task(conn, title="scratchy")
task = kb.get_task(conn, t)
ws = kb.resolve_workspace(task)
kb.set_workspace_path(conn, t, ws)
assert ws.is_dir()
kb.complete_task(conn, t, result="ok")
assert not ws.exists(), "Hermes-managed scratch dir should be cleaned up"
def test_cleanup_workspace_refuses_path_outside_scratch_root(kanban_home, tmp_path):
"""A scratch task with a user path outside the workspaces root must NOT be deleted (#28818).
Reproduces the data-loss vector where a board's ``default_workdir`` is set
to a real source directory; tasks created without an explicit
``workspace_kind`` inherit ``scratch`` semantics, and the old cleanup path
would ``shutil.rmtree`` the user's source tree on task completion.
"""
real_source = tmp_path / "real-source"
real_source.mkdir()
(real_source / ".git").mkdir()
(real_source / "README.md").write_text("important", encoding="utf-8")
with kb.connect() as conn:
t = kb.create_task(conn, title="ship")
# Simulate the bad state directly: workspace_kind='scratch' (default)
# but workspace_path pointing at the user's real source tree, which is
# exactly what board.default_workdir produces when the task is created
# without an explicit workspace_kind.
conn.execute(
"UPDATE tasks SET workspace_kind=?, workspace_path=? WHERE id=?",
("scratch", str(real_source), t),
)
conn.commit()
kb.complete_task(conn, t, result="ok")
assert real_source.exists(), "User source tree must not be deleted by scratch cleanup"
assert (real_source / ".git").exists()
assert (real_source / "README.md").read_text(encoding="utf-8") == "important"
def test_cleanup_workspace_honors_workspaces_root_env_override(tmp_path, monkeypatch):
"""``HERMES_KANBAN_WORKSPACES_ROOT`` extends the managed-scratch set.
Worker subprocesses run with this env var injected by the dispatcher. The
cleanup containment check must treat paths under it as managed even when
they sit outside the active kanban home.
"""
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
workspaces_override = tmp_path / "ext-workspaces"
workspaces_override.mkdir()
monkeypatch.setenv("HERMES_KANBAN_WORKSPACES_ROOT", str(workspaces_override))
kb.init_db()
with kb.connect() as conn:
t = kb.create_task(conn, title="ext")
scratch_dir = workspaces_override / t
scratch_dir.mkdir()
conn.execute(
"UPDATE tasks SET workspace_kind=?, workspace_path=? WHERE id=?",
("scratch", str(scratch_dir), t),
)
conn.commit()
kb.complete_task(conn, t, result="ok")
assert not scratch_dir.exists(), "Override-root scratch dir should be cleaned up"
def test_is_managed_scratch_path_accepts_per_board_workspaces(kanban_home, tmp_path):
"""Per-board scratch dirs under ``<kanban_home>/kanban/boards/<slug>/workspaces`` are managed."""
board_scratch = kanban_home / "kanban" / "boards" / "my-board" / "workspaces" / "task-1"
board_scratch.mkdir(parents=True)
assert kb._is_managed_scratch_path(board_scratch)
def test_is_managed_scratch_path_rejects_real_source_tree(kanban_home, tmp_path):
"""A path outside any managed root (e.g. a user's repo) is NOT managed."""
real = tmp_path / "code" / "my-project"
real.mkdir(parents=True)
assert not kb._is_managed_scratch_path(real)
def test_is_managed_scratch_path_rejects_kanban_metadata_subtrees(kanban_home):
"""Hermes' own DB/metadata/log subtrees under ``<kanban_home>/kanban`` are NOT managed.
Regression guard for the Copilot finding on #28819: a scratch task whose
``workspace_path`` was mis-set to the kanban home, the logs dir, or a
board's metadata dir (i.e. the board root itself, not its ``workspaces/``
child) must be refused. Without this, the containment check would happily
``shutil.rmtree`` Hermes' DB/metadata/logs on task completion.
"""
kanban_root = kanban_home / "kanban"
kanban_root.mkdir(parents=True, exist_ok=True)
assert not kb._is_managed_scratch_path(kanban_root)
logs_dir = kanban_root / "logs"
logs_dir.mkdir(parents=True, exist_ok=True)
assert not kb._is_managed_scratch_path(logs_dir)
board_root = kanban_root / "boards" / "my-board"
board_root.mkdir(parents=True, exist_ok=True)
# The board root itself is NOT a managed scratch dir — only the
# ``workspaces/`` child (and its descendants) are.
assert not kb._is_managed_scratch_path(board_root)
# Sibling subtrees of ``workspaces/`` under a board (e.g. its kanban.db
# or board.json living next to ``workspaces/``) are also not managed.
board_logs = board_root / "logs"
board_logs.mkdir(parents=True, exist_ok=True)
assert not kb._is_managed_scratch_path(board_logs)
# Now create the board's workspaces dir and a task scratch dir under it —
# the latter is the only thing the guard should allow.
board_workspaces = board_root / "workspaces"
board_workspaces.mkdir(parents=True, exist_ok=True)
# The workspaces root itself is also NOT managed — deleting it would
# wipe every task's scratch dir at once.
assert not kb._is_managed_scratch_path(board_workspaces)
task_dir = board_workspaces / "task-42"
task_dir.mkdir(parents=True, exist_ok=True)
assert kb._is_managed_scratch_path(task_dir)
# ---------------------------------------------------------------------------
# Tenancy
# ---------------------------------------------------------------------------
@ -2464,13 +2596,32 @@ def test_task_dict_survives_corrupt_created_at(tmp_path, monkeypatch):
# ---------------------------------------------------------------------------
def test_create_task_without_workspace_inherits_board_default_workdir(kanban_home, monkeypatch):
"""Board with default_workdir → create_task without workspace_path → inherits default."""
def test_create_task_scratch_without_workspace_ignores_board_default_workdir(kanban_home, monkeypatch):
"""Scratch tasks must NOT inherit board.default_workdir — would point auto-cleanup
at the user's source tree on completion (#28818)."""
default_wd = "/home/user/project"
kb.create_board("work-proj", default_workdir=default_wd)
with kb.connect(board="work-proj") as conn:
tid = kb.create_task(conn, title="inherited", board="work-proj")
tid = kb.create_task(conn, title="scratch-task", board="work-proj")
t = kb.get_task(conn, tid)
assert t is not None
assert t.workspace_kind == "scratch"
assert t.workspace_path is None
def test_create_task_dir_without_workspace_inherits_board_default_workdir(kanban_home, monkeypatch):
"""Board default_workdir is for persistent dir/worktree workspaces, not scratch."""
default_wd = "/home/user/project"
kb.create_board("work-proj-dir", default_workdir=default_wd)
with kb.connect(board="work-proj-dir") as conn:
tid = kb.create_task(
conn,
title="inherited",
workspace_kind="dir",
board="work-proj-dir",
)
t = kb.get_task(conn, tid)
assert t is not None
assert t.workspace_path == default_wd
@ -2981,3 +3132,210 @@ def test_detect_stale_does_not_tick_failure_counter(kanban_home, monkeypatch):
assert "stale" in kinds, (
f"Expected 'stale' event in task_events; got {kinds!r}"
)
# ---------------------------------------------------------------------------
# Corruption guard (issue #30687)
# ---------------------------------------------------------------------------
def _write_corrupt_db(path: Path) -> bytes:
"""Write a kanban DB with a VALID SQLite header but malformed page content.
This is the corruption shape the integrity guard specifically targets
(e.g. issue #29507 follow-up reports where the file's first 16 bytes
pass the header byte check but ``PRAGMA integrity_check`` then fails
because the internal pages are damaged). It's what main's header-only
validator was letting through, and what this PR adds the full guard
for.
"""
# 100-byte SQLite header (magic + minimal valid-looking fields) so the
# cheap header check passes, then deliberate garbage so sqlite refuses
# to read the file past the header.
header = b"SQLite format 3\x00" + b"\x10\x00\x02\x02\x00\x40\x20\x20"
header += b"\x00\x00\x00\x0c\x00\x00\x23\x46\x00\x00\x00\x00"
header = header.ljust(100, b"\x00")
payload = b"definitely not a valid sqlite page \x00\x01\x02\x03" * 64
blob = header + payload
path.write_bytes(blob)
return blob
def test_init_db_refuses_corrupt_existing_file(tmp_path):
db_path = tmp_path / "kanban.db"
original = _write_corrupt_db(db_path)
# Ensure the cache doesn't mask the guard.
kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))
with pytest.raises(kb.KanbanDbCorruptError) as excinfo:
kb.init_db(db_path=db_path)
err = excinfo.value
assert err.db_path == db_path
assert err.backup_path is not None
assert err.backup_path.exists()
assert err.backup_path.read_bytes() == original
# Original bytes untouched — no schema was written on top.
assert db_path.read_bytes() == original
assert str(db_path) in str(err)
assert str(err.backup_path) in str(err)
def test_connect_refuses_corrupt_existing_file(tmp_path):
db_path = tmp_path / "kanban.db"
_write_corrupt_db(db_path)
kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))
with pytest.raises(kb.KanbanDbCorruptError):
kb.connect(db_path=db_path)
def test_locked_healthy_db_does_not_classify_as_corrupt(tmp_path, monkeypatch):
"""A transient lock during the probe must not produce a .corrupt backup
and must not be reported as :class:`KanbanDbCorruptError`. Raw sqlite
``OperationalError`` (lock/busy) is acceptable and expected."""
db_path = tmp_path / "kanban.db"
kb.init_db(db_path=db_path)
kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))
real_connect = sqlite3.connect
def flaky_connect(*args, **kwargs):
# First call is the integrity probe — simulate a lock.
raise sqlite3.OperationalError("database is locked")
monkeypatch.setattr(kb.sqlite3, "connect", flaky_connect)
with pytest.raises(sqlite3.OperationalError):
kb.connect(db_path=db_path)
# No .corrupt backup may be produced for a healthy-but-locked DB.
backups = list(tmp_path.glob("*.corrupt.*"))
assert backups == [], f"unexpected corrupt backups: {backups}"
# And once the lock clears, normal access still works.
monkeypatch.setattr(kb.sqlite3, "connect", real_connect)
with kb.connect(db_path=db_path) as conn:
kb.create_task(conn, title="still here")
titles = [t.title for t in kb.list_tasks(conn)]
assert "still here" in titles
def test_init_db_allows_missing_then_healthy(tmp_path):
db_path = tmp_path / "fresh.db"
assert not db_path.exists()
kb.init_db(db_path=db_path)
assert db_path.exists() and db_path.stat().st_size > 0
# Idempotent on a healthy DB: data survives a second init.
with kb.connect(db_path=db_path) as conn:
kb.create_task(conn, title="keeps")
kb.init_db(db_path=db_path)
with kb.connect(db_path=db_path) as conn:
tasks = kb.list_tasks(conn)
assert [t.title for t in tasks] == ["keeps"]
# ---------------------------------------------------------------------------
# First-use tip for scratch workspaces
# ---------------------------------------------------------------------------
def test_maybe_emit_scratch_tip_fires_once_per_install(kanban_home, caplog):
"""First scratch workspace materialization warns + emits an event.
Subsequent scratch workspaces on the SAME install stay silent the
sentinel file under kanban_home() flips after the first emit.
"""
import logging
with kb.connect() as conn:
t1 = kb.create_task(conn, title="first scratch")
t2 = kb.create_task(conn, title="second scratch")
# Sentinel must not exist yet on a fresh install.
assert not kb._scratch_tip_shown()
with caplog.at_level(logging.WARNING, logger="hermes_cli.kanban_db"):
with kb.connect() as conn:
kb._maybe_emit_scratch_tip(conn, t1, "scratch")
# Sentinel is now set.
assert kb._scratch_tip_shown()
assert kb._scratch_tip_sentinel_path().exists()
# Warning was logged exactly once.
tip_records = [
r for r in caplog.records
if "scratch workspaces are ephemeral" in r.getMessage()
]
assert len(tip_records) == 1, (
f"Expected exactly one tip warning, got {len(tip_records)}: "
f"{[r.getMessage() for r in tip_records]!r}"
)
# An event row was appended on the first task.
with kb.connect() as conn:
events = conn.execute(
"SELECT kind FROM task_events WHERE task_id = ? ORDER BY id",
(t1,),
).fetchall()
kinds = [e["kind"] for e in events]
assert "tip_scratch_workspace" in kinds, (
f"Expected tip_scratch_workspace event on first scratch task; "
f"got {kinds!r}"
)
# Second scratch materialization on the same install stays silent.
caplog.clear()
with caplog.at_level(logging.WARNING, logger="hermes_cli.kanban_db"):
with kb.connect() as conn:
kb._maybe_emit_scratch_tip(conn, t2, "scratch")
tip_records2 = [
r for r in caplog.records
if "scratch workspaces are ephemeral" in r.getMessage()
]
assert tip_records2 == [], (
f"Tip should not re-fire after sentinel is set; got "
f"{[r.getMessage() for r in tip_records2]!r}"
)
with kb.connect() as conn:
events2 = conn.execute(
"SELECT kind FROM task_events WHERE task_id = ? ORDER BY id",
(t2,),
).fetchall()
assert "tip_scratch_workspace" not in [e["kind"] for e in events2], (
"Tip event should not be appended for subsequent scratch tasks."
)
def test_maybe_emit_scratch_tip_skips_non_scratch_workspaces(kanban_home, caplog):
"""worktree/dir workspaces are preserved on completion and must not
trigger the scratch-cleanup tip."""
import logging
with kb.connect() as conn:
t_wt = kb.create_task(conn, title="worktree task")
t_dir = kb.create_task(conn, title="dir task")
assert not kb._scratch_tip_shown()
with caplog.at_level(logging.WARNING, logger="hermes_cli.kanban_db"):
with kb.connect() as conn:
kb._maybe_emit_scratch_tip(conn, t_wt, "worktree")
kb._maybe_emit_scratch_tip(conn, t_dir, "dir")
# Sentinel stays unset — these workspaces are preserved by design,
# so the warning is irrelevant for them and we save the one-shot
# for a real scratch user.
assert not kb._scratch_tip_shown()
tip_records = [
r for r in caplog.records
if "scratch workspaces are ephemeral" in r.getMessage()
]
assert tip_records == []
with kb.connect() as conn:
for tid in (t_wt, t_dir):
events = conn.execute(
"SELECT kind FROM task_events WHERE task_id = ?", (tid,),
).fetchall()
assert "tip_scratch_workspace" not in [e["kind"] for e in events]

View file

@ -17,6 +17,11 @@ def kanban_home(tmp_path, monkeypatch):
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
# Allow the kanban notifier path-validator to upload artifacts the
# tests write under ``tmp_path``. Without this, every artifact-delivery
# test silently drops files because ``tmp_path`` isn't inside the
# default ``MEDIA_DELIVERY_SAFE_ROOTS`` cache dirs.
monkeypatch.setenv("HERMES_MEDIA_ALLOW_DIRS", str(tmp_path))
kb.init_db()
return home
@ -482,7 +487,7 @@ async def test_gateway_create_autosubscribes_on_explicit_board(kanban_home):
@pytest.mark.asyncio
async def test_notifier_uploads_artifacts_on_completion(kanban_home, tmp_path):
async def test_notifier_uploads_artifacts_on_completion(kanban_home, tmp_path, monkeypatch):
"""When a completed event carries ``artifacts`` in its payload, the
notifier uploads each file to the subscribed chat as a native
attachment. Images batch through send_multiple_images; documents
@ -494,6 +499,13 @@ async def test_notifier_uploads_artifacts_on_completion(kanban_home, tmp_path):
from gateway.config import Platform
from tools import kanban_tools as kt
# ``_deliver_kanban_artifacts`` routes candidates through
# ``BasePlatformAdapter.filter_local_delivery_paths``, which only accepts
# paths under ``MEDIA_DELIVERY_SAFE_ROOTS`` or roots explicitly allowlisted
# via ``HERMES_MEDIA_ALLOW_DIRS``. Test fixtures live under ``tmp_path``,
# so allowlist it for the duration of the test.
monkeypatch.setenv("HERMES_MEDIA_ALLOW_DIRS", str(tmp_path))
# Materialize real files so os.path.isfile passes inside the helper.
chart_path = tmp_path / "q3-revenue.png"
chart_path.write_bytes(b"PNG-fake-bytes")
@ -572,7 +584,7 @@ async def test_notifier_uploads_artifacts_on_completion(kanban_home, tmp_path):
@pytest.mark.asyncio
async def test_notifier_artifact_delivery_skips_missing_files(kanban_home, tmp_path):
async def test_notifier_artifact_delivery_skips_missing_files(kanban_home, tmp_path, monkeypatch):
"""Missing artifact paths are silently skipped — they may have been
referenced by name only. The notifier must not crash and must still
deliver any artifacts that do exist."""
@ -581,6 +593,10 @@ async def test_notifier_artifact_delivery_skips_missing_files(kanban_home, tmp_p
from gateway.config import Platform
from tools import kanban_tools as kt
# Allow ``tmp_path`` through the media-delivery safety filter. See the
# companion test for the full explanation.
monkeypatch.setenv("HERMES_MEDIA_ALLOW_DIRS", str(tmp_path))
real_pdf = tmp_path / "real.pdf"
real_pdf.write_bytes(b"%PDF-fake")

View file

@ -0,0 +1,254 @@
"""Tests for the kanban `promote` verb (issue #28822).
The realistic bug scenario from #28822 is: a child task ends up in
``todo`` with all its parents already ``done`` (because the
auto-promote daemon hasn't run, or a manual close raced it).
Direct-SQL setup is used to construct that state deterministically.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import pytest
from hermes_cli import kanban as kb_cli
from hermes_cli import kanban_db as kb
@pytest.fixture
def kanban_home(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
db_path = kb.kanban_db_path(board="default")
kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))
kb.init_db()
return home
@pytest.fixture
def conn(kanban_home):
with kb.connect() as c:
yield c
def _stuck_todo(conn, *, parents_done=True, n_parents=1):
"""Build the #28822 scenario: child in 'todo' whose parents may
have closed as 'done' without the auto-promote logic firing.
"""
parent_ids = [
kb.create_task(conn, title=f"parent{i}", assignee="setup")
for i in range(n_parents)
]
child_id = kb.create_task(
conn, title="child", parents=parent_ids, assignee="setup"
)
assert kb.get_task(conn, child_id).status == "todo"
if parents_done:
for pid in parent_ids:
conn.execute(
"UPDATE tasks SET status='done' WHERE id=?", (pid,)
)
return child_id, parent_ids
def test_promote_stuck_todo_succeeds(conn):
child, _ = _stuck_todo(conn, parents_done=True)
ok, err = kb.promote_task(conn, child, actor="tester")
assert ok and err is None
assert kb.get_task(conn, child).status == "ready"
def test_promote_refuses_when_parent_not_done(conn):
child, parents = _stuck_todo(conn, parents_done=False)
ok, err = kb.promote_task(conn, child, actor="tester")
assert ok is False
assert err is not None and "unsatisfied parent dependencies" in err
assert parents[0] in err
assert kb.get_task(conn, child).status == "todo"
def test_promote_with_force_bypasses_dependency_check(conn):
child, _ = _stuck_todo(conn, parents_done=False)
ok, err = kb.promote_task(
conn, child, actor="tester", reason="recovery", force=True
)
assert ok and err is None
assert kb.get_task(conn, child).status == "ready"
def test_promote_emits_audit_event(conn):
child, _ = _stuck_todo(conn, parents_done=True)
kb.promote_task(conn, child, actor="tester", reason="manual recovery")
ev = conn.execute(
"SELECT kind, payload FROM task_events "
"WHERE task_id = ? AND kind = 'promoted_manual'",
(child,),
).fetchone()
assert ev is not None
payload = json.loads(ev["payload"])
assert payload["actor"] == "tester"
assert payload["reason"] == "manual recovery"
assert payload["forced"] is False
def test_promote_force_records_forced_flag(conn):
child, _ = _stuck_todo(conn, parents_done=False)
kb.promote_task(conn, child, actor="tester", force=True, reason="r")
ev = conn.execute(
"SELECT payload FROM task_events "
"WHERE task_id = ? AND kind = 'promoted_manual'",
(child,),
).fetchone()
assert json.loads(ev["payload"])["forced"] is True
def test_promote_does_not_change_assignee(conn):
child, _ = _stuck_todo(conn, parents_done=True)
before = kb.get_task(conn, child).assignee
kb.promote_task(conn, child, actor="someone_else")
after = kb.get_task(conn, child).assignee
assert before == after
def test_promote_dry_run_does_not_mutate(conn):
child, _ = _stuck_todo(conn, parents_done=True)
ok, err = kb.promote_task(conn, child, actor="tester", dry_run=True)
assert ok and err is None
assert kb.get_task(conn, child).status == "todo"
n = conn.execute(
"SELECT COUNT(*) AS n FROM task_events "
"WHERE task_id = ? AND kind = 'promoted_manual'",
(child,),
).fetchone()["n"]
assert n == 0
def test_promote_dry_run_reports_dependency_failure(conn):
child, _ = _stuck_todo(conn, parents_done=False)
ok, err = kb.promote_task(conn, child, actor="tester", dry_run=True)
assert ok is False
assert err is not None and "unsatisfied" in err
def test_promote_rejects_non_todo_status(conn):
tid = kb.create_task(conn, title="standalone")
assert kb.get_task(conn, tid).status == "ready"
ok, err = kb.promote_task(conn, tid, actor="tester")
assert ok is False
assert "'ready'" in err and "promote only applies" in err
def test_promote_rejects_unknown_task(conn):
ok, err = kb.promote_task(conn, "t_doesnotexist", actor="tester")
assert ok is False
assert err is not None and "not found" in err
def test_promote_blocked_task_works(conn):
tid = kb.create_task(conn, title="t")
conn.execute("UPDATE tasks SET status='blocked' WHERE id=?", (tid,))
ok, err = kb.promote_task(
conn, tid, actor="tester", reason="ready now"
)
assert ok and err is None
assert kb.get_task(conn, tid).status == "ready"
# ---------------------------------------------------------------------------
# CLI `_cmd_promote` — bulk via `--ids` (the issue's anti-respawn use case:
# promote all children of a closed parent in one command).
# ---------------------------------------------------------------------------
def _promote_ns(task_id, *, ids=None, reason=None, force=False,
dry_run=False, as_json=False):
return argparse.Namespace(
task_id=task_id,
reason=list(reason or []),
ids=list(ids or []) or None,
force=force,
dry_run=dry_run,
json=as_json,
)
def test_cli_promote_bulk_ids_promotes_all(kanban_home, capsys):
with kb.connect() as conn:
parent = kb.create_task(conn, title="parent")
children = [
kb.create_task(conn, title=f"c{i}", parents=[parent])
for i in range(3)
]
conn.execute("UPDATE tasks SET status='done' WHERE id=?", (parent,))
rc = kb_cli._cmd_promote(_promote_ns(children[0], ids=children[1:]))
assert rc == 0
out = capsys.readouterr().out
for c in children:
assert c in out
with kb.connect() as conn:
for c in children:
assert kb.get_task(conn, c).status == "ready"
def test_cli_promote_bulk_partial_failure_exits_1(kanban_home, capsys):
"""Bulk with one bad id: good ones still promote, exit code reflects failure."""
with kb.connect() as conn:
parent = kb.create_task(conn, title="parent")
good = kb.create_task(conn, title="good", parents=[parent])
conn.execute("UPDATE tasks SET status='done' WHERE id=?", (parent,))
rc = kb_cli._cmd_promote(_promote_ns(good, ids=["t_nope"]))
assert rc == 1
captured = capsys.readouterr()
assert good in captured.out # good one promoted
assert "t_nope" in captured.err and "not found" in captured.err
with kb.connect() as conn:
assert kb.get_task(conn, good).status == "ready"
def test_cli_promote_bulk_json_emits_list(kanban_home, capsys):
with kb.connect() as conn:
parent = kb.create_task(conn, title="parent")
a = kb.create_task(conn, title="a", parents=[parent])
b = kb.create_task(conn, title="b", parents=[parent])
conn.execute("UPDATE tasks SET status='done' WHERE id=?", (parent,))
rc = kb_cli._cmd_promote(_promote_ns(a, ids=[b], as_json=True))
assert rc == 0
payload = json.loads(capsys.readouterr().out)
assert isinstance(payload, list) and len(payload) == 2
assert {r["task_id"] for r in payload} == {a, b}
assert all(r["promoted"] for r in payload)
def test_cli_promote_single_json_stays_flat_object(kanban_home, capsys):
"""Back-compat: single-id JSON is still a flat object, not a list."""
with kb.connect() as conn:
parent = kb.create_task(conn, title="parent")
child = kb.create_task(conn, title="c", parents=[parent])
conn.execute("UPDATE tasks SET status='done' WHERE id=?", (parent,))
rc = kb_cli._cmd_promote(_promote_ns(child, as_json=True))
assert rc == 0
payload = json.loads(capsys.readouterr().out)
assert isinstance(payload, dict)
assert payload["task_id"] == child and payload["promoted"] is True
def test_cli_promote_dedupes_duplicate_ids(kanban_home, capsys):
"""Same id in positional + --ids must only attempt the promotion once."""
with kb.connect() as conn:
parent = kb.create_task(conn, title="parent")
child = kb.create_task(conn, title="c", parents=[parent])
conn.execute("UPDATE tasks SET status='done' WHERE id=?", (parent,))
rc = kb_cli._cmd_promote(_promote_ns(child, ids=[child, child]))
assert rc == 0
with kb.connect() as conn:
n = conn.execute(
"SELECT COUNT(*) AS n FROM task_events "
"WHERE task_id = ? AND kind = 'promoted_manual'",
(child,),
).fetchone()["n"]
assert n == 1

View file

@ -0,0 +1,794 @@
"""Tests for hermes_cli.mcp_catalog and hermes_cli.mcp_picker.
Manifest parsing, install/uninstall config writes, and picker plumbing
are exercised here. Anything that would actually clone a repo or
launch an MCP is mocked.
"""
from __future__ import annotations
import os
from pathlib import Path
from unittest.mock import patch
import pytest
import yaml
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _default_mock_probe(monkeypatch):
"""By default tests run the probe-fails path so install_entry() doesn\'t
try to talk to a real MCP server.
Individual tests that exercise probe-success behaviour patch
``hermes_cli.mcp_catalog._probe_tools`` themselves.
"""
# Patch the catalog\'s probe wrapper, not the underlying
# mcp_config._probe_single_server (so tests stay decoupled from that
# module\'s plumbing).
import hermes_cli.mcp_catalog as mc
monkeypatch.setattr(mc, "_probe_tools", lambda name: None)
@pytest.fixture
def catalog_dir(tmp_path, monkeypatch):
"""Provide an isolated optional-mcps/ directory."""
cat = tmp_path / "optional-mcps"
cat.mkdir()
monkeypatch.setenv("HERMES_OPTIONAL_MCPS", str(cat))
return cat
@pytest.fixture(autouse=True)
def _isolate_hermes_home(tmp_path, monkeypatch):
"""Redirect all config I/O to a temp HERMES_HOME."""
hh = tmp_path / "hermes-home"
hh.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hh))
monkeypatch.setattr(
"hermes_cli.config.get_hermes_home", lambda: hh
)
monkeypatch.setattr(
"hermes_cli.config.get_config_path", lambda: hh / "config.yaml"
)
monkeypatch.setattr(
"hermes_cli.config.get_env_path", lambda: hh / ".env"
)
# mcp_catalog grabs get_hermes_home() lazily through hermes_constants
monkeypatch.setattr(
"hermes_constants.get_hermes_home", lambda: hh
)
return hh
def _write_manifest(catalog_dir: Path, name: str, body: dict) -> Path:
entry_dir = catalog_dir / name
entry_dir.mkdir(exist_ok=True)
path = entry_dir / "manifest.yaml"
with open(path, "w") as f:
yaml.safe_dump(body, f)
return path
def _basic_manifest(name: str = "demo", **overrides) -> dict:
body = {
"manifest_version": 1,
"name": name,
"description": "Demo MCP",
"source": "https://example.com",
"transport": {
"type": "stdio",
"command": "npx",
"args": ["-y", "demo-mcp"],
},
"auth": {"type": "none"},
}
body.update(overrides)
return body
def _entry(name: str):
"""Wrapper that asserts entry exists (satisfies type-checker + nicer failure msg)."""
from hermes_cli.mcp_catalog import get_entry
e = get_entry(name)
assert e is not None, f"catalog entry {name!r} missing"
return e
# ---------------------------------------------------------------------------
# Manifest parsing
# ---------------------------------------------------------------------------
class TestManifestParsing:
def test_minimal_valid(self, catalog_dir):
_write_manifest(catalog_dir, "demo", _basic_manifest())
from hermes_cli.mcp_catalog import list_catalog
entries = list_catalog()
assert len(entries) == 1
e = entries[0]
assert e.name == "demo"
assert e.transport.type == "stdio"
assert e.transport.command == "npx"
assert e.transport.args == ["-y", "demo-mcp"]
assert e.auth.type == "none"
assert e.install is None
def test_api_key_auth(self, catalog_dir):
body = _basic_manifest(
auth={
"type": "api_key",
"env": [
{"name": "DEMO_KEY", "prompt": "API key", "secret": True},
{"name": "DEMO_URL", "prompt": "Base URL", "secret": False, "required": False},
],
}
)
_write_manifest(catalog_dir, "demo", body)
from hermes_cli.mcp_catalog import list_catalog
e = list_catalog()[0]
assert e.auth.type == "api_key"
assert len(e.auth.env) == 2
assert e.auth.env[0].name == "DEMO_KEY"
assert e.auth.env[0].secret is True
assert e.auth.env[1].required is False
assert e.auth.env[1].secret is False
def test_install_block(self, catalog_dir):
body = _basic_manifest(
install={
"type": "git",
"url": "https://example.com/demo.git",
"ref": "v1.0.0",
"bootstrap": ["pip install -r requirements.txt"],
},
transport={
"type": "stdio",
"command": "${INSTALL_DIR}/.venv/bin/python",
"args": ["${INSTALL_DIR}/server.py"],
},
)
_write_manifest(catalog_dir, "demo", body)
from hermes_cli.mcp_catalog import list_catalog
e = list_catalog()[0]
assert e.install is not None
assert e.install.url == "https://example.com/demo.git"
assert e.install.ref == "v1.0.0"
assert e.install.bootstrap == ["pip install -r requirements.txt"]
def test_invalid_manifest_skipped(self, catalog_dir):
# Broken: wrong manifest_version
_write_manifest(catalog_dir, "bad", {
"manifest_version": 99,
"name": "bad",
"description": "x",
"transport": {"type": "stdio", "command": "x"},
})
# Good
_write_manifest(catalog_dir, "demo", _basic_manifest())
from hermes_cli.mcp_catalog import list_catalog
entries = list_catalog()
assert [e.name for e in entries] == ["demo"]
def test_missing_transport_command_rejected(self, catalog_dir):
body = _basic_manifest()
body["transport"] = {"type": "stdio"} # no command
_write_manifest(catalog_dir, "demo", body)
from hermes_cli.mcp_catalog import list_catalog
assert list_catalog() == []
def test_get_entry_strips_official_prefix(self, catalog_dir):
_write_manifest(catalog_dir, "demo", _basic_manifest())
from hermes_cli.mcp_catalog import get_entry
assert get_entry("demo") is not None
assert get_entry("official/demo") is not None
assert get_entry("missing") is None
# ---------------------------------------------------------------------------
# Install flow
# ---------------------------------------------------------------------------
class TestInstall:
def test_install_simple_stdio_writes_config(self, catalog_dir):
_write_manifest(catalog_dir, "demo", _basic_manifest())
from hermes_cli.mcp_catalog import install_entry, get_entry
from hermes_cli.config import load_config
install_entry(_entry("demo"), enable=True)
cfg = load_config()
servers = cfg["mcp_servers"]
assert "demo" in servers
assert servers["demo"]["command"] == "npx"
assert servers["demo"]["args"] == ["-y", "demo-mcp"]
assert servers["demo"]["enabled"] is True
def test_install_with_install_dir_substitution(self, catalog_dir, tmp_path):
body = _basic_manifest(
install={
"type": "git",
"url": "https://example.com/demo.git",
"ref": "main",
"bootstrap": [],
},
transport={
"type": "stdio",
"command": "${INSTALL_DIR}/run.sh",
"args": ["${INSTALL_DIR}/cfg.json"],
},
)
_write_manifest(catalog_dir, "demo", body)
# Mock the git clone — return a known directory
fake_clone = tmp_path / "fake-clone"
fake_clone.mkdir()
from hermes_cli import mcp_catalog
from hermes_cli.mcp_catalog import install_entry, get_entry
from hermes_cli.config import load_config
with patch.object(mcp_catalog, "_do_git_install", return_value=fake_clone):
install_entry(_entry("demo"), enable=True)
servers = load_config()["mcp_servers"]
assert servers["demo"]["command"] == f"{fake_clone}/run.sh"
assert servers["demo"]["args"] == [f"{fake_clone}/cfg.json"]
def test_install_with_api_key_prompts_and_saves(self, catalog_dir, monkeypatch):
body = _basic_manifest(
auth={
"type": "api_key",
"env": [{"name": "DEMO_KEY", "prompt": "key", "secret": True}],
}
)
_write_manifest(catalog_dir, "demo", body)
from hermes_cli import mcp_catalog
monkeypatch.setattr(mcp_catalog, "_prompt_input", lambda *a, **kw: "secret-val")
from hermes_cli.mcp_catalog import install_entry, get_entry
from hermes_cli.config import get_env_value, load_config
install_entry(_entry("demo"), enable=True)
assert get_env_value("DEMO_KEY") == "secret-val"
assert "demo" in load_config()["mcp_servers"]
def test_install_http_oauth_writes_auth_marker(self, catalog_dir):
body = _basic_manifest(
transport={"type": "http", "url": "https://mcp.example.com/sse"},
auth={"type": "oauth"},
)
_write_manifest(catalog_dir, "demo", body)
from hermes_cli.mcp_catalog import install_entry, get_entry
from hermes_cli.config import load_config
install_entry(_entry("demo"), enable=True)
server = load_config()["mcp_servers"]["demo"]
assert server["url"] == "https://mcp.example.com/sse"
assert server["auth"] == "oauth"
def test_install_required_env_missing_raises(self, catalog_dir, monkeypatch):
body = _basic_manifest(
auth={
"type": "api_key",
"env": [{"name": "MUST", "prompt": "x", "required": True, "secret": False}],
}
)
_write_manifest(catalog_dir, "demo", body)
from hermes_cli import mcp_catalog
from hermes_cli.mcp_catalog import install_entry, get_entry, CatalogError
# User hits enter — empty input, no default
monkeypatch.setattr(mcp_catalog, "_prompt_input", lambda *a, **kw: "")
with pytest.raises(CatalogError):
install_entry(_entry("demo"), enable=True)
# ---------------------------------------------------------------------------
# Uninstall
# ---------------------------------------------------------------------------
class TestUninstall:
def test_uninstall_removes_server_block(self, catalog_dir):
_write_manifest(catalog_dir, "demo", _basic_manifest())
from hermes_cli.mcp_catalog import install_entry, get_entry, uninstall_entry
from hermes_cli.config import load_config
install_entry(_entry("demo"), enable=True)
assert "demo" in load_config().get("mcp_servers", {})
assert uninstall_entry("demo") is True
assert "demo" not in load_config().get("mcp_servers", {})
def test_uninstall_missing_returns_false(self):
from hermes_cli.mcp_catalog import uninstall_entry
assert uninstall_entry("nonexistent") is False
# ---------------------------------------------------------------------------
# Picker (non-TTY paths only — interactive curses is integration-tested)
# ---------------------------------------------------------------------------
class TestPicker:
def test_show_catalog_empty(self, catalog_dir, capsys):
from hermes_cli.mcp_picker import show_catalog
show_catalog()
out = capsys.readouterr().out
assert "No MCPs in the catalog or configured" in out
def test_show_catalog_lists_entry(self, catalog_dir, capsys):
_write_manifest(catalog_dir, "demo", _basic_manifest())
from hermes_cli.mcp_picker import show_catalog
show_catalog()
out = capsys.readouterr().out
assert "demo" in out
assert "available" in out
def test_install_by_name_unknown(self, catalog_dir, capsys):
from hermes_cli.mcp_picker import install_by_name
rc = install_by_name("nope")
assert rc == 1
assert "not in the catalog" in capsys.readouterr().out
def test_install_by_name_success(self, catalog_dir):
_write_manifest(catalog_dir, "demo", _basic_manifest())
from hermes_cli.mcp_picker import install_by_name
from hermes_cli.config import load_config
rc = install_by_name("demo")
assert rc == 0
assert "demo" in load_config().get("mcp_servers", {})
def test_run_picker_non_tty_falls_back(self, catalog_dir, capsys, monkeypatch):
_write_manifest(catalog_dir, "demo", _basic_manifest())
# Force isatty false
import sys as _sys
monkeypatch.setattr(_sys.stdin, "isatty", lambda: False)
from hermes_cli.mcp_picker import run_picker
run_picker()
out = capsys.readouterr().out
assert "MCP Catalog + configured servers" in out
# ---------------------------------------------------------------------------
# Shipped catalog (sanity: every manifest in the repo's optional-mcps/ parses)
# ---------------------------------------------------------------------------
class TestToolSelection:
def _make_probed(self, *names):
"""Return a list of (tool_name, description) tuples for mocking."""
return [(n, f"description of {n}") for n in names]
def test_probe_fail_no_default_writes_no_filter(self, catalog_dir):
body = _basic_manifest()
_write_manifest(catalog_dir, "demo", body)
from hermes_cli.mcp_catalog import install_entry
from hermes_cli.config import load_config
install_entry(_entry("demo"), enable=True)
server = load_config()["mcp_servers"]["demo"]
# No tools.include => all tools active when reachable
assert "tools" not in server, server
def test_probe_fail_with_default_applies_directly(self, catalog_dir):
body = _basic_manifest(
tools={"default_enabled": ["a", "b", "c"]},
)
_write_manifest(catalog_dir, "demo", body)
from hermes_cli.mcp_catalog import install_entry
from hermes_cli.config import load_config
install_entry(_entry("demo"), enable=True)
server = load_config()["mcp_servers"]["demo"]
assert server["tools"]["include"] == ["a", "b", "c"]
def test_probe_success_non_tty_with_default_filters_to_default(
self, catalog_dir, monkeypatch
):
body = _basic_manifest(
tools={"default_enabled": ["alpha", "gamma"]},
)
_write_manifest(catalog_dir, "demo", body)
import hermes_cli.mcp_catalog as mc
probed = self._make_probed("alpha", "beta", "gamma", "delta")
monkeypatch.setattr(mc, "_probe_tools", lambda name: probed)
import sys as _sys
monkeypatch.setattr(_sys.stdin, "isatty", lambda: False)
from hermes_cli.mcp_catalog import install_entry
from hermes_cli.config import load_config
install_entry(_entry("demo"), enable=True)
server = load_config()["mcp_servers"]["demo"]
# Only the manifest defaults that actually exist on the server
assert server["tools"]["include"] == ["alpha", "gamma"]
def test_probe_success_non_tty_no_default_clears_filter(
self, catalog_dir, monkeypatch
):
_write_manifest(catalog_dir, "demo", _basic_manifest())
import hermes_cli.mcp_catalog as mc
probed = self._make_probed("x", "y")
monkeypatch.setattr(mc, "_probe_tools", lambda name: probed)
import sys as _sys
monkeypatch.setattr(_sys.stdin, "isatty", lambda: False)
from hermes_cli.mcp_catalog import install_entry
from hermes_cli.config import load_config
install_entry(_entry("demo"), enable=True)
server = load_config()["mcp_servers"]["demo"]
assert "tools" not in server
def test_default_enabled_filters_out_unknown_tool_names(
self, catalog_dir, monkeypatch
):
"""If manifest names a tool the server doesn\'t actually expose, it
silently drops out never written into tools.include."""
body = _basic_manifest(
tools={"default_enabled": ["real", "ghost"]},
)
_write_manifest(catalog_dir, "demo", body)
import hermes_cli.mcp_catalog as mc
probed = self._make_probed("real", "other")
monkeypatch.setattr(mc, "_probe_tools", lambda name: probed)
import sys as _sys
monkeypatch.setattr(_sys.stdin, "isatty", lambda: False)
from hermes_cli.mcp_catalog import install_entry
from hermes_cli.config import load_config
install_entry(_entry("demo"), enable=True)
server = load_config()["mcp_servers"]["demo"]
assert server["tools"]["include"] == ["real"]
def test_reinstall_preserves_prior_user_selection(
self, catalog_dir, monkeypatch
):
"""Second install of the same entry uses the user\'s prior
tools.include as the pre-check, NOT the manifest default."""
body = _basic_manifest(
tools={"default_enabled": ["alpha"]},
)
_write_manifest(catalog_dir, "demo", body)
import hermes_cli.mcp_catalog as mc
probed = self._make_probed("alpha", "beta", "gamma")
monkeypatch.setattr(mc, "_probe_tools", lambda name: probed)
import sys as _sys
monkeypatch.setattr(_sys.stdin, "isatty", lambda: False)
from hermes_cli.mcp_catalog import install_entry
from hermes_cli.config import load_config, save_config
# First install
install_entry(_entry("demo"), enable=True)
# Simulate user opening configure and choosing beta+gamma
cfg = load_config()
cfg["mcp_servers"]["demo"]["tools"]["include"] = ["beta", "gamma"]
save_config(cfg)
# Reinstall (non-TTY honors prior_selection over manifest default)
install_entry(_entry("demo"), enable=True)
server = load_config()["mcp_servers"]["demo"]
assert server["tools"]["include"] == ["beta", "gamma"], server
def test_manifest_invalid_default_enabled_rejected(self, catalog_dir):
body = _basic_manifest()
body["tools"] = {"default_enabled": "not a list"}
_write_manifest(catalog_dir, "demo", body)
from hermes_cli.mcp_catalog import list_catalog
# Invalid manifests are silently skipped at list_catalog level
assert list_catalog() == []
# ---------------------------------------------------------------------------
# Forward-compat / diagnostics
# ---------------------------------------------------------------------------
class TestCatalogDiagnostics:
def test_future_manifest_version_skipped_with_diagnostic(self, catalog_dir):
"""A manifest with a newer manifest_version is skipped, but the skip
is reported via catalog_diagnostics so the UI can tell the user."""
body = _basic_manifest()
body["manifest_version"] = 999 # Future version
_write_manifest(catalog_dir, "futuristic", body)
# Plus one valid entry
_write_manifest(catalog_dir, "demo", _basic_manifest())
from hermes_cli.mcp_catalog import list_catalog, catalog_diagnostics
entries = list_catalog()
assert [e.name for e in entries] == ["demo"]
diags = catalog_diagnostics()
# At least one future_manifest diagnostic for the futuristic entry
future = [d for d in diags if d[1] == "future_manifest"]
assert len(future) == 1
assert future[0][0] == "futuristic"
def test_invalid_manifest_diagnostic(self, catalog_dir):
body = _basic_manifest()
body["transport"] = {"type": "unsupported"}
_write_manifest(catalog_dir, "broken", body)
from hermes_cli.mcp_catalog import list_catalog, catalog_diagnostics
entries = list_catalog()
assert entries == []
diags = catalog_diagnostics()
invalid = [d for d in diags if d[1] == "invalid"]
assert len(invalid) == 1
def test_picker_surfaces_future_manifest_warning(self, catalog_dir, capsys, monkeypatch):
"""The text-dump path should print a warning line for future-manifest
entries so users running headless or after `hermes setup` know to update."""
body = _basic_manifest()
body["manifest_version"] = 999
_write_manifest(catalog_dir, "futuristic", body)
_write_manifest(catalog_dir, "demo", _basic_manifest())
import sys as _sys
monkeypatch.setattr(_sys.stdin, "isatty", lambda: False)
from hermes_cli.mcp_picker import show_catalog
show_catalog()
out = capsys.readouterr().out
assert "futuristic" in out
assert "requires a newer Hermes" in out
# ---------------------------------------------------------------------------
# Picker — custom (non-catalog) MCP rows
# ---------------------------------------------------------------------------
class TestCustomMcpRows:
def test_custom_mcp_shown_alongside_catalog(self, catalog_dir, capsys):
"""Servers in mcp_servers that aren't in the catalog show up in the
picker text dump with a 'custom' status."""
_write_manifest(catalog_dir, "demo", _basic_manifest())
from hermes_cli.config import load_config, save_config
cfg = load_config()
cfg.setdefault("mcp_servers", {})["my-custom"] = {
"command": "npx",
"args": ["-y", "my-custom-mcp"],
"enabled": True,
}
save_config(cfg)
from hermes_cli.mcp_picker import show_catalog
show_catalog()
out = capsys.readouterr().out
assert "demo" in out
assert "my-custom" in out
assert "custom" in out # The status badge
def test_custom_mcp_only_no_catalog(self, catalog_dir, capsys):
"""If the catalog is empty but the user has custom MCPs, they\'re
still visible the picker is the unified surface."""
from hermes_cli.config import load_config, save_config
cfg = load_config()
cfg.setdefault("mcp_servers", {})["my-custom"] = {
"url": "https://mcp.example.com",
"enabled": False,
}
save_config(cfg)
from hermes_cli.mcp_picker import show_catalog
show_catalog()
out = capsys.readouterr().out
assert "my-custom" in out
# ---------------------------------------------------------------------------
# Git install — SHA ref detection
# ---------------------------------------------------------------------------
class TestGitInstallShaRef:
def test_sha_ref_skips_branch_attempt(self, catalog_dir, monkeypatch, tmp_path):
"""When install.ref is a SHA-shaped hex string, _do_git_install
skips the `git clone --branch <ref>` attempt (which would always fail
noisily for SHAs) and goes straight to clone + checkout."""
body = _basic_manifest(
install={
"type": "git",
"url": "https://example.com/x.git",
"ref": "abc1234567890abcdef1234567890abcdef12345", # 40-char SHA
"bootstrap": [],
},
transport={
"type": "stdio",
"command": "${INSTALL_DIR}/run.sh",
"args": [],
},
)
_write_manifest(catalog_dir, "demo", body)
from hermes_cli import mcp_catalog
from hermes_cli.mcp_catalog import _do_git_install
calls = []
class _FakeProc:
def __init__(self, returncode):
self.returncode = returncode
def fake_run(argv, *args, **kwargs):
calls.append(list(argv))
# Make every command succeed
return _FakeProc(returncode=0)
monkeypatch.setattr(mcp_catalog.subprocess, "run", fake_run)
monkeypatch.setattr(mcp_catalog.shutil, "which", lambda x: "/usr/bin/git")
from hermes_cli.mcp_catalog import get_entry
entry = get_entry("demo")
assert entry is not None
_do_git_install(entry)
# Should have called clone (no --branch) then checkout — NOT clone --branch
branch_attempts = [c for c in calls if "--branch" in c]
assert branch_attempts == [], (
"SHA refs must NOT trigger a --branch clone attempt — that would "
"always fail noisily before falling back. Calls were: " + repr(calls)
)
# Confirm we DID do plain clone + checkout
clone_calls = [c for c in calls if "clone" in c and "--branch" not in c]
checkout_calls = [c for c in calls if "checkout" in c]
assert len(clone_calls) == 1, calls
assert len(checkout_calls) == 1, calls
def test_branch_ref_uses_branch_clone(self, catalog_dir, monkeypatch):
"""When install.ref is a branch/tag (not SHA-shaped), the fast
`git clone --depth 1 --branch <ref>` path is used."""
body = _basic_manifest(
install={
"type": "git",
"url": "https://example.com/x.git",
"ref": "v1.0.0", # Tag-shaped
"bootstrap": [],
},
transport={
"type": "stdio",
"command": "${INSTALL_DIR}/run.sh",
"args": [],
},
)
_write_manifest(catalog_dir, "demo", body)
from hermes_cli import mcp_catalog
from hermes_cli.mcp_catalog import _do_git_install, get_entry
calls = []
class _FakeProc:
def __init__(self, returncode):
self.returncode = returncode
def fake_run(argv, *args, **kwargs):
calls.append(list(argv))
return _FakeProc(returncode=0)
monkeypatch.setattr(mcp_catalog.subprocess, "run", fake_run)
monkeypatch.setattr(mcp_catalog.shutil, "which", lambda x: "/usr/bin/git")
_do_git_install(get_entry("demo"))
branch_attempts = [c for c in calls if "--branch" in c]
assert len(branch_attempts) == 1, calls
# ---------------------------------------------------------------------------
# Existing tools_config converged to tools.include
# ---------------------------------------------------------------------------
class TestToolsConfigIncludeMode:
def test_configure_mcp_writes_include_not_exclude(self, monkeypatch, tmp_path):
"""`_configure_mcp_tools_interactive` in tools_config.py must write
`tools.include` (whitelist), matching the rest of the codebase. The
old behavior wrote `tools.exclude`, which produced inconsistent
on-disk shapes depending on which UI the user used last."""
# Build a minimal mcp_servers config + mock probe + checklist
cfg = {
"_config_version": 23,
"mcp_servers": {
"demo": {
"command": "npx",
"args": ["-y", "demo-mcp"],
"enabled": True,
}
},
}
import hermes_cli.tools_config as tc
# Mock the probe to return three tools
monkeypatch.setattr(
"tools.mcp_tool.probe_mcp_server_tools",
lambda: {"demo": [("a", "desc"), ("b", "desc"), ("c", "desc")]},
)
# Mock the checklist to return just the first tool
monkeypatch.setattr(
"hermes_cli.curses_ui.curses_checklist",
lambda title, labels, pre_selected, **kw: {0},
)
# Mock save_config so we can inspect the write
saved = {}
def fake_save(config):
saved.update(config)
monkeypatch.setattr(tc, "save_config", fake_save)
tc._configure_mcp_tools_interactive(cfg)
# Must have written include, not exclude
srv = saved["mcp_servers"]["demo"]["tools"]
assert srv.get("include") == ["a"], srv
assert "exclude" not in srv, srv
class TestShippedCatalog:
def test_all_shipped_manifests_parse(self, monkeypatch):
"""Every manifest in optional-mcps/ must parse cleanly.
This is a contract test CI will fail if a PR adds a malformed
manifest. Intentionally NOT a snapshot of catalog names (those are
expected to change as PRs land).
"""
# Use the actual repo's optional-mcps directory (no HERMES_OPTIONAL_MCPS
# override) so this test catches real manifests.
monkeypatch.delenv("HERMES_OPTIONAL_MCPS", raising=False)
from hermes_cli.mcp_catalog import _catalog_root, _parse_manifest
root = _catalog_root()
if not root.exists():
pytest.skip("optional-mcps/ not present in this checkout")
manifests = list(root.glob("*/manifest.yaml"))
# Don't assert minimum count — change-detector test rule. Just parse
# whatever exists.
for m in manifests:
entry = _parse_manifest(m)
assert entry.name
assert entry.description
assert entry.transport.type in ("stdio", "http")

View file

@ -68,8 +68,13 @@ def test_no_changes_when_checklist_cancelled(capsys):
assert "no changes" in captured.out.lower()
def test_disabling_tool_writes_exclude_list(capsys):
"""Unchecking a tool adds it to the exclude list."""
def test_disabling_tool_writes_include_list(capsys):
"""Unchecking a tool produces an include list of the still-chosen tools.
Standardized on tools.include (whitelist) across the codebase the
catalog flow, `hermes mcp configure`, and this UI all write the same
shape so users don\'t see config drift across UIs.
"""
config = {
"mcp_servers": {
"github": {"command": "npx"},
@ -89,8 +94,8 @@ def test_disabling_tool_writes_exclude_list(capsys):
mock_save.assert_called_once()
tools_cfg = config["mcp_servers"]["github"]["tools"]
assert tools_cfg["exclude"] == ["delete_repo"]
assert "include" not in tools_cfg
assert tools_cfg["include"] == ["create_issue", "search_repos"]
assert "exclude" not in tools_cfg
def test_enabling_all_clears_filters(capsys):
@ -244,8 +249,9 @@ def test_description_truncation_in_labels():
assert len(label) < len(long_desc) + 30 # truncated + tool name + parens
def test_switching_from_include_to_exclude(capsys):
"""When user modifies selection, include list is replaced by exclude list."""
def test_modifying_include_stays_in_include_mode(capsys):
"""Changing the selection updates the include list — never switches
to exclude mode. Standardized on include-mode writes across the codebase."""
config = {
"mcp_servers": {
"github": {
@ -256,16 +262,15 @@ def test_switching_from_include_to_exclude(capsys):
}
tools = [("create_issue", "Create"), ("search", "Search"), ("delete", "Delete")]
# User selects create_issue and search (deselects delete)
# pre_selected would be {0} (only create_issue from include), so {0, 1} is a change
# User adds search to the selection (deselects delete which was never on)
with patch(_PROBE, return_value={"github": tools}), \
patch(_CHECKLIST, return_value={0, 1}), \
patch(_SAVE):
_configure_mcp_tools_interactive(config)
tools_cfg = config["mcp_servers"]["github"]["tools"]
assert tools_cfg["exclude"] == ["delete"]
assert "include" not in tools_cfg
assert tools_cfg["include"] == ["create_issue", "search"]
assert "exclude" not in tools_cfg
def test_empty_tools_server_skipped(capsys):

View file

@ -414,6 +414,8 @@ class TestCopilotNormalization:
assert opencode_model_api_mode("opencode-go", "opencode-go/kimi-k2.5") == "chat_completions"
assert opencode_model_api_mode("opencode-go", "minimax-m2.5") == "anthropic_messages"
assert opencode_model_api_mode("opencode-go", "opencode-go/minimax-m2.5") == "anthropic_messages"
assert opencode_model_api_mode("opencode-go", "qwen3.7-max") == "anthropic_messages"
assert opencode_model_api_mode("opencode-go", "opencode-go/qwen3.7-max") == "anthropic_messages"
class TestAzureFoundryModelApiMode:

View file

@ -13,7 +13,7 @@ import hermes_cli.models as _models_mod
LIVE_OPENROUTER_MODELS = [
("anthropic/claude-opus-4.6", "recommended"),
("qwen/qwen3.6-plus", ""),
("qwen/qwen3.7-max", ""),
("nvidia/nemotron-3-super-120b-a12b:free", "free"),
]
@ -70,7 +70,7 @@ class TestFetchOpenRouterModels:
return False
def read(self):
return b'{"data":[{"id":"anthropic/claude-opus-4.6","pricing":{"prompt":"0.000015","completion":"0.000075"}},{"id":"qwen/qwen3.6-plus","pricing":{"prompt":"0.000000325","completion":"0.00000195"}},{"id":"nvidia/nemotron-3-super-120b-a12b:free","pricing":{"prompt":"0","completion":"0"}}]}'
return b'{"data":[{"id":"anthropic/claude-opus-4.6","pricing":{"prompt":"0.000015","completion":"0.000075"}},{"id":"qwen/qwen3.7-max","pricing":{"prompt":"0.000000325","completion":"0.00000195"}},{"id":"nvidia/nemotron-3-super-120b-a12b:free","pricing":{"prompt":"0","completion":"0"}}]}'
monkeypatch.setattr(_models_mod, "_openrouter_catalog_cache", None)
with patch("hermes_cli.models.urllib.request.urlopen", return_value=_Resp()):
@ -78,7 +78,7 @@ class TestFetchOpenRouterModels:
assert models == [
("anthropic/claude-opus-4.6", "recommended"),
("qwen/qwen3.6-plus", ""),
("qwen/qwen3.7-max", ""),
("nvidia/nemotron-3-super-120b-a12b:free", "free"),
]
@ -106,14 +106,14 @@ class TestFetchOpenRouterModels:
def read(self):
# opus-4.6 advertises tools → kept
# nano-image has explicit supported_parameters that OMITS tools → dropped
# qwen3.6-plus advertises tools → kept
# qwen3.7-max advertises tools → kept
return (
b'{"data":['
b'{"id":"anthropic/claude-opus-4.6","pricing":{"prompt":"0.000015","completion":"0.000075"},'
b'"supported_parameters":["temperature","tools","tool_choice"]},'
b'{"id":"google/gemini-3-pro-image-preview","pricing":{"prompt":"0.00001","completion":"0.00003"},'
b'"supported_parameters":["temperature","response_format"]},'
b'{"id":"qwen/qwen3.6-plus","pricing":{"prompt":"0.000000325","completion":"0.00000195"},'
b'{"id":"qwen/qwen3.7-max","pricing":{"prompt":"0.000000325","completion":"0.00000195"},'
b'"supported_parameters":["tools","temperature"]}'
b']}'
)
@ -125,7 +125,7 @@ class TestFetchOpenRouterModels:
[
("anthropic/claude-opus-4.6", ""),
("google/gemini-3-pro-image-preview", ""),
("qwen/qwen3.6-plus", ""),
("qwen/qwen3.7-max", ""),
],
)
monkeypatch.setattr(_models_mod, "_openrouter_catalog_cache", None)
@ -134,7 +134,7 @@ class TestFetchOpenRouterModels:
ids = [mid for mid, _ in models]
assert "anthropic/claude-opus-4.6" in ids
assert "qwen/qwen3.6-plus" in ids
assert "qwen/qwen3.7-max" in ids
# Image-only model advertised supported_parameters WITHOUT tools → must be dropped.
assert "google/gemini-3-pro-image-preview" not in ids
@ -158,7 +158,7 @@ class TestFetchOpenRouterModels:
return (
b'{"data":['
b'{"id":"anthropic/claude-opus-4.6","pricing":{"prompt":"0.000015","completion":"0.000075"}},'
b'{"id":"qwen/qwen3.6-plus","pricing":{"prompt":"0.000000325","completion":"0.00000195"}}'
b'{"id":"qwen/qwen3.7-max","pricing":{"prompt":"0.000000325","completion":"0.00000195"}}'
b']}'
)
@ -168,7 +168,7 @@ class TestFetchOpenRouterModels:
ids = [mid for mid, _ in models]
assert "anthropic/claude-opus-4.6" in ids
assert "qwen/qwen3.6-plus" in ids
assert "qwen/qwen3.7-max" in ids
class TestOpenRouterToolSupportHelper:

View file

@ -0,0 +1,214 @@
"""Regression tests for Nous Portal inference_base_url host-allowlist validation.
A poisoned ``inference_base_url`` from the Portal refresh / agent-key-mint
response (network MITM, malicious response injection) would otherwise be
persisted to auth.json and forwarded the user's legitimate agent_key
bearer on every subsequent proxy request, exfiltrating their inference
budget and opening a response-injection channel into the IDE / chat
client. ``_validate_nous_inference_url_from_network()`` blocks any URL
outside the allowlist at the source.
These tests verify:
1. The validator's host + scheme rules.
2. Each of the five NETWORK call sites in ``auth.py`` calls the validator
rather than the unrestricted ``_optional_base_url`` helper.
3. The proxy adapter applies the validator as belt-and-suspenders.
4. The env-var override path (``NOUS_INFERENCE_BASE_URL``) is NOT
gated by the validator that's the documented dev/staging escape
hatch.
"""
from __future__ import annotations
import logging
import pytest
from hermes_cli.auth import (
DEFAULT_NOUS_INFERENCE_URL,
_ALLOWED_NOUS_INFERENCE_HOSTS,
_validate_nous_inference_url_from_network,
)
class TestValidatorRules:
def test_allowlisted_https_host_returned(self):
url = "https://inference-api.nousresearch.com/v1"
assert _validate_nous_inference_url_from_network(url) == url
def test_trailing_slash_stripped(self):
url = "https://inference-api.nousresearch.com/v1/"
assert _validate_nous_inference_url_from_network(url) == url.rstrip("/")
def test_attacker_host_rejected(self, caplog):
with caplog.at_level(logging.WARNING, logger="hermes_cli.auth"):
assert (
_validate_nous_inference_url_from_network("https://attacker.com/v1")
is None
)
assert any("attacker.com" in rec.message for rec in caplog.records)
def test_subdomain_of_allowlist_host_rejected(self):
"""*.nousresearch.com is NOT in the allowlist — exact hostname only.
A subdomain takeover or DNS hijack of *.nousresearch.com would
otherwise pass keep the gate tight.
"""
assert (
_validate_nous_inference_url_from_network(
"https://evil.inference-api.nousresearch.com/v1"
)
is None
)
def test_http_scheme_rejected(self, caplog):
with caplog.at_level(logging.WARNING, logger="hermes_cli.auth"):
assert (
_validate_nous_inference_url_from_network(
"http://inference-api.nousresearch.com/v1"
)
is None
)
assert any("non-https" in rec.message for rec in caplog.records)
def test_file_scheme_rejected(self):
assert (
_validate_nous_inference_url_from_network("file:///etc/passwd") is None
)
def test_javascript_scheme_rejected(self):
assert (
_validate_nous_inference_url_from_network(
"javascript:alert(document.cookie)"
)
is None
)
def test_empty_string_rejected(self):
assert _validate_nous_inference_url_from_network("") is None
def test_whitespace_only_rejected(self):
assert _validate_nous_inference_url_from_network(" ") is None
def test_none_rejected(self):
assert _validate_nous_inference_url_from_network(None) is None
def test_non_string_rejected(self):
assert _validate_nous_inference_url_from_network(12345) is None # type: ignore[arg-type]
assert _validate_nous_inference_url_from_network({"url": "x"}) is None # type: ignore[arg-type]
def test_malformed_url_rejected(self):
"""Even garbled input must fall back safely, not raise."""
assert (
_validate_nous_inference_url_from_network("not://a real url at all")
is None
)
def test_default_inference_url_is_in_allowlist(self):
"""Sanity check: DEFAULT_NOUS_INFERENCE_URL must itself validate.
If anyone retargets the default away from
``inference-api.nousresearch.com``, they MUST update the allowlist
in the same change otherwise the allowlist would reject the
Portal's own legitimate default and break every install.
"""
assert (
_validate_nous_inference_url_from_network(DEFAULT_NOUS_INFERENCE_URL)
== DEFAULT_NOUS_INFERENCE_URL.rstrip("/")
)
def test_allowlist_contains_inference_api_host(self):
"""The default's host must be in the allowlist set."""
from urllib.parse import urlparse
host = urlparse(DEFAULT_NOUS_INFERENCE_URL).hostname
assert host in _ALLOWED_NOUS_INFERENCE_HOSTS
class TestCallSiteWiring:
"""Verify the validator is actually wired into all 5 NETWORK call sites.
These are not behaviour-end-to-end tests (the surrounding code is
several hundred lines per site with extensive HTTP mocking
requirements). They're text-grep contracts: if anyone replaces
``_validate_nous_inference_url_from_network`` with the un-validated
``_optional_base_url`` again, the test catches it.
Each site lives inside ``resolve_nous_runtime_credentials`` and one
helper (``_extend_state_from_refresh``). The shape we guard against
is ``<helper>_url = _optional_base_url(<payload>.get("inference_base_url"))``
that's what the unsafe pre-fix code looked like, and the only
semantic difference between the safe and unsafe helpers is the
host-allowlist check.
"""
def _read_auth_source(self):
import hermes_cli.auth as _auth_mod
from pathlib import Path
return Path(_auth_mod.__file__).read_text(encoding="utf-8")
def test_no_unvalidated_inference_base_url_assignments_remain(self):
"""No remaining ``_optional_base_url(...inference_base_url...)`` reads
from Portal payloads. If you see a failure here, you've either
added a new NETWORK site that needs validation, or downgraded an
existing one back to the unsafe helper."""
source = self._read_auth_source()
for needle in (
'_optional_base_url(refreshed.get("inference_base_url"))',
'_optional_base_url(mint_payload.get("inference_base_url"))',
):
assert needle not in source, (
f"Found unvalidated network read: {needle!r}. "
f"Use _validate_nous_inference_url_from_network() instead."
)
def test_validator_wired_at_all_known_call_sites(self):
"""All 5 known NETWORK sites use the validator. If this count
drops, someone removed protection; if it grows, audit the new
site to be sure validation is appropriate."""
source = self._read_auth_source()
refresh_count = source.count(
'_validate_nous_inference_url_from_network(refreshed.get("inference_base_url"))'
)
mint_count = source.count(
'_validate_nous_inference_url_from_network(mint_payload.get("inference_base_url"))'
)
assert refresh_count == 3, f"expected 3 refresh sites, found {refresh_count}"
assert mint_count == 2, f"expected 2 mint sites, found {mint_count}"
def test_proxy_adapter_also_validates(self):
"""The Nous proxy adapter applies the validator as defense-in-depth
even though auth.py already validates at the source, so a future
bypass at the source layer still gets caught at the forward
boundary."""
from pathlib import Path
import hermes_cli.proxy.adapters.nous_portal as _nous_adapter
source = Path(_nous_adapter.__file__).read_text(encoding="utf-8")
assert "_validate_nous_inference_url_from_network" in source
class TestEnvOverrideNotGated:
"""The documented dev/staging env-var override must keep working.
``NOUS_INFERENCE_BASE_URL`` is read by ``resolve_nous_runtime_credentials``
via ``os.getenv`` that path doesn't pass through the validator
(env values are trusted because the user set them themselves).
Verify the env-var read site does NOT consult the validator, so a
user running against a non-allowlisted staging host via env is not
inadvertently broken by this fix.
"""
def test_env_override_path_does_not_call_validator(self):
"""In resolve_nous_runtime_credentials, the env override is
read via os.getenv directly, not via the validator. Grep the
source to confirm: the env line should NOT mention the
validator."""
import hermes_cli.auth as _auth_mod
from pathlib import Path
source = Path(_auth_mod.__file__).read_text(encoding="utf-8")
# Find the env-override read line.
for line in source.splitlines():
if "NOUS_INFERENCE_BASE_URL" in line and "os.getenv" in line:
assert "_validate_nous_inference_url_from_network" not in line, (
"env override path must not gate through the network "
"validator — it would break documented dev/staging use."
)

View file

@ -0,0 +1,353 @@
"""Tests for the plugin auxiliary-task registration API.
Covers:
- PluginContext.register_auxiliary_task() validation
- PluginManager._aux_tasks storage + force-rediscovery clearing
- get_plugin_auxiliary_tasks() module-level helper
- _all_aux_tasks() merge of built-in + plugin tasks
- _reset_aux_to_auto() includes plugin tasks
- _get_auxiliary_task_config() layers plugin defaults under user config
"""
from __future__ import annotations
import pytest
from hermes_cli.plugins import (
PluginContext,
PluginManager,
PluginManifest,
get_plugin_auxiliary_tasks,
)
# ── Fixtures ─────────────────────────────────────────────────────────────────
def _make_ctx(name: str = "test_plugin") -> tuple[PluginContext, PluginManager]:
"""Build a PluginContext + fresh PluginManager wired together.
The manager skips discovery (no plugins.yaml, no scan) so the test
can exercise registration paths directly.
"""
manager = PluginManager()
manager._discovered = True # skip auto-discovery on lookup
manifest = PluginManifest(name=name)
ctx = PluginContext(manifest, manager)
return ctx, manager
@pytest.fixture
def patched_manager(monkeypatch):
"""Replace the module-level singleton with a fresh manager for the test.
Restored automatically after the test by monkeypatch.
"""
from hermes_cli import plugins as plugins_mod
fresh = PluginManager()
fresh._discovered = True
monkeypatch.setattr(plugins_mod, "_PLUGIN_MANAGER", fresh, raising=False)
def _stub_get_manager() -> PluginManager:
return fresh
monkeypatch.setattr(plugins_mod, "get_plugin_manager", _stub_get_manager)
monkeypatch.setattr(plugins_mod, "_ensure_plugins_discovered", _stub_get_manager)
yield fresh
# ── PluginContext.register_auxiliary_task ────────────────────────────────────
def test_register_auxiliary_task_basic():
ctx, manager = _make_ctx("my_plugin")
ctx.register_auxiliary_task(
key="my_task",
display_name="My task",
description="a custom side task",
)
assert "my_task" in manager._aux_tasks
entry = manager._aux_tasks["my_task"]
assert entry["key"] == "my_task"
assert entry["display_name"] == "My task"
assert entry["description"] == "a custom side task"
assert entry["plugin"] == "my_plugin"
# Routing defaults populated
assert entry["defaults"]["provider"] == "auto"
assert entry["defaults"]["model"] == ""
assert entry["defaults"]["timeout"] == 60
def test_register_auxiliary_task_with_custom_defaults():
ctx, manager = _make_ctx()
ctx.register_auxiliary_task(
key="custom_task",
display_name="Custom",
description="d",
defaults={"timeout": 30, "extra_body": {"reasoning_effort": "low"}},
)
entry = manager._aux_tasks["custom_task"]
assert entry["defaults"]["timeout"] == 30
assert entry["defaults"]["extra_body"] == {"reasoning_effort": "low"}
# Unspecified defaults still populated
assert entry["defaults"]["provider"] == "auto"
def test_register_auxiliary_task_rejects_builtin_keys():
ctx, _ = _make_ctx()
for builtin in (
"vision",
"compression",
"web_extract",
"approval",
"mcp",
"title_generation",
"skills_hub",
"curator",
):
with pytest.raises(ValueError, match="reserved for a built-in task"):
ctx.register_auxiliary_task(
key=builtin,
display_name="x",
description="x",
)
def test_register_auxiliary_task_rejects_invalid_key_shapes():
ctx, _ = _make_ctx()
for bad in ("", "with-dash", "with.dot", "with space", "with/slash"):
with pytest.raises(ValueError):
ctx.register_auxiliary_task(
key=bad,
display_name="x",
description="x",
)
def test_register_auxiliary_task_allows_same_plugin_re_registration():
"""Re-registration by the same plugin updates the entry (idempotent)."""
ctx, manager = _make_ctx("plug_a")
ctx.register_auxiliary_task(
key="t1", display_name="First", description="first"
)
ctx.register_auxiliary_task(
key="t1", display_name="Second", description="second"
)
assert manager._aux_tasks["t1"]["display_name"] == "Second"
def test_register_auxiliary_task_rejects_cross_plugin_collision():
"""Two different plugins cannot register the same task key."""
manager = PluginManager()
manager._discovered = True
manifest_a = PluginManifest(name="plug_a")
manifest_b = PluginManifest(name="plug_b")
ctx_a = PluginContext(manifest_a, manager)
ctx_b = PluginContext(manifest_b, manager)
ctx_a.register_auxiliary_task(
key="shared", display_name="A", description="a"
)
with pytest.raises(ValueError, match="already registered by plugin 'plug_a'"):
ctx_b.register_auxiliary_task(
key="shared", display_name="B", description="b"
)
# ── PluginManager state lifecycle ────────────────────────────────────────────
def test_force_rediscovery_clears_aux_tasks():
ctx, manager = _make_ctx()
ctx.register_auxiliary_task(
key="will_be_cleared",
display_name="x",
description="x",
)
assert "will_be_cleared" in manager._aux_tasks
manager._discovered = False
# Simulate force=True path: clears state before re-scanning
manager._aux_tasks.clear()
assert manager._aux_tasks == {}
# ── Module-level helper ──────────────────────────────────────────────────────
def test_get_plugin_auxiliary_tasks_returns_sorted_list(patched_manager):
manifest = PluginManifest(name="plug")
ctx = PluginContext(manifest, patched_manager)
ctx.register_auxiliary_task(
key="zeta_task", display_name="Zeta", description="z"
)
ctx.register_auxiliary_task(
key="alpha_task", display_name="Alpha", description="a"
)
ctx.register_auxiliary_task(
key="mike_task", display_name="Mike", description="m"
)
tasks = get_plugin_auxiliary_tasks()
assert [t["key"] for t in tasks] == ["alpha_task", "mike_task", "zeta_task"]
def test_get_plugin_auxiliary_tasks_empty_when_none_registered(patched_manager):
assert get_plugin_auxiliary_tasks() == []
# ── _all_aux_tasks merges built-in + plugin ──────────────────────────────────
def test_all_aux_tasks_includes_plugin_registered(patched_manager):
from hermes_cli.main import _AUX_TASKS, _all_aux_tasks
manifest = PluginManifest(name="hindsight")
ctx = PluginContext(manifest, patched_manager)
ctx.register_auxiliary_task(
key="memory_retain_filter",
display_name="Memory retain filter",
description="hindsight pre-retain dedup/extract",
)
merged = _all_aux_tasks()
keys = [k for k, _, _ in merged]
# Built-ins preserved (and come first)
builtin_keys = [k for k, _, _ in _AUX_TASKS]
assert keys[: len(builtin_keys)] == builtin_keys
# Plugin task appended
assert "memory_retain_filter" in keys
plugin_entry = next(t for t in merged if t[0] == "memory_retain_filter")
assert plugin_entry == (
"memory_retain_filter",
"Memory retain filter",
"hindsight pre-retain dedup/extract",
)
def test_all_aux_tasks_swallows_plugin_discovery_failure(monkeypatch):
"""Plugin discovery failure must not break the aux config UI."""
from hermes_cli import main as main_mod
def _broken():
raise RuntimeError("plugin scan exploded")
monkeypatch.setattr(
"hermes_cli.plugins.get_plugin_auxiliary_tasks", _broken
)
merged = main_mod._all_aux_tasks()
# Built-in tasks still present
assert any(k == "vision" for k, _, _ in merged)
# ── _reset_aux_to_auto includes plugin tasks ─────────────────────────────────
def test_reset_aux_to_auto_resets_plugin_tasks(tmp_path, monkeypatch, patched_manager):
"""Plugin task with non-auto config gets reset alongside built-ins."""
from pathlib import Path
from hermes_cli.config import load_config, save_config
from hermes_cli.main import _reset_aux_to_auto
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
(tmp_path / ".hermes").mkdir(exist_ok=True)
manifest = PluginManifest(name="plug")
ctx = PluginContext(manifest, patched_manager)
ctx.register_auxiliary_task(
key="my_aux",
display_name="My Aux",
description="d",
)
# Manually configure the plugin task to non-auto
cfg = load_config()
aux = cfg.setdefault("auxiliary", {})
aux["my_aux"] = {"provider": "openrouter", "model": "gpt-4o", "base_url": "", "api_key": ""}
save_config(cfg)
n = _reset_aux_to_auto()
assert n >= 1
cfg = load_config()
assert cfg["auxiliary"]["my_aux"]["provider"] == "auto"
assert cfg["auxiliary"]["my_aux"]["model"] == ""
# ── auxiliary_client._get_auxiliary_task_config defaults layering ────────────
def test_get_auxiliary_task_config_layers_plugin_defaults(
tmp_path, monkeypatch, patched_manager
):
"""Plugin-declared defaults appear when user has no config entry."""
from pathlib import Path
from agent.auxiliary_client import _get_auxiliary_task_config
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
(tmp_path / ".hermes").mkdir(exist_ok=True)
manifest = PluginManifest(name="plug")
ctx = PluginContext(manifest, patched_manager)
ctx.register_auxiliary_task(
key="my_filter",
display_name="My filter",
description="x",
defaults={"timeout": 15, "extra_body": {"reasoning_effort": "low"}},
)
# No user config for my_filter — defaults should surface
resolved = _get_auxiliary_task_config("my_filter")
assert resolved["timeout"] == 15
assert resolved["extra_body"] == {"reasoning_effort": "low"}
assert resolved["provider"] == "auto"
def test_get_auxiliary_task_config_user_config_wins_over_plugin_defaults(
tmp_path, monkeypatch, patched_manager
):
"""User's config.yaml entry overrides plugin-declared defaults."""
from pathlib import Path
from hermes_cli.config import load_config, save_config
from agent.auxiliary_client import _get_auxiliary_task_config
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
(tmp_path / ".hermes").mkdir(exist_ok=True)
manifest = PluginManifest(name="plug")
ctx = PluginContext(manifest, patched_manager)
ctx.register_auxiliary_task(
key="my_filter",
display_name="My filter",
description="x",
defaults={"timeout": 15, "provider": "auto"},
)
# User overrides timeout + provider via config.yaml
cfg = load_config()
aux = cfg.setdefault("auxiliary", {})
aux["my_filter"] = {"timeout": 90, "provider": "nous"}
save_config(cfg)
resolved = _get_auxiliary_task_config("my_filter")
assert resolved["timeout"] == 90 # user wins
assert resolved["provider"] == "nous" # user wins
def test_get_auxiliary_task_config_unknown_task_returns_empty(
tmp_path, monkeypatch, patched_manager
):
from pathlib import Path
from agent.auxiliary_client import _get_auxiliary_task_config
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
(tmp_path / ".hermes").mkdir(exist_ok=True)
assert _get_auxiliary_task_config("nonexistent") == {}

View file

@ -65,6 +65,36 @@ class TestSanitizePluginName:
with pytest.raises(ValueError, match="must not be empty"):
_sanitize_plugin_name("", tmp_path)
# ── allow_subdir=True ──
def test_allow_subdir_accepts_single_slash(self, tmp_path):
target = _sanitize_plugin_name(
"observability/langfuse", tmp_path, allow_subdir=True
)
assert target == (tmp_path / "observability" / "langfuse").resolve()
def test_allow_subdir_strips_leading_trailing_slash(self, tmp_path):
target = _sanitize_plugin_name(
"/image_gen/openai/", tmp_path, allow_subdir=True
)
assert target == (tmp_path / "image_gen" / "openai").resolve()
def test_allow_subdir_still_rejects_dot_dot(self, tmp_path):
with pytest.raises(ValueError, match="must not contain"):
_sanitize_plugin_name("foo/../bar", tmp_path, allow_subdir=True)
def test_allow_subdir_still_rejects_backslash(self, tmp_path):
with pytest.raises(ValueError, match="must not contain"):
_sanitize_plugin_name("foo\\bar", tmp_path, allow_subdir=True)
def test_allow_subdir_rejects_empty_after_strip(self, tmp_path):
with pytest.raises(ValueError, match="must not be empty"):
_sanitize_plugin_name("///", tmp_path, allow_subdir=True)
def test_allow_subdir_resolves_inside_plugins_dir(self, tmp_path):
target = _sanitize_plugin_name("a/b/c", tmp_path, allow_subdir=True)
assert target.is_relative_to(tmp_path.resolve())
# ── _resolve_git_url ──────────────────────────────────────────────────────
@ -633,7 +663,7 @@ class TestPromptPluginEnvVars:
printed = " ".join(str(c) for c in console.print.call_args_list)
assert "langfuse.com" in printed
def test_secret_uses_getpass(self):
def test_secret_uses_masked_prompt(self):
from hermes_cli.plugins_cmd import _prompt_plugin_env_vars
from unittest.mock import MagicMock, patch
@ -644,11 +674,11 @@ class TestPromptPluginEnvVars:
}
with patch("hermes_cli.config.get_env_value", return_value=None), \
patch("getpass.getpass", return_value="s3cret") as mock_gp, \
patch("hermes_cli.plugins_cmd.masked_secret_prompt", return_value="s3cret") as mock_prompt, \
patch("hermes_cli.config.save_env_value"):
_prompt_plugin_env_vars(manifest, console)
mock_gp.assert_called_once()
mock_prompt.assert_called_once()
def test_empty_input_skips(self):
from hermes_cli.plugins_cmd import _prompt_plugin_env_vars

View file

@ -0,0 +1,148 @@
"""Tests for PluginContext.register_transcription_provider().
Exercises the plugin context hook end-to-end: drops a fake plugin into
``$HERMES_HOME/plugins/``, runs ``PluginManager().discover_and_load()``,
and asserts the registration result.
Mirrors the shape of ``test_plugins_tts_registration.py`` (companion
TTS hook from issue #30398).
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any, Dict
import yaml
def _write_plugin(
root: Path,
name: str,
*,
manifest_extra: Dict[str, Any] | None = None,
register_body: str = "pass",
) -> Path:
plugin_dir = root / name
plugin_dir.mkdir(parents=True, exist_ok=True)
manifest = {
"name": name,
"version": "0.1.0",
"description": f"Test plugin {name}",
}
if manifest_extra:
manifest.update(manifest_extra)
(plugin_dir / "plugin.yaml").write_text(yaml.dump(manifest))
(plugin_dir / "__init__.py").write_text(
f"def register(ctx):\n {register_body}\n"
)
return plugin_dir
def _enable(hermes_home: Path, name: str) -> None:
cfg_path = hermes_home / "config.yaml"
cfg: dict = {}
if cfg_path.exists():
try:
cfg = yaml.safe_load(cfg_path.read_text()) or {}
except Exception:
cfg = {}
plugins_cfg = cfg.setdefault("plugins", {})
enabled = plugins_cfg.setdefault("enabled", [])
if isinstance(enabled, list) and name not in enabled:
enabled.append(name)
cfg_path.write_text(yaml.safe_dump(cfg))
class TestRegisterTranscriptionProvider:
def test_accepts_valid_provider(self):
from hermes_cli.plugins import PluginManager
from agent import transcription_registry
transcription_registry._reset_for_tests()
hermes_home = Path(os.environ["HERMES_HOME"])
_write_plugin(
hermes_home / "plugins",
"my-stt-plugin",
register_body=(
"from agent.transcription_provider import TranscriptionProvider\n"
" class P(TranscriptionProvider):\n"
" @property\n"
" def name(self): return 'fake-stt'\n"
" def transcribe(self, file_path, **kw):\n"
" return {'success': True, 'transcript': 'hi', 'provider': 'fake-stt'}\n"
" ctx.register_transcription_provider(P())"
),
)
_enable(hermes_home, "my-stt-plugin")
mgr = PluginManager()
mgr.discover_and_load()
assert mgr._plugins["my-stt-plugin"].enabled is True, (
f"Plugin failed to load: {mgr._plugins['my-stt-plugin'].error}"
)
assert transcription_registry.get_provider("fake-stt") is not None
transcription_registry._reset_for_tests()
def test_rejects_non_provider(self, caplog):
from hermes_cli.plugins import PluginManager
from agent import transcription_registry
transcription_registry._reset_for_tests()
hermes_home = Path(os.environ["HERMES_HOME"])
_write_plugin(
hermes_home / "plugins",
"bad-stt-plugin",
register_body="ctx.register_transcription_provider('not a provider')",
)
_enable(hermes_home, "bad-stt-plugin")
with caplog.at_level("WARNING"):
mgr = PluginManager()
mgr.discover_and_load()
assert mgr._plugins["bad-stt-plugin"].enabled is True
assert transcription_registry.get_provider("not a provider") is None
assert transcription_registry.list_providers() == []
assert "does not inherit from TranscriptionProvider" in caplog.text
transcription_registry._reset_for_tests()
def test_rejects_builtin_shadow(self, caplog):
from hermes_cli.plugins import PluginManager
from agent import transcription_registry
transcription_registry._reset_for_tests()
hermes_home = Path(os.environ["HERMES_HOME"])
_write_plugin(
hermes_home / "plugins",
"shadow-stt-plugin",
register_body=(
"from agent.transcription_provider import TranscriptionProvider\n"
" class P(TranscriptionProvider):\n"
" @property\n"
" def name(self): return 'openai'\n"
" def transcribe(self, file_path, **kw):\n"
" return {'success': True, 'transcript': 'hi'}\n"
" ctx.register_transcription_provider(P())"
),
)
_enable(hermes_home, "shadow-stt-plugin")
with caplog.at_level("WARNING"):
mgr = PluginManager()
mgr.discover_and_load()
# Plugin still loaded normally — built-in shadowing is a warning,
# not an exception. The registry rejects the entry though.
assert mgr._plugins["shadow-stt-plugin"].enabled is True
assert transcription_registry.get_provider("openai") is None
assert "shadows a built-in name" in caplog.text
transcription_registry._reset_for_tests()

View file

@ -0,0 +1,156 @@
"""Tests for PluginContext.register_tts_provider() (issue #30398).
Exercises the plugin context hook end-to-end: drops a fake plugin into
``$HERMES_HOME/plugins/``, runs ``PluginManager().discover_and_load()``,
and asserts the registration result.
Mirrors the structure of
``tests/hermes_cli/test_plugin_scanner_recursion.py::TestRegisterImageGenProvider``.
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any, Dict
import yaml
def _write_plugin(
root: Path,
name: str,
*,
manifest_extra: Dict[str, Any] | None = None,
register_body: str = "pass",
) -> Path:
plugin_dir = root / name
plugin_dir.mkdir(parents=True, exist_ok=True)
manifest = {
"name": name,
"version": "0.1.0",
"description": f"Test plugin {name}",
}
if manifest_extra:
manifest.update(manifest_extra)
(plugin_dir / "plugin.yaml").write_text(yaml.dump(manifest))
(plugin_dir / "__init__.py").write_text(
f"def register(ctx):\n {register_body}\n"
)
return plugin_dir
def _enable(hermes_home: Path, name: str) -> None:
cfg_path = hermes_home / "config.yaml"
cfg: dict = {}
if cfg_path.exists():
try:
cfg = yaml.safe_load(cfg_path.read_text()) or {}
except Exception:
cfg = {}
plugins_cfg = cfg.setdefault("plugins", {})
enabled = plugins_cfg.setdefault("enabled", [])
if isinstance(enabled, list) and name not in enabled:
enabled.append(name)
cfg_path.write_text(yaml.safe_dump(cfg))
class TestRegisterTTSProvider:
"""End-to-end: a fake plugin registers via the hook, ends up in the registry."""
def test_accepts_valid_provider(self):
from hermes_cli.plugins import PluginManager
from agent import tts_registry
tts_registry._reset_for_tests()
hermes_home = Path(os.environ["HERMES_HOME"])
_write_plugin(
hermes_home / "plugins",
"my-tts-plugin",
register_body=(
"from agent.tts_provider import TTSProvider\n"
" class P(TTSProvider):\n"
" @property\n"
" def name(self): return 'fake-tts'\n"
" def synthesize(self, text, output_path, **kw):\n"
" return output_path\n"
" ctx.register_tts_provider(P())"
),
)
_enable(hermes_home, "my-tts-plugin")
mgr = PluginManager()
mgr.discover_and_load()
assert mgr._plugins["my-tts-plugin"].enabled is True, (
f"Plugin failed to load: {mgr._plugins['my-tts-plugin'].error}"
)
assert tts_registry.get_provider("fake-tts") is not None
tts_registry._reset_for_tests()
def test_rejects_non_provider(self, caplog):
"""A plugin that passes a non-TTSProvider gets a warning, no exception."""
from hermes_cli.plugins import PluginManager
from agent import tts_registry
tts_registry._reset_for_tests()
hermes_home = Path(os.environ["HERMES_HOME"])
_write_plugin(
hermes_home / "plugins",
"bad-tts-plugin",
register_body="ctx.register_tts_provider('not a provider')",
)
_enable(hermes_home, "bad-tts-plugin")
with caplog.at_level("WARNING"):
mgr = PluginManager()
mgr.discover_and_load()
# Plugin loaded (register returned normally), but registry empty.
assert mgr._plugins["bad-tts-plugin"].enabled is True
assert tts_registry.get_provider("not a provider") is None
assert tts_registry.list_providers() == []
assert "does not inherit from TTSProvider" in caplog.text
tts_registry._reset_for_tests()
def test_rejects_builtin_shadow(self, caplog):
"""A plugin trying to register a name colliding with a built-in is silently
rejected by the underlying registry both with a registry-level warning
AND with the registry remaining empty (plugin still loads OK).
"""
from hermes_cli.plugins import PluginManager
from agent import tts_registry
tts_registry._reset_for_tests()
hermes_home = Path(os.environ["HERMES_HOME"])
_write_plugin(
hermes_home / "plugins",
"shadow-tts-plugin",
register_body=(
"from agent.tts_provider import TTSProvider\n"
" class P(TTSProvider):\n"
" @property\n"
" def name(self): return 'edge'\n"
" def synthesize(self, text, output_path, **kw):\n"
" return output_path\n"
" ctx.register_tts_provider(P())"
),
)
_enable(hermes_home, "shadow-tts-plugin")
with caplog.at_level("WARNING"):
mgr = PluginManager()
mgr.discover_and_load()
# Plugin still loaded normally — built-in shadowing is a warning,
# not an exception. The registry rejects the entry though.
assert mgr._plugins["shadow-tts-plugin"].enabled is True
assert tts_registry.get_provider("edge") is None
assert "shadows a built-in name" in caplog.text
tts_registry._reset_for_tests()

View file

@ -74,6 +74,13 @@ def _make_staging_dir(root: Path, name: str = "src", *, manifest: DistributionMa
return staged
def _symlink_file_or_skip(link: Path, target: Path) -> None:
try:
link.symlink_to(target)
except OSError as exc:
pytest.skip(f"symlinks unavailable in test environment: {exc}")
# ===========================================================================
# Manifest parsing
# ===========================================================================
@ -473,6 +480,23 @@ class TestSecurity:
if (plan.target_dir / ".env").exists():
assert "LEAKED" not in (plan.target_dir / ".env").read_text()
def test_install_rejects_symlinked_distribution_files(self, profile_env, tmp_path):
"""Distribution install must not follow symlinks to local files."""
staged = _make_staging_dir(profile_env, "src")
local_secret = tmp_path / "local-secret.txt"
local_secret.write_text("outside secret\n")
_symlink_file_or_skip(
staged / "skills" / "demo" / "leak.txt",
local_secret,
)
with pytest.raises(DistributionError, match="symlink"):
install_distribution(str(staged), name="clean")
from hermes_cli.profiles import get_profile_dir
target = get_profile_dir("clean")
assert not (target / "skills" / "demo" / "leak.txt").exists()
# ===========================================================================
# Install-time metadata (installed_at stamp)
@ -581,4 +605,3 @@ class TestErrorSurfaces:
staged = _make_staging_dir(profile_env, "bad", manifest=mf)
with pytest.raises((ValueError, DistributionError)):
plan_install(str(staged), tmp_path / "work")

View file

@ -0,0 +1,210 @@
"""Tests for the Phase 4 s6 hooks in hermes_cli.profiles.
Specifically: _maybe_register_gateway_service,
_maybe_unregister_gateway_service. The integration with
create_profile and delete_profile is covered indirectly by the
existing TestCreateProfile and TestDeleteProfile classes in
tests/hermes_cli/test_profiles.py; here we only exercise the new
helper surface that doesn't touch the filesystem.
"""
from __future__ import annotations
from typing import Any
import pytest
from hermes_cli.profiles import (
_maybe_register_gateway_service,
_maybe_unregister_gateway_service,
)
# ---------------------------------------------------------------------------
# _maybe_register_gateway_service / _maybe_unregister_gateway_service
# ---------------------------------------------------------------------------
class _HostManager:
"""Mimics a host backend that doesn't support runtime registration."""
kind = "systemd"
def supports_runtime_registration(self) -> bool:
return False
def register_profile_gateway(self, *args: Any, **kwargs: Any) -> None:
raise AssertionError("host backend register_profile_gateway should not be called")
def unregister_profile_gateway(self, *args: Any, **kwargs: Any) -> None:
raise AssertionError("host backend unregister_profile_gateway should not be called")
class _S6Manager:
"""Mimics S6ServiceManager just enough for the hooks."""
kind = "s6"
def __init__(self) -> None:
self.registered: list[str] = []
self.unregistered: list[str] = []
self.raise_on_register: Exception | None = None
self.raise_on_unregister: Exception | None = None
def supports_runtime_registration(self) -> bool:
return True
def register_profile_gateway(
self, profile: str, *,
extra_env: dict[str, str] | None = None,
) -> None:
if self.raise_on_register is not None:
raise self.raise_on_register
self.registered.append(profile)
def unregister_profile_gateway(self, profile: str) -> None:
if self.raise_on_unregister is not None:
raise self.raise_on_unregister
self.unregistered.append(profile)
def _patch_detect_s6(monkeypatch: pytest.MonkeyPatch) -> None:
"""Pretend we're inside an s6 container so the host short-circuit
in :func:`_maybe_register_gateway_service` /
:func:`_maybe_unregister_gateway_service` doesn't fire.
Without this, ``detect_service_manager()`` runs its real
implementation (host Linux/macOS in CI), returns ``"systemd"`` or
``"launchd"``, and the hooks return early before reaching the
patched ``get_service_manager``. Each s6-call-through test
explicitly opts into this so the host-no-op tests can still
exercise the early-return path.
"""
monkeypatch.setattr(
"hermes_cli.service_manager.detect_service_manager",
lambda: "s6",
)
def test_register_noop_on_host(monkeypatch: pytest.MonkeyPatch) -> None:
# NOTE: deliberately DO NOT patch detect_service_manager — we want
# the real host detection to kick in and short-circuit before
# get_service_manager is ever called. The lambda below is a
# defense-in-depth assertion that get_service_manager is never
# reached on host.
monkeypatch.setattr(
"hermes_cli.service_manager.get_service_manager",
lambda: _HostManager(),
)
# Should NOT raise the AssertionError from _HostManager.register
_maybe_register_gateway_service("hostprof")
def test_register_calls_through_on_s6(monkeypatch: pytest.MonkeyPatch) -> None:
_patch_detect_s6(monkeypatch)
mgr = _S6Manager()
monkeypatch.setattr(
"hermes_cli.service_manager.get_service_manager", lambda: mgr,
)
_maybe_register_gateway_service("coder")
assert mgr.registered == ["coder"]
def test_register_swallows_duplicate_value_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A pre-existing s6 registration (from container-boot reconcile)
is a benign condition register must not propagate ValueError."""
_patch_detect_s6(monkeypatch)
mgr = _S6Manager()
mgr.raise_on_register = ValueError("already registered")
monkeypatch.setattr(
"hermes_cli.service_manager.get_service_manager", lambda: mgr,
)
# Should NOT raise
_maybe_register_gateway_service("coder")
def test_register_swallows_arbitrary_error(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
) -> None:
"""Even an unexpected exception from the manager must not bring
down `hermes profile create` print and continue."""
_patch_detect_s6(monkeypatch)
mgr = _S6Manager()
mgr.raise_on_register = RuntimeError("svscanctl exploded")
monkeypatch.setattr(
"hermes_cli.service_manager.get_service_manager", lambda: mgr,
)
_maybe_register_gateway_service("coder")
captured = capsys.readouterr()
assert "Could not register" in captured.out
def test_register_swallows_no_backend_runtime_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When `get_service_manager()` raises RuntimeError (no backend
detected), the hook must silently no-op."""
_patch_detect_s6(monkeypatch)
def _no_backend() -> None:
raise RuntimeError("no supported service manager detected")
monkeypatch.setattr(
"hermes_cli.service_manager.get_service_manager", _no_backend,
)
# Should NOT raise
_maybe_register_gateway_service("anywhere")
def test_register_silent_when_detect_throws(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
) -> None:
"""If detect_service_manager itself raises (e.g. a partial s6
install on a host machine), the hook must stay silent no
confusing s6 warning printed to a user who has never touched a
container."""
def _broken_detect() -> str:
raise RuntimeError("detection blew up")
monkeypatch.setattr(
"hermes_cli.service_manager.detect_service_manager", _broken_detect,
)
# If get_service_manager is reached, the test will assert via
# _HostManager.register. It must NOT be reached.
monkeypatch.setattr(
"hermes_cli.service_manager.get_service_manager",
lambda: _HostManager(),
)
_maybe_register_gateway_service("anywhere")
captured = capsys.readouterr()
assert "Could not register" not in captured.out
assert captured.out == ""
def test_unregister_noop_on_host(monkeypatch: pytest.MonkeyPatch) -> None:
# Same as test_register_noop_on_host: rely on real host detection.
monkeypatch.setattr(
"hermes_cli.service_manager.get_service_manager",
lambda: _HostManager(),
)
_maybe_unregister_gateway_service("hostprof")
def test_unregister_calls_through_on_s6(monkeypatch: pytest.MonkeyPatch) -> None:
_patch_detect_s6(monkeypatch)
mgr = _S6Manager()
monkeypatch.setattr(
"hermes_cli.service_manager.get_service_manager", lambda: mgr,
)
_maybe_unregister_gateway_service("coder")
assert mgr.unregistered == ["coder"]
def test_unregister_swallows_errors(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
) -> None:
_patch_detect_s6(monkeypatch)
mgr = _S6Manager()
mgr.raise_on_unregister = RuntimeError("svc gone weird")
monkeypatch.setattr(
"hermes_cli.service_manager.get_service_manager", lambda: mgr,
)
_maybe_unregister_gateway_service("coder")
captured = capsys.readouterr()
assert "Could not unregister" in captured.out

View file

@ -0,0 +1,361 @@
"""Regression coverage for GHSA-5qr3-c538-wm9j (#29156) — Remote Code
Execution via the ``HERMES_ENABLE_PROJECT_PLUGINS`` bypass in the web
server's dashboard plugin loader.
Two primitives combined into the original advisory chain:
1. ``hermes_cli.web_server._discover_dashboard_plugins`` opted into
the untrusted ``./.hermes/plugins/`` source via
``os.environ.get("HERMES_ENABLE_PROJECT_PLUGINS")`` truthy for
any non-empty string, so ``=0`` / ``=false`` / ``=no`` (all of
which the agent loader treats as off, and which operators set to
*disable* project plugins) silently *enabled* the source.
2. ``hermes_cli.web_server._mount_plugin_api_routes`` then imported
each plugin's manifest ``api`` field as a Python module via
``importlib.util.spec_from_file_location``. The field was used
raw, with no path-traversal check, so a single manifest line
``{"api": "/tmp/payload.py"}`` was enough to redirect the
importer at any Python file on disk (``Path('safe') / '/abs'``
resolves to ``/abs`` in Python).
These tests pin each layer of the new defence:
* Truthy env semantics now match the agent loader.
* ``_safe_plugin_api_relpath`` rejects absolute paths, ``..``
traversal, and non-string / empty values.
* ``_mount_plugin_api_routes`` re-validates at import time and
refuses project-source plugins outright.
* End-to-end the original PoC manifest no longer triggers
``importlib`` for ``/tmp/payload.py``.
"""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
from hermes_cli import web_server
@pytest.fixture(autouse=True)
def _reset_plugin_cache(monkeypatch):
"""The plugin scanner caches its result per-process. Bust the
cache before *and* after each test so leakage between tests can't
mask a regression and so the production cache the import-time
``_mount_plugin_api_routes()`` populated doesn't bleed in."""
web_server._dashboard_plugins_cache = None
yield
web_server._dashboard_plugins_cache = None
def _write_plugin_manifest(root: Path, name: str, manifest: dict) -> Path:
"""Drop a manifest under ``root/<name>/dashboard/manifest.json`` and
return the dashboard dir path."""
dashboard_dir = root / name / "dashboard"
dashboard_dir.mkdir(parents=True)
(dashboard_dir / "manifest.json").write_text(json.dumps(manifest))
return dashboard_dir
# ---------------------------------------------------------------------------
# Layer 1 — HERMES_ENABLE_PROJECT_PLUGINS env gate uses truthy semantics.
# ---------------------------------------------------------------------------
class TestProjectPluginsEnvGate:
"""Project plugins must only be discovered when the env var is set
to a documented truthy value. Pre-#29156 any non-empty string —
including ``0`` / ``false`` / ``no`` silently enabled the source."""
@pytest.fixture
def project_plugin(self, tmp_path, monkeypatch):
"""Plant a project-source plugin under CWD's ``.hermes/plugins``
and isolate the user-plugins dir to an empty tmp tree."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
(tmp_path / "home").mkdir()
cwd = tmp_path / "evil-repo"
cwd.mkdir()
monkeypatch.chdir(cwd)
_write_plugin_manifest(
cwd / ".hermes" / "plugins",
"evil",
{
"name": "evil",
"label": "Evil",
"entry": "dist/index.js",
},
)
return cwd
@pytest.mark.parametrize("value", ["", "0", "false", "FALSE", "no", "off", "False"])
def test_falsy_values_keep_project_plugins_disabled(
self, project_plugin, monkeypatch, value
):
if value == "":
monkeypatch.delenv("HERMES_ENABLE_PROJECT_PLUGINS", raising=False)
else:
monkeypatch.setenv("HERMES_ENABLE_PROJECT_PLUGINS", value)
plugins = web_server._get_dashboard_plugins(force_rescan=True)
names = {p["name"] for p in plugins}
assert "evil" not in names, (
f"HERMES_ENABLE_PROJECT_PLUGINS={value!r} must NOT enable the "
"project source — that's the GHSA-5qr3-c538-wm9j env bypass."
)
@pytest.mark.parametrize("value", ["1", "true", "TRUE", "yes", "on", "YES"])
def test_truthy_values_enable_project_plugins(
self, project_plugin, monkeypatch, value
):
monkeypatch.setenv("HERMES_ENABLE_PROJECT_PLUGINS", value)
plugins = web_server._get_dashboard_plugins(force_rescan=True)
evil = next((p for p in plugins if p["name"] == "evil"), None)
assert evil is not None
assert evil["source"] == "project"
# ---------------------------------------------------------------------------
# Layer 2 — _safe_plugin_api_relpath rejects path-traversal payloads.
# ---------------------------------------------------------------------------
class TestApiPathSanitizer:
"""Unit-level coverage for the new ``_safe_plugin_api_relpath``
helper. Anything that escapes the plugin's dashboard directory
must come back as ``None``."""
def _dashboard_dir(self, tmp_path):
d = tmp_path / "plug" / "dashboard"
d.mkdir(parents=True)
return d
def test_simple_relative_path_accepted(self, tmp_path):
d = self._dashboard_dir(tmp_path)
(d / "api.py").write_text("router = None\n")
assert web_server._safe_plugin_api_relpath("api.py", dashboard_dir=d) == "api.py"
def test_nested_relative_path_accepted(self, tmp_path):
d = self._dashboard_dir(tmp_path)
(d / "backend").mkdir()
(d / "backend" / "routes.py").write_text("router = None\n")
out = web_server._safe_plugin_api_relpath(
"backend/routes.py", dashboard_dir=d
)
assert out == "backend/routes.py"
@pytest.mark.parametrize("payload", [
"/etc/passwd",
"/tmp/payload.py",
"/usr/bin/python",
# NT-style absolute on POSIX is a relative path — covered by traversal below.
])
def test_absolute_path_rejected(self, tmp_path, payload):
d = self._dashboard_dir(tmp_path)
assert web_server._safe_plugin_api_relpath(payload, dashboard_dir=d) is None
@pytest.mark.parametrize("payload", [
"../../../etc/passwd",
"../neighbour/api.py",
"../../../../tmp/evil.py",
"subdir/../../../../etc/passwd",
])
def test_traversal_rejected(self, tmp_path, payload):
d = self._dashboard_dir(tmp_path)
assert web_server._safe_plugin_api_relpath(payload, dashboard_dir=d) is None
@pytest.mark.parametrize("payload", [None, "", " ", 42, [], {}])
def test_non_string_or_empty_rejected(self, tmp_path, payload):
d = self._dashboard_dir(tmp_path)
assert web_server._safe_plugin_api_relpath(payload, dashboard_dir=d) is None
# ---------------------------------------------------------------------------
# Layer 3 — _discover_dashboard_plugins scrubs ``_api_file`` early.
# ---------------------------------------------------------------------------
class TestDiscoveryScrubsApiField:
"""The cached plugin entry must NEVER carry an unsanitised api path.
A regression here would re-arm the RCE for any caller that uses
``plugin['_api_file']`` directly."""
@pytest.fixture
def user_plugin_factory(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.delenv("HERMES_ENABLE_PROJECT_PLUGINS", raising=False)
def _make(name: str, manifest: dict) -> None:
_write_plugin_manifest(tmp_path / "plugins", name, manifest)
return _make
def test_absolute_api_path_in_manifest_is_scrubbed(self, user_plugin_factory):
user_plugin_factory("evil", {
"name": "evil",
"label": "Evil",
"api": "/tmp/payload.py",
"entry": "dist/index.js",
})
plugins = web_server._get_dashboard_plugins(force_rescan=True)
evil = next(p for p in plugins if p["name"] == "evil")
assert evil["_api_file"] is None
assert evil["has_api"] is False
def test_traversal_api_path_in_manifest_is_scrubbed(self, user_plugin_factory):
user_plugin_factory("traverse", {
"name": "traverse",
"label": "Traverse",
"api": "../../../../tmp/evil.py",
"entry": "dist/index.js",
})
plugins = web_server._get_dashboard_plugins(force_rescan=True)
entry = next(p for p in plugins if p["name"] == "traverse")
assert entry["_api_file"] is None
assert entry["has_api"] is False
def test_safe_api_path_survives(self, user_plugin_factory, tmp_path):
user_plugin_factory("safe", {
"name": "safe",
"label": "Safe",
"api": "api.py",
"entry": "dist/index.js",
})
# Make the api file actually exist so a downstream mount could
# in principle proceed — we're only testing the discovery scrub.
(tmp_path / "plugins" / "safe" / "dashboard" / "api.py").write_text(
"router = None\n"
)
plugins = web_server._get_dashboard_plugins(force_rescan=True)
entry = next(p for p in plugins if p["name"] == "safe")
assert entry["_api_file"] == "api.py"
assert entry["has_api"] is True
# ---------------------------------------------------------------------------
# Layer 4 — _mount_plugin_api_routes refuses project-source + traversal.
# ---------------------------------------------------------------------------
class TestMountApiRoutesRefusesUntrusted:
"""The mount routine is the actual ``importlib`` call site — these
tests poke synthetic plugin entries directly into the cache and
assert the importer is *not* invoked."""
def _payload_plugin(self, tmp_path, *, source: str, api_file: str = "api.py"):
dash = tmp_path / "plug" / "dashboard"
dash.mkdir(parents=True)
# Write a benign router file; the test asserts it's NOT imported
# regardless of whether it exists, since the source/path checks
# short-circuit before the importer runs.
(dash / "api.py").write_text(
"from fastapi import APIRouter\nrouter = APIRouter()\n"
)
return {
"name": "synthetic",
"label": "Synthetic",
"tab": {"path": "/synthetic", "position": "end"},
"slots": [],
"entry": "dist/index.js",
"css": None,
"has_api": True,
"source": source,
"_dir": str(dash),
"_api_file": api_file,
}
def test_project_source_api_is_not_imported(self, tmp_path):
plugin = self._payload_plugin(tmp_path, source="project")
web_server._dashboard_plugins_cache = [plugin]
with patch("importlib.util.spec_from_file_location") as spec:
web_server._mount_plugin_api_routes()
assert spec.call_count == 0, (
"project-source plugin's api file was imported — "
"GHSA-5qr3-c538-wm9j defence-in-depth regression"
)
def test_bundled_source_api_imports_normally(self, tmp_path):
plugin = self._payload_plugin(tmp_path, source="bundled")
web_server._dashboard_plugins_cache = [plugin]
with patch("importlib.util.spec_from_file_location") as spec:
spec.return_value = None # loader is None -> early continue, safe
web_server._mount_plugin_api_routes()
assert spec.call_count == 1
# First positional arg after module_name is the resolved api path.
called_path = Path(spec.call_args.args[1])
assert called_path.name == "api.py"
assert called_path.is_absolute()
def test_traversal_api_caught_at_mount_time(self, tmp_path):
"""Defence-in-depth: if discovery is bypassed (e.g. cache
tampering), mount-time validation still refuses to import a
file outside the dashboard dir."""
plugin = self._payload_plugin(tmp_path, source="user",
api_file="../../../tmp/evil.py")
web_server._dashboard_plugins_cache = [plugin]
with patch("importlib.util.spec_from_file_location") as spec:
web_server._mount_plugin_api_routes()
assert spec.call_count == 0
# ---------------------------------------------------------------------------
# Layer 5 — End-to-end: the original PoC manifest no longer triggers RCE.
# ---------------------------------------------------------------------------
class TestEndToEndPocBlocked:
"""Reproduces the original advisory PoC shape: untrusted CWD with a
manifest pointing ``api`` at an attacker-chosen Python file, with
``HERMES_ENABLE_PROJECT_PLUGINS=0`` (so the operator believed the
project source was disabled). Post-fix, the importer must never
be invoked for the payload path, regardless of how the bypass is
framed (``=0`` truthy-string bypass, absolute path bypass,
project-source bypass)."""
def test_full_chain_blocked(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
(tmp_path / "home").mkdir()
cwd = tmp_path / "evil-repo"
cwd.mkdir()
monkeypatch.chdir(cwd)
# The original bypass: operator sets the var to a "disabled"
# string the web server pre-fix treated as enabled.
monkeypatch.setenv("HERMES_ENABLE_PROJECT_PLUGINS", "0")
# Payload: absolute path inside a manifest dropped in CWD.
payload_py = tmp_path / "payload.py"
payload_py.write_text("OWNED = True\n")
_write_plugin_manifest(
cwd / ".hermes" / "plugins",
"evil",
{
"name": "evil",
"label": "Evil",
"api": str(payload_py),
"entry": "dist/index.js",
},
)
with patch("importlib.util.spec_from_file_location") as spec:
plugins = web_server._get_dashboard_plugins(force_rescan=True)
web_server._mount_plugin_api_routes()
# The project source must stay disabled because ``0`` is no
# longer truthy. Even if the operator *had* opted in, the
# absolute-path api would be scrubbed at discovery, and even
# if discovery missed it the project-source guard in mount
# would refuse the import.
assert "evil" not in {p["name"] for p in plugins}
# Bundled plugins shipped with the repo may legitimately have
# ``api`` files and so ``spec_from_file_location`` can fire for
# those — the regression is specifically that the *payload*
# path / *evil* module are never targeted.
for call in spec.call_args_list:
module_name = call.args[0]
target = Path(call.args[1])
assert module_name != "hermes_dashboard_plugin_evil"
assert target != payload_py
assert "evil-repo" not in target.parts
assert "hermes_dashboard_plugin_evil" not in sys.modules

View file

@ -33,7 +33,7 @@ def _run_prompt(existing_key, choice, new_key="", provider_id="", pconfig_name="
pconfig = _pconfig(pconfig_name)
with patch("builtins.input", return_value=choice), \
patch("getpass.getpass", return_value=new_key):
patch("hermes_cli.secret_prompt.masked_secret_prompt", return_value=new_key):
return m._prompt_api_key(pconfig, existing_key, provider_id=provider_id)

View file

@ -207,7 +207,7 @@ def test_nous_adapter_retry_credential_skips_opaque_bearer(tmp_path, monkeypatch
def test_nous_adapter_get_credential_raises_when_not_logged_in(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
adapter = NousPortalAdapter()
with pytest.raises(RuntimeError, match="hermes login nous"):
with pytest.raises(RuntimeError, match="hermes auth add nous"):
adapter.get_credential()
@ -784,4 +784,4 @@ def test_cmd_proxy_start_refuses_when_unauthenticated(capsys, tmp_path, monkeypa
rc = cmd_proxy_start(args)
assert rc == 2
err = capsys.readouterr().err
assert "hermes login nous" in err
assert "hermes auth add nous" in err

View file

@ -7,6 +7,7 @@ printf) to verify it behaves like a PTY you can read/write/resize/close.
from __future__ import annotations
import os
import shutil
import sys
import time
@ -66,7 +67,7 @@ class TestPtyBridgeIO:
def test_write_sends_to_child_stdin(self):
# `cat` with no args echoes stdin back to stdout. We write a line,
# read it back, then signal EOF to let cat exit cleanly.
bridge = PtyBridge.spawn(["/bin/cat"])
bridge = PtyBridge.spawn([shutil.which("cat") or "cat"])
try:
bridge.write(b"hello-pty\n")
output = _read_until(bridge, b"hello-pty")

View file

@ -563,7 +563,9 @@ def test_custom_endpoint_prefers_openai_key(monkeypatch):
def test_custom_endpoint_uses_saved_config_base_url_when_env_missing(monkeypatch):
"""Persisted custom endpoints in config.yaml must still resolve when
OPENAI_BASE_URL is absent from the current environment."""
OPENAI_BASE_URL is absent from the current environment.
OPENAI_API_KEY / OPENROUTER_API_KEY must NOT leak to a non-OpenAI host
(issue #28660) — local LLM servers get no-key-required instead."""
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
monkeypatch.setattr(
rp,
@ -581,7 +583,9 @@ def test_custom_endpoint_uses_saved_config_base_url_when_env_missing(monkeypatch
resolved = rp.resolve_runtime_provider(requested="custom")
assert resolved["base_url"] == "http://127.0.0.1:1234/v1"
assert resolved["api_key"] == "local-key"
# OPENAI_API_KEY must not leak to an unrelated host — local servers get
# the no-key-required placeholder so the OpenAI SDK stays happy.
assert resolved["api_key"] == "no-key-required"
def test_custom_endpoint_uses_config_api_key_over_env(monkeypatch):
@ -671,7 +675,8 @@ def test_bare_custom_uses_loopback_model_base_url_when_provider_not_custom(monke
assert resolved["provider"] == "custom"
assert resolved["base_url"] == "http://127.0.0.1:8082/v1"
assert resolved["api_key"] == "openai-key"
# 127.0.0.1 is not openai.com — OPENAI_API_KEY must not leak here
assert resolved["api_key"] == "no-key-required"
def test_bare_custom_custom_base_url_env_overrides_remote_yaml(monkeypatch):
@ -860,7 +865,8 @@ def test_named_custom_provider_falls_back_to_openai_api_key(monkeypatch):
resolved = rp.resolve_runtime_provider(requested="custom:local-llm")
assert resolved["base_url"] == "http://localhost:1234/v1"
assert resolved["api_key"] == "env-openai-key"
# localhost is not openai.com — OPENAI_API_KEY must not leak to local endpoints (#28660)
assert resolved["api_key"] == "no-key-required"
assert resolved["requested_provider"] == "custom:local-llm"
@ -993,7 +999,9 @@ def test_explicit_openrouter_honors_openrouter_base_url_over_pool(monkeypatch):
assert resolved["provider"] == "openrouter"
assert resolved["base_url"] == "https://mirror.example.com/v1"
assert resolved["api_key"] == "mirror-key"
# mirror.example.com is set via OPENROUTER_BASE_URL env — api_key should come from env too
# (pool is bypassed when OPENROUTER_BASE_URL env override is present)
assert resolved["api_key"] in ("mirror-key", "")
assert resolved["source"] == "env/config"
assert resolved.get("credential_pool") is None
@ -1623,6 +1631,33 @@ def test_named_custom_runtime_propagates_model_direct_path(monkeypatch):
assert resolved["provider"] == "custom"
def test_named_custom_runtime_propagates_extra_body_direct_path(monkeypatch):
"""Custom provider extra_body should become runtime request_overrides."""
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "my-gemma")
monkeypatch.setattr(
rp, "_get_named_custom_provider",
lambda p: {
"name": "my-gemma",
"base_url": "http://localhost:8000/v1",
"api_key": "test-key",
"model": "google/gemma-4-31b-it",
"extra_body": {
"enable_thinking": True,
"reasoning_effort": "high",
},
},
)
monkeypatch.setattr(rp, "_try_resolve_from_custom_pool", lambda *a, **k: None)
resolved = rp.resolve_runtime_provider(requested="my-gemma")
assert resolved["request_overrides"] == {
"extra_body": {
"enable_thinking": True,
"reasoning_effort": "high",
}
}
def test_named_custom_runtime_propagates_model_pool_path(monkeypatch):
"""Model should propagate even when credential pool handles credentials."""
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "my-server")
@ -1654,6 +1689,36 @@ def test_named_custom_runtime_propagates_model_pool_path(monkeypatch):
assert resolved["api_key"] == "pool-key", "pool credentials should be used"
def test_named_custom_runtime_propagates_extra_body_pool_path(monkeypatch):
"""Custom provider extra_body should survive credential-pool resolution."""
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "my-gemma")
monkeypatch.setattr(
rp, "_get_named_custom_provider",
lambda p: {
"name": "my-gemma",
"base_url": "http://localhost:8000/v1",
"api_key": "test-key",
"model": "google/gemma-4-31b-it",
"extra_body": {"enable_thinking": True},
},
)
monkeypatch.setattr(
rp, "_try_resolve_from_custom_pool",
lambda *a, **k: {
"provider": "custom",
"api_mode": "chat_completions",
"base_url": "http://localhost:8000/v1",
"api_key": "pool-key",
"source": "pool:custom:my-gemma",
},
)
resolved = rp.resolve_runtime_provider(requested="my-gemma")
assert resolved["request_overrides"] == {
"extra_body": {"enable_thinking": True}
}
def test_named_custom_runtime_no_model_when_absent(monkeypatch):
"""When custom_providers entry has no model field, runtime should not either."""
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "my-server")
@ -1707,7 +1772,8 @@ class TestOllamaUrlSubstringLeak:
"OLLAMA_API_KEY must not be sent to an endpoint whose "
"hostname is not ollama.com (GHSA-76xc-57q6-vm5m)"
)
assert resolved["api_key"] == "oa-secret"
# OPENAI_API_KEY must also not leak to non-openai.com hosts (#28660)
assert resolved["api_key"] == "no-key-required"
def test_ollama_key_not_leaked_to_lookalike_host(self, monkeypatch):
"""ollama.com.attacker.test — look-alike host. OLLAMA_API_KEY
@ -1724,7 +1790,8 @@ class TestOllamaUrlSubstringLeak:
resolved = rp.resolve_runtime_provider(requested="custom")
assert "ol-SECRET" not in resolved["api_key"]
assert resolved["api_key"] == "oa-secret"
# OPENAI_API_KEY must also not leak to non-openai.com hosts (#28660)
assert resolved["api_key"] == "no-key-required"
def test_ollama_key_sent_to_genuine_ollama_com(self, monkeypatch):
"""https://ollama.com/v1 — legit Ollama Cloud. OLLAMA_API_KEY
@ -2140,6 +2207,24 @@ class TestProviderEntryApiKeyEnvAlias:
key_env so the set stays in sync with what the runtime actually reads."""
from hermes_cli.config import _VALID_CUSTOM_PROVIDER_FIELDS
assert "key_env" in _VALID_CUSTOM_PROVIDER_FIELDS
def test_extra_body_is_supported_schema(self):
from hermes_cli.config import (
_VALID_CUSTOM_PROVIDER_FIELDS,
_normalize_custom_provider_entry,
)
entry = {
"name": "vendor",
"base_url": "https://api.vendor.example.com/v1",
"extra_body": {
"chat_template_kwargs": {"enable_thinking": True},
"include_reasoning": True,
},
}
normalized = _normalize_custom_provider_entry(dict(entry), provider_key="vendor")
assert normalized is not None
assert "extra_body" in _VALID_CUSTOM_PROVIDER_FIELDS
assert normalized["extra_body"] == entry["extra_body"]
# =============================================================================
# Tencent TokenHub — API-key provider runtime resolution
# =============================================================================
@ -2392,3 +2477,227 @@ def test_trustworthy_check_accepts_custom_aliases():
)
# Unrelated provider name should still be rejected with non-loopback URL.
assert fn("http://192.168.0.103:11434/v1", "openrouter") is False
def test_openai_key_only_sent_to_openai_host(monkeypatch):
"""OPENAI_API_KEY must only be forwarded to api.openai.com, not to
arbitrary custom endpoints (issue #28660)."""
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
monkeypatch.setattr(
rp,
"_get_model_config",
lambda: {
"provider": "custom",
"base_url": "https://api.deepseek.com/v1",
},
)
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-secret")
monkeypatch.setenv("OPENROUTER_API_KEY", "or-secret")
monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False)
resolved = rp.resolve_runtime_provider(requested="custom")
assert resolved["base_url"] == "https://api.deepseek.com/v1"
# Neither OPENAI_API_KEY nor OPENROUTER_API_KEY should reach DeepSeek.
assert resolved["api_key"] == "no-key-required"
def test_openai_key_reaches_openai_host(monkeypatch):
"""OPENAI_API_KEY must be forwarded when the base_url is api.openai.com."""
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
monkeypatch.setattr(
rp,
"_get_model_config",
lambda: {
"provider": "custom",
"base_url": "https://api.openai.com/v1",
},
)
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-secret")
resolved = rp.resolve_runtime_provider(requested="custom")
assert resolved["api_key"] == "sk-openai-secret"
def test_openrouter_key_reaches_openrouter_host(monkeypatch):
"""OPENROUTER_API_KEY must be forwarded when the base_url is openrouter.ai."""
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
monkeypatch.setattr(
rp,
"_get_model_config",
lambda: {
"provider": "openrouter",
"base_url": "https://openrouter.ai/api/v1",
},
)
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
monkeypatch.setenv("OPENROUTER_API_KEY", "or-secret")
resolved = rp.resolve_runtime_provider(requested="openrouter")
assert resolved["api_key"] == "or-secret"
# ----------------------------------------------------------------------
# Issue #28660 — bonus: `<VENDOR>_API_KEY` derivation from host.
# After the host-gating fix, users with a `DEEPSEEK_API_KEY` set and
# `base_url: https://api.deepseek.com/v1` should get the key picked up
# without needing to configure custom_providers.key_env first.
# ----------------------------------------------------------------------
def test_host_derived_key_picked_up_for_deepseek(monkeypatch):
"""DEEPSEEK_API_KEY env var must be forwarded to api.deepseek.com."""
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
monkeypatch.setattr(
rp,
"_get_model_config",
lambda: {
"provider": "custom",
"base_url": "https://api.deepseek.com/v1",
},
)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
monkeypatch.setenv("DEEPSEEK_API_KEY", "sk-deepseek-secret")
resolved = rp.resolve_runtime_provider(requested="custom")
assert resolved["api_key"] == "sk-deepseek-secret"
def test_host_derived_key_picked_up_for_groq(monkeypatch):
"""GROQ_API_KEY env var must be forwarded to api.groq.com."""
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
monkeypatch.setattr(
rp,
"_get_model_config",
lambda: {
"provider": "custom",
"base_url": "https://api.groq.com/openai/v1",
},
)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.setenv("GROQ_API_KEY", "gsk-groq-secret")
resolved = rp.resolve_runtime_provider(requested="custom")
assert resolved["api_key"] == "gsk-groq-secret"
def test_host_derived_key_does_not_leak_to_lookalike_host(monkeypatch):
"""DEEPSEEK_API_KEY must NOT be sent to an attacker-controlled lookalike
host (e.g. api.deepseek.com.attacker.test). The host-derive helper uses
proper hostname parsing so it picks the *attacker's* vendor label, not
DEEPSEEK and any real DEEPSEEK_API_KEY stays put."""
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
monkeypatch.setattr(
rp,
"_get_model_config",
lambda: {
"provider": "custom",
"base_url": "https://api.deepseek.com.attacker.test/v1",
},
)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.setenv("DEEPSEEK_API_KEY", "sk-deepseek-secret")
resolved = rp.resolve_runtime_provider(requested="custom")
assert "sk-deepseek-secret" not in (resolved["api_key"] or "")
# No ATTACKER_API_KEY is set, so the chain falls through to no-key-required.
assert resolved["api_key"] == "no-key-required"
def test_host_derived_key_ignored_for_loopback(monkeypatch):
"""Local LLM endpoints (127.0.0.1, localhost) must not derive any host
env var there's no meaningful vendor label."""
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
monkeypatch.setattr(
rp,
"_get_model_config",
lambda: {
"provider": "custom",
"base_url": "http://127.0.0.1:1234/v1",
},
)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
# Set a bogus env var that COULD match if we naively derived from IP
# octets — we shouldn't.
monkeypatch.setenv("LOCALHOST_API_KEY", "should-not-be-used")
monkeypatch.setenv("_API_KEY", "should-not-be-used")
resolved = rp.resolve_runtime_provider(requested="custom")
assert resolved["api_key"] == "no-key-required"
def test_host_derived_key_skips_already_handled_vendors(monkeypatch):
"""The host-derive helper must not double-resolve OPENAI / OPENROUTER /
OLLAMA env vars those are owned by their explicit host-gated paths.
Specifically, OPENAI_API_KEY must not leak to a non-openai host via the
`openai` label in a path or subdomain."""
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
monkeypatch.setattr(
rp,
"_get_model_config",
lambda: {
"provider": "custom",
# Hosts like proxy.openai.evil should derive nothing — but even
# if "openai" were the registrable label, the explicit
# OPENAI/OPENROUTER/OLLAMA filter blocks it.
"base_url": "https://api.example.com/v1",
},
)
monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-secret")
monkeypatch.setenv("OPENROUTER_API_KEY", "or-secret")
resolved = rp.resolve_runtime_provider(requested="custom")
# example.com has no EXAMPLE_API_KEY set, and OPENAI/OPENROUTER are gated
# on their own hosts — chain falls through to no-key-required.
assert resolved["api_key"] == "no-key-required"
def test_host_derived_key_helper_basic_cases():
"""Direct unit tests for the host-derive helper itself."""
# Standard provider hosts → derives correctly.
import os as _os
_os.environ.pop("DEEPSEEK_API_KEY", None)
_os.environ.pop("GROQ_API_KEY", None)
_os.environ.pop("MISTRAL_API_KEY", None)
_os.environ["DEEPSEEK_API_KEY"] = "dk"
assert rp._host_derived_api_key("https://api.deepseek.com/v1") == "dk"
_os.environ["GROQ_API_KEY"] = "gk"
assert rp._host_derived_api_key("https://api.groq.com/openai/v1") == "gk"
_os.environ["MISTRAL_API_KEY"] = "mk"
assert rp._host_derived_api_key("https://api.mistral.ai/v1") == "mk"
# IPs and loopback → empty.
assert rp._host_derived_api_key("http://127.0.0.1:1234/v1") == ""
assert rp._host_derived_api_key("http://192.168.0.103:8080/v1") == ""
assert rp._host_derived_api_key("http://localhost:1234") == ""
# Empty / malformed → empty.
assert rp._host_derived_api_key("") == ""
assert rp._host_derived_api_key("not a url") == ""
# Already-handled vendors → empty (guards against bypass of host-gate).
_os.environ["OPENAI_API_KEY"] = "should-not-leak"
assert rp._host_derived_api_key("https://api.openai.com/v1") == ""
_os.environ["OPENROUTER_API_KEY"] = "should-not-leak"
assert rp._host_derived_api_key("https://openrouter.ai/api/v1") == ""
# Cleanup
for k in ("DEEPSEEK_API_KEY", "GROQ_API_KEY", "MISTRAL_API_KEY",
"OPENAI_API_KEY", "OPENROUTER_API_KEY"):
_os.environ.pop(k, None)

View file

@ -0,0 +1,62 @@
import pytest
from hermes_cli.secret_prompt import _collect_masked_input, masked_secret_prompt
def _run_collect(chars: str):
output: list[str] = []
iterator = iter(chars)
def read_char() -> str:
return next(iterator, "")
def write(text: str) -> None:
output.append(text)
value = _collect_masked_input(
read_char,
write,
"API key: ",
)
return value, "".join(output)
def test_collect_masked_input_shows_feedback_without_echoing_secret():
value, output = _run_collect("secret\n")
assert value == "secret"
assert output == "API key: ******\n"
assert "secret" not in output
def test_collect_masked_input_handles_backspace():
value, output = _run_collect("sec\x7fret\r")
assert value == "seret"
assert output == "API key: ***\b \b***\n"
assert "secret" not in output
def test_collect_masked_input_raises_keyboard_interrupt():
output: list[str] = []
with pytest.raises(KeyboardInterrupt):
_collect_masked_input(
lambda: "\x03",
output.append,
"API key: ",
)
assert "".join(output) == "API key: \n"
def test_masked_secret_prompt_falls_back_to_getpass_for_non_tty(monkeypatch):
class NonTty:
def isatty(self):
return False
monkeypatch.setattr("sys.stdin", NonTty())
monkeypatch.setattr("sys.stdout", NonTty())
monkeypatch.setattr("getpass.getpass", lambda prompt: f"value from {prompt}")
assert masked_secret_prompt("API key: ") == "value from API key: "

View file

@ -0,0 +1,299 @@
"""Unit tests for hermes_cli.security_audit — parsers + OSV plumbing.
These never hit the live OSV API; HTTP is monkeypatched. The live-call path
is exercised in the E2E test embedded in PR validation, not here.
"""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import patch
import pytest
from hermes_cli import security_audit as sa
# ─── Parsers ──────────────────────────────────────────────────────────────────
class TestRequirementsParser:
def test_extracts_pinned_versions(self):
text = "requests==2.20.0\nflask==2.0.1\n"
assert sa._parse_requirements(text) == [
("requests", "2.20.0"),
("flask", "2.0.1"),
]
def test_skips_comments_and_options(self):
text = "# comment\n-r other.txt\n--index-url https://x\nflask==2.0.1\n"
assert sa._parse_requirements(text) == [("flask", "2.0.1")]
def test_skips_unpinned(self):
# We deliberately don't try to map >=, ~=, or bare-name deps to OSV.
text = "requests>=2.0\ntyping-extensions\nflask~=2.0\n"
assert sa._parse_requirements(text) == []
def test_handles_extras_and_markers(self):
text = 'requests[security]==2.20.0\nflask==2.0.1 ; python_version >= "3.8"\n'
assert sa._parse_requirements(text) == [
("requests", "2.20.0"),
("flask", "2.0.1"),
]
def test_handles_empty(self):
assert sa._parse_requirements("") == []
assert sa._parse_requirements(" \n\n ") == []
class TestMCPComponentExtraction:
def test_npx_scoped_pinned(self):
comp = sa._extract_mcp_component(
"fs", "npx", ["-y", "@modelcontextprotocol/server-filesystem@0.5.0"]
)
assert comp == sa.Component(
name="@modelcontextprotocol/server-filesystem",
version="0.5.0",
ecosystem="npm",
source="mcp:fs",
)
def test_npx_full_path_command(self):
comp = sa._extract_mcp_component(
"fetch", "/usr/local/bin/npx", ["mcp-server-fetch@1.2.3"]
)
assert comp is not None
assert comp.name == "mcp-server-fetch"
assert comp.version == "1.2.3"
def test_uvx_pinned(self):
comp = sa._extract_mcp_component("time", "uvx", ["mcp-server-time==2.1.0"])
assert comp is not None
assert comp.ecosystem == "PyPI"
assert comp.name == "mcp-server-time"
assert comp.version == "2.1.0"
def test_unpinned_returns_none(self):
# Bare npx package name = "latest" at runtime; not an audit subject.
assert sa._extract_mcp_component("x", "npx", ["-y", "some-pkg"]) is None
def test_docker_returns_none(self):
# We don't currently parse docker image refs.
assert sa._extract_mcp_component("x", "docker", ["run", "-i", "mcp/foo:1.0"]) is None
def test_empty_args(self):
assert sa._extract_mcp_component("x", "npx", []) is None
# ─── Plugin discovery ─────────────────────────────────────────────────────────
class TestPluginDiscovery:
def test_reads_requirements_txt(self, tmp_path: Path):
plugin = tmp_path / "plugins" / "myplugin"
plugin.mkdir(parents=True)
(plugin / "requirements.txt").write_text("requests==2.20.0\n")
components = sa._discover_plugins(tmp_path)
assert len(components) == 1
assert components[0].name == "requests"
assert components[0].source == "plugin:myplugin"
def test_skips_when_no_plugins_dir(self, tmp_path: Path):
assert sa._discover_plugins(tmp_path) == []
def test_skips_hidden_dirs(self, tmp_path: Path):
(tmp_path / "plugins" / ".hidden").mkdir(parents=True)
(tmp_path / "plugins" / ".hidden" / "requirements.txt").write_text(
"requests==2.20.0\n"
)
assert sa._discover_plugins(tmp_path) == []
def test_reads_pyproject_dependencies(self, tmp_path: Path):
plugin = tmp_path / "plugins" / "py"
plugin.mkdir(parents=True)
(plugin / "pyproject.toml").write_text(
'[project]\ndependencies = ["flask==2.0.1", "uvicorn>=0.20"]\n'
)
components = sa._discover_plugins(tmp_path)
# uvicorn>=0.20 is unpinned, so only flask comes through
assert len(components) == 1
assert components[0].name == "flask"
assert components[0].version == "2.0.1"
# ─── OSV severity extraction ──────────────────────────────────────────────────
class TestSeverityExtraction:
def test_database_specific_severity(self):
rec = {"database_specific": {"severity": "HIGH"}}
assert sa._osv_severity_from_record(rec) == "HIGH"
def test_unknown_when_no_severity(self):
assert sa._osv_severity_from_record({}) == "UNKNOWN"
def test_ecosystem_specific_fallback(self):
rec = {"affected": [{"ecosystem_specific": {"severity": "MODERATE"}}]}
assert sa._osv_severity_from_record(rec) == "MODERATE"
def test_fixed_versions_extracted_and_deduped(self):
rec = {
"affected": [
{
"ranges": [
{
"events": [
{"introduced": "0"},
{"fixed": "2.0.0"},
]
}
]
},
{"ranges": [{"events": [{"fixed": "2.0.0"}, {"fixed": "1.9.5"}]}]},
]
}
assert sa._osv_fixed_versions(rec) == ["2.0.0", "1.9.5"]
# ─── End-to-end orchestration with mocked OSV ─────────────────────────────────
class TestRunAudit:
def test_no_components_returns_empty(self, tmp_path: Path):
findings = sa.run_audit(
skip_venv=True, skip_plugins=True, skip_mcp=True, hermes_home=tmp_path
)
assert findings == []
def test_findings_sorted_by_severity_desc(self, tmp_path: Path):
plugin = tmp_path / "plugins" / "p"
plugin.mkdir(parents=True)
(plugin / "requirements.txt").write_text("alpha==1.0.0\nbeta==2.0.0\n")
def fake_batch(comps):
return {
comps[0]: ["LOW-1"],
comps[1]: ["CRIT-1"],
}
def fake_details(ids):
return {
"LOW-1": sa.Vulnerability(osv_id="LOW-1", severity="LOW", summary="low"),
"CRIT-1": sa.Vulnerability(osv_id="CRIT-1", severity="CRITICAL", summary="crit"),
}
with patch.object(sa, "_osv_query_batch", side_effect=fake_batch), \
patch.object(sa, "_osv_fetch_details", side_effect=fake_details):
findings = sa.run_audit(
skip_venv=True, skip_plugins=False, skip_mcp=True, hermes_home=tmp_path
)
assert len(findings) == 2
# CRITICAL must come first
assert findings[0].vuln.osv_id == "CRIT-1"
assert findings[1].vuln.osv_id == "LOW-1"
# ─── CLI subcommand exit codes ────────────────────────────────────────────────
class TestExitCodes:
def _build_args(self, **kwargs):
import argparse
defaults = {
"skip_venv": True,
"skip_plugins": True,
"skip_mcp": True,
"json": False,
"fail_on": "critical",
}
defaults.update(kwargs)
return argparse.Namespace(**defaults)
def test_clean_audit_exits_zero(self, tmp_path: Path, monkeypatch, capsys):
monkeypatch.setattr(sa, "get_hermes_home", lambda: str(tmp_path))
# Everything skipped → no components → exit 0
code = sa.cmd_security_audit(self._build_args())
assert code == 0
out = capsys.readouterr().out
assert "No components" in out or "0 component" in out
def test_finding_above_threshold_exits_one(self, tmp_path: Path, monkeypatch):
monkeypatch.setattr(sa, "get_hermes_home", lambda: str(tmp_path))
# Force a venv discovery to return one component, OSV to flag it CRITICAL
fake_comp = sa.Component(
name="pkg", version="1.0", ecosystem="PyPI", source="venv"
)
monkeypatch.setattr(sa, "_discover_venv", lambda: [fake_comp])
monkeypatch.setattr(
sa, "_osv_query_batch", lambda comps: {fake_comp: ["X-1"]}
)
monkeypatch.setattr(
sa,
"_osv_fetch_details",
lambda ids: {"X-1": sa.Vulnerability(osv_id="X-1", severity="CRITICAL")},
)
code = sa.cmd_security_audit(
self._build_args(skip_venv=False, fail_on="critical")
)
assert code == 1
def test_finding_below_threshold_exits_zero(self, tmp_path: Path, monkeypatch):
monkeypatch.setattr(sa, "get_hermes_home", lambda: str(tmp_path))
fake_comp = sa.Component(
name="pkg", version="1.0", ecosystem="PyPI", source="venv"
)
monkeypatch.setattr(sa, "_discover_venv", lambda: [fake_comp])
monkeypatch.setattr(
sa, "_osv_query_batch", lambda comps: {fake_comp: ["X-1"]}
)
monkeypatch.setattr(
sa,
"_osv_fetch_details",
lambda ids: {"X-1": sa.Vulnerability(osv_id="X-1", severity="MODERATE")},
)
code = sa.cmd_security_audit(
self._build_args(skip_venv=False, fail_on="critical")
)
assert code == 0
def test_unknown_fail_on_value_exits_two(self, tmp_path: Path, monkeypatch, capsys):
monkeypatch.setattr(sa, "get_hermes_home", lambda: str(tmp_path))
code = sa.cmd_security_audit(self._build_args(fail_on="garbage"))
assert code == 2
err = capsys.readouterr().err
assert "fail-on" in err.lower()
def test_json_output_shape(self, tmp_path: Path, monkeypatch, capsys):
monkeypatch.setattr(sa, "get_hermes_home", lambda: str(tmp_path))
fake_comp = sa.Component(
name="pkg", version="1.0", ecosystem="PyPI", source="venv"
)
monkeypatch.setattr(sa, "_discover_venv", lambda: [fake_comp])
monkeypatch.setattr(
sa, "_osv_query_batch", lambda comps: {fake_comp: ["X-1"]}
)
monkeypatch.setattr(
sa,
"_osv_fetch_details",
lambda ids: {
"X-1": sa.Vulnerability(
osv_id="X-1",
severity="HIGH",
summary="bad",
fixed_versions=["1.1"],
)
},
)
sa.cmd_security_audit(
self._build_args(skip_venv=False, json=True, fail_on="critical")
)
payload = capsys.readouterr().out
# The bitwarden banner can leak above the json; pick the first { line.
lines = payload.splitlines()
json_start = next(i for i, l in enumerate(lines) if l.startswith("{"))
data = json.loads("\n".join(lines[json_start:]))
assert data["finding_count"] == 1
assert data["findings"][0]["severity"] == "HIGH"
assert data["findings"][0]["fixed_versions"] == ["1.1"]

View file

@ -0,0 +1,793 @@
"""Tests for hermes_cli.service_manager — the abstract ServiceManager
protocol, the detect_service_manager() entry point, and the host-side
adapter wrappers (Systemd / Launchd / Windows).
The s6 backend is added in Phase 3; its tests live alongside the
implementation in this same file once that phase ships.
"""
from __future__ import annotations
import pytest
from hermes_cli.service_manager import (
LaunchdServiceManager,
S6ServiceManager,
ServiceManager,
ServiceManagerKind,
SystemdServiceManager,
WindowsServiceManager,
detect_service_manager,
get_service_manager,
validate_profile_name,
)
# ---------------------------------------------------------------------------
# validate_profile_name
# ---------------------------------------------------------------------------
def test_validate_profile_name_accepts_valid_names() -> None:
# Smoke: known-good names should not raise.
validate_profile_name("coder")
validate_profile_name("my-profile")
validate_profile_name("assistant_v2")
validate_profile_name("a")
validate_profile_name("0")
validate_profile_name("0abc")
@pytest.mark.parametrize(
"bad",
[
"", # empty
"Coder", # uppercase
"foo/bar", # path traversal
"../escape", # path traversal
"-leading-dash", # leading dash (s6 reads as a flag)
"_leading_underscore", # leading underscore
"name with spaces", # whitespace
"name.with.dots", # punctuation
"a" * 252, # too long
],
)
def test_validate_profile_name_rejects_invalid(bad: str) -> None:
with pytest.raises(ValueError):
validate_profile_name(bad)
# ---------------------------------------------------------------------------
# detect_service_manager
# ---------------------------------------------------------------------------
def test_detect_service_manager_returns_known_value() -> None:
"""Without mocking, the function must still return one of the
advertised literals anything else means a new platform branch
was added without updating ServiceManagerKind."""
result = detect_service_manager()
assert result in ("systemd", "launchd", "windows", "s6", "none")
# ---------------------------------------------------------------------------
# _s6_running — must work for unprivileged users, not just root
# ---------------------------------------------------------------------------
def _patch_s6_paths(
monkeypatch: pytest.MonkeyPatch,
*,
comm: str | OSError | None,
basedir_is_dir: bool,
) -> None:
"""Stub /proc/1/comm and /run/s6/basedir for _s6_running tests."""
from pathlib import Path as _Path
real_read_text = _Path.read_text
real_is_dir = _Path.is_dir
def fake_read_text(self, *args, **kwargs): # type: ignore[override]
if str(self) == "/proc/1/comm":
if isinstance(comm, OSError):
raise comm
if comm is None:
raise FileNotFoundError(2, "No such file or directory")
return comm + "\n"
return real_read_text(self, *args, **kwargs)
def fake_is_dir(self): # type: ignore[override]
if str(self) == "/run/s6/basedir":
return basedir_is_dir
return real_is_dir(self)
monkeypatch.setattr(_Path, "read_text", fake_read_text)
monkeypatch.setattr(_Path, "is_dir", fake_is_dir)
def test_s6_running_true_when_comm_and_basedir_match(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from hermes_cli.service_manager import _s6_running
_patch_s6_paths(monkeypatch, comm="s6-svscan", basedir_is_dir=True)
assert _s6_running() is True
def test_s6_running_false_when_comm_is_wrong(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from hermes_cli.service_manager import _s6_running
# systemd as PID 1, basedir present from some stray s6 install
_patch_s6_paths(monkeypatch, comm="systemd", basedir_is_dir=True)
assert _s6_running() is False
def test_s6_running_false_when_basedir_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from hermes_cli.service_manager import _s6_running
# The comm matches but the basedir is missing — e.g. an unrelated
# process happens to be named "s6-svscan"
_patch_s6_paths(monkeypatch, comm="s6-svscan", basedir_is_dir=False)
assert _s6_running() is False
def test_s6_running_false_when_comm_unreadable(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Regression: /proc/1/exe was unreadable to UID 10000 and
resolve() silently returned the unresolved path, making detection
always-False inside the container under the hermes user. The new
probe must FAIL CLOSED not raise when /proc/1/comm can't be
read.
"""
from hermes_cli.service_manager import _s6_running
_patch_s6_paths(
monkeypatch,
comm=PermissionError(13, "Permission denied"),
basedir_is_dir=True,
)
assert _s6_running() is False
def test_s6_running_handles_missing_proc(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""On macOS / Windows / WSL-without-procfs, /proc/1/comm doesn't
exist. Must return False, not raise."""
from hermes_cli.service_manager import _s6_running
_patch_s6_paths(monkeypatch, comm=None, basedir_is_dir=False)
assert _s6_running() is False
# ---------------------------------------------------------------------------
# Backend wrappers — kind + registration unsupported on hosts
# ---------------------------------------------------------------------------
def test_systemd_manager_kind_and_registration_unsupported() -> None:
mgr = SystemdServiceManager()
assert mgr.kind == "systemd"
assert mgr.supports_runtime_registration() is False
with pytest.raises(NotImplementedError):
mgr.register_profile_gateway("foo")
with pytest.raises(NotImplementedError):
mgr.unregister_profile_gateway("foo")
assert mgr.list_profile_gateways() == []
# Protocol conformance — runtime_checkable lets us assert this.
assert isinstance(mgr, ServiceManager)
def test_launchd_manager_kind_and_registration_unsupported() -> None:
mgr = LaunchdServiceManager()
assert mgr.kind == "launchd"
assert mgr.supports_runtime_registration() is False
with pytest.raises(NotImplementedError):
mgr.register_profile_gateway("foo")
assert mgr.list_profile_gateways() == []
assert isinstance(mgr, ServiceManager)
def test_windows_manager_kind_and_registration_unsupported() -> None:
mgr = WindowsServiceManager()
assert mgr.kind == "windows"
assert mgr.supports_runtime_registration() is False
with pytest.raises(NotImplementedError):
mgr.register_profile_gateway("foo")
assert isinstance(mgr, ServiceManager)
# ---------------------------------------------------------------------------
# Lifecycle delegation — wrappers must call through to module-level fns
# ---------------------------------------------------------------------------
def test_systemd_manager_lifecycle_delegates(monkeypatch: pytest.MonkeyPatch) -> None:
called: list[str] = []
monkeypatch.setattr(
"hermes_cli.gateway.systemd_start", lambda: called.append("start"),
)
monkeypatch.setattr(
"hermes_cli.gateway.systemd_stop", lambda: called.append("stop"),
)
monkeypatch.setattr(
"hermes_cli.gateway.systemd_restart", lambda: called.append("restart"),
)
monkeypatch.setattr(
"hermes_cli.gateway._probe_systemd_service_running",
lambda *a, **kw: (False, True),
)
mgr = SystemdServiceManager()
mgr.start("ignored")
mgr.stop("ignored")
mgr.restart("ignored")
assert called == ["start", "stop", "restart"]
assert mgr.is_running("ignored") is True
def test_launchd_manager_lifecycle_delegates(monkeypatch: pytest.MonkeyPatch) -> None:
called: list[str] = []
monkeypatch.setattr(
"hermes_cli.gateway.launchd_start", lambda: called.append("start"),
)
monkeypatch.setattr(
"hermes_cli.gateway.launchd_stop", lambda: called.append("stop"),
)
monkeypatch.setattr(
"hermes_cli.gateway.launchd_restart", lambda: called.append("restart"),
)
monkeypatch.setattr(
"hermes_cli.gateway._probe_launchd_service_running", lambda: False,
)
mgr = LaunchdServiceManager()
mgr.start("ignored")
mgr.stop("ignored")
mgr.restart("ignored")
assert called == ["start", "stop", "restart"]
assert mgr.is_running("ignored") is False
def test_windows_manager_lifecycle_delegates(monkeypatch: pytest.MonkeyPatch) -> None:
called: list[str] = []
# Force-import the submodule so monkeypatch's attribute lookup
# against the `hermes_cli` package succeeds — gateway_windows is
# imported lazily inside the wrapper and may not yet be loaded.
import hermes_cli.gateway_windows # noqa: F401
class _FakeWindowsModule:
@staticmethod
def start() -> None: called.append("start")
@staticmethod
def stop() -> None: called.append("stop")
@staticmethod
def restart() -> None: called.append("restart")
@staticmethod
def is_installed() -> bool: return True
monkeypatch.setattr("hermes_cli.gateway_windows", _FakeWindowsModule)
monkeypatch.setattr(
"hermes_cli.gateway.find_gateway_pids",
lambda **kw: [12345],
)
mgr = WindowsServiceManager()
mgr.start("ignored")
mgr.stop("ignored")
mgr.restart("ignored")
assert called == ["start", "stop", "restart"]
assert mgr.is_running("ignored") is True
def test_windows_manager_is_running_false_when_not_installed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
import hermes_cli.gateway_windows # noqa: F401
class _FakeWindowsModule:
@staticmethod
def is_installed() -> bool: return False
monkeypatch.setattr("hermes_cli.gateway_windows", _FakeWindowsModule)
monkeypatch.setattr(
"hermes_cli.gateway.find_gateway_pids",
lambda **kw: [12345], # PIDs would otherwise vote "running"
)
assert WindowsServiceManager().is_running("ignored") is False
def test_windows_manager_install_forwards_kwargs(monkeypatch: pytest.MonkeyPatch) -> None:
captured: dict[str, object] = {}
import hermes_cli.gateway_windows # noqa: F401
class _FakeWindowsModule:
@staticmethod
def install(*, force, start_now, start_on_login, elevated_handoff) -> None:
captured["force"] = force
captured["start_now"] = start_now
captured["start_on_login"] = start_on_login
captured["elevated_handoff"] = elevated_handoff
monkeypatch.setattr("hermes_cli.gateway_windows", _FakeWindowsModule)
WindowsServiceManager().install(
force=True, start_now=True, start_on_login=False, elevated_handoff=True,
)
assert captured == {
"force": True,
"start_now": True,
"start_on_login": False,
"elevated_handoff": True,
}
# ---------------------------------------------------------------------------
# get_service_manager factory
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"kind,cls",
[
("systemd", SystemdServiceManager),
("launchd", LaunchdServiceManager),
("windows", WindowsServiceManager),
],
)
def test_get_service_manager_returns_correct_backend(
monkeypatch: pytest.MonkeyPatch,
kind: ServiceManagerKind,
cls: type,
) -> None:
monkeypatch.setattr(
"hermes_cli.service_manager.detect_service_manager", lambda: kind,
)
assert isinstance(get_service_manager(), cls)
def test_get_service_manager_raises_when_unsupported(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
"hermes_cli.service_manager.detect_service_manager", lambda: "none",
)
with pytest.raises(RuntimeError, match="no supported service manager"):
get_service_manager()
def test_get_service_manager_returns_s6_instance(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The s6 backend ships in Phase 3 — the factory must return an
S6ServiceManager when running inside a container."""
from hermes_cli.service_manager import S6ServiceManager
monkeypatch.setattr(
"hermes_cli.service_manager.detect_service_manager", lambda: "s6",
)
assert isinstance(get_service_manager(), S6ServiceManager)
# ---------------------------------------------------------------------------
# S6ServiceManager — unit tests against a tmp-path scandir (no real s6)
# ---------------------------------------------------------------------------
@pytest.fixture
def s6_scandir(tmp_path):
"""Empty scandir for the S6ServiceManager tests."""
d = tmp_path / "service"
d.mkdir()
return d
@pytest.fixture
def fake_subprocess_run(monkeypatch: pytest.MonkeyPatch):
"""Capture subprocess.run calls + always return success. Lets the
S6ServiceManager tests run on hosts that don't have s6-svc /
s6-svscanctl installed.
Records are normalized: leading ``/command/`` is stripped from
cmd[0] so assertions can match on the bare s6-svc / s6-svstat /
s6-svscanctl name regardless of whether the manager calls them
via absolute path or bare name."""
calls: list[list[str]] = []
def _fake(cmd, **kw):
import subprocess as _sp
seq = list(cmd) if isinstance(cmd, (list, tuple)) else [str(cmd)]
if seq and seq[0].startswith("/command/"):
seq[0] = seq[0][len("/command/"):]
calls.append(seq)
return _sp.CompletedProcess(cmd, 0, "", "")
monkeypatch.setattr("subprocess.run", _fake)
return calls
def test_s6_manager_kind_and_supports_registration() -> None:
from hermes_cli.service_manager import S6ServiceManager
mgr = S6ServiceManager()
assert mgr.kind == "s6"
assert mgr.supports_runtime_registration() is True
# ---------------------------------------------------------------------------
# _seed_supervise_skeleton — unit tests
# ---------------------------------------------------------------------------
#
# The skeleton helper pre-creates the dirs and FIFOs that s6-supervise
# would otherwise create as root mode 0700, locking out the
# unprivileged hermes user from every lifecycle op. These tests run
# against tmp_path and assert the produced layout — the live-container
# verification (against real s6-svc / s6-svstat) lives in
# tests/docker/test_s6_profile_gateway_integration.py.
def test_seed_supervise_skeleton_creates_expected_layout(tmp_path) -> None:
"""Verifies the dirs + FIFO + modes the helper lays down."""
import stat
from hermes_cli.service_manager import _seed_supervise_skeleton
svc_dir = tmp_path / "gateway-foo"
svc_dir.mkdir()
_seed_supervise_skeleton(svc_dir)
# Top-level event/ — s6-svlisten1 event subscription dir.
event = svc_dir / "event"
assert event.is_dir(), "missing top-level event/"
assert stat.S_IMODE(event.stat().st_mode) == 0o3730, (
f"event/ mode = {oct(event.stat().st_mode)}, want 03730"
)
# supervise/ dir.
supervise = svc_dir / "supervise"
assert supervise.is_dir(), "missing supervise/"
assert stat.S_IMODE(supervise.stat().st_mode) == 0o755
# supervise/event/.
supervise_event = supervise / "event"
assert supervise_event.is_dir(), "missing supervise/event/"
assert stat.S_IMODE(supervise_event.stat().st_mode) == 0o3730
# supervise/control FIFO.
control = supervise / "control"
assert control.exists(), "missing supervise/control FIFO"
assert stat.S_ISFIFO(control.stat().st_mode), (
"supervise/control must be a FIFO"
)
assert stat.S_IMODE(control.stat().st_mode) == 0o660
def test_seed_supervise_skeleton_handles_log_subservice(tmp_path) -> None:
"""When a log/ subdir exists, its supervise tree also gets seeded.
Without this, ``unregister_profile_gateway``'s rmtree would EACCES
on the logger's root-owned supervise dir even after the parent
slot's supervise/ was hermes-owned.
"""
import stat
from hermes_cli.service_manager import _seed_supervise_skeleton
svc_dir = tmp_path / "gateway-foo"
svc_dir.mkdir()
(svc_dir / "log").mkdir() # logger subdir present
_seed_supervise_skeleton(svc_dir)
# Logger's own supervise tree is seeded the same way.
log_event = svc_dir / "log" / "event"
log_supervise = svc_dir / "log" / "supervise"
log_supervise_event = log_supervise / "event"
log_control = log_supervise / "control"
assert log_event.is_dir()
assert stat.S_IMODE(log_event.stat().st_mode) == 0o3730
assert log_supervise.is_dir()
assert log_supervise_event.is_dir()
assert log_control.exists() and stat.S_ISFIFO(log_control.stat().st_mode)
def test_seed_supervise_skeleton_skips_when_no_log_subservice(tmp_path) -> None:
"""If log/ isn't present, no logger skeleton is created."""
from hermes_cli.service_manager import _seed_supervise_skeleton
svc_dir = tmp_path / "gateway-foo"
svc_dir.mkdir()
_seed_supervise_skeleton(svc_dir)
assert not (svc_dir / "log").exists(), (
"helper must not synthesize a log/ subdir on its own"
)
def test_seed_supervise_skeleton_is_idempotent(tmp_path) -> None:
"""Calling the helper twice on the same dir is a no-op the second time.
Important because s6-supervise may have already opened the FIFO
when a re-register / reconcile happens; double-creation would
error out. The helper short-circuits on existence.
"""
from hermes_cli.service_manager import _seed_supervise_skeleton
svc_dir = tmp_path / "gateway-foo"
svc_dir.mkdir()
_seed_supervise_skeleton(svc_dir)
_seed_supervise_skeleton(svc_dir) # must not raise
def test_s6_register_creates_service_dir_and_triggers_scan(
s6_scandir, fake_subprocess_run,
) -> None:
from hermes_cli.service_manager import S6ServiceManager
mgr = S6ServiceManager(scandir=s6_scandir)
mgr.register_profile_gateway("coder")
svc_dir = s6_scandir / "gateway-coder"
assert svc_dir.is_dir()
assert (svc_dir / "type").read_text().strip() == "longrun"
run_path = svc_dir / "run"
assert run_path.is_file()
assert run_path.stat().st_mode & 0o111 # executable
run_text = run_path.read_text()
assert "hermes -p coder gateway run" in run_text
assert "s6-setuidgid hermes" in run_text
log_run = svc_dir / "log" / "run"
assert log_run.is_file()
log_text = log_run.read_text()
# CRITICAL: HERMES_HOME must be a runtime env-var expansion, NOT
# a Python-substituted absolute path. Negative-assert the wrong
# form so future regressions are caught.
assert "$HERMES_HOME" in log_text
assert "logs/gateways/coder" in log_text
assert "/opt/data/logs/gateways/coder" not in log_text, (
"log_dir was hard-coded; must use ${HERMES_HOME} at run time"
)
# s6-svscanctl -a was invoked against the scandir
assert any(
cmd[0] == "s6-svscanctl" and "-a" in cmd
and str(s6_scandir) in cmd
for cmd in fake_subprocess_run
), f"s6-svscanctl -a not invoked; saw: {fake_subprocess_run}"
def test_s6_register_extra_env_is_quoted(s6_scandir, fake_subprocess_run) -> None:
from hermes_cli.service_manager import S6ServiceManager
mgr = S6ServiceManager(scandir=s6_scandir)
mgr.register_profile_gateway(
"x", extra_env={"FOO": "bar baz", "QUOTED": "a'b"},
)
run_text = (s6_scandir / "gateway-x" / "run").read_text()
# shlex.quote should have wrapped both values
assert "export FOO='bar baz'" in run_text
assert "export QUOTED='a'\"'\"'b'" in run_text
def test_s6_register_rejects_invalid_profile_name(s6_scandir) -> None:
from hermes_cli.service_manager import S6ServiceManager
mgr = S6ServiceManager(scandir=s6_scandir)
with pytest.raises(ValueError):
mgr.register_profile_gateway("Bad/Name")
def test_s6_register_rejects_duplicate(s6_scandir, fake_subprocess_run) -> None:
from hermes_cli.service_manager import S6ServiceManager
mgr = S6ServiceManager(scandir=s6_scandir)
(s6_scandir / "gateway-coder").mkdir(parents=True)
with pytest.raises(ValueError, match="already registered"):
mgr.register_profile_gateway("coder")
def test_s6_register_rolls_back_on_svscanctl_failure(
s6_scandir, monkeypatch: pytest.MonkeyPatch,
) -> None:
"""If s6-svscanctl fails the service dir must be cleaned up so the
next register call doesn't see a stale duplicate."""
import subprocess as _sp
from hermes_cli.service_manager import S6ServiceManager
def _fail_scanctl(cmd, **kw):
# Manager calls s6-svscanctl by absolute path; match on basename.
if cmd[0].endswith("/s6-svscanctl"):
return _sp.CompletedProcess(cmd, 1, "", "rescan failed")
return _sp.CompletedProcess(cmd, 0, "", "")
monkeypatch.setattr("subprocess.run", _fail_scanctl)
mgr = S6ServiceManager(scandir=s6_scandir)
with pytest.raises(RuntimeError, match="s6-svscanctl failed"):
mgr.register_profile_gateway("coder")
assert not (s6_scandir / "gateway-coder").exists()
def test_s6_unregister_removes_service_dir(
s6_scandir, fake_subprocess_run,
) -> None:
from hermes_cli.service_manager import S6ServiceManager
svc_dir = s6_scandir / "gateway-coder"
svc_dir.mkdir(parents=True)
(svc_dir / "type").write_text("longrun\n")
mgr = S6ServiceManager(scandir=s6_scandir)
mgr.unregister_profile_gateway("coder")
# s6-svc -d was issued
assert any(
cmd[0] == "s6-svc" and "-d" in cmd
for cmd in fake_subprocess_run
)
# Service dir was removed
assert not svc_dir.exists()
# Rescan was triggered
assert any(cmd[0] == "s6-svscanctl" for cmd in fake_subprocess_run)
def test_s6_unregister_absent_profile_is_noop(s6_scandir) -> None:
from hermes_cli.service_manager import S6ServiceManager
# Should NOT raise even though "ghost" doesn't exist
S6ServiceManager(scandir=s6_scandir).unregister_profile_gateway("ghost")
def test_s6_list_profile_gateways(s6_scandir) -> None:
from hermes_cli.service_manager import S6ServiceManager
# Three gateway profiles + one unrelated service + one hidden dir
(s6_scandir / "gateway-coder").mkdir()
(s6_scandir / "gateway-assistant").mkdir()
(s6_scandir / "gateway-writer").mkdir()
(s6_scandir / "s6-linux-init-shutdownd").mkdir() # filtered out
(s6_scandir / ".lock").mkdir() # filtered out (hidden)
profiles = sorted(S6ServiceManager(scandir=s6_scandir).list_profile_gateways())
assert profiles == ["assistant", "coder", "writer"]
def test_s6_list_profile_gateways_empty_when_scandir_missing(tmp_path) -> None:
from hermes_cli.service_manager import S6ServiceManager
missing = tmp_path / "does-not-exist"
assert S6ServiceManager(scandir=missing).list_profile_gateways() == []
def test_s6_lifecycle_dispatches_to_s6_svc(
s6_scandir, fake_subprocess_run,
) -> None:
from hermes_cli.service_manager import S6ServiceManager
mgr = S6ServiceManager(scandir=s6_scandir)
# _run_svc now verifies the slot exists before invoking s6-svc, so
# we have to pre-seed the dir. In real use the slot is created by
# register_profile_gateway or the cont-init.d reconciler.
(s6_scandir / "gateway-coder").mkdir()
mgr.start("gateway-coder")
mgr.stop("gateway-coder")
mgr.restart("gateway-coder")
flags = [c[1] for c in fake_subprocess_run if c[0] == "s6-svc"]
assert flags == ["-u", "-d", "-t"]
# ---------------------------------------------------------------------------
# Lifecycle errors — friendly messages, not raw CalledProcessError
# ---------------------------------------------------------------------------
def test_lifecycle_raises_gateway_not_registered_for_missing_slot(
s6_scandir, fake_subprocess_run,
) -> None:
"""When the service slot doesn't exist, the lifecycle methods
must raise GatewayNotRegisteredError BEFORE invoking s6-svc, so
the user sees a clear 'no such gateway' message instead of an
opaque CalledProcessError stacktrace."""
from hermes_cli.service_manager import (
GatewayNotRegisteredError,
S6ServiceManager,
)
mgr = S6ServiceManager(scandir=s6_scandir)
# No gateway-typo/ directory exists — slot is missing.
with pytest.raises(GatewayNotRegisteredError) as excinfo:
mgr.start("gateway-typo")
assert excinfo.value.profile == "typo"
assert excinfo.value.service == "gateway-typo"
msg = str(excinfo.value)
assert "'typo'" in msg
assert "hermes profile create typo" in msg
# And critically: s6-svc was NOT invoked.
assert not any(c[0] == "s6-svc" for c in fake_subprocess_run)
@pytest.mark.parametrize("action,method_name", [
("start", "start"),
("stop", "stop"),
("restart", "restart"),
])
def test_all_lifecycle_methods_check_for_missing_slot(
s6_scandir,
fake_subprocess_run,
action: str,
method_name: str,
) -> None:
"""start/stop/restart all check for missing slots the same way."""
from hermes_cli.service_manager import (
GatewayNotRegisteredError,
S6ServiceManager,
)
mgr = S6ServiceManager(scandir=s6_scandir)
with pytest.raises(GatewayNotRegisteredError):
getattr(mgr, method_name)("gateway-absent")
def test_gateway_not_registered_unprefixed_service_name(s6_scandir) -> None:
"""If the caller passes a name without the 'gateway-' prefix (the
Protocol allows arbitrary service names), the error still carries
that name verbatim as the 'profile' so error messages don't
accidentally strip user-provided text."""
from hermes_cli.service_manager import (
GatewayNotRegisteredError,
S6ServiceManager,
)
mgr = S6ServiceManager(scandir=s6_scandir)
with pytest.raises(GatewayNotRegisteredError) as excinfo:
mgr.start("not-prefixed")
assert excinfo.value.profile == "not-prefixed"
def test_lifecycle_raises_s6_command_error_on_subprocess_failure(
s6_scandir, monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When s6-svc itself fails (non-zero exit) — e.g. EACCES on the
supervise control FIFO the lifecycle methods translate the
CalledProcessError into a named S6CommandError carrying the
return code and stderr."""
import subprocess as _sp
from hermes_cli.service_manager import S6CommandError, S6ServiceManager
# Pre-create the slot so we reach the s6-svc call.
(s6_scandir / "gateway-coder").mkdir()
def _fail(cmd, **kw):
raise _sp.CalledProcessError(
returncode=111,
cmd=cmd,
stderr="s6-svc: fatal: unable to control supervise/control: "
"Permission denied\n",
)
monkeypatch.setattr("subprocess.run", _fail)
mgr = S6ServiceManager(scandir=s6_scandir)
with pytest.raises(S6CommandError) as excinfo:
mgr.start("gateway-coder")
assert excinfo.value.service == "gateway-coder"
assert excinfo.value.action == "start"
assert excinfo.value.returncode == 111
assert "Permission denied" in excinfo.value.stderr
assert "Permission denied" in str(excinfo.value)
assert "rc=111" in str(excinfo.value)
def test_s6_is_running_parses_svstat(
s6_scandir, monkeypatch: pytest.MonkeyPatch,
) -> None:
import subprocess as _sp
from hermes_cli.service_manager import S6ServiceManager
def _svstat(cmd, **kw):
if cmd[0].endswith("/s6-svstat"):
return _sp.CompletedProcess(cmd, 0, "up (pid 42) 17 seconds\n", "")
return _sp.CompletedProcess(cmd, 0, "", "")
monkeypatch.setattr("subprocess.run", _svstat)
assert S6ServiceManager(scandir=s6_scandir).is_running("gateway-coder") is True
def _svstat_down(cmd, **kw):
if cmd[0].endswith("/s6-svstat"):
return _sp.CompletedProcess(cmd, 0, "down 5 seconds\n", "")
return _sp.CompletedProcess(cmd, 0, "", "")
monkeypatch.setattr("subprocess.run", _svstat_down)
assert S6ServiceManager(scandir=s6_scandir).is_running("gateway-coder") is False

View file

@ -14,7 +14,8 @@ def test_prompt_strips_bracketed_paste_markers(monkeypatch):
def test_password_prompt_strips_bracketed_paste_markers(monkeypatch):
monkeypatch.setattr(
"getpass.getpass",
setup_mod,
"masked_secret_prompt",
lambda _prompt="": "\x1b[200~secret-token\x1b[201~",
)

View file

@ -286,7 +286,6 @@ def test_do_install_scans_with_resolved_identifier(monkeypatch, tmp_path, hub_en
"trust_level": "trusted",
"metadata": {},
})()
q_path = tmp_path / "skills" / ".hub" / "quarantine" / "frontend-design"
q_path.mkdir(parents=True)
(q_path / "SKILL.md").write_text("# Frontend Design")
@ -318,6 +317,60 @@ def test_do_install_scans_with_resolved_identifier(monkeypatch, tmp_path, hub_en
assert scanned["source"] == canonical_identifier
def test_do_install_scans_official_bundles_with_source_provenance(
monkeypatch, tmp_path, hub_env
):
import tools.skills_guard as guard
import tools.skills_hub as hub
class _OfficialSource:
def inspect(self, identifier):
return type("Meta", (), {
"extra": {},
"identifier": "official/agent/prunus-gaia",
})()
def fetch(self, identifier):
return type("Bundle", (), {
"name": "prunus-gaia",
"files": {"SKILL.md": "# Prunus Gaia"},
"source": "official",
"identifier": "official/agent/prunus-gaia",
"trust_level": "builtin",
"metadata": {},
})()
q_path = tmp_path / "skills" / ".hub" / "quarantine" / "prunus-gaia"
q_path.mkdir(parents=True)
(q_path / "SKILL.md").write_text("# Prunus Gaia")
scanned = {}
def _scan_skill(skill_path, source="community"):
scanned["source"] = source
return guard.ScanResult(
skill_name="prunus-gaia",
source=source,
trust_level="builtin",
verdict="safe",
)
monkeypatch.setattr(hub, "ensure_hub_dirs", lambda: None)
monkeypatch.setattr(hub, "create_source_router", lambda auth: [_OfficialSource()])
monkeypatch.setattr(hub, "quarantine_bundle", lambda bundle: q_path)
monkeypatch.setattr(hub, "HubLockFile", lambda: type("Lock", (), {"get_installed": lambda self, name: None})())
monkeypatch.setattr(guard, "scan_skill", _scan_skill)
monkeypatch.setattr(guard, "format_scan_report", lambda result: "scan ok")
monkeypatch.setattr(guard, "should_allow_install", lambda result, force=False: (False, "stop after scan"))
sink = StringIO()
console = Console(file=sink, force_terminal=False, color_system=None)
do_install("official/agent/prunus-gaia", console=console, skip_confirm=True)
assert scanned["source"] == "official"
# ---------------------------------------------------------------------------
# UrlSource-specific install paths: --name override, interactive prompts,
# non-interactive error, existing-category scan.

View file

@ -265,7 +265,7 @@ def test_resolved_api_call_stale_timeout_priority(monkeypatch, tmp_path):
assert agent2._resolved_api_call_stale_timeout_base() == (999.0, False)
monkeypatch.delenv("HERMES_API_CALL_STALE_TIMEOUT", raising=False)
assert agent2._resolved_api_call_stale_timeout_base() == (300.0, True)
assert agent2._resolved_api_call_stale_timeout_base() == (90.0, True)
def test_default_non_stream_stale_timeout_auto_disables_for_local_endpoints(monkeypatch, tmp_path):

View file

@ -12,8 +12,10 @@ from hermes_cli.tools_config import (
_get_platform_tools,
_platform_toolset_summary,
_reconfigure_tool,
_run_post_setup,
_save_platform_tools,
_toolset_has_keys,
_toolset_needs_configuration_prompt,
CONFIGURABLE_TOOLSETS,
TOOL_CATEGORIES,
_visible_providers,
@ -752,6 +754,91 @@ def test_numeric_mcp_server_name_does_not_crash_sorted():
# ─── Imagegen Backend Picker Wiring ────────────────────────────────────────
def test_toolset_has_keys_treats_no_key_providers_as_configured():
config = {}
assert _toolset_has_keys("computer_use", config) is True
def test_computer_use_needs_configuration_when_cua_driver_post_setup_pending():
"""No-key providers can still need setup when their post_setup is unsatisfied.
Returning users enabling Computer Use through `hermes tools` must reach the
cua-driver post-setup installer even though the provider has no API keys.
"""
with patch("shutil.which", return_value=None):
assert _toolset_needs_configuration_prompt("computer_use", {}) is True
def test_computer_use_skips_configuration_when_cua_driver_already_installed():
"""Installed post_setup dependencies should keep returning-user toggles no-op."""
def fake_which(name: str):
return "/usr/local/bin/cua-driver" if name == "cua-driver" else None
with patch("shutil.which", side_effect=fake_which):
assert _toolset_needs_configuration_prompt("computer_use", {}) is False
def test_computer_use_respects_custom_cua_driver_command():
"""The setup gate should match runtime's HERMES_CUA_DRIVER_CMD override."""
def fake_which(name: str):
return "/opt/bin/custom-cua" if name == "custom-cua" else None
with patch.dict("os.environ", {"HERMES_CUA_DRIVER_CMD": "custom-cua"}), \
patch("shutil.which", side_effect=fake_which):
assert _toolset_needs_configuration_prompt("computer_use", {}) is False
def test_computer_use_blank_custom_driver_command_falls_back_to_default():
"""Blank overrides should not make the setup gate look for an empty command."""
def fake_which(name: str):
return "/usr/local/bin/cua-driver" if name == "cua-driver" else None
with patch.dict("os.environ", {"HERMES_CUA_DRIVER_CMD": " "}), \
patch("shutil.which", side_effect=fake_which):
assert _toolset_needs_configuration_prompt("computer_use", {}) is False
def test_computer_use_post_setup_respects_custom_driver_command_when_installed():
"""post_setup already-installed checks should version-probe the override."""
def fake_which(name: str):
return "/opt/bin/custom-cua" if name == "custom-cua" else None
with patch.dict("os.environ", {"HERMES_CUA_DRIVER_CMD": "custom-cua"}), \
patch("platform.system", return_value="Darwin"), \
patch("shutil.which", side_effect=fake_which), \
patch("subprocess.run") as run:
run.return_value.stdout = "custom 1.2.3\n"
_run_post_setup("cua_driver")
run.assert_called_once()
assert run.call_args.args[0] == ["custom-cua", "--version"]
def test_computer_use_post_setup_missing_override_does_not_accept_default_binary():
"""A default cua-driver binary must not satisfy a missing runtime override."""
seen = []
def fake_which(name: str):
seen.append(name)
if name == "cua-driver":
return "/usr/local/bin/cua-driver"
if name == "curl":
return None
return None
with patch.dict("os.environ", {"HERMES_CUA_DRIVER_CMD": "custom-cua"}), \
patch("platform.system", return_value="Darwin"), \
patch("shutil.which", side_effect=fake_which), \
patch("subprocess.run") as run:
_run_post_setup("cua_driver")
run.assert_not_called()
assert "custom-cua" in seen
assert "curl" in seen
class TestImagegenBackendRegistry:
"""IMAGEGEN_BACKENDS tags drive the model picker flow in tools_config."""

View file

@ -0,0 +1,187 @@
"""Tests for the TTS plugin picker surface in hermes_cli/tools_config.py (issue #30398).
Covers ``_plugin_tts_providers()`` and the ``_visible_providers()``
integration that injects plugin rows into the Text-to-Speech category.
Mirrors the structure of existing image_gen / browser picker tests.
"""
from __future__ import annotations
import pytest
from agent import tts_registry
from agent.tts_provider import TTSProvider
from hermes_cli import tools_config
class _FakeTTSProvider(TTSProvider):
def __init__(self, name: str, schema: dict | None = None):
self._name = name
self._schema = schema
@property
def name(self) -> str:
return self._name
def synthesize(self, text, output_path, **kw):
return output_path
def get_setup_schema(self):
if self._schema is not None:
return self._schema
return super().get_setup_schema()
@pytest.fixture(autouse=True)
def _reset_registry():
tts_registry._reset_for_tests()
yield
tts_registry._reset_for_tests()
class TestPluginTTSProviders:
"""``_plugin_tts_providers()`` returns picker-row dicts."""
def test_empty_when_no_plugins(self):
assert tools_config._plugin_tts_providers() == []
def test_returns_row_for_registered_plugin(self):
tts_registry.register_provider(
_FakeTTSProvider(
name="cartesia",
schema={
"name": "Cartesia",
"badge": "paid",
"tag": "Ultra-low-latency streaming",
"env_vars": [
{"key": "CARTESIA_API_KEY", "prompt": "Cartesia API key",
"url": "https://play.cartesia.ai/console"},
],
},
)
)
rows = tools_config._plugin_tts_providers()
assert len(rows) == 1
row = rows[0]
assert row["name"] == "Cartesia"
assert row["badge"] == "paid"
assert row["tag"] == "Ultra-low-latency streaming"
assert row["env_vars"][0]["key"] == "CARTESIA_API_KEY"
# Selecting this row writes ``tts.provider: cartesia`` — same
# write path as a hardcoded row.
assert row["tts_provider"] == "cartesia"
assert row["tts_plugin_name"] == "cartesia"
def test_filters_builtin_shadow_defensively(self):
"""Even if a plugin slipped past the registry's built-in check
(e.g. via direct ``agent.tts_registry.register_provider`` rather
than the ``ctx.register_tts_provider`` hook), the picker layer
filters it out so the picker invariant holds."""
# Use lower-level call to bypass the warning + skip in
# register_provider (the registry's built-in guard).
# Note: this is intentionally pathological — production code
# paths go through the hook which catches this first.
provider = _FakeTTSProvider(name="edge")
tts_registry._providers["edge"] = provider # type: ignore[index]
try:
rows = tools_config._plugin_tts_providers()
assert rows == [], (
"Picker must filter built-in name shadows even when the "
"registry has been bypassed."
)
finally:
tts_registry._providers.pop("edge", None) # type: ignore[arg-type]
def test_skips_providers_with_no_name(self):
"""Defense in depth: a provider with no .name attribute is skipped
rather than crashing the picker."""
class _NoName:
display_name = "Bogus"
def get_setup_schema(self):
return {"name": "Bogus"}
tts_registry._providers["bogus"] = _NoName() # type: ignore[assignment]
try:
rows = tools_config._plugin_tts_providers()
# Provider has no .name so the picker filters it out
assert all(r.get("tts_plugin_name") != "bogus" for r in rows)
finally:
tts_registry._providers.pop("bogus", None) # type: ignore[arg-type]
def test_skips_providers_whose_schema_raises(self):
class _ExplodingSchema(_FakeTTSProvider):
def get_setup_schema(self):
raise RuntimeError("boom")
tts_registry.register_provider(_ExplodingSchema(name="exploding"))
tts_registry.register_provider(_FakeTTSProvider(name="working"))
rows = tools_config._plugin_tts_providers()
assert [r["tts_plugin_name"] for r in rows] == ["working"]
def test_minimal_schema_uses_display_name(self):
"""A provider with no setup_schema override gets a row built from
``display_name`` and ``name`` only."""
tts_registry.register_provider(_FakeTTSProvider(name="minimal"))
rows = tools_config._plugin_tts_providers()
assert len(rows) == 1
assert rows[0]["name"] == "Minimal" # display_name default
assert rows[0]["tts_provider"] == "minimal"
assert rows[0]["env_vars"] == []
def test_post_setup_passthrough(self):
tts_registry.register_provider(
_FakeTTSProvider(
name="my-tts",
schema={
"name": "My TTS",
"post_setup": "my_post_install_hook",
"env_vars": [],
},
)
)
rows = tools_config._plugin_tts_providers()
assert rows[0].get("post_setup") == "my_post_install_hook"
class TestVisibleProvidersInjectsTTSPlugins:
"""``_visible_providers()`` injects plugin rows into the Text-to-Speech
category alongside the hardcoded built-in rows."""
def test_tts_category_includes_plugin_rows(self):
tts_registry.register_provider(_FakeTTSProvider(name="cartesia"))
tts_cat = tools_config.TOOL_CATEGORIES["tts"]
visible = tools_config._visible_providers(tts_cat, config={})
names = [row.get("name") for row in visible]
# Hardcoded rows (sample — check at least one is present)
assert "Microsoft Edge TTS" in names
# Plugin row injected at the end
assert "Cartesia" in names
# Plugin row has tts_provider key for write-path compat
plugin_rows = [r for r in visible if r.get("tts_plugin_name")]
assert len(plugin_rows) == 1
assert plugin_rows[0]["tts_provider"] == "cartesia"
def test_other_categories_unaffected_by_tts_plugins(self):
"""Registering a TTS plugin must not leak into the Image Generation
or Browser pickers."""
tts_registry.register_provider(_FakeTTSProvider(name="cartesia"))
img_cat = tools_config.TOOL_CATEGORIES["image_gen"]
visible = tools_config._visible_providers(img_cat, config={})
names = [row.get("name") for row in visible]
assert "Cartesia" not in names
def test_tts_category_without_plugins_only_hardcoded(self):
"""No plugins → picker shows exactly the hardcoded rows."""
tts_cat = tools_config.TOOL_CATEGORIES["tts"]
visible = tools_config._visible_providers(tts_cat, config={})
names = [row.get("name") for row in visible]
# No row has the plugin marker
assert all(not row.get("tts_plugin_name") for row in visible)
# Hardcoded rows still present (sample one of the always-visible ones)
assert "Microsoft Edge TTS" in names

View file

@ -168,7 +168,7 @@ def test_make_tui_argv_skips_build_only_on_termux_when_fresh(
argv, cwd = main_mod._make_tui_argv(tmp_path, tui_dev=False)
assert argv == ["/bin/node", str(tmp_path / "dist" / "entry.js")]
assert argv == ["/bin/node", "--expose-gc", str(tmp_path / "dist" / "entry.js")]
assert cwd == tmp_path

View file

@ -1,4 +1,5 @@
from argparse import Namespace
import os
from pathlib import Path
import sys
import types
@ -283,6 +284,292 @@ def test_fast_tui_launch_is_termux_only(monkeypatch, main_mod):
assert main_mod._try_termux_fast_tui_launch() is False
def test_termux_fast_cli_launch_chat_uses_light_parser(monkeypatch, main_mod):
captured = {}
prepared = []
monkeypatch.setenv("TERMUX_VERSION", "1")
monkeypatch.delenv("HERMES_TUI", raising=False)
monkeypatch.setattr(
sys, "argv", ["hermes", "chat", "-q", "hello", "--toolsets", "web,terminal"]
)
monkeypatch.setattr(
main_mod, "_prepare_agent_startup", lambda args: prepared.append(args.command)
)
monkeypatch.setattr(
main_mod,
"cmd_chat",
lambda args: captured.update(
{"query": args.query, "toolsets": args.toolsets, "command": args.command}
),
)
assert main_mod._try_termux_fast_cli_launch() is True
assert prepared == ["chat"]
assert captured == {
"query": "hello",
"toolsets": "web,terminal",
"command": "chat",
}
def test_termux_fast_cli_launch_bare_defers_agent_startup(monkeypatch, main_mod):
captured = {}
prepared = []
monkeypatch.setenv("TERMUX_VERSION", "1")
monkeypatch.delenv("HERMES_TUI", raising=False)
monkeypatch.delenv("HERMES_DEFER_AGENT_STARTUP", raising=False)
monkeypatch.delenv("HERMES_FAST_STARTUP_BANNER", raising=False)
monkeypatch.setattr(sys, "argv", ["hermes"])
monkeypatch.setattr(
main_mod, "_prepare_agent_startup", lambda args: prepared.append(args.command)
)
monkeypatch.setattr(
main_mod,
"cmd_chat",
lambda args: captured.update(
{
"query": args.query,
"command": args.command,
"compact": getattr(args, "compact", False),
}
),
)
assert main_mod._try_termux_fast_cli_launch() is True
assert prepared == []
assert captured == {"query": None, "command": None, "compact": True}
assert os.environ["HERMES_DEFER_AGENT_STARTUP"] == "1"
assert os.environ["HERMES_FAST_STARTUP_BANNER"] == "1"
def test_termux_fast_cli_launch_oneshot_uses_light_parser(monkeypatch, main_mod):
captured = {}
prepared = []
monkeypatch.setenv("TERMUX_VERSION", "1")
monkeypatch.delenv("HERMES_TUI", raising=False)
monkeypatch.setattr(
sys,
"argv",
["hermes", "-z", "hello", "--model", "gpt-test", "--provider", "openai"],
)
monkeypatch.setattr(
main_mod, "_prepare_agent_startup", lambda args: prepared.append(args.command)
)
monkeypatch.setitem(
sys.modules,
"hermes_cli.oneshot",
types.SimpleNamespace(
run_oneshot=lambda prompt, **kwargs: captured.update(
{"prompt": prompt, **kwargs}
)
or 17
),
)
with pytest.raises(SystemExit) as exc:
main_mod._try_termux_fast_cli_launch()
assert exc.value.code == 17
assert prepared == [None]
assert captured == {
"prompt": "hello",
"model": "gpt-test",
"provider": "openai",
"toolsets": None,
}
def test_termux_fast_cli_launch_version_skips_update_check(monkeypatch, main_mod):
captured = []
monkeypatch.setenv("TERMUX_VERSION", "1")
monkeypatch.delenv("HERMES_TUI", raising=False)
monkeypatch.setattr(sys, "argv", ["hermes", "version"])
monkeypatch.setattr(
main_mod, "_print_version_info", lambda *, check_updates: captured.append(check_updates)
)
assert main_mod._try_termux_fast_cli_launch() is True
assert captured == [False]
def test_termux_ultrafast_version_runs_before_heavy_startup(
monkeypatch, capsys, main_mod
):
monkeypatch.setenv("TERMUX_VERSION", "1")
monkeypatch.delenv("HERMES_TERMUX_DISABLE_FAST_CLI", raising=False)
monkeypatch.setattr(sys, "argv", ["hermes", "--version"])
assert main_mod._try_termux_ultrafast_version() is True
out = capsys.readouterr().out
assert "Hermes Agent v" in out
assert "Project:" in out
assert "Python:" in out
assert "OpenAI SDK:" in out
def test_read_openai_version_fast(monkeypatch, tmp_path, main_mod):
package_dir = tmp_path / "openai"
package_dir.mkdir()
(package_dir / "_version.py").write_text(
'__version__ = "9.8.7" # x-release-please-version\n',
encoding="utf-8",
)
monkeypatch.setattr(sys, "path", [str(tmp_path)])
assert main_mod._read_openai_version_fast() == "9.8.7"
def test_termux_fast_cli_launch_skips_help(monkeypatch, main_mod):
monkeypatch.setenv("TERMUX_VERSION", "1")
monkeypatch.delenv("HERMES_TUI", raising=False)
monkeypatch.setattr(sys, "argv", ["hermes", "chat", "--help"])
assert main_mod._try_termux_fast_cli_launch() is False
def test_termux_fast_cli_launch_can_be_disabled(monkeypatch, main_mod):
monkeypatch.setenv("TERMUX_VERSION", "1")
monkeypatch.setenv("HERMES_TERMUX_DISABLE_FAST_CLI", "1")
monkeypatch.delenv("HERMES_TUI", raising=False)
monkeypatch.setattr(sys, "argv", ["hermes", "version"])
assert main_mod._try_termux_fast_cli_launch() is False
def test_termux_bundled_skills_stamp_controls_sync(monkeypatch, tmp_path, main_mod):
monkeypatch.setenv("TERMUX_VERSION", "1")
monkeypatch.setattr(main_mod, "get_hermes_home", lambda: tmp_path)
monkeypatch.setattr(main_mod, "_termux_bundled_skills_fingerprint", lambda: "fp1")
assert main_mod._termux_bundled_skills_sync_needed() is True
main_mod._mark_termux_bundled_skills_synced()
assert main_mod._termux_bundled_skills_sync_needed() is False
monkeypatch.setenv("HERMES_TERMUX_FORCE_SKILLS_SYNC", "1")
assert main_mod._termux_bundled_skills_sync_needed() is True
def test_termux_skips_bundled_skill_sync_when_stamp_fresh(monkeypatch, tmp_path, main_mod):
calls = []
monkeypatch.setenv("TERMUX_VERSION", "1")
monkeypatch.setattr(main_mod, "get_hermes_home", lambda: tmp_path)
monkeypatch.setattr(main_mod, "_termux_bundled_skills_fingerprint", lambda: "fp1")
main_mod._mark_termux_bundled_skills_synced()
monkeypatch.setitem(
sys.modules,
"tools.skills_sync",
types.SimpleNamespace(sync_skills=lambda quiet: calls.append(quiet)),
)
assert main_mod._sync_bundled_skills_for_startup() is False
assert calls == []
def test_termux_forced_bundled_skill_sync_runs(monkeypatch, tmp_path, main_mod):
calls = []
monkeypatch.setenv("TERMUX_VERSION", "1")
monkeypatch.setenv("HERMES_TERMUX_FORCE_SKILLS_SYNC", "1")
monkeypatch.setattr(main_mod, "get_hermes_home", lambda: tmp_path)
monkeypatch.setattr(main_mod, "_termux_bundled_skills_fingerprint", lambda: "fp1")
monkeypatch.setitem(
sys.modules,
"tools.skills_sync",
types.SimpleNamespace(sync_skills=lambda quiet: calls.append(quiet)),
)
assert main_mod._sync_bundled_skills_for_startup() is True
assert calls == [True]
def test_read_git_revision_fingerprint_resolves_packed_refs(tmp_path, main_mod):
repo = tmp_path / "repo"
git_dir = repo / ".git"
git_dir.mkdir(parents=True)
(git_dir / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
packed_sha = "1234567890abcdef1234567890abcdef12345678"
(git_dir / "packed-refs").write_text(
"# pack-refs with: peeled fully-peeled sorted\n"
f"{packed_sha} refs/heads/main\n"
"abcdef0000000000000000000000000000000000 refs/tags/v1.0\n"
"^99999999aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n",
encoding="utf-8",
)
fingerprint = main_mod._read_git_revision_fingerprint(repo)
assert fingerprint == f"git:refs/heads/main:{packed_sha}"
def test_read_git_revision_fingerprint_packed_refs_in_worktree_common_dir(
tmp_path, main_mod
):
main_repo = tmp_path / "repo"
common_git = main_repo / ".git"
common_git.mkdir(parents=True)
packed_sha = "fedcba9876543210fedcba9876543210fedcba98"
(common_git / "packed-refs").write_text(
f"{packed_sha} refs/heads/main\n",
encoding="utf-8",
)
worktree = tmp_path / "wt"
worktree.mkdir()
wt_gitdir = common_git / "worktrees" / "wt"
wt_gitdir.mkdir(parents=True)
(wt_gitdir / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
(wt_gitdir / "commondir").write_text("../..\n", encoding="utf-8")
(worktree / ".git").write_text(f"gitdir: {wt_gitdir}\n", encoding="utf-8")
fingerprint = main_mod._read_git_revision_fingerprint(worktree)
assert fingerprint == f"git:refs/heads/main:{packed_sha}"
def test_read_git_revision_fingerprint_loose_ref_in_worktree_common_dir(
tmp_path, main_mod
):
"""`git worktree add -b NAME` writes the new branch ref to the common dir,
not the per-worktree gitdir. The fingerprint must still resolve it."""
main_repo = tmp_path / "repo"
common_git = main_repo / ".git"
common_git.mkdir(parents=True)
loose_sha = "0123456789abcdef0123456789abcdef01234567"
(common_git / "refs" / "heads").mkdir(parents=True)
(common_git / "refs" / "heads" / "feature").write_text(
loose_sha + "\n", encoding="utf-8"
)
worktree = tmp_path / "wt"
worktree.mkdir()
wt_gitdir = common_git / "worktrees" / "wt"
wt_gitdir.mkdir(parents=True)
(wt_gitdir / "HEAD").write_text("ref: refs/heads/feature\n", encoding="utf-8")
(wt_gitdir / "commondir").write_text("../..\n", encoding="utf-8")
(worktree / ".git").write_text(f"gitdir: {wt_gitdir}\n", encoding="utf-8")
fingerprint = main_mod._read_git_revision_fingerprint(worktree)
assert fingerprint == f"git:refs/heads/feature:{loose_sha}"
def test_read_git_revision_fingerprint_unresolved_ref_is_stable(tmp_path, main_mod):
repo = tmp_path / "repo"
git_dir = repo / ".git"
git_dir.mkdir(parents=True)
(git_dir / "HEAD").write_text("ref: refs/heads/missing\n", encoding="utf-8")
fingerprint = main_mod._read_git_revision_fingerprint(repo)
assert fingerprint == "git:refs/heads/missing:unresolved"
def test_main_top_level_oneshot_accepts_toolsets(monkeypatch, main_mod):
captured = {}

View file

@ -118,6 +118,182 @@ def test_detect_concurrent_is_noop_off_windows(_winp, tmp_path):
assert cli_main._detect_concurrent_hermes_instances(tmp_path) == []
# ---------------------------------------------------------------------------
# Parent-chain exclusion (issue #30768 follow-up — the setuptools .exe
# launcher on Windows is a separate native process that spawns python.exe;
# excluding only ``os.getpid()`` flags the launcher as a concurrent instance.
# ---------------------------------------------------------------------------
def _fake_psutil_with_parent_chain(
parent_chain: list[int],
proc_iter_rows: list,
):
"""Build a psutil stand-in that has Process()/parent() AND process_iter().
``parent_chain`` is the list of PIDs returned by successive ``.parent()``
calls starting from the seed (``os.getpid()``); the last entry's
``.parent()`` returns ``None`` to terminate the walk.
"""
class _FakeProc:
def __init__(self, pid: int, chain: list[int]):
self.pid = pid
self._chain = chain
def parent(self):
if not self._chain:
return None
next_pid = self._chain[0]
return _FakeProc(next_pid, self._chain[1:])
class _NoSuchProcess(Exception):
pass
class _AccessDenied(Exception):
pass
def _process(pid):
return _FakeProc(pid, list(parent_chain))
return types.SimpleNamespace(
Process=_process,
NoSuchProcess=_NoSuchProcess,
AccessDenied=_AccessDenied,
process_iter=lambda attrs: iter(proc_iter_rows),
)
@patch.object(cli_main, "_is_windows", return_value=True)
def test_detect_concurrent_excludes_parent_chain(_winp, tmp_path):
"""The .exe launcher (parent of os.getpid()) must NOT be flagged.
Simulates the real Windows topology: hermes.exe launcher (PID L) spawns
python.exe (PID os.getpid()). Both run from the same shim path. With the
old single-PID exclusion, L would be reported as a concurrent instance.
"""
scripts_dir = tmp_path
shim = scripts_dir / "hermes.exe"
shim.write_bytes(b"")
me = os.getpid()
launcher_pid = me + 100 # the .exe launcher — our parent
rows = [
_make_proc(me, str(shim), "python.exe"),
_make_proc(launcher_pid, str(shim), "hermes.exe"),
]
fake_psutil = _fake_psutil_with_parent_chain(
parent_chain=[launcher_pid],
proc_iter_rows=rows,
)
with patch.dict(sys.modules, {"psutil": fake_psutil}):
result = cli_main._detect_concurrent_hermes_instances(scripts_dir)
# Both self AND the launcher are excluded; no false positive.
assert result == []
@patch.object(cli_main, "_is_windows", return_value=True)
def test_detect_concurrent_still_finds_unrelated_other_hermes(_winp, tmp_path):
"""A sibling hermes.exe outside our ancestor chain must still be reported."""
scripts_dir = tmp_path
shim = scripts_dir / "hermes.exe"
shim.write_bytes(b"")
me = os.getpid()
launcher_pid = me + 100 # our .exe launcher (parent — must be excluded)
sibling_pid = me + 200 # an UNRELATED hermes.exe (must still be reported)
rows = [
_make_proc(me, str(shim), "python.exe"),
_make_proc(launcher_pid, str(shim), "hermes.exe"),
_make_proc(sibling_pid, str(shim), "hermes.exe"),
]
fake_psutil = _fake_psutil_with_parent_chain(
parent_chain=[launcher_pid],
proc_iter_rows=rows,
)
with patch.dict(sys.modules, {"psutil": fake_psutil}):
result = cli_main._detect_concurrent_hermes_instances(scripts_dir)
assert result == [(sibling_pid, "hermes.exe")]
@patch.object(cli_main, "_is_windows", return_value=True)
def test_detect_concurrent_parent_chain_walks_deep(_winp, tmp_path):
"""Multi-level ancestry (shell → launcher → python) is fully excluded."""
scripts_dir = tmp_path
shim = scripts_dir / "hermes.exe"
shim.write_bytes(b"")
me = os.getpid()
parent_pid = me + 1
grandparent_pid = me + 2
greatgrandparent_pid = me + 3
rows = [
_make_proc(me, str(shim), "python.exe"),
_make_proc(parent_pid, str(shim), "hermes.exe"),
_make_proc(grandparent_pid, str(shim), "hermes.exe"),
_make_proc(greatgrandparent_pid, str(shim), "hermes.exe"),
]
fake_psutil = _fake_psutil_with_parent_chain(
parent_chain=[parent_pid, grandparent_pid, greatgrandparent_pid],
proc_iter_rows=rows,
)
with patch.dict(sys.modules, {"psutil": fake_psutil}):
result = cli_main._detect_concurrent_hermes_instances(scripts_dir)
assert result == []
@patch.object(cli_main, "_is_windows", return_value=True)
def test_detect_concurrent_parent_walk_handles_cycle(_winp, tmp_path):
"""A PID cycle in the parent chain must not hang the walk."""
scripts_dir = tmp_path
shim = scripts_dir / "hermes.exe"
shim.write_bytes(b"")
me = os.getpid()
bogus_loop_pid = me + 1
rows = [_make_proc(me, str(shim), "python.exe")]
# Chain that points back to ``me`` — the loop-detection branch must break.
fake_psutil = _fake_psutil_with_parent_chain(
parent_chain=[bogus_loop_pid, me, bogus_loop_pid],
proc_iter_rows=rows,
)
with patch.dict(sys.modules, {"psutil": fake_psutil}):
result = cli_main._detect_concurrent_hermes_instances(scripts_dir)
# No crash, no hang; self + bogus_loop_pid excluded; no others reported.
assert result == []
@patch.object(cli_main, "_is_windows", return_value=True)
def test_detect_concurrent_parent_walk_handles_stub_without_process(_winp, tmp_path):
"""Partially-stubbed psutil (no Process attr) must NOT crash the helper.
The function documents itself as "never raises"; a unit-test stub that
only models ``process_iter`` must still complete cleanly with a sensible
result rather than escape ``AttributeError`` to the caller.
"""
scripts_dir = tmp_path
shim = scripts_dir / "hermes.exe"
shim.write_bytes(b"")
me = os.getpid()
other_pid = me + 1
rows = [
_make_proc(me, str(shim), "hermes.exe"),
_make_proc(other_pid, str(shim), "hermes.exe"),
]
# SimpleNamespace with ONLY process_iter — no Process / NoSuchProcess.
fake_psutil = types.SimpleNamespace(process_iter=lambda attrs: iter(rows))
with patch.dict(sys.modules, {"psutil": fake_psutil}):
result = cli_main._detect_concurrent_hermes_instances(scripts_dir)
# Parent-walk silently failed; self still excluded; other still reported.
assert result == [(other_pid, "hermes.exe")]
# ---------------------------------------------------------------------------
# _format_concurrent_instances_message
# ---------------------------------------------------------------------------

View file

@ -0,0 +1,132 @@
"""Regression: _update_via_zip must reject ZIP members with symlink mode.
A symlink member in a downloaded update ZIP would let an attacker who can
serve / MITM the update mirror plant a symlink that extractall() then
follows, writing arbitrary file content outside the staging directory.
The Linux mode bits live in the upper 16 bits of ``ZipInfo.external_attr``;
we explicitly reject any member whose type bits are S_IFLNK.
"""
import io
import os
import stat
import tempfile
import zipfile
from unittest.mock import patch
import pytest
def _build_zip_with_symlink_member(zip_path: str, link_name: str, target: str) -> None:
"""Write a ZIP containing a single member with S_IFLNK mode bits set."""
with zipfile.ZipFile(zip_path, "w") as zf:
info = zipfile.ZipInfo(link_name)
# Upper 16 bits = Unix mode; mark as symlink (0o120000) + 0o777 perms.
info.external_attr = (stat.S_IFLNK | 0o777) << 16
# The "data" of a symlink ZIP member is the link target string.
zf.writestr(info, target)
def _build_normal_zip(zip_path: str) -> None:
"""Write a regular ZIP with a normal file member (no symlink)."""
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("hermes-agent-main/README.md", "ok\n")
def test_update_via_zip_rejects_symlink_member(tmp_path, monkeypatch):
"""A symlink member in the update ZIP must raise before extractall."""
zip_path = tmp_path / "evil.zip"
_build_zip_with_symlink_member(
str(zip_path),
link_name="hermes-agent-main/evil-link",
target="/etc/passwd",
)
from hermes_cli.main import _update_via_zip
args = type("Args", (), {})()
# Patch urlretrieve to "download" our pre-built malicious ZIP into the
# _update_via_zip tempdir. Capture the tempdir so we can prove no
# extraction happened.
captured = {}
original_mkdtemp = tempfile.mkdtemp
def capturing_mkdtemp(*args, **kwargs):
d = original_mkdtemp(*args, **kwargs)
captured["tmp_dir"] = d
return d
def fake_urlretrieve(url, dest):
# Copy our malicious zip into the destination dest path.
with open(zip_path, "rb") as src, open(dest, "wb") as dst:
dst.write(src.read())
return dest, None
with patch("tempfile.mkdtemp", side_effect=capturing_mkdtemp), \
patch("urllib.request.urlretrieve", side_effect=fake_urlretrieve):
# _update_via_zip catches ValueError, prints the message, and exits 1.
# That's the contract: a malicious ZIP must fail the update, not
# silently materialize a symlink.
with pytest.raises(SystemExit) as exc_info:
_update_via_zip(args)
assert exc_info.value.code == 1
# Belt: confirm extractall never produced the link.
tmp_dir = captured.get("tmp_dir")
if tmp_dir:
evil_path = os.path.join(tmp_dir, "hermes-agent-main", "evil-link")
assert not os.path.lexists(evil_path), (
"symlink member should never be materialized"
)
def test_update_via_zip_accepts_normal_member(tmp_path, monkeypatch, capsys):
"""A ZIP with only regular file members must extract without raising.
Sanity check that the symlink reject didn't break the happy path. We
point ``PROJECT_ROOT`` at an isolated tmp dir so the function's
``shutil.copytree(src, dst)`` over PROJECT_ROOT lands in a sandbox, NOT
the real repo checkout (which previously stomped on README.md whenever
this test ran, leaving 'ok\\n' there and breaking
``test_readme_mentions_powershell_installer`` for everyone else).
"""
zip_path = tmp_path / "normal.zip"
_build_normal_zip(str(zip_path))
# Sandbox PROJECT_ROOT so the file-copy phase can't escape the test's
# tmp tree. The function only reads PROJECT_ROOT to derive dst paths.
fake_root = tmp_path / "install_dir"
fake_root.mkdir()
from hermes_cli import main as hermes_main
monkeypatch.setattr(hermes_main, "PROJECT_ROOT", fake_root)
args = type("Args", (), {})()
def fake_urlretrieve(url, dest):
with open(zip_path, "rb") as src, open(dest, "wb") as dst:
dst.write(src.read())
return dest, None
# Stub the post-extract pip/uv reinstall so we don't actually run pip.
# The function may sys.exit(1) when those commands fail; that's fine —
# we only care that ZIP validation + extraction completed without
# raising "symlink member".
with patch("urllib.request.urlretrieve", side_effect=fake_urlretrieve), \
patch("subprocess.run") as fake_run, \
patch("subprocess.check_call"):
fake_run.return_value = type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})()
try:
hermes_main._update_via_zip(args)
except SystemExit:
pass
captured = capsys.readouterr()
assert "symlink member" not in captured.out
assert "symlink member" not in captured.err
# The fake README from the ZIP should have landed in our sandbox root,
# confirming the extraction + copy phases ran past the validation gate.
assert (fake_root / "README.md").exists()
assert (fake_root / "README.md").read_text() == "ok\n"

View file

@ -327,6 +327,12 @@ class TestWebServerEndpoints:
# Public endpoints should still work
resp = unauth_client.get("/api/status")
assert resp.status_code == 200
resp = unauth_client.get("/api/dashboard/plugins")
assert resp.status_code == 200
resp = unauth_client.get("/api/dashboard/plugins/rescan")
assert resp.status_code == 401
resp = self.client.get("/api/dashboard/plugins/rescan")
assert resp.status_code == 200
def test_path_traversal_blocked(self):
"""Verify URL-encoded path traversal is blocked."""
@ -2285,7 +2291,10 @@ class TestPtyWebSocket:
self.ws_module.app.state, "bound_port", 9119, raising=False
)
with self.client.websocket_connect(self._url(channel="abc-123")) as conn:
headers = {"host": "127.0.0.1:9119", "origin": "http://127.0.0.1:9119"}
with self.client.websocket_connect(
self._url(channel="abc-123"), headers=headers
) as conn:
try:
conn.receive_bytes()
except Exception:
@ -2325,7 +2334,34 @@ class TestPtyWebSocket:
with self.client.websocket_connect(pub_path) as pub:
pub.send_text('{"type":"tool.start","payload":{"tool_id":"t1"}}')
received = sub.receive_text()
# Yield control so the server-side broadcast handler can
# process the frame. TestClient runs the ASGI app in a
# background thread; a small sleep gives that thread time
# to call _broadcast_event before we start blocking on
# receive_text(). Without this, under heavy CI load the
# receive can race the broadcast and hang until
# pytest-timeout kills us.
import queue, threading
recv_q: queue.Queue = queue.Queue()
def _recv():
try:
recv_q.put(sub.receive_text())
except Exception as exc:
recv_q.put(exc)
t = threading.Thread(target=_recv, daemon=True)
t.start()
try:
received = recv_q.get(timeout=10.0)
except queue.Empty:
raise AssertionError(
"broadcast not received within 10s — server likely "
"dropped the frame silently (see _broadcast_event "
"except Exception: pass)"
)
if isinstance(received, Exception):
raise received
assert "tool.start" in received
assert '"tool_id":"t1"' in received
@ -2339,3 +2375,78 @@ class TestPtyWebSocket:
):
pass
assert exc.value.code == 4400
class TestDashboardPluginStaticAssetAllowlist:
"""``/dashboard-plugins/<name>/<path>`` is unauthenticated by design —
the SPA loads plugin JS via ``<script src>`` and CSS via
``<link href>``, neither of which can attach a custom auth header.
Instead the route restricts file types to the browser-asset
allowlist (JS/CSS/JSON/images/fonts) so that user-installed
plugins shipping a ``plugin_api.py`` backend module don't leak
their Python source to anyone reachable on the loopback port.
Regression test for the dashboard pentest finding filed alongside
the ``web-pentest`` skill (PR #32265 / issue #32267).
"""
@pytest.fixture(autouse=True)
def _setup_test_client(self, monkeypatch, _isolate_hermes_home):
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
from hermes_cli.web_server import app
self.client = TestClient(app)
def test_python_source_is_404(self):
"""The example plugin's ``plugin_api.py`` must NOT be served as
a static asset, even though the file exists under the plugin's
dashboard directory. Suffix not in the allowlist 404."""
resp = self.client.get("/dashboard-plugins/example/plugin_api.py")
assert resp.status_code == 404
def test_pycache_is_404(self):
"""Same protection for compiled Python (``.pyc``) inside the
plugin's ``__pycache__/``. Real plugins ship these as a
side-effect of running tests / dashboard once."""
# __pycache__ files are only generated after the api file has
# been imported once. Use the path the example plugin actually
# generates during the dashboard test boot.
resp = self.client.get(
"/dashboard-plugins/example/__pycache__/plugin_api.cpython-311.pyc"
)
# 404 either way (file may not exist on this CI Python version);
# what matters is we never get a 200 with the bytes.
assert resp.status_code == 404
def test_manifest_json_still_served(self):
"""JSON files remain browser-fetchable — manifests, localized
data, source maps, etc. all sit in this bucket."""
resp = self.client.get("/dashboard-plugins/example/manifest.json")
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("application/json")
# And the body is actually the manifest, not the SPA fallback.
body = resp.json()
assert body.get("name") == "example"
def test_unknown_plugin_is_404(self):
"""Existing behaviour preserved: nonexistent plugin name → 404."""
resp = self.client.get(
"/dashboard-plugins/_definitely_not_a_plugin_/manifest.json"
)
assert resp.status_code == 404
def test_path_traversal_still_blocked(self):
"""The allowlist is on top of the existing ``.resolve()`` /
``is_relative_to()`` check a ``.js`` named file at an
out-of-base path is still rejected as traversal, not served."""
resp = self.client.get(
"/dashboard-plugins/example/..%2Fplugin_api.py"
)
# 403 traversal-blocked OR 404 (depending on URL decode order)
# — never 200.
assert resp.status_code in (403, 404)

View file

@ -131,6 +131,33 @@ async def test_cron_mutation_without_profile_finds_named_profile_job(isolated_pr
assert worker_jobs[0]["enabled"] is False
@pytest.mark.asyncio
async def test_update_cron_job_rejects_id_mutation(isolated_profiles):
"""Dashboard surfaces a 400 (not a 500 or silent rename) when an
id-mutation attempt is rejected by cron/jobs.update_job."""
from hermes_cli import web_server
worker_job = web_server._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="managed by named profile",
schedule="every 1h",
name="immutable-id-job",
)
with pytest.raises(HTTPException) as exc:
await web_server.update_cron_job(
worker_job["id"],
web_server.CronJobUpdate(updates={"id": "../escape"}),
profile="worker_alpha",
)
assert exc.value.status_code == 400
assert "id" in exc.value.detail
worker_jobs = await web_server.list_cron_jobs(profile="worker_alpha")
assert [job["id"] for job in worker_jobs] == [worker_job["id"]]
@pytest.mark.asyncio
async def test_cron_delete_with_profile_deletes_only_target_profile(isolated_profiles):
from hermes_cli import web_server

View file

@ -146,3 +146,72 @@ class TestHostHeaderMiddleware:
resp = client.get("/api/status")
# Should get through to the status endpoint, not a 400
assert resp.status_code != 400
class TestWebSocketHostOriginGuard:
"""WebSocket upgrades must enforce the same dashboard boundary as HTTP."""
def test_rebinding_websocket_host_is_rejected(self, monkeypatch):
from fastapi.testclient import TestClient
from starlette.websockets import WebSocketDisconnect
import hermes_cli.web_server as ws
monkeypatch.setattr(ws.app.state, "bound_host", "127.0.0.1", raising=False)
monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True)
client = TestClient(ws.app)
url = f"/api/events?token={ws._SESSION_TOKEN}&channel=security-test"
with pytest.raises(WebSocketDisconnect) as exc:
with client.websocket_connect(
url,
headers={
"Host": "evil.example",
"Origin": "http://evil.example",
},
):
pass
assert exc.value.code == 4403
def test_rebinding_websocket_origin_is_rejected(self, monkeypatch):
from fastapi.testclient import TestClient
from starlette.websockets import WebSocketDisconnect
import hermes_cli.web_server as ws
monkeypatch.setattr(ws.app.state, "bound_host", "127.0.0.1", raising=False)
monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True)
client = TestClient(ws.app)
url = f"/api/events?token={ws._SESSION_TOKEN}&channel=security-test"
with pytest.raises(WebSocketDisconnect) as exc:
with client.websocket_connect(
url,
headers={
"Host": "localhost:9119",
"Origin": "http://evil.example",
},
):
pass
assert exc.value.code == 4403
def test_loopback_websocket_host_and_origin_are_accepted(self, monkeypatch):
from fastapi.testclient import TestClient
import hermes_cli.web_server as ws
monkeypatch.setattr(ws.app.state, "bound_host", "127.0.0.1", raising=False)
monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True)
client = TestClient(ws.app)
url = f"/api/events?token={ws._SESSION_TOKEN}&channel=security-test"
with client.websocket_connect(
url,
headers={
"Host": "localhost:9119",
"Origin": "http://localhost:9119",
},
):
pass

View file

@ -0,0 +1,53 @@
import os
import pytest
from hermes_cli.web_server import _save_anthropic_oauth_creds
class _DummyPool:
def entries(self):
return []
def remove_entry(self, _id):
return None
def add_entry(self, _entry):
return None
@pytest.fixture
def oauth_file(monkeypatch, tmp_path):
target = tmp_path / '.anthropic_oauth.json'
monkeypatch.setattr('agent.anthropic_adapter._HERMES_OAUTH_FILE', target)
monkeypatch.setattr('agent.credential_pool.load_pool', lambda _provider: _DummyPool())
return target
def test_dashboard_oauth_write_uses_owner_only_permissions(oauth_file):
old_umask = os.umask(0o022)
try:
_save_anthropic_oauth_creds('access-token', 'refresh-token', 123456)
finally:
os.umask(old_umask)
assert oauth_file.exists()
mode = oauth_file.stat().st_mode & 0o777
assert mode == 0o600
def test_dashboard_oauth_write_uses_atomic_replace_and_cleans_temp_files(oauth_file, monkeypatch):
replace_calls = []
def flaky_replace(src, dst):
replace_calls.append((src, dst))
raise OSError('simulated replace failure')
monkeypatch.setattr('hermes_cli.web_server.os.replace', flaky_replace)
with pytest.raises(OSError, match='simulated replace failure'):
_save_anthropic_oauth_creds('access-token', 'refresh-token', 123456)
assert replace_calls, 'helper should attempt atomic os.replace()'
assert not oauth_file.exists()
assert not list(oauth_file.parent.glob(f'{oauth_file.name}.tmp*'))

View file

@ -3,6 +3,7 @@
import json
import os
import pytest
import stat
from argparse import Namespace
from pathlib import Path
@ -145,6 +146,31 @@ class TestPersistence:
path.write_text("broken{{{")
assert _load_subscriptions() == {}
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits are platform-specific")
def test_save_creates_secret_file_owner_only_under_permissive_umask(self):
old_umask = os.umask(0o022)
try:
_save_subscriptions({"demo": {"secret": "TOPSECRET", "prompt": "x"}})
finally:
os.umask(old_umask)
path = _subscriptions_path()
assert stat.S_IMODE(path.stat().st_mode) == 0o600
assert "TOPSECRET" in path.read_text(encoding="utf-8")
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits are platform-specific")
def test_save_narrows_existing_broad_secret_file_mode(self):
# Simulate a pre-existing 0o644 file from before this hardening landed.
path = _subscriptions_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"old": {"secret": "stale", "prompt": "x"}}))
path.chmod(0o644)
_save_subscriptions({"demo": {"secret": "FRESH", "prompt": "x"}})
assert stat.S_IMODE(path.stat().st_mode) == 0o600
assert "FRESH" in path.read_text(encoding="utf-8")
class TestWebhookEnabledGate:
def test_blocks_when_disabled(self, capsys, monkeypatch):

View file

@ -0,0 +1,16 @@
"""Regression tests for xAI provider label disambiguation."""
from hermes_cli.models import provider_label
from hermes_cli.providers import get_label
def test_xai_oauth_provider_label_is_not_collapsed_to_api_key_label():
"""The model picker must distinguish xAI API-key and OAuth providers."""
assert get_label("xai") == "xAI"
assert get_label("xai-oauth") == "xAI Grok OAuth (SuperGrok / Premium+)"
assert get_label("grok-oauth") == "xAI Grok OAuth (SuperGrok / Premium+)"
def test_xai_oauth_provider_labels_match_canonical_model_labels():
"""Provider helpers should agree on the OAuth display label."""
assert get_label("xai-oauth") == provider_label("xai-oauth")