Merge remote-tracking branch 'origin/main' into hermes/hermes-6b48295e

This commit is contained in:
Teknium 2026-06-11 07:38:25 -07:00
commit 2ecb4e62bb
No known key found for this signature in database
239 changed files with 18356 additions and 2494 deletions

View file

@ -146,6 +146,12 @@ class TestShouldExclude:
from hermes_cli.backup import _should_exclude
assert not _should_exclude(Path("logs/agent.log"))
def test_includes_nested_hermes_agent_in_skills(self):
"""skills/autonomous-ai-agents/hermes-agent/ must NOT be excluded —
only the root-level hermes-agent/ repo is skipped."""
from hermes_cli.backup import _should_exclude
assert not _should_exclude(Path("skills/autonomous-ai-agents/hermes-agent/SKILL.md"))
assert not _should_exclude(Path("skills/autonomous-ai-agents/hermes-agent/sub/item.txt"))
# ---------------------------------------------------------------------------
# Backup tests
@ -186,6 +192,66 @@ class TestBackup:
# Skins
assert "skins/cyber.yaml" in names
def test_db_snapshots_staged_beside_output_zip(self, tmp_path, monkeypatch):
"""SQLite staging temp files must be created on the output zip's
filesystem (dir=out_path.parent), NOT the system /tmp default a
small tmpfs there silently drops large DBs from the backup (#35376)."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
_make_hermes_tree(hermes_home)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
out_dir = tmp_path / "external-drive"
out_dir.mkdir()
out_zip = out_dir / "backup.zip"
args = Namespace(output=str(out_zip))
import hermes_cli.backup as backup_mod
staged_dirs = []
real_ntf = backup_mod.tempfile.NamedTemporaryFile
def _spy(*a, **kw):
staged_dirs.append(kw.get("dir"))
return real_ntf(*a, **kw)
monkeypatch.setattr(backup_mod.tempfile, "NamedTemporaryFile", _spy)
backup_mod.run_backup(args)
# At least one .db was staged, and every staging call targeted the
# output zip's directory rather than the system temp default.
assert staged_dirs, "no SQLite snapshot was staged"
assert all(d == str(out_dir) for d in staged_dirs), staged_dirs
def test_pre_update_db_snapshots_staged_beside_output_zip(self, tmp_path, monkeypatch):
"""The pre-update/pre-migration zip path (_write_full_zip_backup) must
also stage SQLite snapshots beside its output zip, not in /tmp."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
_make_hermes_tree(hermes_home)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
out_zip = hermes_home / "backups" / "pre-update-test.zip"
out_zip.parent.mkdir(parents=True, exist_ok=True)
import hermes_cli.backup as backup_mod
staged_dirs = []
real_ntf = backup_mod.tempfile.NamedTemporaryFile
def _spy(*a, **kw):
staged_dirs.append(kw.get("dir"))
return real_ntf(*a, **kw)
monkeypatch.setattr(backup_mod.tempfile, "NamedTemporaryFile", _spy)
result = backup_mod._write_full_zip_backup(out_zip, hermes_home)
assert result is not None
assert staged_dirs, "no SQLite snapshot was staged"
assert all(d == str(out_zip.parent) for d in staged_dirs), staged_dirs
def test_excludes_hermes_agent(self, tmp_path, monkeypatch):
"""Backup does NOT include hermes-agent/ directory."""
hermes_home = tmp_path / ".hermes"
@ -206,6 +272,37 @@ class TestBackup:
agent_files = [n for n in names if "hermes-agent" in n]
assert agent_files == [], f"hermes-agent files leaked into backup: {agent_files}"
def test_includes_nested_hermes_agent_in_skills(self, tmp_path, monkeypatch):
"""Backup includes skills/.../hermes-agent/ but NOT root hermes-agent/."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
_make_hermes_tree(hermes_home)
# Add a nested hermes-agent directory inside skills (like the real layout)
nested = hermes_home / "skills" / "autonomous-ai-agents" / "hermes-agent"
nested.mkdir(parents=True)
(nested / "SKILL.md").write_text("# Hermes Agent Skill\n")
(nested / "sub").mkdir()
(nested / "sub" / "item.txt").write_text("nested content\n")
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()
# Root hermes-agent must be excluded
root_agent = [n for n in names if n.startswith("hermes-agent/")]
assert root_agent == [], f"root hermes-agent leaked: {root_agent}"
# Nested skill hermes-agent must be included
assert "skills/autonomous-ai-agents/hermes-agent/SKILL.md" in names
assert "skills/autonomous-ai-agents/hermes-agent/sub/item.txt" in names
def test_excludes_pycache(self, tmp_path, monkeypatch):
"""Backup does NOT include __pycache__ dirs."""
hermes_home = tmp_path / ".hermes"

View file

@ -691,6 +691,169 @@ class TestSubcommandCompletion:
completions = _completions(SlashCommandCompleter(), "/help ")
assert completions == []
def test_tools_subcommand_completion(self):
"""`/tools ` should suggest list, disable, enable."""
completions = _completions(SlashCommandCompleter(), "/tools ")
texts = {c.text for c in completions}
assert texts == {"list", "disable", "enable"}
def test_tools_subcommand_prefix_filters(self):
completions = _completions(SlashCommandCompleter(), "/tools en")
texts = {c.text for c in completions}
assert texts == {"enable"}
def test_tools_enable_completes_toolset_names(self, monkeypatch):
"""`/tools enable ` should suggest currently-disabled toolsets."""
from hermes_cli import commands as commands_mod
# `web` is enabled, `spotify` is disabled — enabling should only offer
# the disabled ones.
monkeypatch.setattr(
"hermes_cli.tools_config._get_platform_tools",
lambda *_a, **_k: {"web", "file"},
)
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {})
monkeypatch.setattr(
"hermes_cli.tools_config._get_plugin_toolset_keys",
lambda: set(),
)
completions = _completions(SlashCommandCompleter(), "/tools enable ")
texts = {c.text for c in completions}
# Should include disabled toolsets, exclude already-enabled ones.
assert "web" not in texts
assert "file" not in texts
assert "spotify" in texts
def test_tools_disable_completes_enabled_toolsets_only(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.tools_config._get_platform_tools",
lambda *_a, **_k: {"web", "file"},
)
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {})
monkeypatch.setattr(
"hermes_cli.tools_config._get_plugin_toolset_keys",
lambda: set(),
)
completions = _completions(SlashCommandCompleter(), "/tools disable ")
texts = {c.text for c in completions}
# Should include enabled toolsets, exclude disabled ones.
assert texts == {"web", "file"}
def test_tools_enable_partial_filters(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.tools_config._get_platform_tools",
lambda *_a, **_k: set(),
)
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {})
monkeypatch.setattr(
"hermes_cli.tools_config._get_plugin_toolset_keys",
lambda: set(),
)
completions = _completions(SlashCommandCompleter(), "/tools enable sp")
texts = {c.text for c in completions}
assert texts == {"spotify"}
def test_tools_enable_skips_already_listed(self, monkeypatch):
"""If the user already typed a name, don't suggest it again."""
monkeypatch.setattr(
"hermes_cli.tools_config._get_platform_tools",
lambda *_a, **_k: set(),
)
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {})
monkeypatch.setattr(
"hermes_cli.tools_config._get_plugin_toolset_keys",
lambda: set(),
)
completions = _completions(SlashCommandCompleter(), "/tools enable spotify ")
texts = {c.text for c in completions}
assert "spotify" not in texts
def test_tools_suggests_mcp_server_prefixes(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.tools_config._get_platform_tools",
lambda *_a, **_k: set(),
)
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"mcp_servers": {"github": {}, "linear": {}}},
)
monkeypatch.setattr(
"hermes_cli.tools_config._get_plugin_toolset_keys",
lambda: set(),
)
completions = _completions(SlashCommandCompleter(), "/tools enable git")
texts = {c.text for c in completions}
assert "github:" in texts
def _fake_gateway(self, monkeypatch, platforms):
"""Patch load_gateway_config with a fake whose connected platforms are
the keys of `platforms` (name -> home as None or a (chat_id, name) tuple).
"""
from types import SimpleNamespace
enums = {name: SimpleNamespace(value=name) for name in platforms}
homes = {
name: (None if home is None else SimpleNamespace(chat_id=home[0], name=home[1]))
for name, home in platforms.items()
}
fake = SimpleNamespace(
get_connected_platforms=lambda: list(enums.values()),
get_home_channel=lambda p: homes[p.value],
)
monkeypatch.setattr("gateway.config.load_gateway_config", lambda: fake)
def test_handoff_completes_connected_platforms(self, monkeypatch):
"""`/handoff ` offers connected platforms, with or without a home channel."""
self._fake_gateway(
monkeypatch,
{
"telegram": ("123", "Me"),
"discord": None, # no home channel yet -> still listed
},
)
texts = {c.text for c in _completions(SlashCommandCompleter(), "/handoff ")}
assert texts == {"telegram", "discord"}
def test_handoff_filters_by_prefix(self, monkeypatch):
self._fake_gateway(
monkeypatch,
{
"telegram": ("1", "H"),
"signal": ("2", "H"),
},
)
texts = {c.text for c in _completions(SlashCommandCompleter(), "/handoff te")}
assert texts == {"telegram"}
def test_handoff_no_completion_after_platform_chosen(self, monkeypatch):
self._fake_gateway(monkeypatch, {"telegram": ("1", "H")})
assert _completions(SlashCommandCompleter(), "/handoff telegram ") == []
def test_handoff_completion_swallows_config_errors(self, monkeypatch):
def _boom():
raise RuntimeError("no gateway config")
monkeypatch.setattr("gateway.config.load_gateway_config", _boom)
assert _completions(SlashCommandCompleter(), "/handoff ") == []
def test_personality_completes_configured_personalities(self):
"""`/personality ` lists real personalities, not just `none`.
Regression: the completer read load_config().agent.personalities, a path
that never exists, so it always came back empty. It must resolve from the
CLI config the runtime actually applies (which ships built-ins).
"""
texts = {c.text for c in _completions(SlashCommandCompleter(), "/personality ")}
assert "none" in texts
assert len(texts) > 1
# ── Ghost text (SlashCommandAutoSuggest) ────────────────────────────────

