The setup profile's `setup` toolset was empty. It now carries one tool, manage_catalog: - search: catalog plugins (the Plugins tab's live catalog resolver) and hub skills, with whether each is already installed in the default profile. Read-only. - install: opens the same connection operation manage_connections opens, with rows of kind plugin / skill. Nothing installs until the user approves a row. An approved row installs into `default` (or the profile the Advanced modal named) through dashboard_install_plugin / the hub's headless install, so the catalog pin, kill list, security scan and live activation (#119644) are the host's. The row settles with the live MCP tool names and the plugin's skill. The model sends catalog ids and an action only; every other key is refused before anything runs. An unknown id or a plugin this OS cannot run is drawn failed with the installer's own text. Anywhere a catalog card cannot be drawn (TUI, CLI, messaging, registry dispatch) the result is the `hermes plugins install` / `hermes skills install` pointer. - contract: plugin/skill targets follow the MCP transitions. - run.apply_answer / reissue route a card answer to the module that owns the operation. - tool_search: `setup` joins the direct-surface toolsets, so the guide's one tool is never deferred behind tool_search. - docs: tools reference, toolsets reference, plugin catalog page. Linear NS-964.
230 lines
10 KiB
Python
230 lines
10 KiB
Python
"""``manage_catalog`` (the setup profile's catalog install through the connection card).
|
|
|
|
Contracts:
|
|
- the tool reaches only a session of the ``role: setup`` profile, and only a desktop chat draws it
|
|
- the model sends catalog ids and an action; every other key is refused before anything runs
|
|
- an id the catalog does not know, or a plugin this OS cannot run, is drawn failed and never installed
|
|
- an approved row installs into ``default`` (or the profile the Advanced modal named) and reports
|
|
what went live; the card never witnesses the outcome
|
|
"""
|
|
|
|
import json
|
|
import threading
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from tools.connectors import live
|
|
from tools.connectors.catalog_tool import NOT_HERE, manage_catalog
|
|
from tools.connectors.contract import TargetState
|
|
from tools.connectors.run import apply_answer
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clean_live():
|
|
live.reset_for_tests()
|
|
yield
|
|
live.reset_for_tests()
|
|
|
|
|
|
def _entry(name, *, platforms=(), requires_env=()):
|
|
return SimpleNamespace(
|
|
name=name, repo=f"https://github.com/example/{name}", sha="a" * 40, subdir="", tier="official",
|
|
description=f"Drives {name}. More text.", requires_hermes="", platforms=list(platforms),
|
|
capabilities=SimpleNamespace(requires_env=list(requires_env)),
|
|
)
|
|
|
|
|
|
class FakeInstaller:
|
|
"""The host side of a row: the catalog, the platform refusal and the installer."""
|
|
|
|
def __init__(self, entries=(), *, refuse=None, install_error=""):
|
|
self.entries = {e.name: e for e in entries}
|
|
self.refusal = refuse or {}
|
|
self.install_error = install_error
|
|
self.installs = []
|
|
|
|
def plugin_entry(self, name):
|
|
return self.entries.get(name)
|
|
|
|
def refuse(self, entry):
|
|
if entry.name in self.refusal:
|
|
raise RuntimeError(self.refusal[entry.name])
|
|
|
|
def install_plugin(self, name, *, force, enable, ref):
|
|
from hermes_constants import get_hermes_home
|
|
|
|
self.installs.append({"name": name, "force": force, "enable": enable, "ref": ref,
|
|
"home": Path(get_hermes_home())})
|
|
if self.install_error:
|
|
return {"ok": False, "error": self.install_error}
|
|
tools = [f"mcp__{name}__status", f"mcp__{name}__launch"]
|
|
return {"ok": True, "plugin_name": name, "missing_env": [], "activation": {
|
|
"live_now": {"mcp_servers": [{"name": name, "connected": True, "tools": tools}], "skills": []}}}
|
|
|
|
def skill_meta(self, identifier):
|
|
return None
|
|
|
|
def install_skill(self, identifier, *, force):
|
|
raise AssertionError("no skill in these tests")
|
|
|
|
|
|
def _card(answer, *, session_id="s1", profile_home=None):
|
|
"""A desktop card that answers the live operation a moment after it is drawn. ``profile_home`` is
|
|
the session's profile, which ``connection.respond`` looks the operation up under."""
|
|
seen = []
|
|
|
|
def callback(payload):
|
|
seen.append(payload)
|
|
|
|
def respond():
|
|
operation = live.get(session_id, payload["op_id"], profile_home=profile_home)
|
|
if operation is not None:
|
|
apply_answer(operation, json.dumps(answer(payload)))
|
|
|
|
threading.Timer(0.01, respond).start()
|
|
|
|
callback.seen = seen
|
|
return callback
|
|
|
|
|
|
def _install(items, installer, card):
|
|
with patch("tools.connectors.run.WATCH_INTERVAL_SECONDS", 0.01):
|
|
return json.loads(manage_catalog({"action": "install", "items": items}, session_id="s1",
|
|
connection_callback=card, card_surface=True, installer=installer))
|
|
|
|
|
|
def _approve(env=None):
|
|
return lambda payload: {"targets": [{"name": t["name"], "status": "approved", "env": env}
|
|
for t in payload["targets"] if t["state"] == "pending"]}
|
|
|
|
|
|
def test_only_a_setup_profile_session_selects_the_tool(tmp_path, monkeypatch):
|
|
import model_tools
|
|
from hermes_cli.profiles import write_profile_meta
|
|
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
|
|
|
homes = {}
|
|
for name, role in (("default", None), ("hermes-setup", "setup")):
|
|
homes[name] = tmp_path / name
|
|
homes[name].mkdir()
|
|
if role:
|
|
write_profile_meta(homes[name], role=role)
|
|
for name, expected in (("default", False), ("hermes-setup", True)):
|
|
token = set_hermes_home_override(str(homes[name]))
|
|
try:
|
|
# Even an explicit request for the toolset does not reach a profile without the role.
|
|
names = model_tools._select_tool_names(["setup", "web"], None, quiet_mode=True)
|
|
finally:
|
|
reset_hermes_home_override(token)
|
|
assert ("manage_catalog" in names) is expected, name
|
|
# The setup guide's one tool is sent directly, never hidden behind tool_search.
|
|
from tools.tool_search import is_deferrable_tool_name
|
|
assert not is_deferrable_tool_name("manage_catalog")
|
|
|
|
|
|
@pytest.mark.parametrize("args", [
|
|
{"action": "install", "items": [{"kind": "plugin", "id": "x"}], "profile": "work"},
|
|
{"action": "install", "items": [{"kind": "plugin", "id": "x", "sha": "b" * 40}]},
|
|
{"action": "install", "items": [{"kind": "plugin", "id": "x", "url": "https://evil.example/x"}]},
|
|
])
|
|
def test_a_source_version_or_profile_from_the_model_is_refused_before_anything_runs(args):
|
|
installer, card = FakeInstaller([_entry("x")]), _card(_approve())
|
|
out = json.loads(manage_catalog(args, session_id="s1", connection_callback=card, card_surface=True,
|
|
installer=installer))
|
|
assert "error" in out and not card.seen and not installer.installs
|
|
|
|
|
|
def test_off_the_desktop_the_result_points_at_the_cli_and_opens_no_card():
|
|
installer, card = FakeInstaller([_entry("x")]), _card(_approve())
|
|
for callback, surface in ((None, True), (card, False)):
|
|
out = json.loads(manage_catalog({"action": "install", "items": [{"kind": "plugin", "id": "x"}]},
|
|
session_id="s1", connection_callback=callback, card_surface=surface,
|
|
installer=installer))
|
|
assert out["error"] == NOT_HERE
|
|
assert not card.seen and not installer.installs and live.current("s1") is None
|
|
|
|
|
|
def test_unknown_and_unsupported_ids_are_drawn_failed_and_never_installed():
|
|
installer = FakeInstaller([_entry("nvidia-app", platforms=["windows"])],
|
|
refuse={"nvidia-app": "Plugin 'nvidia-app' is unavailable on darwin; "
|
|
"supported platforms: windows."})
|
|
card = _card(lambda payload: {"settled_by": "continue"})
|
|
out = _install([{"kind": "plugin", "id": "nope"}, {"kind": "plugin", "id": "nvidia-app"}], installer, card)
|
|
drawn = {t["name"]: t for t in card.seen[0]["targets"]}
|
|
assert {t["state"] for t in drawn.values()} == {TargetState.failed.value} # drawn with the reason
|
|
rows = {t["name"]: t for t in out["targets"]}
|
|
assert "catalog" in rows["nope"]["detail"]
|
|
assert "unavailable on darwin" in rows["nvidia-app"]["detail"]
|
|
assert rows["nvidia-app"]["display"] == "Nvidia App" and rows["nvidia-app"]["platforms"] == ["windows"]
|
|
assert not installer.installs
|
|
|
|
|
|
def test_an_approved_row_installs_into_default_and_lists_the_live_tools(tmp_path):
|
|
from hermes_cli.profiles import get_profile_dir, write_profile_meta
|
|
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
|
|
|
setup_home = tmp_path / "hermes-setup"
|
|
setup_home.mkdir()
|
|
write_profile_meta(setup_home, role="setup")
|
|
installer = FakeInstaller([_entry("blender")])
|
|
card = _card(_approve(), profile_home=str(setup_home))
|
|
token = set_hermes_home_override(str(setup_home)) # the call comes from the setup chat
|
|
try:
|
|
out = _install([{"kind": "plugin", "id": "blender"}], installer, card)
|
|
finally:
|
|
reset_hermes_home_override(token)
|
|
(row,) = out["targets"]
|
|
assert card.seen[0]["targets"][0]["state"] == TargetState.pending.value # nothing ran before the card
|
|
assert row["state"] == TargetState.connected.value and row["target_profile"] == "default"
|
|
assert row["tools"] == ["mcp__blender__status", "mcp__blender__launch"]
|
|
(call,) = installer.installs
|
|
assert (call["force"], call["enable"], call["ref"]) == (False, True, None)
|
|
assert call["home"].resolve() == Path(get_profile_dir("default")).resolve() != setup_home.resolve()
|
|
|
|
|
|
def test_advanced_values_pick_the_profile_force_and_pin(tmp_path):
|
|
from hermes_cli.profiles import create_profile
|
|
|
|
work = create_profile("work", no_alias=True)
|
|
installer = FakeInstaller([_entry("blender")])
|
|
pin = "c" * 40
|
|
out = _install([{"kind": "plugin", "id": "blender"}], installer,
|
|
_card(_approve({"target_profile": "work", "force": "1", "enable": "0", "ref": pin})))
|
|
(row,) = out["targets"]
|
|
(call,) = installer.installs
|
|
assert (call["force"], call["enable"], call["ref"]) == (True, False, pin)
|
|
assert call["home"].resolve() == Path(work).resolve() and row["target_profile"] == "work"
|
|
assert "not enabled" in row["detail"]
|
|
|
|
|
|
def test_a_failed_install_stays_failed_until_the_user_tries_again():
|
|
installer = FakeInstaller([_entry("blender")], install_error="clone failed: network down")
|
|
attempts = []
|
|
|
|
def answer(payload):
|
|
(target,) = payload["targets"]
|
|
attempts.append(target["state"])
|
|
return {"targets": [{"name": "blender", "status": "approved", "env": None}]}
|
|
|
|
card = _card(answer)
|
|
with patch("tools.connectors.run.WATCH_INTERVAL_SECONDS", 0.01):
|
|
result = {}
|
|
thread = threading.Thread(target=lambda: result.setdefault("out", json.loads(manage_catalog(
|
|
{"action": "install", "items": [{"kind": "plugin", "id": "blender"}]}, session_id="s1",
|
|
connection_callback=card, card_surface=True, installer=installer))))
|
|
thread.start()
|
|
for _ in range(200):
|
|
operation = live.current("s1")
|
|
if operation is not None and operation.targets[0].state == TargetState.failed:
|
|
break
|
|
threading.Event().wait(0.01)
|
|
assert operation.targets[0].detail == "clone failed: network down"
|
|
installer.install_error = ""
|
|
apply_answer(operation, json.dumps({"targets": [{"name": "blender", "status": "approved"}]}))
|
|
thread.join(5)
|
|
assert result["out"]["targets"][0]["state"] == TargetState.connected.value
|
|
assert len(installer.installs) == 2
|