Files
hermes-agent/tests/hermes_cli/test_mcp_catalog_discovery.py

131 lines
6.0 KiB
Python

"""Catalog HTTP contract: opt-in backend signals with real A/B/A profile config I/O."""
import asyncio
from pathlib import Path
import sys
import pytest
import yaml
from fastapi.testclient import TestClient
@pytest.fixture
def catalog_client(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
other = home / "profiles" / "b"
other.mkdir(parents=True)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setenv("PATH", "")
for directory, servers in ((home, {"demo": {"command": "unused", "enabled": False}}),
(other, {"demo": {"command": "unused", "enabled": True},
"legacy": {"command": "unused"}})):
(directory / "config.yaml").write_text(yaml.safe_dump(
{"mcp_servers": servers, "terminal": {"backend": "docker"}}), encoding="utf-8")
catalog = tmp_path / "catalog"
for name, suggest in (("demo", {"keywords": ["demo"], "applications": ["Fixture Paint"],
"examples": ["Draw a picture."], "requires_app": True}),
("legacy", {"keywords": ["legacy"]})):
entry = catalog / name
entry.mkdir(parents=True)
(entry / "manifest.yaml").write_text(yaml.safe_dump({
"manifest_version": 1, "name": name, "description": "Fixture entry",
"transport": {"type": "stdio", "command": "must-not-run"}, "suggest": suggest,
}), encoding="utf-8")
monkeypatch.setenv("HERMES_OPTIONAL_MCPS", str(catalog))
from agent import secret_scope
from tui_gateway import launch_profile_policy
monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", False)
monkeypatch.setattr(launch_profile_policy, "_snapshot", None)
from hermes_cli.web_server import _SESSION_HEADER_NAME, _SESSION_TOKEN, app
client = TestClient(app)
client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
return client, home, other
def test_catalog_detection_is_opt_in_and_preserves_profile_state(catalog_client, tmp_path, monkeypatch):
from hermes_cli import mcp_app_detection as detection
from hermes_constants import get_hermes_home
client, home, other = catalog_client
import socket
import subprocess
def forbidden(*_args, **_kwargs):
pytest.fail("Catalog discovery must not execute apps, install or probe the network")
monkeypatch.setattr(subprocess, "Popen", forbidden)
monkeypatch.setattr(socket, "create_connection", forbidden)
app_root = tmp_path / "Applications"
app_root.mkdir()
if sys.platform == "darwin":
(app_root / "Fixture Paint.app").mkdir()
elif sys.platform == "win32":
(app_root / "Fixture Paint").mkdir()
else:
(app_root / "fixture.desktop").write_text("[Desktop Entry]\nName=Fixture Paint\nExec=must-not-run\n")
monkeypatch.setattr(detection, "_application_roots", lambda: [app_root])
calls = []
discover = detection.discover_catalog_apps
def checked_discover(criteria):
with pytest.raises(RuntimeError):
asyncio.get_running_loop() # Scans stay off the event loop.
assert get_hermes_home() == home # No named-profile scope/skills lock spans discovery.
calls.append(criteria)
return discover(criteria)
monkeypatch.setattr(detection, "discover_catalog_apps", checked_discover)
snapshots = {directory: (directory / "config.yaml").read_bytes() for directory in (home, other)}
for profile, enabled, legacy_installed in ((None, False, False), ("b", True, True), (None, False, False)):
params = {"profile": profile} if profile else {}
before = len(calls)
old = client.get("/api/mcp/catalog", params=params)
assert old.status_code == 200, old.text
assert len(calls) == before
assert "discovery" not in old.json()
assert all("detected_apps" not in entry for entry in old.json()["entries"])
new = client.get("/api/mcp/catalog", params={**params, "detect_apps": "true", "future_hint": "ignored"})
assert new.status_code == 200, new.text
data = new.json()
assert data["discovery"] == {"scope": "backend", "status": "ok", "platform": sys.platform}
entries = {entry["name"]: entry for entry in data["entries"]}
assert entries["demo"]["detected_apps"] == ["Fixture Paint"]
assert entries["demo"]["installed"] is True and entries["demo"]["enabled"] is enabled
assert entries["legacy"]["detected_apps"] == []
assert entries["legacy"]["installed"] is legacy_installed
assert entries["legacy"]["suggest"]["applications"] == []
assert entries["legacy"]["suggest"]["requires_app"] is False
for entry in data["entries"]:
entry.pop("detected_apps")
data.pop("discovery")
assert data == old.json()
assert len(calls) == before + 1
assert {directory: (directory / "config.yaml").read_bytes() for directory in snapshots} == snapshots
assert client.get("/api/mcp/catalog", params={"profile": "missing", "detect_apps": True}).status_code == 404
def test_catalog_discovery_failure_retains_entries_as_unknown(catalog_client, monkeypatch):
from hermes_cli import mcp_app_detection as detection
client, _home, _other = catalog_client
calls = []
def failed_discovery(criteria):
calls.append(criteria)
raise OSError("private path must not be returned")
monkeypatch.setattr(detection, "discover_catalog_apps", failed_discovery)
old = client.get("/api/mcp/catalog?detect_apps=false").json()
assert calls == []
response = client.get("/api/mcp/catalog?detect_apps=true")
assert response.status_code == 200
data = response.json()
assert data["discovery"] == {"scope": "backend", "status": "unavailable", "platform": sys.platform}
assert all(entry.pop("detected_apps") == [] for entry in data["entries"])
data.pop("discovery")
assert data == old
assert "private path" not in response.text
assert client.get("/api/mcp/catalog?detect_apps=not-a-bool").status_code == 422