View file

@ -55,7 +55,6 @@ class TestCronCommandLifecycle:
repeat=None,
skill=None,
skills=["maps", "blogwatcher"],
profile="default",
clear_skills=False,
)
)
@ -64,7 +63,6 @@ class TestCronCommandLifecycle:
assert updated["name"] == "Edited Job"
assert updated["prompt"] == "Revised prompt"
assert updated["schedule_display"] == "every 120m"
assert updated["profile"] == "default"
cron_command(
Namespace(
@ -77,14 +75,12 @@ class TestCronCommandLifecycle:
repeat=None,
skill=None,
skills=None,
profile="",
clear_skills=True,
)
)
cleared = get_job(job["id"])
assert cleared["skills"] == []
assert cleared["skill"] is None
assert cleared["profile"] is None
out = capsys.readouterr().out
assert "Updated job" in out
@ -100,7 +96,6 @@ class TestCronCommandLifecycle:
repeat=None,
skill=None,
skills=["blogwatcher", "maps"],
profile="default",
)
)
out = capsys.readouterr().out
@ -110,7 +105,6 @@ class TestCronCommandLifecycle:
assert len(jobs) == 1
assert jobs[0]["skills"] == ["blogwatcher", "maps"]
assert jobs[0]["name"] == "Skill combo"
assert jobs[0]["profile"] == "default"
def test_list_does_not_crash_when_repeat_is_null(self, tmp_cron_dir, capsys):
"""A one-shot job can be persisted with ``"repeat": null``. `cron

View file

@ -47,20 +47,19 @@ def test_cron_aliases():
def test_cron_create_options():
parser = _build()
ns = parser.parse_args([
"cron", "create", "0 9 * * *", "do the thing",
"cron", "create", "0 9 * * *", "daily task prompt",
"--name", "daily", "--deliver", "origin", "--repeat", "3",
"--skill", "a", "--skill", "b", "--no-agent",
"--workdir", "/tmp/x", "--profile", "work",
"--workdir", "/tmp/x",
])
assert ns.schedule == "0 9 * * *"
assert ns.prompt == "do the thing"
assert ns.prompt == "daily task prompt"
assert ns.name == "daily"
assert ns.deliver == "origin"
assert ns.repeat == 3
assert ns.skills == ["a", "b"]
assert ns.no_agent is True
assert ns.workdir == "/tmp/x"
assert ns.profile == "work"
def test_cron_edit_no_agent_tristate():

View file

@ -201,6 +201,91 @@ class TestWebhookEndpoints:
r = self.client.post("/api/webhooks", json={"name": "gh", "deliver": "log"})
assert r.status_code == 400
def test_enable_platform_starts_gateway_restart(self, monkeypatch):
import hermes_cli.web_server as ws
from hermes_cli.config import load_config
ws._ACTION_PROCS.pop("gateway-restart", None)
restart_calls = []
class FakeRestartProc:
pid = 4242
def fake_spawn_action(subcommand, name):
restart_calls.append((subcommand, name))
return FakeRestartProc()
monkeypatch.setattr(ws, "_spawn_hermes_action", fake_spawn_action)
r = self.client.post("/api/webhooks/enable")
assert r.status_code == 200
assert r.json() == {
"ok": True,
"platform": "webhook",
"enabled": True,
"needs_restart": False,
"restart_started": True,
"restart_action": "gateway-restart",
"restart_pid": 4242,
}
assert restart_calls == [(["gateway", "restart"], "gateway-restart")]
assert load_config()["platforms"]["webhook"]["enabled"] is True
assert self.client.get("/api/webhooks").json()["enabled"] is True
def test_enable_platform_reports_restart_failure_after_save(self, monkeypatch):
import hermes_cli.web_server as ws
from hermes_cli.config import load_config
ws._ACTION_PROCS.pop("gateway-restart", None)
def fail_spawn_action(subcommand, name):
assert subcommand == ["gateway", "restart"]
assert name == "gateway-restart"
raise RuntimeError("supervisor unavailable")
monkeypatch.setattr(ws, "_spawn_hermes_action", fail_spawn_action)
r = self.client.post("/api/webhooks/enable")
assert r.status_code == 200
data = r.json()
assert data["ok"] is True
assert data["platform"] == "webhook"
assert data["enabled"] is True
assert data["needs_restart"] is True
assert data["restart_started"] is False
assert "supervisor unavailable" in data["restart_error"]
assert load_config()["platforms"]["webhook"]["enabled"] is True
def test_enable_platform_reuses_inflight_gateway_restart(self, monkeypatch):
import hermes_cli.web_server as ws
from hermes_cli.config import load_config
ws._ACTION_PROCS.pop("gateway-restart", None)
class FakeRunningProc:
pid = 5151
def poll(self):
return None
monkeypatch.setitem(ws._ACTION_PROCS, "gateway-restart", FakeRunningProc())
def fail_spawn_action(subcommand, name):
raise AssertionError("must not spawn a second concurrent restart")
monkeypatch.setattr(ws, "_spawn_hermes_action", fail_spawn_action)
r = self.client.post("/api/webhooks/enable")
assert r.status_code == 200
data = r.json()
assert data["needs_restart"] is False
assert data["restart_started"] is True
assert data["restart_pid"] == 5151
assert load_config()["platforms"]["webhook"]["enabled"] is True
class TestOpsEndpoints:
@pytest.fixture(autouse=True)
@ -622,6 +707,10 @@ class TestAdminEndpointsAuthGate:
resp = self.client.get(path)
assert resp.status_code in (401, 403)
def test_webhooks_enable_post_gated(self):
resp = self.client.post("/api/webhooks/enable")
assert resp.status_code in (401, 403)
class TestUpdateCheckEndpoint:
"""``GET /api/hermes/update/check`` reports availability without applying.
@ -953,4 +1042,3 @@ class TestToolsConfigEndpoints:
kwargs["json"] = payload
r = fn(path, **kwargs)
assert r.status_code == 401, f"{method} {path} not gated"

View file

@ -0,0 +1,130 @@
"""Tests for the unified profile→machine dashboard launch routing.
`<profile> dashboard` routes to ONE machine-level dashboard instead of
spawning a per-profile server: attach (open browser at ?profile=) when one
is already listening, else re-exec as the machine dashboard with the
launching profile preselected. `--isolated` opts out.
"""
import sys
import types
import pytest
@pytest.fixture
def main_mod():
import hermes_cli.main as main_mod
return main_mod
def _args(**kw):
defaults = dict(
status=False, stop=False, host="127.0.0.1", port=9119,
no_open=True, insecure=False, skip_build=False,
isolated=False, open_profile="",
)
defaults.update(kw)
return types.SimpleNamespace(**defaults)
class TestUnifiedDashboardRouting:
def test_profile_launch_attaches_to_running_dashboard(self, main_mod, monkeypatch):
monkeypatch.setattr(
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
)
monkeypatch.setattr(main_mod, "_dashboard_listening", lambda host, port: True)
execs = []
monkeypatch.setattr(main_mod.os, "execvpe", lambda *a, **k: execs.append(a))
with pytest.raises(SystemExit) as exc:
main_mod.cmd_dashboard(_args())
assert exc.value.code == 0
assert execs == [] # attached, never re-exec'd
def test_profile_launch_attach_opens_scoped_url(self, main_mod, monkeypatch):
"""The attach path must open the browser at ?profile=<name> — that
URL is the entire point of attaching (preselects the switcher)."""
monkeypatch.setattr(
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
)
monkeypatch.setattr(main_mod, "_dashboard_listening", lambda host, port: True)
opened = []
import webbrowser
monkeypatch.setattr(webbrowser, "open", lambda url: opened.append(url))
with pytest.raises(SystemExit) as exc:
main_mod.cmd_dashboard(_args(no_open=False))
assert exc.value.code == 0
assert opened == ["http://127.0.0.1:9119/?profile=worker_x"]
def test_profile_launch_reexecs_machine_dashboard(self, main_mod, monkeypatch):
monkeypatch.setattr(
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
)
monkeypatch.setattr(main_mod, "_dashboard_listening", lambda host, port: False)
execs = []
def fake_exec(exe, argv, env):
execs.append((exe, argv, env))
raise SystemExit(0) # execvpe never returns
monkeypatch.setattr(main_mod.os, "execvpe", fake_exec)
with pytest.raises(SystemExit):
main_mod.cmd_dashboard(_args())
assert len(execs) == 1
exe, argv, env = execs[0]
assert exe == sys.executable
# Pinned to the default profile + launching profile preselected.
assert "-p" in argv and argv[argv.index("-p") + 1] == "default"
assert "--open-profile" in argv
assert argv[argv.index("--open-profile") + 1] == "worker_x"
# Profile HERMES_HOME dropped so the child binds the machine root.
assert "HERMES_HOME" not in env
def test_isolated_flag_skips_routing(self, main_mod, monkeypatch):
monkeypatch.setattr(
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
)
listening_calls = []
monkeypatch.setattr(
main_mod, "_dashboard_listening",
lambda host, port: listening_calls.append(1) or True,
)
# With --isolated the routing block is skipped entirely; the command
# proceeds to dependency checks. Make the first post-routing step
# bail so the test doesn't actually start a server.
monkeypatch.setitem(sys.modules, "fastapi", None)
with pytest.raises((SystemExit, AttributeError, ImportError, TypeError)):
main_mod.cmd_dashboard(_args(isolated=True))
assert listening_calls == []
def test_default_profile_launch_skips_routing(self, main_mod, monkeypatch):
monkeypatch.setattr(
"hermes_cli.profiles.get_active_profile_name", lambda: "default"
)
listening_calls = []
monkeypatch.setattr(
main_mod, "_dashboard_listening",
lambda host, port: listening_calls.append(1) or True,
)
monkeypatch.setitem(sys.modules, "fastapi", None)
with pytest.raises((SystemExit, AttributeError, ImportError, TypeError)):
main_mod.cmd_dashboard(_args())
assert listening_calls == []
def test_reexec_child_does_not_reroute(self, main_mod, monkeypatch):
"""The re-exec'd child carries --open-profile; the guard must treat
that as 'already routed' and never re-exec again (no exec loop)."""
monkeypatch.setattr(
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
)
execs = []
monkeypatch.setattr(main_mod.os, "execvpe", lambda *a, **k: execs.append(a))
monkeypatch.setitem(sys.modules, "fastapi", None)
with pytest.raises((SystemExit, AttributeError, ImportError, TypeError)):
main_mod.cmd_dashboard(_args(open_profile="worker_x"))
assert execs == []

View file

@ -369,6 +369,16 @@ def test_systemd_install_checks_linger_status(monkeypatch, tmp_path, capsys):
unit_path = tmp_path / "systemd" / "user" / "hermes-gateway.service"
monkeypatch.setattr(gateway, "get_systemd_unit_path", lambda system=False: unit_path)
# Synthetic unit with a non-temp home: the real generator bakes the
# hermetic test HERMES_HOME (a tmp dir), which the temp-home write
# guard correctly refuses.
monkeypatch.setattr(
gateway,
"generate_systemd_unit",
lambda system=False, run_as_user=None: (
'[Service]\nEnvironment="HERMES_HOME=/home/alice/.hermes"\n'
),
)
calls = []
helper_calls = []
@ -396,6 +406,15 @@ def test_systemd_install_can_skip_enable_on_startup(monkeypatch, tmp_path, capsy
unit_path = tmp_path / "systemd" / "user" / "hermes-gateway.service"
monkeypatch.setattr(gateway, "get_systemd_unit_path", lambda system=False: unit_path)
# Non-temp home so the temp-home write guard (which trips on the
# hermetic test HERMES_HOME) stays out of the way.
monkeypatch.setattr(
gateway,
"generate_systemd_unit",
lambda system=False, run_as_user=None: (
'[Service]\nEnvironment="HERMES_HOME=/home/alice/.hermes"\n'
),
)
calls = []
helper_calls = []

View file

@ -102,6 +102,15 @@ def test_systemd_install_calls_linger_helper(monkeypatch, tmp_path, capsys):
unit_path = tmp_path / "systemd" / "user" / "hermes-gateway.service"
monkeypatch.setattr(gateway, "get_systemd_unit_path", lambda system=False: unit_path)
# Non-temp home so the temp-home write guard (which trips on the
# hermetic test HERMES_HOME) stays out of the way.
monkeypatch.setattr(
gateway,
"generate_systemd_unit",
lambda system=False, run_as_user=None: (
'[Service]\nEnvironment="HERMES_HOME=/home/alice/.hermes"\n'
),
)
calls = []

View file

@ -289,6 +289,105 @@ class TestSystemdServiceRefresh:
"daemon-reload" in str(c) for c in ran
), "daemon-reload must not run when write was refused"
def test_refresh_refuses_to_bake_any_tempdir_home_into_real_user_unit(
self, tmp_path, monkeypatch
):
"""Structural guard: a manual E2E HERMES_HOME like
``/tmp/hermes-e2e-41264`` carries none of the pytest markers but
poisons the unit identically (seen live 2026-06-11 an E2E probe ran
``hermes gateway restart`` with a /tmp HERMES_HOME exported; the
restart's unit refresh baked it into the production unit and the
post-update restart produced a 7-hour zombie gateway). The refresh
must refuse ANY temp-dir HERMES_HOME, not just pytest-shaped ones.
"""
unit_path = tmp_path / "hermes-gateway.service"
unit_path.write_text("old unit\n", encoding="utf-8")
monkeypatch.setattr(
gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path
)
polluted_unit = (
"[Service]\n"
'Environment="HERMES_HOME=/tmp/hermes-e2e-41264"\n'
"WorkingDirectory=/tmp/hermes-e2e-41264\n"
)
monkeypatch.setattr(
gateway_cli,
"generate_systemd_unit",
lambda system=False, run_as_user=None: polluted_unit,
)
ran = []
def fake_run(cmd, check=True, **kwargs):
ran.append(cmd)
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
result = gateway_cli.refresh_systemd_unit_if_needed(system=False)
assert result is False, "refresh should refuse to write a temp-home unit"
assert (
unit_path.read_text(encoding="utf-8") == "old unit\n"
), "installed unit must be left untouched"
assert not any(
"daemon-reload" in str(c) for c in ran
), "daemon-reload must not run when write was refused"
class TestTempHomeServiceDefinitionGuard:
"""_temp_home_in_service_definition() — structural temp-dir detection."""
def test_detects_tmp_home_in_systemd_unit(self):
unit = '[Service]\nEnvironment="HERMES_HOME=/tmp/hermes-e2e-41264"\n'
assert (
gateway_cli._temp_home_in_service_definition(unit)
== "/tmp/hermes-e2e-41264"
)
def test_detects_var_tmp_home(self):
unit = '[Service]\nEnvironment="HERMES_HOME=/var/tmp/hermes-x"\n'
assert gateway_cli._temp_home_in_service_definition(unit) is not None
def test_detects_tempdir_env_home(self, monkeypatch, tmp_path):
import tempfile as _tempfile
monkeypatch.setattr(_tempfile, "gettempdir", lambda: str(tmp_path))
unit = f'[Service]\nEnvironment="HERMES_HOME={tmp_path}/hermes-home"\n'
assert gateway_cli._temp_home_in_service_definition(unit) is not None
def test_detects_tmp_home_in_launchd_plist(self):
plist = (
"<dict>\n <key>HERMES_HOME</key>\n"
" <string>/tmp/hermes-e2e-99999</string>\n</dict>\n"
)
assert (
gateway_cli._temp_home_in_service_definition(plist)
== "/tmp/hermes-e2e-99999"
)
def test_accepts_real_home(self):
unit = '[Service]\nEnvironment="HERMES_HOME=/home/alice/.hermes"\n'
assert gateway_cli._temp_home_in_service_definition(unit) is None
def test_accepts_macos_real_home_plist(self):
plist = (
"<dict>\n <key>HERMES_HOME</key>\n"
" <string>/Users/alice/.hermes</string>\n</dict>\n"
)
assert gateway_cli._temp_home_in_service_definition(plist) is None
def test_accepts_unit_without_hermes_home(self):
unit = "[Service]\nExecStart=/usr/bin/python -m hermes_cli.main gateway run\n"
assert gateway_cli._temp_home_in_service_definition(unit) is None
def test_tmp_prefixed_non_temp_path_is_accepted(self):
# /tmpfs-data is NOT under /tmp — prefix matching must be
# component-wise, not string startswith.
unit = '[Service]\nEnvironment="HERMES_HOME=/tmpfs-data/.hermes"\n'
assert gateway_cli._temp_home_in_service_definition(unit) is None
class TestRequireServiceInstalled:
def test_exits_with_install_hint_when_unit_missing(self, tmp_path, monkeypatch, capsys):
@ -481,6 +580,17 @@ class TestLaunchdServiceRecovery:
plist_path.write_text("<plist>old content</plist>", encoding="utf-8")
monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path)
# Patch the generator with synthetic content carrying a real-looking
# home — the temp-home guard refuses to write plists whose
# HERMES_HOME resolves under the (pytest tmp) test HERMES_HOME.
monkeypatch.setattr(
gateway_cli,
"generate_launchd_plist",
lambda: (
"<plist>--replace\n<key>HERMES_HOME</key>"
"<string>/Users/alice/.hermes</string></plist>"
),
)
calls = []
@ -495,7 +605,10 @@ class TestLaunchdServiceRecovery:
label = gateway_cli.get_launchd_label()
domain = gateway_cli._launchd_domain()
assert "--replace" in plist_path.read_text(encoding="utf-8")
assert calls[:2] == [
# The calls list includes launchctl print probes from _launchd_domain()
# before the bootout/bootstrap calls. Filter to only bootout/bootstrap.
service_calls = [c for c in calls if "bootout" in c or "bootstrap" in c]
assert service_calls[:2] == [
["launchctl", "bootout", f"{domain}/{label}"],
["launchctl", "bootstrap", domain, str(plist_path)],
]
@ -679,10 +792,22 @@ class TestLaunchdServiceRecovery:
assert "stale" in output.lower()
assert "not loaded" in output.lower()
def test_launchd_domain_uses_user_domain(self):
def test_launchd_domain_uses_user_domain(self, monkeypatch):
# The user/<uid> domain (not gui/<uid>) is the one reachable from
# non-Aqua/background sessions on macOS 26+ (issue #23387).
assert gateway_cli._launchd_domain() == f"user/{os.getuid()}"
# When gui/<uid> fails to probe and user/<uid> succeeds,
# _launchd_domain() must return user/<uid>.
gateway_cli._resolved_launchd_domain = None
monkeypatch.setattr(os, "getuid", lambda: 501)
label = gateway_cli.get_launchd_label()
def fake_run(cmd, check=False, **kwargs):
if "print" in cmd and "gui/" in " ".join(cmd):
raise subprocess.CalledProcessError(1, cmd, stderr="Domain error")
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
assert gateway_cli._launchd_domain() == "user/501"
def test_launchctl_domain_unsupported_recognizes_macos26_codes(self):
# Codes that persist after a fresh bootstrap → launchd truly unavailable.
@ -761,6 +886,17 @@ class TestLaunchdServiceRecovery:
"""macOS bootstrap error 5 should spawn a detached gateway, not crash."""
plist_path = tmp_path / "ai.hermes.gateway.plist"
monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path)
# Synthetic plist with a non-temp home so the temp-home write guard
# (which would trip on the pytest-tmp test HERMES_HOME) stays out of
# the way — this test exercises the bootstrap-error fallback.
monkeypatch.setattr(
gateway_cli,
"generate_launchd_plist",
lambda: (
"<plist><key>HERMES_HOME</key>"
"<string>/Users/alice/.hermes</string></plist>"
),
)
def fake_run(cmd, check=False, **kwargs):
if cmd[:2] == ["launchctl", "bootstrap"]:
@ -836,6 +972,114 @@ class TestLaunchdServiceRecovery:
assert "nohup hermes gateway run" in out
class TestLaunchdDomainDetection:
"""Regression tests for _launchd_domain() probing (#40831).
The function must detect which launchd domain actually contains (or can
manage) the service, rather than hardcoding ``user/<uid>`` or ``gui/<uid>``.
"""
def _reset_domain_cache(self):
"""Clear any cached domain result between tests."""
gateway_cli._resolved_launchd_domain = None
def test_prefers_gui_domain_when_service_loaded_there(self, monkeypatch):
"""In an Aqua session where the service is loaded under gui/<uid>,
_launchd_domain() must return ``gui/<uid>`` not ``user/<uid>``."""
self._reset_domain_cache()
monkeypatch.setattr(os, "getuid", lambda: 501)
label = gateway_cli.get_launchd_label()
run_calls = []
def fake_run(cmd, check=False, **kwargs):
run_calls.append(cmd)
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
domain = gateway_cli._launchd_domain()
assert domain == f"gui/501"
# Should have probed gui first
assert run_calls[0] == ["launchctl", "print", f"gui/501/{label}"]
def test_falls_back_to_user_domain_when_gui_fails(self, monkeypatch):
"""In a Background/SSH session where gui/<uid> fails but user/<uid>
works, _launchd_domain() must return ``user/<uid>``."""
self._reset_domain_cache()
monkeypatch.setattr(os, "getuid", lambda: 501)
label = gateway_cli.get_launchd_label()
run_calls = []
def fake_run(cmd, check=False, **kwargs):
run_calls.append(cmd)
if "print" in cmd and "gui/" in " ".join(cmd):
raise subprocess.CalledProcessError(1, cmd, stderr="Domain error")
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
domain = gateway_cli._launchd_domain()
assert domain == f"user/501"
# Should have tried gui first, then user
assert len(run_calls) >= 2
def test_uses_managername_heuristic_when_both_probe_fail(self, monkeypatch):
"""When neither domain contains a loaded service, use
``launchctl managername`` as a tiebreaker: Aqua -> gui, else -> user."""
self._reset_domain_cache()
monkeypatch.setattr(os, "getuid", lambda: 501)
label = gateway_cli.get_launchd_label()
def fake_run(cmd, check=False, **kwargs):
if "print" in cmd:
raise subprocess.CalledProcessError(1, cmd, stderr="not found")
if "managername" in cmd:
return SimpleNamespace(returncode=0, stdout="Aqua\n", stderr="")
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
domain = gateway_cli._launchd_domain()
assert domain == f"gui/501"
def test_managername_background_selects_user_domain(self, monkeypatch):
"""When managername is Background (non-Aqua), use user/<uid>."""
self._reset_domain_cache()
monkeypatch.setattr(os, "getuid", lambda: 501)
def fake_run(cmd, check=False, **kwargs):
if "print" in cmd:
raise subprocess.CalledProcessError(1, cmd, stderr="not found")
if "managername" in cmd:
return SimpleNamespace(returncode=0, stdout="Background\n", stderr="")
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
domain = gateway_cli._launchd_domain()
assert domain == f"user/501"
def test_caches_result_across_calls(self, monkeypatch):
"""Domain detection should run once and cache the result."""
self._reset_domain_cache()
monkeypatch.setattr(os, "getuid", lambda: 501)
run_count = [0]
def fake_run(cmd, check=False, **kwargs):
run_count[0] += 1
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
d1 = gateway_cli._launchd_domain()
d2 = gateway_cli._launchd_domain()
assert d1 == d2
assert run_count[0] == 1 # Only probed once
class TestGatewayServiceDetection:
def test_supports_systemd_services_requires_systemctl_binary(self, monkeypatch):
monkeypatch.setattr(gateway_cli, "is_linux", lambda: True)

