Merge remote-tracking branch 'origin/main' into hermes/hermes-6b48295e
This commit is contained in:
commit
2ecb4e62bb
239 changed files with 18356 additions and 2494 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) ────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
130
tests/hermes_cli/test_dashboard_unified_launch.py
Normal file
130
tests/hermes_cli/test_dashboard_unified_launch.py
Normal 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 == []
|
||||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
385
tests/hermes_cli/test_web_server_profile_unification.py
Normal file
385
tests/hermes_cli/test_web_server_profile_unification.py
Normal 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
|
||||
259
tests/hermes_cli/test_web_server_skill_editor.py
Normal file
259
tests/hermes_cli/test_web_server_skill_editor.py
Normal 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"] == []
|
||||
210
tests/hermes_cli/test_web_server_skills_profiles.py
Normal file
210
tests/hermes_cli/test_web_server_skills_profiles.py
Normal 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
|
||||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue