Files
hermes-agent/tests/hermes_cli/test_skills_failure_exit_code.py
Teknium 23a68610a5 fix(skills): print CLI errors unwrapped so the Desktop toast keeps the subject
The Desktop Hub toasts a failed action's last 3 log lines. Rich wrapped
_print_error at 80 columns on the action pipe, so the hub-shadowed message
reached the toast as "uses the same name and shadows it. To get the
built-in back: ..." without "'computer-use' is a built-in skill". Errors
now print as one soft-wrapped line.

Tests: fold the slug-resolution case into the bundled-name test and the
scan-gate case into the exit-code table (4 new test functions -> 2). The
`skills update` row calls the skills router directly instead of
hermes_cli.main, which must never run with `update` in argv.
2026-10-05 16:00:48 -07:00

98 lines
4.6 KiB
Python

"""A failed ``hermes skills install|update|uninstall|snapshot import`` exits non-zero (#118301).
The Desktop spawns these as detached actions and learns their outcome only from the
exit code (``apps/desktop/src/store/hub-actions.ts`` toasts on non-zero), so a refused
action that exits 0 reads as "the button did nothing" while the reason sits unread in
the action log. The scan gate is the common case: a blocked install used to print
"Not installed: ..." and exit 0.
"""
import sys
import pytest
import hermes_cli.skills_hub as cli_hub
def _scan_gate_env(monkeypatch, tmp_path, *, verdict="dangerous", installed=None):
"""Wire ``do_install`` down to the real scan-policy decision for a community skill."""
import tools.skills_guard as guard
import tools.skills_hub as hub
import tools.skills_hub_install as hub_install
bundle = type("Bundle", (), {
"name": "risky-skill", "files": {"SKILL.md": "---\nname: risky-skill\n---\n"},
"source": "skills-sh", "identifier": "org/repo/risky-skill", "trust_level": "community",
"metadata": {},
})()
q_path = tmp_path / "skills" / ".hub" / "quarantine" / "risky-skill"
q_path.mkdir(parents=True)
audit: list = []
monkeypatch.setattr(hub, "ensure_hub_dirs", lambda: None)
monkeypatch.setattr(hub, "append_audit_log", lambda *a, **k: audit.append(a))
monkeypatch.setattr(hub, "HubLockFile", lambda: type(
"Lock", (), {"get_installed": lambda self, n: installed})())
monkeypatch.setattr(cli_hub, "_sources", lambda: [object()])
monkeypatch.setattr(cli_hub, "_resolve_source_meta_and_bundle",
lambda identifier, sources: (None, bundle, sources[0]))
monkeypatch.setattr(cli_hub, "_record_skill_install", lambda *a, **k: None)
monkeypatch.setattr(hub_install, "quarantine_bundle", lambda b: q_path)
finding = guard.Finding("remote_fetch", "critical", "supply_chain", "SKILL.md", 40,
'curl -s "https://example.com"', "remote fetch")
monkeypatch.setattr(cli_hub, "_scan_quarantined", lambda *a, **k: guard.ScanResult(
skill_name="risky-skill", source="org/repo/risky-skill", trust_level="community",
verdict=verdict, findings=[finding, finding]))
installs: list = []
def _install(q, name, category, b, result):
installs.append(name)
d = tmp_path / "skills" / name
d.mkdir(parents=True, exist_ok=True)
return d
monkeypatch.setattr(hub_install, "install_from_quarantine", _install)
monkeypatch.setattr(hub, "SKILLS_DIR", tmp_path / "skills")
return installs, audit
# --- the process exit code, through the real `hermes` entry point ---
@pytest.mark.parametrize("argv, target, outcome, code", [
(["skills", "install", "org/repo/x", "--yes"], "do_install", False, 1),
(["skills", "install", "org/repo/x", "--yes"], "do_install", True, 0),
(["skills", "install", "org/repo/x", "--yes"], "do_install", None, 0),
(["skills", "update"], "do_update", False, 1),
(["skills", "update"], "do_update", None, 0),
(["skills", "uninstall", "x", "--yes"], "do_uninstall", False, 1),
(["skills", "uninstall", "x", "--yes"], "do_uninstall", True, 0),
(["skills", "snapshot", "import", "snap.json"], "do_snapshot_import", False, 1),
(["skills", "snapshot", "import", "snap.json"], "do_snapshot_import", None, 0),
# The real scan gate, not a stubbed outcome: a refused community skill installs nothing and exits 1.
(["skills", "install", "org/repo/risky-skill", "--yes"], "scan_gate", "dangerous", 1),
(["skills", "install", "org/repo/risky-skill", "--yes"], "scan_gate", "caution", 1),
])
def test_cli_exit_code_follows_the_action_outcome(monkeypatch, tmp_path, capsys, argv, target, outcome, code):
from hermes_cli.main import main
if target == "scan_gate":
installs, audit = _scan_gate_env(monkeypatch, tmp_path, verdict=outcome)
else:
monkeypatch.setattr(cli_hub, target, lambda *a, **k: outcome)
if argv[1] == "update":
# Never run hermes_cli.main with `update` in argv (it can turn into a real self-update): drive
# the same router `hermes skills` returns its exit code from.
from argparse import Namespace
exit_code = cli_hub.skills_command(Namespace(skills_action="update", name=None, force=False)) or 0
else:
monkeypatch.setattr(sys, "argv", ["hermes", *argv])
try:
main()
exit_code = 0
except SystemExit as exc:
exit_code = exc.code or 0
assert exit_code == code
if target == "scan_gate":
assert installs == [] and "Not installed:" in capsys.readouterr().out
assert audit and audit[0][0] == "BLOCKED"