View file

@ -712,6 +712,76 @@ def test_named_custom_provider_uses_saved_credentials(monkeypatch):
assert resolved["source"] == "custom_provider:Local"
def test_bare_custom_resolves_providers_dict_entry_named_custom(monkeypatch):
"""A request for bare ``provider="custom"`` must resolve a literal
``providers.custom`` entry (e.g. a cliproxy endpoint) instead of falling
through to the global default. Regression for cron jobs stored with
``provider: "custom"`` failing with ``auth_unavailable: providers=codex``.
"""
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
monkeypatch.setattr(
rp,
"load_config",
lambda: {
"providers": {
"custom": {
"api": "https://cliproxy.example.com/v1",
"api_key": "cliproxy-key",
"default_model": "gpt-5.4",
"name": "CLIProxy",
}
}
},
)
# Reaching resolve_provider for bare custom with a matching entry means the
# named-custom path was bypassed — that is the bug we are fixing.
monkeypatch.setattr(
rp,
"resolve_provider",
lambda *a, **k: (_ for _ in ()).throw(
AssertionError(
"resolve_provider must not be called; providers.custom should match"
)
),
)
resolved = rp.resolve_runtime_provider(requested="custom")
assert resolved["provider"] == "custom"
assert resolved["base_url"] == "https://cliproxy.example.com/v1"
assert resolved["api_key"] == "cliproxy-key"
assert resolved["requested_provider"] == "custom"
def test_bare_custom_without_named_entry_still_falls_through(monkeypatch):
"""No literal providers.custom entry → bare custom keeps the legacy
model.base_url trust-path behavior, unchanged by the fix."""
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
monkeypatch.setattr(
rp,
"_get_model_config",
lambda: {
"provider": "openrouter",
"base_url": "http://127.0.0.1:8082/v1",
"default": "my-local-model",
},
)
monkeypatch.setattr(
rp,
"load_config",
lambda: {"providers": {"some-other-proxy": {"api": "https://x.example/v1"}}},
)
monkeypatch.delenv("CUSTOM_BASE_URL", raising=False)
monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
resolved = rp.resolve_runtime_provider(requested="custom")
assert resolved["provider"] == "custom"
assert resolved["base_url"] == "http://127.0.0.1:8082/v1"
def test_named_custom_provider_uses_providers_dict_when_list_missing(monkeypatch):
"""After v11→v12 migration deletes custom_providers, resolution should
still find entries in the providers dict via get_compatible_custom_providers."""

View file

@ -975,6 +975,19 @@ def test_toolset_has_keys_treats_no_key_providers_as_configured():
assert _toolset_has_keys("computer_use", config) is True
def test_web_no_prompt_when_usable_keyless():
"""Fresh install: web works via the free Parallel MCP, so enabling the web
toolset should not force provider setup."""
with patch("tools.web_tools.check_web_api_key", return_value=True):
assert _toolset_needs_configuration_prompt("web", {}) is False
def test_web_no_prompt_when_extract_backend_is_extract_capable():
with patch("tools.web_tools.check_web_api_key", return_value=True):
cfg = {"web": {"extract_backend": "parallel"}}
assert _toolset_needs_configuration_prompt("web", cfg) is False
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.

View file

@ -425,3 +425,43 @@ def test_tui_launch_install_uses_workspace_scope(
install_cmd = npm_calls[0]
assert "--workspace" in install_cmd
assert "ui-tui" in install_cmd
def test_make_tui_argv_omits_workspace_when_tui_has_own_lockfile(
tmp_path: Path, main_mod, monkeypatch
) -> None:
"""When ui-tui/ has its own package-lock.json, _workspace_root returns
tui_dir itself. npm install --workspace ui-tui would fail in that case
because npm cannot find a workspace named "ui-tui" inside ui-tui/.
The fix omits --workspace and runs plain npm install from tui_dir.
See #42973.
"""
tui_dir = tmp_path / "ui-tui"
tui_dir.mkdir()
(tui_dir / "package.json").write_text("{}")
# Simulate curl-install layout: tui_dir has its own lockfile
(tui_dir / "package-lock.json").write_text("{}")
# Parent also has lockfile (but _workspace_root prefers tui_dir's own)
(tmp_path / "package-lock.json").write_text("{}")
monkeypatch.delenv("TERMUX_VERSION", raising=False)
monkeypatch.setenv("PREFIX", "/usr")
monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: True)
monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}")
calls = []
def fake_run(*args, **kwargs):
calls.append((args, kwargs))
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(main_mod.subprocess, "run", fake_run)
main_mod._make_tui_argv(tui_dir, tui_dev=False)
install_cmd = calls[0][0][0]
# Must NOT contain --workspace when npm_cwd == tui_dir
assert "--workspace" not in install_cmd, (
f"npm install should omit --workspace when tui_dir has its own lockfile, got: {install_cmd}"
)
assert install_cmd[:2] == ["/bin/npm", "install"]
# cwd must be tui_dir (standalone), not parent
assert calls[0][1]["cwd"] == str(tui_dir)

View file

