mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-08 08:11:38 +00:00
Merge remote-tracking branch 'origin/main' into jq/hermes-update-branch-flag
This commit is contained in:
commit
3d9a26afad
1217 changed files with 178911 additions and 8214 deletions
|
|
@ -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 = {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
13
tests/hermes_cli/test_auth_usable_secret.py
Normal file
13
tests/hermes_cli/test_auth_usable_secret.py
Normal 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)
|
||||
|
|
@ -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 —
|
||||
|
|
|
|||
20
tests/hermes_cli/test_cli_output.py
Normal file
20
tests/hermes_cli/test_cli_output.py
Normal 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"
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
578
tests/hermes_cli/test_container_boot.py
Normal file
578
tests/hermes_cli/test_container_boot.py
Normal 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
|
||||
)
|
||||
131
tests/hermes_cli/test_curses_color_compat.py
Normal file
131
tests/hermes_cli/test_curses_color_compat.py
Normal 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}"
|
||||
)
|
||||
|
|
@ -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
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"}}
|
||||
|
|
|
|||
335
tests/hermes_cli/test_gateway_s6_dispatch.py
Normal file
335
tests/hermes_cli/test_gateway_s6_dispatch.py
Normal 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
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
254
tests/hermes_cli/test_kanban_promote.py
Normal file
254
tests/hermes_cli/test_kanban_promote.py
Normal 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
|
||||
794
tests/hermes_cli/test_mcp_catalog.py
Normal file
794
tests/hermes_cli/test_mcp_catalog.py
Normal 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")
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
214
tests/hermes_cli/test_nous_inference_url_validation.py
Normal file
214
tests/hermes_cli/test_nous_inference_url_validation.py
Normal 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."
|
||||
)
|
||||
353
tests/hermes_cli/test_plugin_auxiliary_tasks.py
Normal file
353
tests/hermes_cli/test_plugin_auxiliary_tasks.py
Normal 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") == {}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
148
tests/hermes_cli/test_plugins_transcription_registration.py
Normal file
148
tests/hermes_cli/test_plugins_transcription_registration.py
Normal 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()
|
||||
156
tests/hermes_cli/test_plugins_tts_registration.py
Normal file
156
tests/hermes_cli/test_plugins_tts_registration.py
Normal 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()
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
210
tests/hermes_cli/test_profiles_s6_hooks.py
Normal file
210
tests/hermes_cli/test_profiles_s6_hooks.py
Normal 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
|
||||
361
tests/hermes_cli/test_project_plugin_rce_bypass.py
Normal file
361
tests/hermes_cli/test_project_plugin_rce_bypass.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
62
tests/hermes_cli/test_secret_prompt.py
Normal file
62
tests/hermes_cli/test_secret_prompt.py
Normal 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: "
|
||||
299
tests/hermes_cli/test_security_audit.py
Normal file
299
tests/hermes_cli/test_security_audit.py
Normal 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"]
|
||||
793
tests/hermes_cli/test_service_manager.py
Normal file
793
tests/hermes_cli/test_service_manager.py
Normal 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
|
||||
|
|
@ -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~",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
187
tests/hermes_cli/test_tts_picker.py
Normal file
187
tests/hermes_cli/test_tts_picker.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
132
tests/hermes_cli/test_update_zip_symlink_reject.py
Normal file
132
tests/hermes_cli/test_update_zip_symlink_reject.py
Normal 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"
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
53
tests/hermes_cli/test_web_server_oauth_write.py
Normal file
53
tests/hermes_cli/test_web_server_oauth_write.py
Normal 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*'))
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
16
tests/hermes_cli/test_xai_provider_labels.py
Normal file
16
tests/hermes_cli/test_xai_provider_labels.py
Normal 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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue