Merge remote-tracking branch 'origin/main' into bb/pets-merge

# Conflicts:
#	hermes_cli/commands.py
#	tui_gateway/server.py
This commit is contained in:
Brooklyn Nicholson 2026-06-23 19:05:22 -05:00
commit e495b33bf1
251 changed files with 23395 additions and 2720 deletions

View file

@ -113,6 +113,33 @@ def test_active_session_registry_prunes_dead_pids(tmp_path, monkeypatch):
lease.release()
def test_transfer_active_session_reanchors_existing_lease(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
monkeypatch.setenv("HERMES_HOME", str(home))
lease, message = active_sessions.try_acquire_active_session(
session_id="session-old",
surface="tui",
config={"max_concurrent_sessions": 1},
metadata={"live_session_id": "ui-1"},
)
assert message is None
assert lease is not None
assert active_sessions.transfer_active_session(
lease,
session_id="session-new",
metadata={"live_session_id": "ui-1"},
)
snapshot = active_sessions.active_session_registry_snapshot()
assert lease.session_id == "session-new"
assert len(snapshot) == 1
assert snapshot[0]["session_id"] == "session-new"
assert snapshot[0]["metadata"] == {"live_session_id": "ui-1"}
lease.release()
def test_pid_alive_uses_safe_pid_exists_without_signalling(monkeypatch):
checked: list[int] = []

View file

@ -190,7 +190,11 @@ def _arrange_startup_fallback(monkeypatch, tmp_path, running_pids):
def test_gateway_cmd_script_uses_pythonw_without_replace_or_start_churn(monkeypatch):
"""Scheduled Task wrapper should launch pythonw once and avoid replace loops."""
monkeypatch.setattr(gateway_windows, "_derive_venv_pythonw", lambda exe: exe.replace("python.exe", "pythonw.exe"))
monkeypatch.setattr(
gateway_windows,
"_resolve_detached_python",
lambda exe: (exe.replace("python.exe", "pythonw.exe"), r"C:\\Hermes\\hermes-agent\\venv", []),
)
content = gateway_windows._build_gateway_cmd_script(
r"C:\\Hermes\\hermes-agent\\venv\\Scripts\\python.exe",
@ -206,6 +210,41 @@ def test_gateway_cmd_script_uses_pythonw_without_replace_or_start_churn(monkeypa
assert "exit /b 0" in content
def test_gateway_cmd_script_uses_uv_safe_base_pythonw(monkeypatch, tmp_path):
"""Scheduled Task wrapper should share the detached uv-venv workaround."""
project = tmp_path / "project"
scripts = project / "venv" / "Scripts"
site_packages = project / "venv" / "Lib" / "site-packages"
hermes_home = tmp_path / "hermes-home"
base = tmp_path / "uv" / "python" / "cpython-3.11-windows-x86_64-none"
scripts.mkdir(parents=True)
site_packages.mkdir(parents=True)
hermes_home.mkdir()
base.mkdir(parents=True)
venv_python = scripts / "python.exe"
venv_pythonw = scripts / "pythonw.exe"
base_pythonw = base / "pythonw.exe"
for exe in (venv_python, venv_pythonw, base_pythonw):
exe.write_text("", encoding="utf-8")
(project / "venv" / "pyvenv.cfg").write_text(
f"home = {base}\nimplementation = CPython\nuv = 0.11.14\nversion_info = 3.11.15\n",
encoding="utf-8",
)
content = gateway_windows._build_gateway_cmd_script(
str(venv_python),
str(hermes_home),
str(hermes_home),
"",
)
assert str(base_pythonw) in content
assert f'set "VIRTUAL_ENV={project / "venv"}"' in content
assert str(site_packages) in content
assert str(venv_pythonw) not in content
def test_elevated_gateway_command_uses_pythonw_hidden_console(monkeypatch):
"""UAC handoff should not leave a second elevated cmd.exe window open."""
calls = []
@ -239,14 +278,18 @@ def test_install_scheduled_task_recreates_instead_of_change(monkeypatch, tmp_pat
"""Install must delete+create so stale minute-repeat task settings are not preserved."""
calls = []
script_path = tmp_path / "Hermes_Gateway_alice.cmd"
xml_seen = {}
monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None)
monkeypatch.setattr(gateway_windows, "_resolve_task_user", lambda: r"DOMAIN\\alice")
def fake_schtasks(args):
calls.append(tuple(args))
if args[0] == "/Delete":
return (0, "SUCCESS", "")
if args[0] == "/Create":
xml_path = Path(args[args.index("/XML") + 1])
xml_seen["text"] = xml_path.read_text(encoding="utf-16")
return (0, "SUCCESS", "")
raise AssertionError(f"unexpected schtasks args: {args}")
@ -257,8 +300,88 @@ def test_install_scheduled_task_recreates_instead_of_change(monkeypatch, tmp_pat
assert "/Change" not in [arg for call in calls for arg in call]
assert calls[0][:4] == ("/Delete", "/F", "/TN", "Hermes_Gateway_alice")
assert calls[1][0] == "/Create"
assert "/SC" in calls[1]
assert "ONLOGON" in calls[1]
assert "/XML" in calls[1]
assert "/SC" not in calls[1]
assert "<Delay>PT30S</Delay>" in xml_seen["text"]
assert "<StartWhenAvailable>true</StartWhenAvailable>" in xml_seen["text"]
assert "<StopOnIdleEnd>false</StopOnIdleEnd>" in xml_seen["text"]
assert "<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>" in xml_seen["text"]
assert "<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>" in xml_seen["text"]
assert "<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>" in xml_seen["text"]
assert "<RestartOnFailure>" in xml_seen["text"]
assert "<Count>999</Count>" in xml_seen["text"]
# Scheduled Task launches the console-less .vbs via wscript.exe, never cmd.exe
# (issue #45599 fix A: no console -> no logon CTRL_CLOSE_EVENT / 0xC000013A).
assert "<Command>wscript.exe</Command>" in xml_seen["text"]
assert "//B //Nologo" in xml_seen["text"]
assert "Hermes_Gateway_alice.vbs" in xml_seen["text"]
assert "cmd.exe" not in xml_seen["text"]
def test_gateway_vbs_script_is_console_less(monkeypatch):
"""The .vbs launcher must avoid cmd.exe entirely and Run pythonw hidden
(issue #45599 fix A: no console -> no logon CTRL_CLOSE_EVENT / 0xC000013A)."""
monkeypatch.setattr(
gateway_windows,
"_resolve_detached_python",
lambda exe: (r"C:\venv\Scripts\pythonw.exe", Path(r"C:\venv"), []),
)
content = gateway_windows._build_gateway_vbs_script(
r"C:\venv\Scripts\python.exe",
r"C:\Hermes",
r"C:\Hermes",
"--profile work",
)
assert "cmd.exe" not in content.lower()
assert 'CreateObject("WScript.Shell")' in content
assert "pythonw.exe" in content
assert "hermes_cli.main" in content
assert "gateway run" in content
assert ", 0, False" in content # hidden window, detached/async
for var in ("HERMES_HOME", "PYTHONIOENCODING", "HERMES_GATEWAY_DETACHED", "VIRTUAL_ENV", "PYTHONPATH"):
assert var in content
assert "--profile" in content and "work" in content
assert content.endswith("\r\n")
def test_gateway_vbs_script_quotes_spaced_paths(monkeypatch):
"""Spaced exe/dir paths stay correctly quoted through the VBScript literal."""
monkeypatch.setattr(
gateway_windows,
"_resolve_detached_python",
lambda exe: (r"C:\Program Files\Py\pythonw.exe", Path(r"C:\v env"), []),
)
content = gateway_windows._build_gateway_vbs_script(
r"C:\Program Files\Py\python.exe",
r"C:\work dir",
r"C:\h home",
"",
)
# list2cmdline quotes the spaced exe; _quote_vbs_string doubles those quotes.
assert '""C:\\Program Files\\Py\\pythonw.exe""' in content
assert 'sh.CurrentDirectory = "C:\\work dir"' in content
def test_gateway_vbs_script_pythonpath_chains_runtime_value(monkeypatch):
"""PYTHONPATH chains onto the task env's existing value, like ;%PYTHONPATH%."""
monkeypatch.setattr(
gateway_windows,
"_resolve_detached_python",
lambda exe: (r"C:\v\pythonw.exe", Path(r"C:\v"), [r"C:\v\Lib\site-packages"]),
)
content = gateway_windows._build_gateway_vbs_script(
r"C:\v\python.exe", r"C:\w", r"C:\h", "",
)
assert 'existing_pp = env.Item("PYTHONPATH")' in content
assert "If Len(existing_pp) > 0 Then" in content
assert r"C:\v\Lib\site-packages" in content
def test_quote_vbs_string_doubles_quotes_and_rejects_newlines():
assert gateway_windows._quote_vbs_string("plain") == '"plain"'
assert gateway_windows._quote_vbs_string('a"b') == '"a""b"'
with pytest.raises(ValueError):
gateway_windows._quote_vbs_string("line1\nline2")
def test_install_scheduled_task_success_start_now_uses_direct_spawn_not_task_run(monkeypatch, tmp_path, capsys):

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,4 @@
"""Tests for ``install_cua_driver`` upgrade semantics and architecture pre-check.
"""Tests for ``install_cua_driver`` upgrade semantics.
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,30 +10,34 @@ 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.
The pre-install arch probe that used to live alongside this function was
deleted (see top-of-file comment in tools_config.py) the upstream
installer has CUA_DRIVER_RS_BAKED_VERSION baked in by CD and errors
cleanly on missing-arch assets, and the upgrade path uses
``cua_driver_update_check()`` (which shells `cua-driver check-update
--json` against the already-installed binary).
"""
from __future__ import annotations
import json
from unittest.mock import MagicMock, patch
from unittest.mock import patch
class TestInstallCuaDriverUpgrade:
def test_upgrade_on_non_macos_is_silent_noop(self):
def test_upgrade_on_unsupported_platform_is_silent_noop(self):
from hermes_cli import tools_config
with patch.object(tools_config, "_print_warning") as warn, \
patch("platform.system", return_value="Linux"):
patch("platform.system", return_value="FreeBSD"):
assert tools_config.install_cua_driver(upgrade=True) is False
warn.assert_not_called()
def test_non_upgrade_on_non_macos_warns(self):
def test_non_upgrade_on_unsupported_platform_warns(self):
from hermes_cli import tools_config
with patch.object(tools_config, "_print_warning") as warn, \
patch("platform.system", return_value="Linux"):
patch("platform.system", return_value="FreeBSD"):
assert tools_config.install_cua_driver(upgrade=False) is False
warn.assert_called()
@ -44,8 +48,6 @@ class TestInstallCuaDriverUpgrade:
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"):
@ -60,8 +62,6 @@ class TestInstallCuaDriverUpgrade:
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
@ -85,128 +85,75 @@ class TestInstallCuaDriverUpgrade:
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()
class TestCheckCuaDriverAssetForArch:
def test_arm64_always_returns_true(self):
class TestArchProbeRemoval:
"""Regression tests for the deletion of `_check_cua_driver_asset_for_arch`.
The old probe queried ``/releases/latest`` on trycua/cua and inspected
asset names. That was wrong in two ways:
1. cua-driver-rs releases are marked **prerelease** on every cut, so
``/releases/latest`` returns the Python ``cua-agent`` / ``cua-computer``
package instead a release with zero binary assets. The probe then
reported "no asset for $arch" on Linux x86_64, Windows, macOS Intel,
Linux arm64 every non-Apple-Silicon host.
2. Even with the right endpoint, it duplicated tag-resolution the upstream
installer already does correctly via ``CUA_DRIVER_RS_BAKED_VERSION``
(auto-baked by CD on every release).
The fix: stop probing. Trust the upstream installer for fresh installs
(it has the baked version + correct API fallback) and the
``cua-driver check-update --json`` MCP-binary native command for the
upgrade path.
"""
def test_probe_function_is_gone(self):
from hermes_cli import tools_config
assert not hasattr(tools_config, "_check_cua_driver_asset_for_arch")
assert not hasattr(tools_config, "_latest_cua_driver_rs_release")
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):
def test_fresh_install_does_not_call_github_api(self):
"""Pre-install no longer probes the GitHub API — the upstream
``install.sh`` resolves the tag from its baked CUA_DRIVER_RS_BAKED_VERSION
line. install.sh errors cleanly when the arch has no asset, so the
probe was duplicate gatekeeping.
"""
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=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()
patch("urllib.request.urlopen") as urlopen, \
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()
urlopen.assert_not_called()
def test_upgrade_x86_64_no_asset_returns_existing_status(self):
"""On upgrade with no Intel asset, return whether binary existed."""
def test_upgrade_with_binary_does_not_call_github_api_directly(self):
"""The upgrade path no longer hits GitHub from Python — it delegates
to the upstream ``install.sh`` (which has the baked release tag and
the proper API fallback). When cua-driver is already installed,
``cua_driver_update_check()`` (added in a separate change) further
short-circuits the network re-install via the binary's native
``check-update --json`` verb.
"""
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:
patch("urllib.request.urlopen") as urlopen, \
patch("subprocess.run"), \
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_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()
runner.assert_called_once()
# Probe deleted — no direct GitHub API call from Python.
urlopen.assert_not_called()

View file

@ -639,6 +639,46 @@ def test_aggregator_dedup_does_not_empty_user_defined_custom_provider():
assert or_row["total_models"] == 1
def test_flat_namespace_reseller_keeps_first_party_models_overlapping_user_proxy():
"""opencode-go / opencode-zen are flagged ``is_aggregator=True`` (their
flat ``/v1/models`` returns bare IDs the model-switch resolver searches),
but they are NOT routing aggregators every model they list is a
first-party model under the user's subscription. When a user also runs a
custom proxy that happens to serve a same-named model, the picker dedup
must NOT strip the reseller's own catalog. Regression for #47077, where
opencode-go showed only 13 of 19 models because minimax-m3/m2.7/m2.5,
glm-5/5.1, and deepseek-v4-flash were deduped against an overlapping
custom provider.
"""
rows = [
_user_provider_row("custom:my-proxy", [
"minimax-m3", "minimax-m2.7", "glm-5", "deepseek-v4-flash",
]),
_aggregator_row("opencode-go", [
"kimi-k2.6", "minimax-m3", "minimax-m2.7", "glm-5",
"deepseek-v4-flash", "qwen3.7-max",
]),
_aggregator_row("openrouter", ["minimax-m3", "anthropic/claude-sonnet-4.6"]),
]
ctx = _empty_ctx()
with _list_auth_returning(rows):
payload = build_models_payload(ctx)
go_row = next(r for r in payload["providers"] if r["slug"] == "opencode-go")
or_row = next(r for r in payload["providers"] if r["slug"] == "openrouter")
# The reseller keeps ALL of its first-party models — nothing stripped.
assert go_row["models"] == [
"kimi-k2.6", "minimax-m3", "minimax-m2.7", "glm-5",
"deepseek-v4-flash", "qwen3.7-max",
]
assert go_row["total_models"] == 6
# A TRUE routing aggregator is still deduped against the user's models.
assert "minimax-m3" not in or_row["models"]
assert "anthropic/claude-sonnet-4.6" in or_row["models"]
def test_two_custom_providers_with_overlap_both_survive():
"""Two user-defined custom endpoints that happen to expose an
overlapping model must each keep their full catalog. Neither is the

View file

@ -179,9 +179,10 @@ def _patch_judge(monkeypatch, verdicts):
"""Make judge_goal return a scripted sequence of verdicts."""
seq = list(verdicts)
def _fake_judge(goal, response, subgoals=None):
def _fake_judge(goal, response, subgoals=None, background_processes=None, **_kw):
v = seq.pop(0) if seq else "done"
return v, f"scripted:{v}", False
# 4-tuple contract: (verdict, reason, parse_failed, wait_directive)
return v, f"scripted:{v}", False, None
monkeypatch.setattr(goals, "judge_goal", _fake_judge)

View file

@ -129,6 +129,23 @@ def test_is_aggregator_leaves_unknown_provider_non_aggregator():
assert providers_mod.is_aggregator("not-a-provider") is False
def test_is_routing_aggregator_excludes_flat_namespace_resellers():
"""opencode-go / opencode-zen stay ``is_aggregator=True`` (model-switch
relies on it to search their flat bare-name catalog), but they are NOT
routing aggregators their models are first-party, so the picker dedup
must not strip them. (#47077)"""
# Still aggregators for model-switch flat-catalog resolution.
assert providers_mod.is_aggregator("opencode-go") is True
assert providers_mod.is_aggregator("opencode-zen") is True
# But NOT routing aggregators for picker-dedup purposes.
assert providers_mod.is_routing_aggregator("opencode-go") is False
assert providers_mod.is_routing_aggregator("opencode-zen") is False
# True routers and custom proxies remain routing aggregators.
assert providers_mod.is_routing_aggregator("openrouter") is True
assert providers_mod.is_routing_aggregator("custom:litellm") is True
assert providers_mod.is_routing_aggregator("not-a-provider") is False
def test_switch_model_accepts_explicit_named_custom_provider(monkeypatch):
"""Shared /model switch pipeline should accept --provider for custom_providers."""
monkeypatch.setattr(

View file

@ -24,7 +24,7 @@ These tests pin each layer of the new defence:
* ``_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.
refuses user/project-source plugin backend code outright.
* End-to-end the original PoC manifest no longer triggers
``importlib`` for ``/tmp/payload.py``.
"""
@ -216,7 +216,7 @@ class TestDiscoveryScrubsApiField:
assert entry["_api_file"] is None
assert entry["has_api"] is False
def test_safe_api_path_survives(self, user_plugin_factory, tmp_path):
def test_user_safe_api_path_is_scrubbed(self, user_plugin_factory, tmp_path):
user_plugin_factory("safe", {
"name": "safe",
"label": "Safe",
@ -230,6 +230,86 @@ class TestDiscoveryScrubsApiField:
)
plugins = web_server._get_dashboard_plugins(force_rescan=True)
entry = next(p for p in plugins if p["name"] == "safe")
assert entry["_api_file"] is None
assert entry["has_api"] is False
def test_project_safe_api_path_is_scrubbed(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
(tmp_path / "home").mkdir()
monkeypatch.setenv("HERMES_ENABLE_PROJECT_PLUGINS", "1")
cwd = tmp_path / "project"
cwd.mkdir()
monkeypatch.chdir(cwd)
dashboard = _write_plugin_manifest(
cwd / ".hermes" / "plugins",
"safe-project",
{
"name": "safe-project",
"label": "Safe Project",
"api": "api.py",
"entry": "dist/index.js",
},
)
(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-project")
assert entry["_api_file"] is None
assert entry["has_api"] is False
def test_bundled_safe_api_path_survives(self, tmp_path, monkeypatch):
hermes_home = tmp_path / "home"
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
hermes_home.mkdir()
monkeypatch.setenv("HERMES_BUNDLED_PLUGINS", str(tmp_path / "bundled"))
dashboard = _write_plugin_manifest(
tmp_path / "bundled",
"safe-bundled",
{
"name": "safe-bundled",
"label": "Safe Bundled",
"api": "api.py",
"entry": "dist/index.js",
},
)
(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-bundled")
assert entry["_api_file"] == "api.py"
assert entry["has_api"] is True
def test_user_plugin_does_not_shadow_bundled_backend(self, tmp_path, monkeypatch):
hermes_home = tmp_path / "home"
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
hermes_home.mkdir()
monkeypatch.setenv("HERMES_BUNDLED_PLUGINS", str(tmp_path / "bundled"))
bundled_dashboard = _write_plugin_manifest(
tmp_path / "bundled",
"shadowed",
{
"name": "shadowed",
"label": "Bundled Shadowed",
"api": "api.py",
"entry": "dist/index.js",
},
)
(bundled_dashboard / "api.py").write_text("router = None\n")
_write_plugin_manifest(
hermes_home / "plugins",
"shadowed",
{
"name": "shadowed",
"label": "User Shadowed",
"api": "api.py",
"entry": "dist/index.js",
},
)
plugins = web_server._get_dashboard_plugins(force_rescan=True)
entry = next(p for p in plugins if p["name"] == "shadowed")
assert entry["source"] == "bundled"
assert entry["_api_file"] == "api.py"
assert entry["has_api"] is True
@ -276,6 +356,16 @@ class TestMountApiRoutesRefusesUntrusted:
"GHSA-5qr3-c538-wm9j defence-in-depth regression"
)
def test_user_source_api_is_not_imported(self, tmp_path):
plugin = self._payload_plugin(tmp_path, source="user")
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, (
"user-installed plugin api file was imported — "
"third-party dashboard plugin backend code must stay inert"
)
def test_bundled_source_api_imports_normally(self, tmp_path):
plugin = self._payload_plugin(tmp_path, source="bundled")
web_server._dashboard_plugins_cache = [plugin]

View file

@ -1,6 +1,30 @@
"""Tests for Slack CLI helpers."""
import argparse
from hermes_cli.slack_cli import _build_full_manifest
from hermes_cli.subcommands.slack import build_slack_parser
def _parse_slack_args(argv):
"""Build the real `hermes slack` parser and parse argv against it."""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="command")
build_slack_parser(subparsers, cmd_slack=lambda _args: 0)
return parser.parse_args(argv)
class TestSlackManifestArgparse:
"""The `--no-assistant` flag wires through argparse to `no_assistant`."""
def test_no_assistant_flag_defaults_false(self):
args = _parse_slack_args(["slack", "manifest"])
assert getattr(args, "no_assistant", False) is False
def test_no_assistant_flag_sets_true(self):
args = _parse_slack_args(["slack", "manifest", "--no-assistant"])
assert args.no_assistant is True
class TestSlackFullManifest:
@ -28,3 +52,35 @@ class TestSlackFullManifest:
assert "assistant:write" in manifest["oauth_config"]["scopes"]["bot"]
bot_events = manifest["settings"]["event_subscriptions"]["bot_events"]
assert "assistant_thread_started" in bot_events
def test_no_assistant_omits_assistant_pieces(self):
manifest = _build_full_manifest(
"Hermes", "Your Hermes agent on Slack", include_assistant=False
)
# assistant_view feature is gone -> Slack renders a flat DM, not the
# Assistant thread pane (where bare slash commands don't dispatch).
assert "assistant_view" not in manifest["features"]
assert "assistant:write" not in manifest["oauth_config"]["scopes"]["bot"]
bot_events = manifest["settings"]["event_subscriptions"]["bot_events"]
assert "assistant_thread_started" not in bot_events
assert "assistant_thread_context_changed" not in bot_events
def test_no_assistant_preserves_core_surface(self):
"""Dropping assistant mode must NOT strip the regular messaging surface."""
manifest = _build_full_manifest(
"Hermes", "Your Hermes agent on Slack", include_assistant=False
)
# Flat DM still needs the Messages tab writable.
assert manifest["features"]["app_home"]["messages_tab_enabled"] is True
# Slash commands and Socket Mode are independent of assistant mode.
assert manifest["features"]["slash_commands"]
assert manifest["settings"]["socket_mode_enabled"] is True
# Channel + DM scopes/events survive so the bot still works everywhere.
bot_scopes = manifest["oauth_config"]["scopes"]["bot"]
for scope in ("commands", "channels:history", "groups:read", "im:history"):
assert scope in bot_scopes
bot_events = manifest["settings"]["event_subscriptions"]["bot_events"]
for event in ("message.im", "message.channels", "message.groups", "app_mention"):
assert event in bot_events

View file

@ -93,7 +93,8 @@ def test_check_for_updates_expired_cache(tmp_path, monkeypatch):
result = check_for_updates()
assert result == 5
assert mock_run.call_count == 3 # origin probe + git fetch + git rev-list
# origin probe + is-shallow probe + git fetch + git rev-list
assert mock_run.call_count == 4
def test_check_for_updates_official_ssh_origin_uses_https_probe(tmp_path):
@ -128,6 +129,99 @@ def test_check_for_updates_official_ssh_origin_uses_https_probe(tmp_path):
assert ["git", "fetch", "origin", "--quiet"] not in calls
def test_check_via_local_git_shallow_clone_behind_reports_no_count(tmp_path):
"""Shallow installer clones must report presence-only, never a bogus count.
On a ``git clone --depth 1`` checkout the history stops at one commit, so
counting ``HEAD..origin/main`` across the shallow boundary yields a huge
nonsense number (the "12492 commits behind" banner). The shallow path must
compare tip SHAs and return UPDATE_AVAILABLE_NO_COUNT instead, and must
never run ``git rev-list --count``.
"""
import hermes_cli.banner as banner
repo_dir = tmp_path / "hermes-agent"
repo_dir.mkdir()
(repo_dir / ".git").mkdir()
calls = []
def fake_run(cmd, **kwargs):
calls.append(cmd)
if cmd == ["git", "remote", "get-url", "origin"]:
return MagicMock(returncode=0, stdout="https://github.com/NousResearch/hermes-agent.git\n")
if cmd == ["git", "rev-parse", "--is-shallow-repository"]:
return MagicMock(returncode=0, stdout="true\n")
if cmd[:2] == ["git", "fetch"]:
return MagicMock(returncode=0, stdout="")
if cmd == ["git", "rev-parse", "HEAD"]:
return MagicMock(returncode=0, stdout="local-sha\n")
if cmd == ["git", "rev-parse", "FETCH_HEAD"]:
return MagicMock(returncode=0, stdout="upstream-sha\n")
if cmd[:3] == ["git", "rev-list", "--count"]:
raise AssertionError("shallow path must not count across the boundary")
raise AssertionError(f"unexpected git command: {cmd!r}")
with patch("hermes_cli.banner.subprocess.run", side_effect=fake_run):
result = banner._check_via_local_git(repo_dir)
assert result == banner.UPDATE_AVAILABLE_NO_COUNT
# The shallow fetch must preserve the boundary (--depth 1), not unshallow.
assert ["git", "fetch", "origin", "--depth", "1", "--quiet"] in calls
def test_check_via_local_git_shallow_clone_up_to_date(tmp_path):
"""Shallow clone whose tip matches upstream reports up-to-date (0)."""
import hermes_cli.banner as banner
repo_dir = tmp_path / "hermes-agent"
repo_dir.mkdir()
(repo_dir / ".git").mkdir()
def fake_run(cmd, **kwargs):
if cmd == ["git", "remote", "get-url", "origin"]:
return MagicMock(returncode=0, stdout="https://github.com/NousResearch/hermes-agent.git\n")
if cmd == ["git", "rev-parse", "--is-shallow-repository"]:
return MagicMock(returncode=0, stdout="true\n")
if cmd[:2] == ["git", "fetch"]:
return MagicMock(returncode=0, stdout="")
if cmd == ["git", "rev-parse", "HEAD"]:
return MagicMock(returncode=0, stdout="same-sha\n")
if cmd == ["git", "rev-parse", "FETCH_HEAD"]:
return MagicMock(returncode=0, stdout="same-sha\n")
raise AssertionError(f"unexpected git command: {cmd!r}")
with patch("hermes_cli.banner.subprocess.run", side_effect=fake_run):
result = banner._check_via_local_git(repo_dir)
assert result == 0
def test_check_via_local_git_full_clone_keeps_exact_count(tmp_path):
"""Full (non-shallow) clones keep the exact rev-list count path."""
import hermes_cli.banner as banner
repo_dir = tmp_path / "hermes-agent"
repo_dir.mkdir()
(repo_dir / ".git").mkdir()
def fake_run(cmd, **kwargs):
if cmd == ["git", "remote", "get-url", "origin"]:
return MagicMock(returncode=0, stdout="https://github.com/NousResearch/hermes-agent.git\n")
if cmd == ["git", "rev-parse", "--is-shallow-repository"]:
return MagicMock(returncode=0, stdout="false\n")
if cmd[:2] == ["git", "fetch"]:
return MagicMock(returncode=0, stdout="")
if cmd[:3] == ["git", "rev-list", "--count"]:
return MagicMock(returncode=0, stdout="7\n")
raise AssertionError(f"unexpected git command: {cmd!r}")
with patch("hermes_cli.banner.subprocess.run", side_effect=fake_run):
result = banner._check_via_local_git(repo_dir)
assert result == 7
def test_check_for_updates_no_git_dir(tmp_path, monkeypatch):
"""Falls back to PyPI check when .git directory doesn't exist anywhere."""
import hermes_cli.banner as banner

View file

@ -597,6 +597,120 @@ def test_resume_windows_gateways_after_update_respawns_unmapped_by_cmdline(
assert "Restarting 1 unmapped Windows gateway process(es)" in out
@patch.object(cli_main, "_is_windows", return_value=True)
def test_pause_returns_cold_start_token_when_installed_but_none_running(
_winp,
monkeypatch,
):
"""No gateway running + autostart entry installed → cold-start token.
A gateway that died between updates (spawning terminal/TUI closed) leaves
nothing for the resume path to relaunch, but the installed autostart entry
is an explicit "I want a gateway" signal. The pause step must return a
token that tells resume to cold-start one.
"""
import hermes_cli.gateway as gateway_mod
from hermes_cli import gateway_windows
monkeypatch.setattr(gateway_mod, "find_gateway_pids", lambda **_k: [])
monkeypatch.setattr(gateway_windows, "is_installed", lambda: True)
token = cli_main._pause_windows_gateways_for_update()
assert token == {
"resume_needed": True,
"profiles": {},
"unmapped_pids": [],
"unmapped": [],
"cold_start_if_installed": True,
}
@patch.object(cli_main, "_is_windows", return_value=True)
def test_pause_returns_none_when_nothing_running_and_not_installed(
_winp,
monkeypatch,
):
"""No gateway running + no autostart entry → no token (gateway-less user).
Users who deliberately run without a gateway must not get one forced on
them by an update.
"""
import hermes_cli.gateway as gateway_mod
from hermes_cli import gateway_windows
monkeypatch.setattr(gateway_mod, "find_gateway_pids", lambda **_k: [])
monkeypatch.setattr(gateway_windows, "is_installed", lambda: False)
assert cli_main._pause_windows_gateways_for_update() is None
@patch.object(cli_main, "_is_windows", return_value=True)
def test_resume_cold_starts_gateway_when_token_requests_it(
_winp,
monkeypatch,
capsys,
):
"""cold_start_if_installed token + nothing running → fresh detached spawn."""
import hermes_cli.gateway as gateway_mod
from hermes_cli import gateway_windows
monkeypatch.setattr(gateway_mod, "find_gateway_pids", lambda **_k: [])
spawned = []
monkeypatch.setattr(
gateway_windows,
"_spawn_detached",
lambda: spawned.append(True) or 4242,
)
token = {
"resume_needed": True,
"profiles": {},
"unmapped_pids": [],
"unmapped": [],
"cold_start_if_installed": True,
}
cli_main._resume_windows_gateways_after_update(token)
assert token["resume_needed"] is False
assert spawned == [True]
assert "Starting Windows gateway after update (PID 4242)" in capsys.readouterr().out
@patch.object(cli_main, "_is_windows", return_value=True)
def test_resume_cold_start_skips_when_gateway_already_running(
_winp,
monkeypatch,
capsys,
):
"""Don't double-start: if a gateway came up between pause and resume
(e.g. the autostart entry fired), the cold-start must no-op."""
import hermes_cli.gateway as gateway_mod
from hermes_cli import gateway_windows
monkeypatch.setattr(gateway_mod, "find_gateway_pids", lambda **_k: [9001])
spawned = []
monkeypatch.setattr(
gateway_windows,
"_spawn_detached",
lambda: spawned.append(True) or 4242,
)
token = {
"resume_needed": True,
"profiles": {},
"unmapped_pids": [],
"unmapped": [],
"cold_start_if_installed": True,
}
cli_main._resume_windows_gateways_after_update(token)
assert spawned == []
assert "Starting Windows gateway after update" not in capsys.readouterr().out
# ---------------------------------------------------------------------------
# cmd_update integration — concurrent-instance gate
# ---------------------------------------------------------------------------

View file

@ -263,6 +263,29 @@ class TestWebServerEndpoints:
import hermes_cli.web_server as web_server
monkeypatch.setattr(hermes_constants, "is_container", lambda: True)
# A docker install inside a container should be managed externally.
monkeypatch.setattr(web_server, "detect_install_method", lambda _root: "docker")
assert web_server._dashboard_local_update_managed_externally() is True
def test_dashboard_update_capability_allows_git_in_container(self, monkeypatch):
"""A git checkout inside a container (e.g. bind-mounted in hermes-webui)
should still offer dashboard updates the checkout is self-managed."""
import hermes_constants
import hermes_cli.web_server as web_server
monkeypatch.setattr(hermes_constants, "is_container", lambda: True)
monkeypatch.setattr(web_server, "detect_install_method", lambda _root: "git")
assert web_server._dashboard_local_update_managed_externally() is False
def test_dashboard_update_capability_blocks_pip_in_container(self, monkeypatch):
"""A pip install inside a container is still managed externally."""
import hermes_constants
import hermes_cli.web_server as web_server
monkeypatch.setattr(hermes_constants, "is_container", lambda: True)
monkeypatch.setattr(web_server, "detect_install_method", lambda _root: "pip")
assert web_server._dashboard_local_update_managed_externally() is True
@ -1011,6 +1034,8 @@ class TestWebServerEndpoints:
spawned = True
raise AssertionError("docker update guard should not spawn hermes update")
# Bypass the managed-externally gate so we reach the docker install check.
monkeypatch.setattr(web_server, "_dashboard_local_update_managed_externally", lambda: False)
monkeypatch.setattr(web_server, "detect_install_method", lambda _root: "docker")
monkeypatch.setattr(web_server, "_spawn_hermes_action", fail_spawn)
web_server._ACTION_PROCS.pop("hermes-update", None)
@ -5070,14 +5095,8 @@ class TestPluginAPIAuth:
"""Tests that plugin API routes require the session token (issue #19533)."""
@pytest.fixture(autouse=True)
def _setup_test_client(self, monkeypatch, _isolate_hermes_home, _install_example_plugin):
"""Create a TestClient without the session token header.
Pulls in ``_install_example_plugin`` so ``test_plugin_route_allows_auth``
has the ``/api/plugins/example/hello`` endpoint available the
example plugin is no longer a bundled plugin, so the fixture
installs it into the per-test ``HERMES_HOME``.
"""
def _setup_test_client(self, monkeypatch, _isolate_hermes_home):
"""Create TestClients with and without the session token header."""
try:
from starlette.testclient import TestClient
except ImportError:
@ -5102,19 +5121,15 @@ class TestPluginAPIAuth:
def test_plugin_route_allows_auth(self):
"""Plugin API routes should work with a valid session token.
Uses ``/api/plugins/example/hello`` from the example-dashboard
test fixture (installed into HERMES_HOME by the class-level
``_install_example_plugin`` fixture) a stable, side-effect-free
GET that's only loaded for tests. With a valid token the handler
should run (200); without one the middleware should 401 before
the handler is reached.
Uses a bundled plugin route so the test covers authenticated plugin
API access without relying on user-installed plugin backend imports.
"""
# Without auth: middleware blocks before reaching the handler.
resp = self.client.get("/api/plugins/example/hello")
resp = self.client.get("/api/plugins/kanban/board")
assert resp.status_code == 401
# With auth: handler runs.
resp = self.auth_client.get("/api/plugins/example/hello")
resp = self.auth_client.get("/api/plugins/kanban/board")
assert resp.status_code == 200
def test_plugin_post_requires_auth(self):