@ -93,7 +93,39 @@ def test_check_for_updates_expired_cache(tmp_path, monkeypatch):
result = check_for_updates()
assert result == 5
assert mock_run.call_count == 2 # git fetch + git rev-list
assert mock_run.call_count == 3 # origin probe + git fetch + git rev-list
def test_check_for_updates_official_ssh_origin_uses_https_probe(tmp_path):
"""Passive update checks must not trigger SSH auth for official installs."""
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="git@github.com:NousResearch/hermes-agent.git\n")
if cmd == ["git", "rev-parse", "HEAD"]:
return MagicMock(returncode=0, stdout="local-sha\n")
if cmd == [
"git",
"ls-remote",
"https://github.com/NousResearch/hermes-agent.git",
"refs/heads/main",
]:
return MagicMock(returncode=0, stdout="upstream-sha\trefs/heads/main\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 == banner.UPDATE_AVAILABLE_NO_COUNT
assert ["git", "fetch", "origin", "--quiet"] not in calls
def test_check_for_updates_no_git_dir(tmp_path, monkeypatch):

View file

@ -1104,6 +1104,113 @@ class TestWebServerEndpoints:
assert confirmed.status_code == 200
assert confirmed.json()["ok"] is True
def test_model_set_normalizes_vendor_slug_for_native_provider(self, monkeypatch):
"""'Use as → Main' with an OpenRouter slug + native provider must not
persist the vendor-prefixed slug verbatim (it 400s against the native
API and reads as "changing models does nothing")."""
monkeypatch.setattr(
"hermes_cli.model_cost_guard.expensive_model_warning",
lambda *_args, **_kwargs: None,
)
resp = self.client.post(
"/api/model/set",
json={
"scope": "main",
"provider": "anthropic",
"model": "anthropic/claude-opus-4.6",
},
)
assert resp.status_code == 200
data = resp.json()
assert data["ok"] is True
assert data["provider"] == "anthropic"
# Vendor prefix stripped + dots→hyphens for the native Anthropic API.
assert data["model"] == "claude-opus-4-6"
from hermes_cli.config import load_config
cfg = load_config()
assert cfg["model"]["provider"] == "anthropic"
assert cfg["model"]["default"] == "claude-opus-4-6"
def test_model_set_maps_unknown_vendor_to_aggregator(self, monkeypatch):
"""A bare vendor name from analytics rows (no billing_provider) is not
a Hermes provider keep the user's aggregator instead of writing a
provider that can never resolve credentials."""
monkeypatch.setattr(
"hermes_cli.model_cost_guard.expensive_model_warning",
lambda *_args, **_kwargs: None,
)
from hermes_cli.config import load_config, save_config
cfg = load_config()
cfg["model"] = {"provider": "openrouter", "default": "openai/gpt-5.5"}
save_config(cfg)
resp = self.client.post(
"/api/model/set",
json={
"scope": "main",
"provider": "moonshotai", # vendor prefix, not a provider
"model": "moonshotai/kimi-k2.6",
},
)
assert resp.status_code == 200
data = resp.json()
assert data["ok"] is True
assert data["provider"] == "openrouter"
assert data["model"] == "moonshotai/kimi-k2.6"
def test_model_set_keeps_aggregator_slug_unchanged(self, monkeypatch):
"""The happy path (picker → openrouter + vendor/model) is untouched."""
monkeypatch.setattr(
"hermes_cli.model_cost_guard.expensive_model_warning",
lambda *_args, **_kwargs: None,
)
resp = self.client.post(
"/api/model/set",
json={
"scope": "main",
"provider": "openrouter",
"model": "anthropic/claude-sonnet-4.6",
},
)
assert resp.status_code == 200
data = resp.json()
assert data["ok"] is True
assert data["provider"] == "openrouter"
assert data["model"] == "anthropic/claude-sonnet-4.6"
def test_ops_import_passes_force_flag(self, tmp_path, monkeypatch):
"""force=True must append --force so the spawned non-interactive
`hermes import` doesn't auto-abort at the overwrite prompt."""
import hermes_cli.web_server as ws
archive = tmp_path / "backup.zip"
import zipfile
with zipfile.ZipFile(archive, "w") as zf:
zf.writestr("config.yaml", "model: {}\n")
captured = {}
def fake_spawn(subcommand, name):
captured["args"] = subcommand
captured["name"] = name
from types import SimpleNamespace as NS
return NS(pid=12345)
monkeypatch.setattr(ws, "_spawn_hermes_action", fake_spawn)
resp = self.client.post(
"/api/ops/import", json={"archive": str(archive), "force": True},
)
assert resp.status_code == 200
assert captured["args"] == ["import", str(archive), "--force"]
resp = self.client.post(
"/api/ops/import", json={"archive": str(archive)},
)
assert resp.status_code == 200
assert captured["args"] == ["import", str(archive)]
def test_reveal_env_var(self, tmp_path):
"""POST /api/env/reveal should return the real unredacted value."""
@ -4441,7 +4548,7 @@ class TestPtyWebSocket:
monkeypatch.setattr(
self.ws_module,
"_resolve_chat_argv",
lambda resume=None, sidecar_url=None: (["/bin/cat"], None, None),
lambda resume=None, sidecar_url=None, profile=None: (["/bin/cat"], None, None),
)
from starlette.websockets import WebSocketDisconnect
@ -4454,7 +4561,7 @@ class TestPtyWebSocket:
monkeypatch.setattr(
self.ws_module,
"_resolve_chat_argv",
lambda resume=None, sidecar_url=None: (["/bin/cat"], None, None),
lambda resume=None, sidecar_url=None, profile=None: (["/bin/cat"], None, None),
)
from starlette.websockets import WebSocketDisconnect
@ -4467,7 +4574,7 @@ class TestPtyWebSocket:
monkeypatch.setattr(
self.ws_module,
"_resolve_chat_argv",
lambda resume=None, sidecar_url=None: (
lambda resume=None, sidecar_url=None, profile=None: (
["/bin/sh", "-c", "printf hermes-ws-ok"],
None,
None,
@ -4497,7 +4604,7 @@ class TestPtyWebSocket:
monkeypatch.setattr(
self.ws_module,
"_resolve_chat_argv",
lambda resume=None, sidecar_url=None: (["/bin/cat"], None, None),
lambda resume=None, sidecar_url=None, profile=None: (["/bin/cat"], None, None),
)
with self.client.websocket_connect(self._url()) as conn:
conn.send_bytes(b"round-trip-payload\n")
@ -4530,7 +4637,7 @@ class TestPtyWebSocket:
self.ws_module,
"_resolve_chat_argv",
# sleep gives the test time to push the resize before the child reads the ioctl.
lambda resume=None, sidecar_url=None: (
lambda resume=None, sidecar_url=None, profile=None: (
[sys.executable, "-c", winsize_script],
None,
None,
@ -4566,7 +4673,7 @@ class TestPtyWebSocket:
monkeypatch.setattr(
self.ws_module,
"_resolve_chat_argv",
lambda resume=None, sidecar_url=None: (["/bin/cat"], None, None),
lambda resume=None, sidecar_url=None, profile=None: (["/bin/cat"], None, None),
)
# Patch PtyBridge.spawn at the web_server module's binding.
import hermes_cli.web_server as ws_mod
@ -4581,7 +4688,7 @@ class TestPtyWebSocket:
def test_resume_parameter_is_forwarded_to_argv(self, monkeypatch):
captured: dict = {}
def fake_resolve(resume=None, sidecar_url=None):
def fake_resolve(resume=None, sidecar_url=None, profile=None):
captured["resume"] = resume
return (["/bin/sh", "-c", "printf resume-arg-ok"], None, None)
@ -4601,7 +4708,7 @@ class TestPtyWebSocket:
same channel which is how tool events reach the dashboard sidebar."""
captured: dict = {}
def fake_resolve(resume=None, sidecar_url=None):
def fake_resolve(resume=None, sidecar_url=None, profile=None):
captured["sidecar_url"] = sidecar_url
return (["/bin/sh", "-c", "printf sidecar-ok"], None, None)

View file

@ -0,0 +1,385 @@
"""Regression tests for the machine-dashboard multi-profile unification.
The dashboard is ONE machine-level management surface: config, env, MCP,
model, and chat-PTY endpoints accept an optional ``profile`` so the global
profile switcher can target any profile's HERMES_HOME. These tests pin:
reads/writes land in the REQUESTED profile, the dashboard's own profile
stays untouched, and the chat PTY env is scoped via HERMES_HOME.
"""
import pytest
import yaml
@pytest.fixture
def isolated_profiles(tmp_path, monkeypatch, _isolate_hermes_home):
"""Isolated default home + one named profile, each with config + .env."""
from hermes_constants import get_hermes_home
from hermes_cli import profiles
default_home = get_hermes_home()
profiles_root = default_home / "profiles"
worker_home = profiles_root / "worker_beta"
for home in (default_home, worker_home):
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text("{}\n", encoding="utf-8")
(worker_home / ".env").write_text("", encoding="utf-8")
monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: default_home)
monkeypatch.setattr(profiles, "_get_profiles_root", lambda: profiles_root)
return {"default": default_home, "worker_beta": worker_home}
@pytest.fixture
def client(monkeypatch, isolated_profiles):
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
import hermes_state
from hermes_constants import get_hermes_home
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
c = TestClient(app)
c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
return c
def _cfg(home):
return yaml.safe_load((home / "config.yaml").read_text()) or {}
class TestProfileScopedConfig:
def test_config_put_lands_in_target_profile_only(self, client, isolated_profiles):
resp = client.put(
"/api/config",
json={"config": {"timezone": "Mars/Olympus"}, "profile": "worker_beta"},
)
assert resp.status_code == 200
assert _cfg(isolated_profiles["worker_beta"]).get("timezone") == "Mars/Olympus"
assert _cfg(isolated_profiles["default"]).get("timezone") != "Mars/Olympus"
def test_config_get_reads_target_profile(self, client, isolated_profiles):
(isolated_profiles["worker_beta"] / "config.yaml").write_text(
"timezone: Venus/Cloud\n", encoding="utf-8"
)
resp = client.get("/api/config", params={"profile": "worker_beta"})
assert resp.status_code == 200
assert resp.json().get("timezone") == "Venus/Cloud"
# Unscoped read sees the dashboard's own config.
resp = client.get("/api/config")
assert resp.json().get("timezone") != "Venus/Cloud"
def test_config_query_param_equivalent_to_body(self, client, isolated_profiles):
"""The SPA's fetchJSON injects ?profile= — must scope like body.profile."""
resp = client.put(
"/api/config?profile=worker_beta",
json={"config": {"timezone": "Pluto/Far"}},
)
assert resp.status_code == 200
assert _cfg(isolated_profiles["worker_beta"]).get("timezone") == "Pluto/Far"
assert _cfg(isolated_profiles["default"]).get("timezone") != "Pluto/Far"
def test_config_raw_round_trip_scoped(self, client, isolated_profiles):
resp = client.put(
"/api/config/raw",
json={"yaml_text": "timezone: Io/Volcano\n", "profile": "worker_beta"},
)
assert resp.status_code == 200
resp = client.get("/api/config/raw", params={"profile": "worker_beta"})
assert "Io/Volcano" in resp.json()["yaml"]
resp = client.get("/api/config/raw")
assert "Io/Volcano" not in resp.json()["yaml"]
def test_unknown_profile_404(self, client, isolated_profiles):
resp = client.get("/api/config", params={"profile": "ghost"})
assert resp.status_code == 404
class TestProfileScopedEnv:
def test_env_set_lands_in_target_profile_only(self, client, isolated_profiles):
resp = client.put(
"/api/env",
json={"key": "FAL_KEY", "value": "test-fal-123", "profile": "worker_beta"},
)
assert resp.status_code == 200
worker_env = (isolated_profiles["worker_beta"] / ".env").read_text()
assert "test-fal-123" in worker_env
default_env_path = isolated_profiles["default"] / ".env"
if default_env_path.exists():
assert "test-fal-123" not in default_env_path.read_text()
def test_env_list_reads_target_profile(self, client, isolated_profiles):
(isolated_profiles["worker_beta"] / ".env").write_text(
"FAL_KEY=worker-only-value\n", encoding="utf-8"
)
resp = client.get("/api/env", params={"profile": "worker_beta"})
assert resp.status_code == 200
assert resp.json()["FAL_KEY"]["is_set"] is True
resp = client.get("/api/env")
assert resp.json()["FAL_KEY"]["is_set"] is False
def test_env_delete_scoped(self, client, isolated_profiles):
(isolated_profiles["worker_beta"] / ".env").write_text(
"FAL_KEY=doomed\n", encoding="utf-8"
)
resp = client.request(
"DELETE",
"/api/env",
json={"key": "FAL_KEY", "profile": "worker_beta"},
)
assert resp.status_code == 200
assert "doomed" not in (isolated_profiles["worker_beta"] / ".env").read_text()
class TestProfileScopedMcp:
def test_mcp_add_and_list_scoped(self, client, isolated_profiles):
resp = client.post(
"/api/mcp/servers",
json={"name": "scoped-srv", "url": "http://localhost:1234/sse",
"profile": "worker_beta"},
)
assert resp.status_code == 200
worker_cfg = _cfg(isolated_profiles["worker_beta"])
assert "scoped-srv" in worker_cfg.get("mcp_servers", {})
assert "scoped-srv" not in _cfg(isolated_profiles["default"]).get("mcp_servers", {})
listing = client.get("/api/mcp/servers", params={"profile": "worker_beta"}).json()
assert any(s["name"] == "scoped-srv" for s in listing["servers"])
listing = client.get("/api/mcp/servers").json()
assert not any(s["name"] == "scoped-srv" for s in listing["servers"])
def test_mcp_enabled_toggle_scoped(self, client, isolated_profiles):
(isolated_profiles["worker_beta"] / "config.yaml").write_text(
"mcp_servers:\n srv1:\n url: http://x/sse\n", encoding="utf-8"
)
resp = client.put(
"/api/mcp/servers/srv1/enabled",
json={"enabled": False, "profile": "worker_beta"},
)
assert resp.status_code == 200
worker_cfg = _cfg(isolated_profiles["worker_beta"])
assert worker_cfg["mcp_servers"]["srv1"]["enabled"] is False
def test_mcp_probe_runs_inside_profile_scope(
self, client, isolated_profiles, monkeypatch
):
"""The test-server probe must execute with the selected profile's
scope active so env-placeholder expansion reads the profile's .env,
matching the config the server was saved into."""
import hermes_cli.mcp_config as mcp_config
from hermes_constants import get_hermes_home
(isolated_profiles["worker_beta"] / "config.yaml").write_text(
"mcp_servers:\n probe-srv:\n url: http://x/sse\n",
encoding="utf-8",
)
seen = {}
def fake_probe(name, config, connect_timeout=30):
seen["home"] = str(get_hermes_home())
return [("tool-a", "desc")]
monkeypatch.setattr(mcp_config, "_probe_single_server", fake_probe)
resp = client.post(
"/api/mcp/servers/probe-srv/test", params={"profile": "worker_beta"}
)
assert resp.status_code == 200
assert resp.json()["ok"] is True
assert seen["home"] == str(isolated_profiles["worker_beta"])
def test_mcp_remove_scoped(self, client, isolated_profiles):
(isolated_profiles["worker_beta"] / "config.yaml").write_text(
"mcp_servers:\n srv2:\n url: http://x/sse\n", encoding="utf-8"
)
# Removing from the DASHBOARD's profile must 404 (srv2 lives in worker).
resp = client.delete("/api/mcp/servers/srv2")
assert resp.status_code == 404
resp = client.delete("/api/mcp/servers/srv2", params={"profile": "worker_beta"})
assert resp.status_code == 200
assert "srv2" not in _cfg(isolated_profiles["worker_beta"]).get("mcp_servers", {})
class TestProfileScopedModel:
def test_model_set_main_scoped(self, client, isolated_profiles):
resp = client.post(
"/api/model/set",
json={
"scope": "main",
"provider": "openrouter",
"model": "test/model-1",
"confirm_expensive_model": True,
"profile": "worker_beta",
},
)
assert resp.status_code == 200
worker_cfg = _cfg(isolated_profiles["worker_beta"])
model_cfg = worker_cfg.get("model", {})
assert isinstance(model_cfg, dict)
assert model_cfg.get("provider") == "openrouter"
default_model = _cfg(isolated_profiles["default"]).get("model", {})
if isinstance(default_model, dict):
assert default_model.get("default") != "test/model-1"
def test_auxiliary_read_scoped_matches_write_target(
self, client, isolated_profiles
):
"""Reads and writes must scope symmetrically: an aux pin written to
the worker profile must show up ONLY in the worker-scoped read.
(Regression: /api/model/auxiliary used to read unscoped while
/api/model/set wrote scoped the Models page displayed the
dashboard profile's pins while editing the selected profile's.)"""
(isolated_profiles["worker_beta"] / "config.yaml").write_text(
"auxiliary:\n vision:\n provider: openrouter\n"
" model: worker/vision-pin\n",
encoding="utf-8",
)
resp = client.get("/api/model/auxiliary", params={"profile": "worker_beta"})
assert resp.status_code == 200
vision = next(t for t in resp.json()["tasks"] if t["task"] == "vision")
assert vision["model"] == "worker/vision-pin"
# Unscoped read = the dashboard's own profile, which has no pin.
resp = client.get("/api/model/auxiliary")
assert resp.status_code == 200
vision = next(t for t in resp.json()["tasks"] if t["task"] == "vision")
assert vision["model"] != "worker/vision-pin"
def test_auxiliary_unknown_profile_404(self, client, isolated_profiles):
resp = client.get("/api/model/auxiliary", params={"profile": "ghost"})
assert resp.status_code == 404
def test_model_options_scoped_to_profile(self, client, isolated_profiles):
"""The Models picker must read the SAME profile model/set writes —
current model/provider in the payload come from the scoped config."""
(isolated_profiles["worker_beta"] / "config.yaml").write_text(
"model:\n provider: openrouter\n default: worker/current-pin\n",
encoding="utf-8",
)
resp = client.get("/api/model/options", params={"profile": "worker_beta"})
assert resp.status_code == 200
body = resp.json()
# The payload carries the current selection somewhere stable; assert
# the worker pin appears in the scoped response and not the unscoped.
assert "worker/current-pin" in resp.text
resp = client.get("/api/model/options")
assert resp.status_code == 200
assert "worker/current-pin" not in resp.text
assert isinstance(body, dict)
def test_model_options_unknown_profile_404(self, client, isolated_profiles):
resp = client.get("/api/model/options", params={"profile": "ghost"})
assert resp.status_code == 404
def test_model_info_unknown_profile_404(self, client, isolated_profiles):
"""Regression: the broad except used to convert the 404 into a 200
with empty model info ("no model set" silently wrong)."""
resp = client.get("/api/model/info", params={"profile": "ghost"})
assert resp.status_code == 404
def test_mcp_catalog_unknown_profile_404(self, client, isolated_profiles):
resp = client.get("/api/mcp/catalog", params={"profile": "ghost"})
assert resp.status_code == 404
class TestProfileScopedPostSetup:
def test_post_setup_spawns_with_profile_flag(
self, client, isolated_profiles, monkeypatch
):
"""Post-setup runs in a -p scoped subprocess so hooks that read
config / write per-profile state see the same HERMES_HOME the rest
of the drawer's writes targeted."""
import hermes_cli.web_server as web_server
calls = []
class _FakeProc:
pid = 777
monkeypatch.setattr(
web_server,
"_spawn_hermes_action",
lambda subcommand, name: calls.append(list(subcommand)) or _FakeProc(),
)
monkeypatch.setattr(
"hermes_cli.tools_config.valid_post_setup_keys",
lambda: {"agent_browser"},
)
resp = client.post(
"/api/tools/toolsets/browser/post-setup",
json={"key": "agent_browser", "profile": "worker_beta"},
)
assert resp.status_code == 200
assert calls == [
["-p", "worker_beta", "tools", "post-setup", "agent_browser"]
]
def test_post_setup_without_profile_keeps_legacy_argv(
self, client, isolated_profiles, monkeypatch
):
import hermes_cli.web_server as web_server
calls = []
class _FakeProc:
pid = 777
monkeypatch.setattr(
web_server,
"_spawn_hermes_action",
lambda subcommand, name: calls.append(list(subcommand)) or _FakeProc(),
)
monkeypatch.setattr(
"hermes_cli.tools_config.valid_post_setup_keys",
lambda: {"agent_browser"},
)
resp = client.post(
"/api/tools/toolsets/browser/post-setup",
json={"key": "agent_browser"},
)
assert resp.status_code == 200
assert calls == [["tools", "post-setup", "agent_browser"]]
class TestProfileScopedChatPty:
def test_chat_argv_scopes_hermes_home(self, isolated_profiles, monkeypatch):
import hermes_cli.web_server as web_server
monkeypatch.setattr(
"hermes_cli.main._make_tui_argv",
lambda root, tui_dev=False: (["cat"], None),
raising=False,
)
argv, cwd, env = web_server._resolve_chat_argv(profile="worker_beta")
assert env is not None
assert env["HERMES_HOME"] == str(isolated_profiles["worker_beta"])
# Scoped chat must NOT attach to the dashboard's in-memory gateway.
assert "HERMES_TUI_GATEWAY_URL" not in env
def test_chat_argv_unscoped_keeps_legacy_env(self, isolated_profiles, monkeypatch):
import hermes_cli.web_server as web_server
monkeypatch.setattr(
"hermes_cli.main._make_tui_argv",
lambda root, tui_dev=False: (["cat"], None),
raising=False,
)
argv, cwd, env = web_server._resolve_chat_argv()
assert env is not None
assert env.get("HERMES_HOME") != str(isolated_profiles["worker_beta"])
def test_chat_argv_unknown_profile_raises(self, isolated_profiles, monkeypatch):
import hermes_cli.web_server as web_server
monkeypatch.setattr(
"hermes_cli.main._make_tui_argv",
lambda root, tui_dev=False: (["cat"], None),
raising=False,
)
# Reuse the HTTPException class web_server itself raises — avoids a
# direct fastapi import (unresolvable in the ty lint environment).
with pytest.raises(web_server.HTTPException) as exc:
web_server._resolve_chat_argv(profile="ghost")
assert exc.value.status_code == 404

View file

@ -0,0 +1,259 @@
"""Tests for the dashboard skill editor endpoints and cron skill attachment.
The Skills page can now create/edit custom skills (SKILL.md) and the Cron
page can attach skills to jobs closing the "SSH + nano is the only way"
gap for headless/VPS users. These tests pin:
- GET /api/skills/content returns raw SKILL.md (and profile-scopes).
- POST /api/skills creates a skill through the same validated write path
as the agent's ``skill_manage`` tool (frontmatter validation enforced).
- PUT /api/skills/content rewrites an existing SKILL.md (404 on unknown).
- POST /api/cron/jobs accepts ``skills`` and persists it on the job;
PUT /api/cron/jobs/{id} can update the list.
"""
import pytest
SKILL_MD = """---
name: {name}
description: a test skill
---
# {name}
Do the thing.
"""
def _write_skill(skills_dir, name):
d = skills_dir / name
d.mkdir(parents=True, exist_ok=True)
(d / "SKILL.md").write_text(SKILL_MD.format(name=name), encoding="utf-8")
@pytest.fixture
def isolated_profiles(tmp_path, monkeypatch, _isolate_hermes_home):
"""Isolated default home + one named profile, each with its own skills."""
from hermes_constants import get_hermes_home
from hermes_cli import profiles
default_home = get_hermes_home()
profiles_root = default_home / "profiles"
worker_home = profiles_root / "worker_alpha"
for home in (default_home, worker_home):
(home / "skills").mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text("{}\n", encoding="utf-8")
_write_skill(default_home / "skills", "dashboard-skill")
_write_skill(worker_home / "skills", "worker-skill")
monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: default_home)
monkeypatch.setattr(profiles, "_get_profiles_root", lambda: profiles_root)
return {"default": default_home, "worker_alpha": worker_home}
@pytest.fixture
def client(monkeypatch, isolated_profiles):
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
import hermes_state
from hermes_constants import get_hermes_home
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
c = TestClient(app)
c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
return c
class TestSkillContent:
def test_get_content_returns_raw_skill_md(self, client, isolated_profiles):
resp = client.get("/api/skills/content", params={"name": "dashboard-skill"})
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "dashboard-skill"
assert data["content"].startswith("---")
assert "Do the thing." in data["content"]
def test_get_content_scopes_to_profile(self, client, isolated_profiles):
resp = client.get(
"/api/skills/content",
params={"name": "worker-skill", "profile": "worker_alpha"},
)
assert resp.status_code == 200
# ...and the worker skill is invisible without the profile param.
resp = client.get("/api/skills/content", params={"name": "worker-skill"})
assert resp.status_code == 404
def test_get_content_unknown_skill_404(self, client, isolated_profiles):
resp = client.get("/api/skills/content", params={"name": "nope"})
assert resp.status_code == 404
class TestSkillCreate:
def test_create_writes_skill_md(self, client, isolated_profiles):
resp = client.post(
"/api/skills",
json={"name": "my-new-skill", "content": SKILL_MD.format(name="my-new-skill")},
)
assert resp.status_code == 200
assert resp.json()["success"] is True
skill_md = isolated_profiles["default"] / "skills" / "my-new-skill" / "SKILL.md"
assert skill_md.exists()
assert "Do the thing." in skill_md.read_text(encoding="utf-8")
def test_create_with_category(self, client, isolated_profiles):
resp = client.post(
"/api/skills",
json={
"name": "cat-skill",
"category": "devops",
"content": SKILL_MD.format(name="cat-skill"),
},
)
assert resp.status_code == 200
assert (
isolated_profiles["default"] / "skills" / "devops" / "cat-skill" / "SKILL.md"
).exists()
def test_create_scopes_to_profile(self, client, isolated_profiles):
resp = client.post(
"/api/skills",
json={
"name": "worker-new",
"content": SKILL_MD.format(name="worker-new"),
"profile": "worker_alpha",
},
)
assert resp.status_code == 200
assert (
isolated_profiles["worker_alpha"] / "skills" / "worker-new" / "SKILL.md"
).exists()
# Dashboard's own skills dir stays clean.
assert not (
isolated_profiles["default"] / "skills" / "worker-new"
).exists()
def test_create_rejects_missing_frontmatter(self, client, isolated_profiles):
resp = client.post(
"/api/skills",
json={"name": "bad-skill", "content": "no frontmatter here"},
)
assert resp.status_code == 400
assert "frontmatter" in resp.json()["detail"].lower()
assert not (isolated_profiles["default"] / "skills" / "bad-skill").exists()
def test_create_rejects_duplicate_name(self, client, isolated_profiles):
resp = client.post(
"/api/skills",
json={
"name": "dashboard-skill",
"content": SKILL_MD.format(name="dashboard-skill"),
},
)
assert resp.status_code == 400
assert "already exists" in resp.json()["detail"]
def test_create_rejects_invalid_name(self, client, isolated_profiles):
resp = client.post(
"/api/skills",
json={"name": "../escape", "content": SKILL_MD.format(name="x")},
)
assert resp.status_code == 400
class TestSkillUpdate:
def test_update_rewrites_skill_md(self, client, isolated_profiles):
new_content = SKILL_MD.format(name="dashboard-skill").replace(
"Do the thing.", "Do the NEW thing."
)
resp = client.put(
"/api/skills/content",
json={"name": "dashboard-skill", "content": new_content},
)
assert resp.status_code == 200
skill_md = (
isolated_profiles["default"] / "skills" / "dashboard-skill" / "SKILL.md"
)
assert "Do the NEW thing." in skill_md.read_text(encoding="utf-8")
def test_update_unknown_skill_404(self, client, isolated_profiles):
resp = client.put(
"/api/skills/content",
json={"name": "nope", "content": SKILL_MD.format(name="nope")},
)
assert resp.status_code == 404
def test_update_invalid_frontmatter_400(self, client, isolated_profiles):
resp = client.put(
"/api/skills/content",
json={"name": "dashboard-skill", "content": "broken"},
)
assert resp.status_code == 400
class TestEditorEndpointsAuth:
@pytest.mark.parametrize(
"method,path,kwargs",
[
("get", "/api/skills/content?name=dashboard-skill", {}),
("post", "/api/skills", {"json": {"name": "x", "content": "y"}}),
("put", "/api/skills/content", {"json": {"name": "x", "content": "y"}}),
],
)
def test_endpoints_401_without_token(
self, client, isolated_profiles, method, path, kwargs
):
from hermes_cli.web_server import _SESSION_HEADER_NAME
client.headers.pop(_SESSION_HEADER_NAME, None)
resp = getattr(client, method)(path, **kwargs)
assert resp.status_code == 401
class TestCronJobSkills:
def test_create_job_with_skills(self, client, isolated_profiles):
resp = client.post(
"/api/cron/jobs",
json={
"prompt": "do work",
"schedule": "every 1h",
"name": "skilled-job",
"skills": ["dashboard-skill"],
},
)
assert resp.status_code == 200
job = resp.json()
assert job["skills"] == ["dashboard-skill"]
# Round-trip: the list endpoint carries the skills field too.
listed = client.get("/api/cron/jobs", params={"profile": "default"}).json()
match = [j for j in listed if j["id"] == job["id"]]
assert match and match[0]["skills"] == ["dashboard-skill"]
def test_update_job_skills(self, client, isolated_profiles):
job = client.post(
"/api/cron/jobs",
json={"prompt": "do work", "schedule": "every 1h"},
).json()
assert job.get("skills") in (None, [])
resp = client.put(
f"/api/cron/jobs/{job['id']}",
json={"updates": {"skills": ["dashboard-skill", "worker-skill"]}},
params={"profile": "default"},
)
assert resp.status_code == 200
assert resp.json()["skills"] == ["dashboard-skill", "worker-skill"]
# Clearing works too.
resp = client.put(
f"/api/cron/jobs/{job['id']}",
json={"updates": {"skills": []}},
params={"profile": "default"},
)
assert resp.status_code == 200
assert resp.json()["skills"] == []

View file

@ -0,0 +1,210 @@
"""Regression tests for dashboard profile-scoped skills/toolsets management.
"Set as active" on the Profiles page only flips the sticky ``active_profile``
file (future CLI/gateway runs) it never retargets the running dashboard
process. Before the ``profile`` parameter existed, toggling a skill after
"activating" a profile silently wrote into the dashboard's own config.
These tests pin the new behavior: reads and writes land in the REQUESTED
profile's HERMES_HOME, and the dashboard's own profile stays untouched.
"""
import pytest
import yaml
def _write_skill(skills_dir, name, description="test skill"):
d = skills_dir / name
d.mkdir(parents=True, exist_ok=True)
(d / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n",
encoding="utf-8",
)
@pytest.fixture
def isolated_profiles(tmp_path, monkeypatch, _isolate_hermes_home):
"""Isolated default home + one named profile, each with its own skills."""
from hermes_constants import get_hermes_home
from hermes_cli import profiles
default_home = get_hermes_home()
profiles_root = default_home / "profiles"
worker_home = profiles_root / "worker_alpha"
for home in (default_home, worker_home):
(home / "skills").mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text("{}\n", encoding="utf-8")
_write_skill(default_home / "skills", "dashboard-skill")
_write_skill(worker_home / "skills", "worker-skill")
monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: default_home)
monkeypatch.setattr(profiles, "_get_profiles_root", lambda: profiles_root)
return {"default": default_home, "worker_alpha": worker_home}
@pytest.fixture
def client(monkeypatch, isolated_profiles):
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
import hermes_state
from hermes_constants import get_hermes_home
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
c = TestClient(app)
c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
return c
def _load_cfg(home):
return yaml.safe_load((home / "config.yaml").read_text()) or {}
class TestProfileScopedSkills:
def test_skills_list_scopes_to_requested_profile(self, client, isolated_profiles):
resp = client.get("/api/skills", params={"profile": "worker_alpha"})
assert resp.status_code == 200
names = {s["name"] for s in resp.json()}
assert "worker-skill" in names
assert "dashboard-skill" not in names
def test_skills_list_without_profile_uses_dashboard_home(
self, client, isolated_profiles
):
resp = client.get("/api/skills")
assert resp.status_code == 200
names = {s["name"] for s in resp.json()}
assert "dashboard-skill" in names
assert "worker-skill" not in names
def test_toggle_writes_into_target_profile_only(self, client, isolated_profiles):
resp = client.put(
"/api/skills/toggle",
json={"name": "worker-skill", "enabled": False, "profile": "worker_alpha"},
)
assert resp.status_code == 200
assert resp.json() == {"ok": True, "name": "worker-skill", "enabled": False}
worker_cfg = _load_cfg(isolated_profiles["worker_alpha"])
assert "worker-skill" in worker_cfg.get("skills", {}).get("disabled", [])
# The dashboard's own config must stay untouched — this was the bug.
default_cfg = _load_cfg(isolated_profiles["default"])
assert "worker-skill" not in default_cfg.get("skills", {}).get("disabled", [])
def test_toggle_reenable_round_trip(self, client, isolated_profiles):
for enabled in (False, True):
client.put(
"/api/skills/toggle",
json={
"name": "worker-skill",
"enabled": enabled,
"profile": "worker_alpha",
},
)
worker_cfg = _load_cfg(isolated_profiles["worker_alpha"])
assert "worker-skill" not in worker_cfg.get("skills", {}).get("disabled", [])
def test_unknown_profile_returns_404(self, client, isolated_profiles):
resp = client.get("/api/skills", params={"profile": "no_such_profile"})
assert resp.status_code == 404
def test_invalid_profile_name_returns_400(self, client, isolated_profiles):
resp = client.get("/api/skills", params={"profile": "Bad Name!"})
assert resp.status_code == 400
def test_scope_restores_module_globals(self, client, isolated_profiles):
"""The SKILLS_DIR swap is per-request; the module global must be
restored even after a scoped call (cron-style locked swap)."""
import tools.skills_tool as skills_tool
before = skills_tool.SKILLS_DIR
client.get("/api/skills", params={"profile": "worker_alpha"})
assert skills_tool.SKILLS_DIR == before
class TestProfileScopedToolsets:
def test_toolset_toggle_scopes_to_profile(self, client, isolated_profiles):
resp = client.put(
"/api/tools/toolsets/x_search",
json={"enabled": True, "profile": "worker_alpha"},
)
assert resp.status_code == 200
worker_cfg = _load_cfg(isolated_profiles["worker_alpha"])
assert "x_search" in worker_cfg.get("platform_toolsets", {}).get("cli", [])
default_cfg = _load_cfg(isolated_profiles["default"])
assert "x_search" not in default_cfg.get("platform_toolsets", {}).get("cli", [])
listing = client.get(
"/api/tools/toolsets", params={"profile": "worker_alpha"}
).json()
assert {t["name"]: t for t in listing}["x_search"]["enabled"] is True
# Unscoped listing reflects the dashboard's own (untouched) config.
listing = client.get("/api/tools/toolsets").json()
assert {t["name"]: t for t in listing}["x_search"]["enabled"] is False
def test_toolset_toggle_unknown_profile_404(self, client, isolated_profiles):
resp = client.put(
"/api/tools/toolsets/x_search",
json={"enabled": True, "profile": "ghost"},
)
assert resp.status_code == 404
class TestProfileScopedHubActions:
def test_hub_install_spawns_with_profile_flag(
self, client, isolated_profiles, monkeypatch
):
"""Hub installs must go through a fresh ``hermes -p <profile>``
subprocess the in-process scope can't reach skills_hub's
import-time SKILLS_DIR binding."""
import hermes_cli.web_server as web_server
calls = []
class _FakeProc:
pid = 4242
def _fake_spawn(subcommand, name):
calls.append((list(subcommand), name))
return _FakeProc()
monkeypatch.setattr(web_server, "_spawn_hermes_action", _fake_spawn)
resp = client.post(
"/api/skills/hub/install",
json={"identifier": "official/demo", "profile": "worker_alpha"},
)
assert resp.status_code == 200
assert calls == [
(["-p", "worker_alpha", "skills", "install", "official/demo"], "skills-install")
]
def test_hub_install_without_profile_keeps_legacy_argv(
self, client, isolated_profiles, monkeypatch
):
import hermes_cli.web_server as web_server
calls = []
class _FakeProc:
pid = 4242
monkeypatch.setattr(
web_server,
"_spawn_hermes_action",
lambda subcommand, name: calls.append(list(subcommand)) or _FakeProc(),
)
resp = client.post(
"/api/skills/hub/install", json={"identifier": "official/demo"}
)
assert resp.status_code == 200
assert calls == [["skills", "install", "official/demo"]]
def test_hub_install_unknown_profile_404(self, client, isolated_profiles):
resp = client.post(
"/api/skills/hub/install",
json={"identifier": "official/demo", "profile": "ghost"},
)
assert resp.status_code == 404

View file

@ -142,6 +142,11 @@ class TestBuildWebUISkipsWhenFresh:
def test_npm_install_uses_workspace_web_scope(self, tmp_path):
web_dir, _ = _make_web_dir(tmp_path)
# Real workspace checkout: the single lockfile lives at the root, so
# _workspace_root(web_dir) resolves to the parent and --workspace web
# scopes the install. (Without a root lockfile, web_dir IS the root and
# --workspace would be dropped — see test below and #42973.)
(tmp_path / "package-lock.json").write_text("{}", encoding="utf-8")
mock_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
build_ok = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
@ -153,6 +158,36 @@ class TestBuildWebUISkipsWhenFresh:
assert "--workspace" in install_cmd
assert install_cmd[install_cmd.index("--workspace") + 1] == "web"
def test_web_install_omits_workspace_when_web_has_own_lockfile(
self, tmp_path, monkeypatch
):
"""web/ with its own lockfile => _workspace_root returns web_dir, so
--workspace web would fail (npm can't find that workspace from inside
web/). The flag must be dropped and the install run plainly from web_dir.
Symmetric to the TUI fix in test_tui_npm_install.py. See #42973.
With web's own lockfile present at cwd, _run_npm_install_deterministic
uses ``npm ci`` (not ``npm install``).
"""
web_dir, _ = _make_web_dir(tmp_path)
(web_dir / "package-lock.json").write_text("{}", encoding="utf-8")
(tmp_path / "package-lock.json").write_text("{}", encoding="utf-8")
monkeypatch.delenv("TERMUX_VERSION", raising=False)
monkeypatch.setenv("PREFIX", "/usr")
install_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
build_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
patch("hermes_cli.main.subprocess.run", return_value=install_cp) as mock_run, \
patch("hermes_cli.main._run_with_idle_timeout", return_value=build_cp):
result = _build_web_ui(web_dir)
assert result is True
args, kwargs = mock_run.call_args
assert "--workspace" not in args[0]
assert args[0] == ["/usr/bin/npm", "ci", "--silent"]
assert kwargs["cwd"] == web_dir
def test_web_build_uses_idle_timeout_helper(self, tmp_path):
"""npm run build now goes through _run_with_idle_timeout (issue #33788).