hermes-agent/tests/hermes_cli/test_web_server.py
2026-07-29 19:48:37 -07:00

3520 lines
132 KiB
Python

"""Tests for hermes_cli.web_server and related config utilities."""
import asyncio
import os
import json
import shutil
import sys
import threading
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch, MagicMock
import pytest
import yaml
from hermes_cli.config import (
reload_env,
redact_key,
OPTIONAL_ENV_VARS,
DEFAULT_CONFIG,
)
# ---------------------------------------------------------------------------
# Shared fixtures
# ---------------------------------------------------------------------------
# Path to the test-only example-dashboard plugin. Lives under
# tests/fixtures/ so the bundled-plugins directory stays clean — stock
# installs no longer ship a dummy "Example" sidebar tab. Tests that
# depend on its routes opt in via the `_install_example_plugin` fixture
# below.
_EXAMPLE_PLUGIN_FIXTURE = (
Path(__file__).resolve().parent.parent / "fixtures" / "plugins" / "example-dashboard"
)
@pytest.fixture
def _install_example_plugin(_isolate_hermes_home):
"""Drop the example-dashboard fixture into the per-test HERMES_HOME
user-plugins directory and force the web_server's dashboard plugin
cache + API mount to rediscover it.
The plugin used to live under ``<repo>/plugins/example-dashboard/``
and was loaded for every install, putting an "Example" tab in every
user's sidebar. It is now a tests-only fixture: any test that needs
``/api/plugins/example/hello`` or ``/dashboard-plugins/example/...``
requests this fixture so the plugin appears only for that test's
isolated ``HERMES_HOME``.
The user-plugin source is preferred over a transient
``HERMES_BUNDLED_PLUGINS`` override because the bundled dir is
resolved per-call (other tests in the suite implicitly rely on the
real bundled plugins — kanban, hermes-achievements, model providers
— being available, and globally swapping that root would yank them
all). User plugins are first in the discovery search order, so
laying down the fixture here is enough.
"""
from hermes_constants import get_hermes_home
from hermes_cli import web_server
user_plugins_dir = get_hermes_home() / "plugins"
user_plugins_dir.mkdir(parents=True, exist_ok=True)
dst = user_plugins_dir / "example-dashboard"
if dst.exists():
shutil.rmtree(dst)
shutil.copytree(_EXAMPLE_PLUGIN_FIXTURE, dst)
# The dashboard now gates user-plugin asset serving + backend import
# behind the ``plugins.enabled`` allow-list (GHSA-mcfc-hp25-cjv7).
# An installed-but-not-enabled user plugin has its API mount skipped
# and its assets 404'd — which is the whole point of the gate. These
# fixtures exist to exercise the *serving* paths, so opt the example
# plugin in exactly as a real operator would with `hermes plugins
# enable example`.
from hermes_cli.config import load_config, save_config
_cfg = load_config()
_plugins_cfg = _cfg.setdefault("plugins", {})
_enabled = _plugins_cfg.get("enabled")
if not isinstance(_enabled, list):
_enabled = []
if "example" not in _enabled:
_enabled.append("example")
_plugins_cfg["enabled"] = _enabled
save_config(_cfg)
# Snapshot the existing routes BEFORE mounting so we can:
# 1. Identify the routes the mount call appends.
# 2. Restore the original list on teardown — otherwise leftover
# ``/api/plugins/example/*`` routes leak into subsequent tests
# and start serving requests against a torn-down HERMES_HOME.
app = web_server.app
original_routes = list(app.router.routes)
# Bust the module-level cache and re-discover so the example plugin
# shows up in `_get_dashboard_plugins()`. `_mount_plugin_api_routes`
# imports the plugin's `plugin_api.py` and ``include_router``s its
# FastAPI router under ``/api/plugins/example/*``. The static-asset
# route at ``/dashboard-plugins/<name>/<path>`` reads the plugins
# list dynamically per request, so the rescan alone is enough for
# the static-asset tests; the API auth tests additionally need the
# route reorder below.
web_server._dashboard_plugins_cache = None
web_server._get_dashboard_plugins(force_rescan=True)
web_server._mount_plugin_api_routes()
# ``include_router`` appends the new routes to the END of
# ``app.router.routes``. That works fine at import time — the SPA
# catch-all ``mount_spa(app)`` registers AFTER the initial mount
# call — but when we mount mid-flight the catch-all is already in
# place, so the new ``/api/plugins/example/*`` route loses the
# match-order race and we get a 404. Move the newly-appended routes
# to the front of the list so FastAPI matches them first. They're
# path-prefixed to ``/api/plugins/example/`` and can't shadow
# anything else.
new_routes = [r for r in app.router.routes if r not in original_routes]
for route in new_routes:
app.router.routes.remove(route)
for offset, route in enumerate(new_routes):
app.router.routes.insert(offset, route)
try:
yield
finally:
# Restore the original route list — drops the example plugin's
# routes so the next test sees a clean app — and clear the
# cache for the same reason.
app.router.routes[:] = original_routes
web_server._dashboard_plugins_cache = None
# ---------------------------------------------------------------------------
# reload_env tests
# ---------------------------------------------------------------------------
class TestReloadEnv:
"""Tests for reload_env() — re-reads .env into os.environ."""
def test_adds_new_vars(self, tmp_path):
"""reload_env() adds vars from .env that are not in os.environ."""
env_file = tmp_path / ".env"
env_file.write_text("TEST_RELOAD_VAR=hello123\n")
with patch.dict(reload_env.__globals__, {"get_env_path": lambda: env_file}):
os.environ.pop("TEST_RELOAD_VAR", None)
count = reload_env()
assert count >= 1
assert os.environ.get("TEST_RELOAD_VAR") == "hello123"
os.environ.pop("TEST_RELOAD_VAR", None)
def test_removes_deleted_known_vars(self, tmp_path):
"""reload_env() removes known Hermes vars not present in .env."""
env_file = tmp_path / ".env"
env_file.write_text("") # empty .env
# Pick a known key from OPTIONAL_ENV_VARS
known_key = next(iter(OPTIONAL_ENV_VARS.keys()))
with patch.dict(reload_env.__globals__, {"get_env_path": lambda: env_file}):
os.environ[known_key] = "stale_value"
count = reload_env()
assert known_key not in os.environ
assert count >= 1
# ---------------------------------------------------------------------------
# redact_key tests
# ---------------------------------------------------------------------------
class TestRedactKey:
def test_long_key_shows_prefix_suffix(self):
result = redact_key("sk-1234567890abcdef")
assert result.startswith("sk-1")
assert result.endswith("cdef")
assert "..." in result
def test_short_key_fully_masked(self):
assert redact_key("short") == "***"
def test_empty_key(self):
result = redact_key("")
assert "not set" in result.lower() or result == "***" or "\x1b" in result
class TestSessionTokenInjection:
"""The desktop shell mints HERMES_DASHBOARD_SESSION_TOKEN and signs its
/api + /api/ws calls with it. The backend must adopt that token, else every
desktop request 401s ("gateway is offline"). A main-merge once silently
dropped this read — this guards the contract, not a literal value.
"""
def test_honors_injected_token(self, monkeypatch):
import hermes_cli.web_server as ws
original_app = ws.app
original_token = ws._SESSION_TOKEN
monkeypatch.setenv("HERMES_DASHBOARD_SESSION_TOKEN", "desktop-seeded-token")
assert ws._resolve_session_token() == "desktop-seeded-token"
# No module reload: the loaded app and its adopted token are untouched.
assert ws.app is original_app
assert ws._SESSION_TOKEN == original_token
def test_falls_back_to_random_token(self, monkeypatch):
import hermes_cli.web_server as ws
monkeypatch.delenv("HERMES_DASHBOARD_SESSION_TOKEN", raising=False)
with patch.object(
ws.secrets, "token_urlsafe", return_value="generated-token"
) as token_urlsafe:
assert ws._resolve_session_token() == "generated-token"
token_urlsafe.assert_called_once_with(32)
def test_session_token_resolution_preserves_loaded_app_auth(self, monkeypatch):
import hermes_cli.web_server as ws
from starlette.testclient import TestClient
original_app = ws.app
original_header_name = ws._SESSION_HEADER_NAME
original_token = ws._SESSION_TOKEN
monkeypatch.setenv("HERMES_DASHBOARD_SESSION_TOKEN", "desktop-seeded-token")
assert ws._resolve_session_token() == "desktop-seeded-token"
monkeypatch.delenv("HERMES_DASHBOARD_SESSION_TOKEN", raising=False)
with patch.object(ws.secrets, "token_urlsafe", return_value="generated-token"):
assert ws._resolve_session_token() == "generated-token"
client = TestClient(original_app)
client.headers[original_header_name] = original_token
assert client.get("/api/__session_token_probe").status_code == 404
assert ws.app is original_app
assert ws._SESSION_HEADER_NAME == original_header_name
assert ws._SESSION_TOKEN == original_token
# ---------------------------------------------------------------------------
# web_server tests (FastAPI endpoints)
# ---------------------------------------------------------------------------
class TestWebServerEndpoints:
"""Test the FastAPI REST endpoints using Starlette TestClient."""
@pytest.fixture(autouse=True)
def _setup_test_client(self, monkeypatch, _isolate_hermes_home):
"""Create a TestClient and isolate the state DB under the test HERMES_HOME."""
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")
self.client = TestClient(app)
self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
def test_messaging_platforms_profile_scopes_gateway_reads(self, monkeypatch):
"""?profile=<name> must resolve liveness from the profile's own home.
The gateway status readers resolve process-level paths and ignore the
HERMES_HOME contextvar override (#56986), so /api/messaging/platforms
has to pass the profile directory explicitly — otherwise it reports a
DIFFERENT profile's gateway as this profile's, which hides a real
outage behind a false "connected" (issue #71211).
"""
import hermes_cli.web_server as web_server
from hermes_cli import profiles as profiles_mod
worker_home = profiles_mod.get_profile_dir("worker")
worker_home.mkdir(parents=True)
seen = {}
def _pid(pid_path=None, **kw):
seen["pid_path"] = pid_path
return None
def _runtime(path=None):
seen["status_path"] = path
return None
def _runtime_pid(runtime=None, *, expected_home=None):
seen["expected_home"] = expected_home
return None
monkeypatch.setattr(web_server, "get_running_pid_cached", _pid)
monkeypatch.setattr(web_server, "get_running_pid", _pid)
monkeypatch.setattr(web_server, "read_runtime_status", _runtime)
monkeypatch.setattr(web_server, "get_runtime_status_running_pid", _runtime_pid)
monkeypatch.setattr(web_server, "_GATEWAY_HEALTH_URL", None)
resp = self.client.get("/api/messaging/platforms?profile=worker")
assert resp.status_code == 200
assert seen["pid_path"] == worker_home / "gateway.pid"
assert seen["status_path"] == worker_home / "gateway_state.json"
assert seen["expected_home"] == worker_home
def test_gateway_drain_bad_action_400(self):
resp = self.client.post("/api/gateway/drain", json={"action": "explode"})
assert resp.status_code == 400
@staticmethod
def _provider_field_map(payload):
return {field["key"]: field for field in payload["fields"]}
def test_declared_surface_put_writes_config_and_secret(self):
from hermes_constants import get_hermes_home
from hermes_cli.config import load_env
resp = self.client.put(
"/api/memory/providers/hindsight/config?surface=declared",
json={
"values": {
"mode": "local_external",
"api_url": "http://localhost:8888",
"api_key": "hs-declared-key",
}
},
)
assert resp.status_code == 200
assert resp.json() == {"ok": True}
assert load_env()["HINDSIGHT_API_KEY"] == "hs-declared-key"
config_path = get_hermes_home() / "hindsight" / "config.json"
provider_config = json.loads(config_path.read_text(encoding="utf-8"))
assert provider_config["mode"] == "local_external"
assert provider_config["api_url"] == "http://localhost:8888"
assert "api_key" not in provider_config
def test_post_memory_provider_setup_routes_pip_through_lazy_deps(self, monkeypatch):
"""NS-605: dashboard pip installs must use the environment-aware
lazy_deps pipeline (durable-target redirect on immutable hosted
images), never a direct `pip install --python sys.executable`."""
import subprocess as _subprocess
import hermes_cli.web_server as web_server
from tools import lazy_deps as ld
# honcho declares pip_dependencies: [honcho-ai]; force it missing.
monkeypatch.setattr(web_server, "_dependency_importable", lambda dep: False)
installed = []
def fake_install_specs(specs, *, timeout=300):
installed.append(tuple(specs))
return ld.InstallSpecsResult(
ok=True, command="uv pip install --target /opt/data/lazy-packages honcho-ai",
stdout="ok", stderr="",
)
monkeypatch.setattr(ld, "install_specs", fake_install_specs)
# Any direct pip/uv subprocess from the memory-provider pip path is
# a regression; external-dep checks may still run subprocess, so only
# trip on pip-flavored commands.
real_run = _subprocess.run
def guarded_run(command, **kwargs):
flat = command if isinstance(command, str) else " ".join(map(str, command))
assert "pip install" not in flat, f"direct pip call leaked: {flat}"
return real_run(command, **kwargs)
monkeypatch.setattr(web_server.subprocess, "run", guarded_run)
resp = self.client.post("/api/memory/providers/honcho/setup", json={"values": {}})
assert resp.status_code == 200
data = resp.json()
pip_rows = [row for row in data["results"] if row["kind"] == "pip"]
assert pip_rows and pip_rows[0]["status"] == "installed"
assert "--target /opt/data/lazy-packages" in pip_rows[0]["command"]
assert installed == [("honcho-ai",)]
def test_put_memory_provider_config_writes_config_and_secret(self):
from hermes_constants import get_hermes_home
from hermes_cli.config import load_config, load_env
resp = self.client.put(
"/api/memory/providers/hindsight/config",
json={
"values": {
"mode": "local_external",
"api_url": "http://localhost:8888",
"api_key": "hs-test-key",
"bank_id": "ben-bank",
"recall_budget": "high",
}
},
)
assert resp.status_code == 200
assert resp.json() == {"ok": True, "active": "hindsight"}
assert load_config()["memory"]["provider"] == "hindsight"
assert load_env()["HINDSIGHT_API_KEY"] == "hs-test-key"
config_path = get_hermes_home() / "hindsight" / "config.json"
provider_config = json.loads(config_path.read_text(encoding="utf-8"))
assert provider_config["mode"] == "local_external"
assert provider_config["api_url"] == "http://localhost:8888"
assert provider_config["bank_id"] == "ben-bank"
assert provider_config["recall_budget"] == "high"
assert "api_key" not in provider_config
def test_get_memory_provider_config_does_not_return_secret(self):
self.client.put(
"/api/memory/providers/hindsight/config",
json={
"values": {
"mode": "cloud",
"api_url": "https://api.hindsight.vectorize.io",
"api_key": "secret-value",
"bank_id": "hermes",
"recall_budget": "mid",
}
},
)
resp = self.client.get("/api/memory/providers/hindsight/config")
assert resp.status_code == 200
data = resp.json()
fields = self._provider_field_map(data)
assert fields["api_key"]["is_set"] is True
assert fields["api_key"]["value"] == ""
assert "secret-value" not in json.dumps(data)
# ── Memory provider config (Honcho host-block backend) ──────────────
@pytest.fixture(autouse=True)
def _isolate_honcho_config(self):
# Honcho tests write the suite-wide HERMES_HOME honcho.json; snapshot and
# restore it so provider status/config state never leaks across tests.
from hermes_constants import get_hermes_home
path = get_hermes_home() / "honcho.json"
before = path.read_bytes() if path.exists() else None
yield
if before is None:
path.unlink(missing_ok=True)
else:
path.write_bytes(before)
@staticmethod
def _seed_local_honcho(cfg=None):
from hermes_constants import get_hermes_home
path = get_hermes_home() / "honcho.json"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(cfg if cfg is not None else {}), encoding="utf-8")
return path
def test_put_honcho_writes_host_block_root_and_secret(self, monkeypatch, tmp_path):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("HONCHO_API_KEY", "guard")
monkeypatch.delenv("HONCHO_API_KEY")
self._seed_local_honcho()
from hermes_constants import get_hermes_home
from hermes_cli.config import load_config, load_env
resp = self.client.put(
"/api/memory/providers/honcho/config?surface=declared",
json={
"values": {
"apiKey": "hch-test-key",
"baseUrl": "https://honcho.example.dev",
"environment": "local",
"workspace": "myws",
"peerName": "eri",
"aiPeer": "hermes",
"sessionStrategy": "per-repo",
}
},
)
assert resp.status_code == 200
assert resp.json() == {"ok": True}
assert load_config()["memory"]["provider"] == "honcho"
assert load_env()["HONCHO_API_KEY"] == "hch-test-key"
cfg = json.loads((get_hermes_home() / "honcho.json").read_text(encoding="utf-8"))
# baseUrl is root-scoped; the rest live in the active host block.
assert cfg["baseUrl"] == "https://honcho.example.dev"
assert cfg["hosts"]["hermes"]["workspace"] == "myws"
assert cfg["hosts"]["hermes"]["peerName"] == "eri"
assert cfg["hosts"]["hermes"]["environment"] == "local"
assert cfg["hosts"]["hermes"]["sessionStrategy"] == "per-repo"
# The key lands where the client reads first; GET keeps it write-only.
assert cfg["hosts"]["hermes"]["apiKey"] == "hch-test-key"
def test_get_honcho_config_does_not_return_secret(self, monkeypatch, tmp_path):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("HONCHO_API_KEY", "guard")
monkeypatch.delenv("HONCHO_API_KEY")
self._seed_local_honcho()
self.client.put(
"/api/memory/providers/honcho/config?surface=declared",
json={"values": {"apiKey": "secret-value"}},
)
resp = self.client.get("/api/memory/providers/honcho/config?surface=declared")
assert resp.status_code == 200
data = resp.json()
fields = self._provider_field_map(data)
assert fields["apiKey"]["is_set"] is True
assert fields["apiKey"]["value"] == ""
assert "secret-value" not in json.dumps(data)
# ── GET /api/media (remote image display) ───────────────────────────
def test_get_media_requires_auth(self):
from hermes_cli.web_server import _SESSION_HEADER_NAME
resp = self.client.get(
"/api/media",
params={"path": "/tmp/x.png"},
headers={_SESSION_HEADER_NAME: "wrong-token"},
)
assert resp.status_code == 401
# ── POST /api/chat/image-upload (browser clipboard/drop images) ─────
# ── Dashboard font override ─────────────────────────────────────────
def _create_session_with_heavy_fields(self, session_id: str) -> None:
from hermes_state import SessionDB
db = SessionDB()
try:
db.create_session(
session_id=session_id,
source="cli",
system_prompt="# SOUL.md\n" + ("prompt body " * 500),
model_config={"temperature": 0.7, "notes": "x" * 200},
)
finally:
db.close()
def test_import_sessions_endpoint_imports_exported_json(self):
from hermes_state import SessionDB
payload = {
"id": "imported-web-session",
"source": "cli",
"title": "Imported from dashboard",
"started_at": 100.0,
"ended_at": 110.0,
"end_reason": "complete",
"messages": [
{"role": "user", "content": "hello", "timestamp": 101.0},
{"role": "assistant", "content": "hi", "timestamp": 102.0},
],
}
resp = self.client.post("/api/sessions/import", json={"sessions": [payload]})
assert resp.status_code == 200
data = resp.json()
assert data["imported"] == 1
assert data["skipped"] == 0
db = SessionDB()
try:
session = db.get_session("imported-web-session")
assert session["title"] == "Imported from dashboard"
assert session["message_count"] == 2
assert [m["content"] for m in db.get_messages("imported-web-session")] == [
"hello",
"hi",
]
finally:
db.close()
duplicate = self.client.post("/api/sessions/import", json={"sessions": [payload]})
assert duplicate.status_code == 200
assert duplicate.json()["skipped_ids"] == ["imported-web-session"]
invalid = self.client.post(
"/api/sessions/import",
json={"sessions": [{"source": "cli", "messages": []}]},
)
assert invalid.status_code == 400
assert invalid.json()["detail"]["errors"] == [
{"index": 0, "error": "session id is required"}
]
def test_latest_descendant_survives_parent_cycle(self):
"""Regression for the #39140 CTE salvage: a corrupted parent chain
that loops (a -> b -> a) must terminate (UNION dedup) instead of
recursing forever like UNION ALL would."""
from hermes_state import SessionDB
db = SessionDB()
try:
db.create_session(session_id="cyc-a", source="cli")
db.create_session(
session_id="cyc-b", source="cli", parent_session_id="cyc-a"
)
db._conn.execute(
"UPDATE sessions SET parent_session_id='cyc-b' WHERE id='cyc-a'"
)
db._conn.commit()
finally:
db.close()
resp = self.client.get("/api/sessions/cyc-a/latest-descendant")
assert resp.status_code == 200
assert resp.json()["session_id"] == "cyc-b"
def test_update_hermes_returns_docker_guidance_without_spawning(self, monkeypatch):
import hermes_cli.web_server as web_server
spawned = False
def fail_spawn(*_args, **_kwargs):
nonlocal spawned
spawned = True
raise AssertionError("docker update guard should not spawn hermes update")
# Bypass the managed-externally gate so we reach the docker install check.
monkeypatch.setattr(web_server, "_dashboard_local_update_managed_externally", lambda: False)
monkeypatch.setattr(web_server, "detect_install_method", lambda _root: "docker")
monkeypatch.setattr(web_server, "_spawn_hermes_action", fail_spawn)
web_server._ACTION_PROCS.pop("hermes-update", None)
web_server._ACTION_RESULTS.pop("hermes-update", None)
resp = self.client.post("/api/hermes/update")
assert resp.status_code == 200
data = resp.json()
assert data["ok"] is False
assert data["name"] == "hermes-update"
assert data["pid"] is None
assert data["error"] == "docker_update_unsupported"
assert "docker pull nousresearch/hermes-agent:latest" in data["message"]
assert spawned is False
status = self.client.get("/api/actions/hermes-update/status")
assert status.status_code == 200
status_data = status.json()
assert status_data["running"] is False
assert status_data["exit_code"] == 1
assert status_data["pid"] is None
assert any("docker pull nousresearch/hermes-agent:latest" in line for line in status_data["lines"])
def _schema_provider_options(self, key):
resp = self.client.get("/api/config/schema")
assert resp.status_code == 200
return resp.json()["fields"][key]["options"]
def test_platform_scoped_messaging_env_vars_are_channel_managed(self):
"""Platform-scoped vars belong to a Channels card; cross-cutting
gateway vars belong to the Keys page.
Uses credentials as the example: the self-configuring knobs
(*_HOME_CHANNEL, *_ALLOW_ALL_USERS, …) were deliberately dropped from
the setup cards and handed back to Keys — see
tests/hermes_cli/test_setup_hidden_env.py.
"""
from hermes_cli.web_server import (
_MESSAGING_KEYS_PAGE_KEYS,
_build_catalog_entry,
_channel_managed_env_keys,
)
discord = _build_catalog_entry("discord")
assert "DISCORD_BOT_TOKEN" in discord["env_vars"]
assert "DISCORD_ALLOWED_USERS" in discord["env_vars"]
managed = _channel_managed_env_keys()
assert "DISCORD_BOT_TOKEN" in managed
assert "MATTERMOST_TOKEN" in managed
assert "GATEWAY_PROXY_URL" not in managed
assert "GATEWAY_PROXY_URL" in _MESSAGING_KEYS_PAGE_KEYS
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_reveal_env_var(self, tmp_path):
"""POST /api/env/reveal should return the real unredacted value."""
from hermes_cli.config import save_env_value
from hermes_cli.web_server import _SESSION_HEADER_NAME, _SESSION_TOKEN
save_env_value("TEST_REVEAL_KEY", "super-secret-value-12345")
resp = self.client.post(
"/api/env/reveal",
json={"key": "TEST_REVEAL_KEY"},
headers={_SESSION_HEADER_NAME: _SESSION_TOKEN},
)
assert resp.status_code == 200
data = resp.json()
assert data["key"] == "TEST_REVEAL_KEY"
assert data["value"] == "super-secret-value-12345"
def test_reveal_env_var_custom_session_header_ignores_proxy_authorization(self, tmp_path):
"""A valid dashboard session header should coexist with proxy auth."""
from hermes_cli.config import save_env_value
from hermes_cli.web_server import _SESSION_HEADER_NAME, _SESSION_TOKEN
save_env_value("TEST_REVEAL_PROXY_AUTH", "secret-value")
resp = self.client.post(
"/api/env/reveal",
json={"key": "TEST_REVEAL_PROXY_AUTH"},
headers={
_SESSION_HEADER_NAME: _SESSION_TOKEN,
"Authorization": "Basic dXNlcjpwYXNz",
},
)
assert resp.status_code == 200
assert resp.json()["value"] == "secret-value"
def test_reveal_env_var_legacy_authorization_header_still_works(self, tmp_path):
"""Keep old dashboard bundles working while the new header rolls out."""
from hermes_cli.config import save_env_value
from hermes_cli.web_server import _SESSION_TOKEN
save_env_value("TEST_REVEAL_LEGACY_AUTH", "secret-value")
resp = self.client.post(
"/api/env/reveal",
json={"key": "TEST_REVEAL_LEGACY_AUTH"},
headers={"Authorization": f"Bearer {_SESSION_TOKEN}"},
)
assert resp.status_code == 200
def test_messaging_catalog_prefers_plugin_label_over_enum_pseudo_member(self):
"""A plugin platform that leaked into Platform.__members__ as a pseudo-
member must still render with its plugin label, not a title-cased id.
Regression: Platform("<plugin id>") caches a pseudo-member in the enum;
the catalog iterated the enum FIRST and claimed the id with no plugin
metadata, so bundled plugin platforms (irc, ntfy, photon, …) rendered
as nameless "Irc"/"Ntfy" cards with empty descriptions.
"""
from gateway.config import Platform
from gateway.platform_registry import PlatformEntry, platform_registry
entry = PlatformEntry(
name="pseudofake",
label="Pseudo Fake (plugin label)",
adapter_factory=lambda cfg: None,
check_fn=lambda: True,
source="plugin",
)
platform_registry.register(entry)
try:
# Materialize the enum pseudo-member the way any earlier config
# read would (Platform(value) on a registered plugin platform).
member = Platform("pseudofake")
assert member.value == "pseudofake"
assert "PSEUDOFAKE" in Platform.__members__
resp = self.client.get("/api/messaging/platforms")
ids = {row["id"]: row for row in resp.json()["platforms"]}
assert "pseudofake" in ids
assert ids["pseudofake"]["name"] == "Pseudo Fake (plugin label)"
finally:
platform_registry.unregister("pseudofake")
Platform._value2member_map_.pop("pseudofake", None)
Platform._member_map_.pop("PSEUDOFAKE", None)
def test_telegram_onboarding_apply_reports_restart_failure_after_save(
self, monkeypatch
):
import hermes_cli.web_server as ws
from hermes_cli.config import load_config, load_env
with ws._telegram_onboarding_lock:
ws._telegram_onboarding_pairings.clear()
def fake_request(method, path, *, body=None, bearer_token=None):
if method == "POST":
return {
"pairing_id": "pair-restart-fails",
"poll_token": "poll-secret",
"suggested_username": "hermes_pair_restart_fails_bot",
"deep_link": "https://t.me/newbot/HermesSetupBot/hermes_pair_restart_fails_bot",
"qr_payload": "https://t.me/newbot/HermesSetupBot/hermes_pair_restart_fails_bot",
"expires_at": "2027-05-18T00:00:00.000Z",
}
assert method == "GET"
assert path == "/v1/telegram/pairings/pair-restart-fails"
assert bearer_token == "poll-secret"
return {
"status": "ready",
"bot_username": "hermes_pair_restart_fails_bot",
"owner_user_id": 123456789,
"token": "123456:SECRET",
}
monkeypatch.setattr(ws, "_telegram_onboarding_request_sync", fake_request)
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)
start = self.client.post("/api/messaging/telegram/onboarding/start", json={})
assert start.status_code == 200
ready = self.client.get("/api/messaging/telegram/onboarding/pair-restart-fails")
assert ready.status_code == 200
assert ready.json()["status"] == "ready"
applied = self.client.post(
"/api/messaging/telegram/onboarding/pair-restart-fails/apply",
json={"allowed_user_ids": ["123456789"]},
)
assert applied.status_code == 200
applied_data = applied.json()
assert applied_data["ok"] is True
assert applied_data["needs_restart"] is True
assert applied_data["restart_started"] is False
assert "supervisor unavailable" in applied_data["restart_error"]
assert "token" not in applied_data
env = load_env()
assert env["TELEGRAM_BOT_TOKEN"] == "123456:SECRET"
assert env["TELEGRAM_ALLOWED_USERS"] == "123456789"
assert load_config()["platforms"]["telegram"]["enabled"] is True
def test_unauthenticated_api_blocked(self):
"""API requests without the session token should be rejected."""
from starlette.testclient import TestClient
from hermes_cli.web_server import app
# Create a client WITHOUT the dashboard session header
unauth_client = TestClient(app)
resp = unauth_client.get("/api/env")
assert resp.status_code == 401
resp = unauth_client.get("/api/config")
assert resp.status_code == 401
# Public endpoints should still work
resp = unauth_client.get("/api/status")
assert resp.status_code == 200
resp = unauth_client.get("/api/dashboard/plugins")
assert resp.status_code == 200
resp = unauth_client.get("/api/dashboard/plugins/rescan")
assert resp.status_code == 401
resp = self.client.get("/api/dashboard/plugins/rescan")
assert resp.status_code == 200
def test_parse_model_ids_handles_openai_and_bare_shapes(self):
"""Model discovery must tolerate the common /v1/models shapes and
never raise (so a slightly non-standard local endpoint still works)."""
from hermes_cli.web_server import _parse_model_ids
class FakeResp:
def __init__(self, payload, ok=True):
self._payload = payload
self.is_success = ok
def json(self):
if isinstance(self._payload, Exception):
raise self._payload
return self._payload
# OpenAI / vLLM / llama.cpp shape.
assert _parse_model_ids(
FakeResp({"data": [{"id": "llama-3.1-8b"}, {"id": "qwen2.5-7b"}]})
) == ["llama-3.1-8b", "qwen2.5-7b"]
# Bare list of ids.
assert _parse_model_ids(FakeResp({"data": ["m1", "m2"]})) == ["m1", "m2"]
# Top-level list.
assert _parse_model_ids(FakeResp([{"id": "x"}])) == ["x"]
# Non-success / malformed / exception → [] (never raises).
assert _parse_model_ids(FakeResp({"data": []}, ok=False)) == []
assert _parse_model_ids(FakeResp({"nope": 1})) == []
assert _parse_model_ids(FakeResp(ValueError("bad json"))) == []
def test_set_model_main_custom_persists_api_key_and_registers_provider(self):
"""A custom endpoint that requires auth must persist model.api_key (where
the runtime reads it) AND register a named custom_providers entry so the
endpoint reappears as a ready row in the picker — matching the
``hermes model`` custom flow. Regression for the desktop loop where a
keyed custom endpoint could never be configured from the GUI."""
from hermes_cli.config import load_config
resp = self.client.post(
"/api/model/set",
json={
"scope": "main",
"provider": "custom",
"model": "gpt-oss-120b",
"base_url": "https://text.example.com/v1",
"api_key": "sk-secret",
},
)
assert resp.status_code == 200
assert resp.json()["ok"] is True
cfg = load_config()
model_cfg = cfg.get("model")
assert isinstance(model_cfg, dict)
assert model_cfg["provider"] == "custom"
assert model_cfg["base_url"] == "https://text.example.com/v1"
assert model_cfg["api_key"] == "sk-secret"
# Registered in custom_providers (dedup by base_url) so the picker shows
# a proper ready row instead of the "needs setup" dead-end.
custom = cfg.get("custom_providers") or []
assert any(
isinstance(e, dict)
and e.get("base_url") == "https://text.example.com/v1"
and e.get("api_key") == "sk-secret"
and e.get("model") == "gpt-oss-120b"
for e in custom
)
def _seed_custom_provider_with_key(self):
from hermes_cli.config import load_config, save_config
cfg = load_config()
cfg["providers"] = {
"acme": {
"name": "Acme",
"base_url": "https://llm.acme.corp/v1",
"model": "acme/m1",
"api_key": "sk-stored-old",
"models": {"acme/m1": {}},
}
}
save_config(cfg)
def test_deleting_the_active_custom_endpoint_clears_its_model_mirror(self):
"""Deleting an endpoint must not leave its credential running the agent.
``activate`` mirrors the endpoint's base_url + credential reference
onto ``model``, and that mirror outranks the environment at client
construction (#62269). Without clearing it the agent keeps
authenticating to the deleted host, and the credential the operator
just removed through the dashboard survives the delete.
"""
from hermes_cli.config import custom_endpoint_key_env, get_env_value, load_config
self.client.post(
"/api/providers/custom-endpoints",
json={
"id": "acme",
"name": "Acme",
"base_url": "https://llm.acme.corp/v1",
"model": "acme/model-1",
"api_key": "sk-acme-secret",
},
)
assert self.client.post(
"/api/providers/custom-endpoints/acme/activate", json={}
).status_code == 200
env_var = custom_endpoint_key_env("acme")
cfg = load_config()
assert cfg["model"]["key_env"] == env_var
assert get_env_value(env_var) == "sk-acme-secret"
assert self.client.request(
"DELETE", "/api/providers/custom-endpoints/acme"
).status_code == 200
cfg = load_config()
assert "acme" not in (cfg.get("providers") or {})
model_cfg = cfg.get("model") or {}
assert not model_cfg.get("api_key"), "deleted endpoint's key still in config.yaml"
assert not model_cfg.get("key_env"), "deleted endpoint's key ref still in config.yaml"
assert not model_cfg.get("base_url"), "deleted endpoint's host still routed to"
assert not model_cfg.get("provider")
assert not get_env_value(env_var), "deleted endpoint's key still in .env"
def test_custom_endpoint_save_keeps_the_api_key_out_of_config(self):
"""The key belongs in .env behind key_env, never in config.yaml (#69449)."""
from hermes_cli.config import custom_endpoint_key_env, get_env_value, load_config
self.client.post(
"/api/providers/custom-endpoints",
json={
"id": "proxy",
"name": "Proxy",
"base_url": "https://llm.example.com/v1",
"model": "m",
"api_key": "sk-super-secret",
"make_default": True,
},
)
cfg = load_config()
entry = cfg["providers"]["proxy"]
env_var = custom_endpoint_key_env("proxy")
assert entry["key_env"] == env_var
assert "api_key" not in entry
assert "api_key" not in cfg["model"]
assert get_env_value(env_var) == "sk-super-secret"
assert "sk-super-secret" not in yaml.safe_dump(cfg)
def test_custom_endpoint_save_leaves_a_hand_written_env_ref_alone(self, monkeypatch):
"""``api_key: ${MY_KEY}`` is already safe — don't copy it elsewhere.
load_config() expands env refs, so such an entry looks like a literal
secret by the time Save sees it. Migrating it would duplicate the
user's secret into a second env var they never asked for.
"""
import yaml
from hermes_cli.config import custom_endpoint_key_env, get_config_path, get_env_value
monkeypatch.setenv("MY_PROXY_KEY", "sk-user-managed")
get_config_path().write_text(
yaml.safe_dump({
"providers": {
"proxy": {
"name": "Proxy",
"base_url": "https://llm.example.com/v1",
"model": "m",
"api_key": "${MY_PROXY_KEY}",
}
},
}),
encoding="utf-8",
)
self.client.post(
"/api/providers/custom-endpoints",
json={
"id": "proxy",
"name": "Proxy",
"base_url": "https://llm.example.com/v1",
"model": "m",
},
)
raw = yaml.safe_load(get_config_path().read_text(encoding="utf-8"))
assert raw["providers"]["proxy"]["api_key"] == "${MY_PROXY_KEY}"
assert not get_env_value(custom_endpoint_key_env("proxy"))
def test_two_endpoints_on_one_host_keep_separate_credentials(self):
"""Two local servers must not share an .env slot.
Deriving the env var from the hostname collapses ``127.0.0.1:8000``
and ``:8001`` onto one name, so saving the second silently overwrites
the first's key.
"""
from hermes_cli.config import custom_endpoint_key_env, get_env_value
for port, key in ((8000, "sk-first"), (8001, "sk-second")):
self.client.post(
"/api/providers/custom-endpoints",
json={
"id": f"local-{port}",
"name": f"Local {port}",
"base_url": f"http://127.0.0.1:{port}/v1",
"model": "m",
"api_key": key,
},
)
assert get_env_value(custom_endpoint_key_env("local-8000")) == "sk-first"
assert get_env_value(custom_endpoint_key_env("local-8001")) == "sk-second"
def test_custom_endpoint_response_reports_a_key_held_in_env(self):
"""has_api_key must follow key_env, not just a plaintext api_key.
Reading only ``api_key`` made the panel report "no API key" for every
endpoint whose credential had been moved to .env.
"""
resp = self.client.post(
"/api/providers/custom-endpoints",
json={
"id": "proxy",
"name": "Proxy",
"base_url": "https://llm.example.com/v1",
"model": "m",
"api_key": "sk-in-env",
},
)
endpoint = next(e for e in resp.json()["endpoints"] if e["id"] == "proxy")
assert endpoint["has_api_key"] is True
assert "sk-in-env" not in (endpoint["api_key_preview"] or "")
def test_activating_an_endpoint_carries_its_credential_either_way(self):
"""Activate must work for both key_env and pre-#69449 plaintext entries."""
from hermes_cli.config import load_config, save_config
cfg = load_config()
cfg["providers"] = {
"legacy": {
"name": "Legacy",
"base_url": "https://llm.legacy.com/v1",
"model": "m",
"api_key": "sk-legacy",
"models": {"m": {}},
},
"modern": {
"name": "Modern",
"base_url": "https://llm.modern.com/v1",
"model": "m",
"key_env": "MODERN_API_KEY",
"models": {"m": {}},
},
}
save_config(cfg)
self.client.post("/api/providers/custom-endpoints/modern/activate", json={})
model_cfg = load_config()["model"]
assert model_cfg["key_env"] == "MODERN_API_KEY"
assert "api_key" not in model_cfg
self.client.post("/api/providers/custom-endpoints/legacy/activate", json={})
model_cfg = load_config()["model"]
assert model_cfg["api_key"] == "sk-legacy"
# ---------------------------------------------------------------------------
# _build_schema_from_config tests
# ---------------------------------------------------------------------------
class TestBuildSchemaFromConfig:
def test_overrides_applied(self):
from hermes_cli.web_server import CONFIG_SCHEMA
# terminal.backend should be a select with options
if "terminal.backend" in CONFIG_SCHEMA:
entry = CONFIG_SCHEMA["terminal.backend"]
assert entry["type"] == "select"
assert "options" in entry
assert "local" in entry["options"]
assert "vercel_sandbox" in entry["options"]
runtime_entry = CONFIG_SCHEMA["terminal.vercel_runtime"]
assert runtime_entry["type"] == "select"
assert "node24" in runtime_entry["options"]
assert "python3.13" in runtime_entry["options"]
assert len(runtime_entry["options"]) >= 3
def test_timezone_field_is_searchable_select(self):
"""timezone must ship as a searchable, clearable select of IANA ids.
Desktop renders this via SearchableSelect (Popover + cmdk); the old
free-text input let users type invalid timezone strings (#68970).
Invariants, not snapshots: valid IANA entries present, sorted, no
blank entry server-side (the clear item is client-side via
``clearable``), and never empty even without tzdata (UTC fallback).
"""
from hermes_cli.web_server import CONFIG_SCHEMA, _timezone_options
entry = CONFIG_SCHEMA["timezone"]
assert entry["type"] == "select"
assert entry.get("searchable") is True
assert entry.get("clearable") is True
options = entry["options"]
assert len(options) >= 1
assert options == sorted(options)
assert "" not in options
assert "UTC" in options
# Fallback path: never returns an empty list.
assert len(_timezone_options()) >= 1
def test_dynamic_merge_recomputes_memory_provider_options(self, monkeypatch):
"""The per-request schema merge re-discovers memory providers.
The import-time _SCHEMA_OVERRIDES freezes the list at server start;
_schema_with_dynamic_provider_options must recompute it so a provider
installed mid-session is selectable without a restart.
"""
from hermes_cli import web_server
monkeypatch.setattr(web_server, "load_config", lambda: {"memory": {"provider": "honcho"}})
monkeypatch.setattr(
web_server,
"_memory_provider_options",
lambda: ["", "honcho", "hindsight", "freshly_installed"],
)
fields = web_server._schema_with_dynamic_provider_options()
assert "freshly_installed" in fields["memory.provider"]["options"]
# The entry is copied, not mutated in place, and keeps its select type.
assert fields["memory.provider"]["type"] == "select"
assert web_server.CONFIG_SCHEMA["memory.provider"] is not fields["memory.provider"]
def test_no_single_field_categories(self):
"""After merging, no category should have just 1 field."""
from hermes_cli.web_server import CONFIG_SCHEMA
from collections import Counter
cats = Counter(e["category"] for e in CONFIG_SCHEMA.values())
for cat, count in cats.items():
assert count >= 2, f"Category '{cat}' has only {count} field(s) — should be merged"
# ---------------------------------------------------------------------------
# Config round-trip tests
# ---------------------------------------------------------------------------
class TestConfigRoundTrip:
"""Verify config survives GET → edit → PUT without data loss."""
@pytest.fixture(autouse=True)
def _setup(self):
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
self.client = TestClient(app)
self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
def test_round_trip_preserves_schema_invisible_nested_keys(self):
"""Nested keys that aren't in CONFIG_SCHEMA must also survive a
round-trip. Deep-merge is required — a shallow merge would drop
``agent.<custom_key>`` when the frontend sends a partial ``agent``
dict containing only schema-known sub-fields."""
from hermes_cli.config import load_config, read_raw_config, save_config
# Seed config with a key under `agent` that isn't in the schema.
# Use a sentinel name to avoid colliding with future schema fields.
save_config({
"agent": {
"max_turns": 50,
"x_dashboard_invisible_test_key": {"nested": "value"},
},
})
# PUT only schema-known agent fields, exactly like the dashboard.
web_config = self.client.get("/api/config").json()
web_config.setdefault("agent", {})
web_config["agent"]["max_turns"] = 75
# Strip our sentinel so we're sending what the schema-driven form
# would send.
web_config["agent"].pop("x_dashboard_invisible_test_key", None)
resp = self.client.put("/api/config", json={"config": web_config})
assert resp.status_code == 200
on_disk = read_raw_config()
assert on_disk.get("agent", {}).get("max_turns") == 75
assert on_disk.get("agent", {}).get("x_dashboard_invisible_test_key") \
== {"nested": "value"}, \
"Shallow-merge regression: agent.x_dashboard_invisible_test_key " \
"was wiped when the frontend sent a partial agent dict."
def test_schema_types_match_config_values(self):
"""Every schema field should have a matching-type value in the config."""
config = self.client.get("/api/config").json()
schema_resp = self.client.get("/api/config/schema").json()
schema = schema_resp["fields"]
def get_nested(obj, path):
parts = path.split(".")
cur = obj
for p in parts:
if cur is None or not isinstance(cur, dict):
return None
cur = cur.get(p)
return cur
mismatches = []
for key, entry in schema.items():
val = get_nested(config, key)
if val is None:
continue # not set in user config — fine
expected = entry["type"]
if expected in {"string", "select"} and not isinstance(val, str):
mismatches.append(f"{key}: expected str, got {type(val).__name__}")
elif expected == "number" and not isinstance(val, (int, float)):
mismatches.append(f"{key}: expected number, got {type(val).__name__}")
elif expected == "boolean" and not isinstance(val, bool):
mismatches.append(f"{key}: expected bool, got {type(val).__name__}")
elif expected == "list" and not isinstance(val, list):
mismatches.append(f"{key}: expected list, got {type(val).__name__}")
assert not mismatches, "Type mismatches:\n" + "\n".join(mismatches)
# ---------------------------------------------------------------------------
# New feature endpoint tests
# ---------------------------------------------------------------------------
class TestNewEndpoints:
"""Tests for session detail, logs, cron, skills, tools, raw config, analytics."""
@pytest.fixture(autouse=True)
def _setup(self, monkeypatch, _isolate_hermes_home):
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")
self.client = TestClient(app)
self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
# --- Automation Blueprints ---
# --- Profiles ---
def test_profiles_create_builder_mcp_auth_is_profile_scoped(
self, monkeypatch
):
from hermes_constants import get_hermes_home
import hermes_cli.profiles as profiles_mod
monkeypatch.setattr(profiles_mod, "create_wrapper_script", lambda name: None)
secret = "profile-builder-secret"
resp = self.client.post(
"/api/profiles",
json={
"name": "builder-auth",
"mcp_servers": [
{
"name": "Bearer Server",
"url": "https://example.com/mcp",
"auth": "header",
"bearer_token": f"Bearer {secret}",
},
{
"name": "oauth-server",
"url": "https://example.com/oauth-mcp",
"auth": "oauth",
},
{
"name": "local-server",
"command": "uvx",
"args": ["mcp-server", "--debug"],
"env": {"API_KEY": "stdio-secret"},
},
{
"name": "missing-token",
"url": "https://example.com/bad",
"auth": "header",
},
{
"name": "http-with-env",
"url": "https://example.com/bad-env",
"env": {"NOT_SUPPORTED": "value"},
},
],
},
)
assert resp.status_code == 200
assert resp.json()["mcp_written"] == 3
root = get_hermes_home()
profile_dir = root / "profiles" / "builder-auth"
config_text = (profile_dir / "config.yaml").read_text(encoding="utf-8")
config = yaml.safe_load(config_text)
servers = config["mcp_servers"]
assert sorted(servers) == [
"Bearer Server",
"local-server",
"oauth-server",
]
assert servers["Bearer Server"] == {
"url": "https://example.com/mcp",
"headers": {
"Authorization": "Bearer ${MCP_BEARER_SERVER_API_KEY}",
},
}
assert servers["oauth-server"] == {
"url": "https://example.com/oauth-mcp",
"auth": "oauth",
}
assert servers["local-server"] == {
"command": "uvx",
"args": ["mcp-server", "--debug"],
"env": {"API_KEY": "stdio-secret"},
}
assert secret not in config_text
profile_env = (profile_dir / ".env").read_text(encoding="utf-8")
assert f"MCP_BEARER_SERVER_API_KEY={secret}" in profile_env
assert "Bearer Bearer" not in profile_env
assert not (root / ".env").exists()
# --- New profiles endpoints: active / description / model / describe-auto ---
def test_discord_toolsets_read_and_write_discord_platform(self):
"""Platform-restricted toolsets must not be saved as successful CLI no-ops."""
from hermes_cli.config import load_config
listing = {t["name"]: t for t in self.client.get("/api/tools/toolsets").json()}
assert listing["discord"]["platform"] == "discord"
assert listing["discord"]["platform_label"] == "Discord"
assert listing["discord"]["enabled"] is False
resp = self.client.put("/api/tools/toolsets/discord", json={"enabled": True})
assert resp.status_code == 200
assert resp.json() == {
"ok": True,
"name": "discord",
"platform": "discord",
"enabled": True,
}
config = load_config()
assert "discord" in config["platform_toolsets"]["discord"]
assert "discord" not in config["platform_toolsets"].get("cli", [])
listing = {t["name"]: t for t in self.client.get("/api/tools/toolsets").json()}
assert listing["discord"]["enabled"] is True
assert listing["discord_admin"]["enabled"] is False
resp = self.client.put(
"/api/tools/toolsets/discord_admin", json={"enabled": True}
)
assert resp.status_code == 200
config = load_config()
assert {"discord", "discord_admin"} <= set(
config["platform_toolsets"]["discord"]
)
def test_get_toolset_config_returns_provider_matrix(self):
"""GET .../config returns provider rows with structured env_vars."""
resp = self.client.get("/api/tools/toolsets/tts/config")
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "tts"
assert data["has_category"] is True
assert isinstance(data["providers"], list)
assert data["providers"], "tts always has at least the built-in providers"
# active_provider is part of the contract so the GUI can highlight the
# provider actually written to config (else it falls back to the first
# keyless one). It's either None or the name of one listed provider.
assert "active_provider" in data
names = {p["name"] for p in data["providers"]}
assert data["active_provider"] is None or data["active_provider"] in names
for prov in data["providers"]:
assert "name" in prov
assert "is_active" in prov
assert "env_vars" in prov
assert isinstance(prov["env_vars"], list)
for ev in prov["env_vars"]:
assert "key" in ev
assert "is_set" in ev
# active_provider summarizes the first provider flagged is_active
# (some catalogs list two rows backed by the same config value, e.g.
# Firecrawl cloud + self-hosted both map to web.backend=firecrawl).
active = [p["name"] for p in data["providers"] if p["is_active"]]
if active:
assert data["active_provider"] == active[0]
else:
assert data["active_provider"] is None
def test_get_toolset_config_reports_truthful_provider_status(self, monkeypatch):
"""Each provider row carries a server-computed readiness `status`.
Regression: the GUI pilled every zero-env-var row "Ready" — including
logged-out Nous Subscription rows, xAI TTS without Grok OAuth, and
never-installed KittenTTS/Piper. The endpoint now reports the honest
state so keyless ≠ ready.
"""
import hermes_cli.tools_config as tools_config
from hermes_cli.nous_account import NousPortalAccountInfo
# Logged out of Nous Portal → managed subscription rows need sign-in.
monkeypatch.setattr(
"hermes_cli.nous_subscription.get_nous_portal_account_info",
lambda *a, **k: NousPortalAccountInfo(
logged_in=False, source="none", fresh=False, paid_service_access=None
),
)
# No xAI credentials → the Grok OAuth-backed row needs sign-in.
monkeypatch.setattr(tools_config, "_xai_credentials_present", lambda: False)
# Local TTS engines not installed → their rows need setup.
monkeypatch.setattr(tools_config, "_module_installed", lambda name: False)
monkeypatch.delenv("ELEVENLABS_API_KEY", raising=False)
resp = self.client.get("/api/tools/toolsets/tts/config")
assert resp.status_code == 200
data = resp.json()
by_name = {p["name"]: p for p in data["providers"]}
valid = {"ready", "needs_keys", "needs_auth", "needs_setup"}
assert all(p["status"] in valid for p in data["providers"])
# Genuinely-free keyless row stays Ready.
assert by_name["Microsoft Edge TTS"]["status"] == "ready"
# Keyless ≠ ready for gated rows:
assert by_name["Nous Subscription"]["status"] == "needs_auth"
assert by_name["xAI TTS"]["status"] == "needs_auth"
assert by_name["KittenTTS"]["status"] == "needs_setup"
assert by_name["Piper"]["status"] == "needs_setup"
# Keyed row with the key unset:
assert by_name["ElevenLabs"]["status"] == "needs_keys"
def test_select_managed_nous_provider_reports_needs_nous_auth(self, monkeypatch):
"""Selecting a managed Nous row while logged out flags needs_nous_auth.
Regression: the GUI PUT wrote browser.cloud_provider + use_gateway
but skipped the Portal entitlement handshake the CLI runs inline
(ensure_nous_portal_access) — so the row never activated and nothing
told the user to sign in. The endpoint now reports the entitlement
gap so the client can drive the existing Nous OAuth flow.
"""
from hermes_cli.nous_account import NousPortalAccountInfo
monkeypatch.setattr(
"hermes_cli.nous_subscription.get_nous_portal_account_info",
lambda *a, **k: NousPortalAccountInfo(
logged_in=False, source="none", fresh=False, paid_service_access=None
),
)
resp = self.client.put(
"/api/tools/toolsets/browser/provider",
json={"provider": "Nous Subscription (Browser Use cloud)"},
)
assert resp.status_code == 200
data = resp.json()
assert data["ok"] is True
assert data["needs_nous_auth"] is True
assert data["feature"] == "browser"
# The selection is still persisted — activation is what's gated.
from hermes_cli.config import load_config
cfg = load_config()
assert cfg["browser"]["cloud_provider"] == "browser-use"
# -- Web capability split (search vs extract backends) ------------------
def test_select_web_search_backend_matches_runtime_resolution(self, monkeypatch):
"""PUT provider with capability=search writes web.search_backend and the
runtime search dispatcher resolves to it — while extract is untouched."""
# Make SearXNG available so both the endpoint gate and the runtime
# availability check agree it's usable.
monkeypatch.setenv("SEARXNG_URL", "http://localhost:8888")
# Give extract an explicit shared backend so the assertion isn't
# hostage to whatever creds exist on the machine running the tests.
monkeypatch.setenv("FIRECRAWL_API_URL", "http://localhost:3002")
base = self.client.put(
"/api/tools/toolsets/web/provider",
json={"provider": "Firecrawl Self-Hosted"},
)
assert base.status_code == 200
resp = self.client.put(
"/api/tools/toolsets/web/provider",
json={"provider": "SearXNG", "capability": "search"},
)
assert resp.status_code == 200
body = resp.json()
assert body["ok"] is True
assert body["capability"] == "search"
from hermes_cli.config import load_config
cfg = load_config()
assert cfg["web"]["search_backend"] == "searxng"
# The shared backend selected first must be preserved for extract.
assert cfg["web"]["backend"] == "firecrawl"
# The REAL runtime resolution — not a parallel reimplementation.
from tools.web_tools import _get_extract_backend, _get_search_backend
assert _get_search_backend() == "searxng"
assert _get_extract_backend() == "firecrawl"
# And the config endpoint reports the same split.
data = self.client.get("/api/tools/toolsets/web/config").json()
assert data["active_search_backend"] == "searxng"
assert data["active_extract_backend"] == "firecrawl"
# -- Terminal execution backend picker ---------------------------------
def test_terminal_ssh_probe_ready_when_configured(self, monkeypatch):
"""SSH host + user in config.yaml -> ready."""
import hermes_cli.web_server as web_server
from hermes_cli.config import load_config, save_config
monkeypatch.setattr(web_server.shutil, "which", lambda name: None)
config = load_config()
config.setdefault("terminal", {})
config["terminal"]["ssh_host"] = "devbox.example.com"
config["terminal"]["ssh_user"] = "hermes"
save_config(config)
body = self.client.get("/api/tools/terminal/backends").json()
ssh = next(r for r in body["backends"] if r["name"] == "ssh")
assert ssh["status"] == "ready"
assert "hermes@devbox.example.com" in ssh["detail"]
def test_analytics_usage_includes_skill_breakdown(self):
from hermes_state import SessionDB
db = SessionDB()
try:
db.create_session(
session_id="skills-analytics-test",
source="cli",
model="anthropic/claude-sonnet-4",
)
db.update_token_counts(
"skills-analytics-test",
input_tokens=120,
output_tokens=45,
)
db.append_message(
"skills-analytics-test",
role="assistant",
content="Loading and updating skills.",
tool_calls=[
{
"function": {
"name": "skill_view",
"arguments": '{"name":"github-pr-workflow"}',
}
},
{
"function": {
"name": "skill_manage",
"arguments": '{"name":"github-code-review"}',
}
},
],
)
finally:
db.close()
resp = self.client.get("/api/analytics/usage?days=7")
assert resp.status_code == 200
data = resp.json()
assert data["skills"]["summary"] == {
"total_skill_loads": 1,
"total_skill_edits": 1,
"total_skill_actions": 2,
"distinct_skills_used": 2,
}
assert len(data["skills"]["top_skills"]) == 2
top_skill = data["skills"]["top_skills"][0]
assert top_skill["skill"] == "github-pr-workflow"
assert top_skill["view_count"] == 1
assert top_skill["manage_count"] == 0
assert top_skill["total_count"] == 1
assert top_skill["last_used_at"] is not None
# ---------------------------------------------------------------------------
# Model context length: normalize/denormalize + /api/model/info
# ---------------------------------------------------------------------------
class TestModelContextLength:
"""Tests for model_context_length in normalize/denormalize and /api/model/info."""
def test_normalize_extracts_context_length_from_dict(self):
"""normalize should surface context_length from model dict."""
from hermes_cli.web_server import _normalize_config_for_web
cfg = {
"model": {
"default": "anthropic/claude-opus-4.6",
"provider": "openrouter",
"context_length": 200000,
}
}
result = _normalize_config_for_web(cfg)
assert result["model"] == "anthropic/claude-opus-4.6"
assert result["model_context_length"] == 200000
def test_normalize_bare_string_model_yields_zero(self):
"""normalize should set model_context_length=0 for bare string model."""
from hermes_cli.web_server import _normalize_config_for_web
result = _normalize_config_for_web({"model": "anthropic/claude-sonnet-4"})
assert result["model"] == "anthropic/claude-sonnet-4"
assert result["model_context_length"] == 0
def test_denormalize_writes_context_length_into_model_dict(self):
"""denormalize should write model_context_length back into model dict."""
from hermes_cli.web_server import _denormalize_config_from_web
from hermes_cli.config import save_config
# Set up disk config with model as a dict
save_config({
"model": {"default": "anthropic/claude-opus-4.6", "provider": "openrouter"}
})
result = _denormalize_config_from_web({
"model": "anthropic/claude-opus-4.6",
"model_context_length": 100000,
})
assert isinstance(result["model"], dict)
assert result["model"]["context_length"] == 100000
assert "model_context_length" not in result # virtual field removed
class TestDenormalizeProviderSwitch:
"""The flat Config-page Model field carries no provider info. When the
model string changes to one served by a different provider, the saved
provider must follow it (issue #14058)."""
def test_vendor_slug_switches_off_non_aggregator_provider(self):
"""ollama-local + a vendor/model slug → switch to openrouter and drop
the stale local base_url (the issue's exact repro)."""
from hermes_cli.web_server import _denormalize_config_from_web
from hermes_cli.config import save_config
save_config({
"model": {
"default": "llama3.2",
"provider": "ollama-local",
"base_url": "http://localhost:11434/v1",
"api_mode": "chat_completions",
}
})
result = _denormalize_config_from_web({"model": "google/gemini-2.5-flash"})
model = result["model"]
assert model["provider"] == "openrouter"
assert model["default"] == "google/gemini-2.5-flash"
# The old ollama-local endpoint must not carry over to openrouter.
assert not model.get("base_url")
def test_context_length_override_survives_provider_switch(self):
"""An explicit context-length override must persist alongside a
provider switch."""
from hermes_cli.web_server import _denormalize_config_from_web
from hermes_cli.config import save_config
save_config({"model": {"default": "llama3.2", "provider": "ollama-local"}})
result = _denormalize_config_from_web({
"model": "google/gemini-2.5-flash",
"model_context_length": 128000,
})
model = result["model"]
assert model["provider"] == "openrouter"
assert model["context_length"] == 128000
class TestModelContextLengthSchema:
"""Tests for model_context_length placement in CONFIG_SCHEMA."""
def test_schema_model_context_length_after_model(self):
"""model_context_length should appear immediately after model in schema."""
from hermes_cli.web_server import CONFIG_SCHEMA
keys = list(CONFIG_SCHEMA.keys())
model_idx = keys.index("model")
assert keys[model_idx + 1] == "model_context_length"
def test_schema_model_context_length_is_number(self):
from hermes_cli.web_server import CONFIG_SCHEMA
entry = CONFIG_SCHEMA["model_context_length"]
assert entry["type"] == "number"
assert "category" in entry
class TestModelInfoEndpoint:
"""Tests for GET /api/model/info endpoint."""
@pytest.fixture(autouse=True)
def _setup(self):
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
from hermes_cli.web_server import app
self.client = TestClient(app)
def test_model_info_with_dict_config(self, monkeypatch):
import hermes_cli.web_server as ws
monkeypatch.setattr(ws, "load_config", lambda: {
"model": {
"default": "anthropic/claude-opus-4.6",
"provider": "openrouter",
"context_length": 100000,
}
})
with patch("agent.model_metadata.get_model_context_length", return_value=200000):
resp = self.client.get("/api/model/info")
data = resp.json()
assert data["model"] == "anthropic/claude-opus-4.6"
assert data["provider"] == "openrouter"
assert data["auto_context_length"] == 200000
assert data["config_context_length"] == 100000
assert data["effective_context_length"] == 100000 # override wins
def test_model_info_graceful_on_metadata_error(self, monkeypatch):
"""Endpoint should return zeros on import/resolution errors, not 500."""
import hermes_cli.web_server as ws
monkeypatch.setattr(ws, "load_config", lambda: {
"model": "some/obscure-model"
})
with patch("agent.model_metadata.get_model_context_length", side_effect=Exception("boom")):
resp = self.client.get("/api/model/info")
assert resp.status_code == 200
data = resp.json()
assert data["auto_context_length"] == 0
# ---------------------------------------------------------------------------
# Gateway health probe tests
# ---------------------------------------------------------------------------
class TestProbeGatewayHealth:
"""Tests for _probe_gateway_health() — cross-container gateway detection."""
def test_probe_uses_configured_short_timeout(self, monkeypatch):
"""The HTTP probe must not fall through to the OS TCP timeout."""
import hermes_cli.web_server as ws
monkeypatch.setattr(ws, "_GATEWAY_HEALTH_URL", "http://gw:8642")
monkeypatch.setattr(ws, "_GATEWAY_HEALTH_TIMEOUT", 0.75)
timeouts = []
def mock_urlopen(req, **kwargs):
timeouts.append(kwargs.get("timeout"))
raise TimeoutError("mock timeout")
monkeypatch.setattr(ws.urllib.request, "urlopen", mock_urlopen)
alive, body = ws._probe_gateway_health()
assert alive is False
assert body is None
assert timeouts == [0.75, 0.75]
def test_detailed_fails_falls_back_to_simple_health(self, monkeypatch):
"""If /health/detailed fails, falls back to /health."""
import hermes_cli.web_server as ws
monkeypatch.setattr(ws, "_GATEWAY_HEALTH_URL", "http://gw:8642")
monkeypatch.setattr(ws, "_GATEWAY_HEALTH_TIMEOUT", 1)
call_count = [0]
def mock_urlopen(req, **kwargs):
call_count[0] += 1
if call_count[0] == 1:
raise ConnectionError("detailed failed")
mock_resp = MagicMock()
mock_resp.status = 200
mock_resp.read.return_value = json.dumps({"status": "ok"}).encode()
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
mock_resp.__exit__ = MagicMock(return_value=False)
return mock_resp
monkeypatch.setattr(ws.urllib.request, "urlopen", mock_urlopen)
alive, body = ws._probe_gateway_health()
assert alive is True
assert body["status"] == "ok"
assert call_count[0] == 2
class TestStatusRemoteGateway:
"""Tests for /api/status with remote gateway health fallback."""
@pytest.fixture(autouse=True)
def _setup_test_client(self):
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
self.client = TestClient(app)
self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
def test_status_falls_back_to_remote_probe(self, monkeypatch):
"""When local PID check fails and remote probe succeeds, gateway shows running."""
import hermes_cli.web_server as ws
monkeypatch.setattr(ws, "get_running_pid_cached", lambda: None)
monkeypatch.setattr(ws, "read_runtime_status", lambda: None)
monkeypatch.setattr(ws, "_GATEWAY_HEALTH_URL", "http://gw:8642")
monkeypatch.setattr(ws, "_probe_gateway_health", lambda: (True, {
"status": "ok",
"gateway_state": "running",
"platforms": {"telegram": {"state": "connected"}},
"pid": 999,
}))
resp = self.client.get("/api/status")
assert resp.status_code == 200
data = resp.json()
assert data["gateway_running"] is True
assert data["gateway_pid"] == 999
assert data["gateway_state"] == "running"
assert data["gateway_health_url"] == "http://gw:8642"
def test_status_remote_probe_not_attempted_when_local_pid_found(self, monkeypatch):
"""When local PID check succeeds, the remote probe is never called."""
import hermes_cli.web_server as ws
monkeypatch.setattr(ws, "get_running_pid_cached", lambda: 1234)
monkeypatch.setattr(ws, "read_runtime_status", lambda: {
"gateway_state": "running",
"platforms": {},
})
monkeypatch.setattr(ws, "_GATEWAY_HEALTH_URL", "http://gw:8642")
probe_called = [False]
original = ws._probe_gateway_health
def track_probe():
probe_called[0] = True
return original()
monkeypatch.setattr(ws, "_probe_gateway_health", track_probe)
resp = self.client.get("/api/status")
assert resp.status_code == 200
assert not probe_called[0]
def test_status_remote_running_null_pid(self, monkeypatch):
"""Remote gateway running but PID not in response — pid should be None."""
import hermes_cli.web_server as ws
monkeypatch.setattr(ws, "get_running_pid_cached", lambda: None)
monkeypatch.setattr(ws, "read_runtime_status", lambda: None)
monkeypatch.setattr(ws, "_GATEWAY_HEALTH_URL", "http://gw:8642")
monkeypatch.setattr(ws, "_probe_gateway_health", lambda: (True, {
"status": "ok",
}))
resp = self.client.get("/api/status")
assert resp.status_code == 200
data = resp.json()
assert data["gateway_running"] is True
assert data["gateway_pid"] is None
assert data["gateway_state"] == "running"
class TestGatewayBusyReadout:
"""Tests for the NAS busy/drainable readout on /api/status.
Behaviour contracts (not snapshots): assert how gateway_busy / gateway_drainable
must RELATE to gateway_running + gateway_state + active_agents, and that every
field degrades to a safe falsy value when the gateway is down or its status
file is absent. Liveness must key off gateway_running, NEVER gateway_updated_at.
"""
@pytest.fixture(autouse=True)
def _setup_test_client(self):
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
self.client = TestClient(app)
self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
def test_draining_state_is_neither_busy_nor_drainable(self, monkeypatch):
"""While draining, the gateway is not a fresh begin-drain target, and
busy is False even with a stale active_agents>0 in the file — the state
gate dominates."""
import hermes_cli.web_server as ws
monkeypatch.setattr(ws, "get_running_pid_cached", lambda: 1234)
monkeypatch.setattr(ws, "read_runtime_status", lambda: {
"gateway_state": "draining",
"platforms": {},
"active_agents": 3,
})
data = self.client.get("/api/status").json()
assert data["gateway_busy"] is False
assert data["gateway_drainable"] is False
def test_active_agents_unparseable_in_file_degrades_to_zero(self, monkeypatch):
"""A corrupt active_agents value in the status file must not 500 or
produce a spurious busy — it degrades to 0/not-busy."""
import hermes_cli.web_server as ws
monkeypatch.setattr(ws, "get_running_pid_cached", lambda: 1234)
monkeypatch.setattr(ws, "read_runtime_status", lambda: {
"gateway_state": "running",
"platforms": {},
"active_agents": "garbage",
})
data = self.client.get("/api/status").json()
assert data["active_agents"] == 0
assert data["gateway_busy"] is False
class TestGatewayUpdatedAtContract:
"""Contract tests for /api/status ``gateway_updated_at``.
The field is promised to consumers (web/src/lib/api.ts declares
``string | null``) as an RFC3339 timestamp or null — NEVER a number.
Legacy gateways wrote epoch floats into gateway_state.json and the file
is hand-editable, so the endpoint must normalize whatever it reads.
"""
@pytest.fixture(autouse=True)
def _setup_test_client(self):
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
self.client = TestClient(app)
self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
@staticmethod
def _assert_contract(value):
"""gateway_updated_at is None or a tz-aware-parseable ISO string."""
from datetime import datetime
assert not isinstance(value, bool), f"bool leaked: {value!r}"
assert not isinstance(value, (int, float)), f"number leaked: {value!r}"
if value is not None:
assert isinstance(value, str)
parsed = datetime.fromisoformat(value)
assert parsed.tzinfo is not None, f"naive timestamp leaked: {value!r}"
def test_local_runtime_valid_epoch_becomes_iso_string(self, monkeypatch):
"""A plausible legacy epoch value is converted, not dropped."""
from datetime import datetime, timezone
import hermes_cli.web_server as ws
epoch = 1750000000
monkeypatch.setattr(ws, "get_running_pid_cached", lambda: 1234)
monkeypatch.setattr(ws, "read_runtime_status", lambda: {
"gateway_state": "running",
"platforms": {},
"active_agents": 0,
"updated_at": epoch,
})
value = self.client.get("/api/status").json()["gateway_updated_at"]
assert isinstance(value, str)
parsed = datetime.fromisoformat(value)
assert parsed.tzinfo is not None
assert parsed == datetime.fromtimestamp(epoch, tz=timezone.utc)
def test_remote_health_numeric_updated_at_normalized(self, monkeypatch):
"""Cross-container path: the remote /health/detailed body is the
runtime source, and a numeric updated_at from an older gateway build
must still come out as string|null."""
import hermes_cli.web_server as ws
monkeypatch.setattr(ws, "get_running_pid_cached", lambda: None)
monkeypatch.setattr(ws, "read_runtime_status", lambda: None)
monkeypatch.setattr(ws, "_GATEWAY_HEALTH_URL", "http://gw:8642")
monkeypatch.setattr(ws, "_probe_gateway_health", lambda: (True, {
"status": "ok",
"gateway_state": "running",
"platforms": {},
"updated_at": 1750000000.25,
"pid": 999,
}))
resp = self.client.get("/api/status")
assert resp.status_code == 200
data = resp.json()
assert data["gateway_running"] is True
self._assert_contract(data["gateway_updated_at"])
# A plausible epoch is converted, not nulled.
assert isinstance(data["gateway_updated_at"], str)
# ---------------------------------------------------------------------------
# Dashboard theme normaliser tests
# ---------------------------------------------------------------------------
class TestNormaliseThemeDefinition:
"""Tests for _normalise_theme_definition() — parses YAML theme files."""
def test_rejects_non_dict(self):
from hermes_cli.web_server import _normalise_theme_definition
assert _normalise_theme_definition("string") is None
assert _normalise_theme_definition(None) is None
assert _normalise_theme_definition([1, 2, 3]) is None
def test_loose_colors_shorthand(self):
"""Bare hex strings under `colors` parse as {hex, alpha=1.0}."""
from hermes_cli.web_server import _normalise_theme_definition
result = _normalise_theme_definition({
"name": "loose",
"colors": {"background": "#000000", "midground": "#ffffff"},
})
assert result is not None
assert result["palette"]["background"] == {"hex": "#000000", "alpha": 1.0}
assert result["palette"]["midground"] == {"hex": "#ffffff", "alpha": 1.0}
# foreground falls back to default (transparent white)
assert result["palette"]["foreground"]["hex"] == "#ffffff"
assert result["palette"]["foreground"]["alpha"] == 0.0
class TestDiscoverUserThemes:
"""Tests for _discover_user_themes() — scans ~/.hermes/dashboard-themes/."""
def test_returns_empty_when_dir_missing(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
from hermes_cli import web_server
assert web_server._discover_user_themes() == []
def test_loads_and_normalises_yaml(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
themes_dir = tmp_path / "dashboard-themes"
themes_dir.mkdir()
(themes_dir / "ocean.yaml").write_text(
"name: ocean\n"
"label: Ocean\n"
"palette:\n"
" background:\n"
" hex: \"#0a1628\"\n"
" alpha: 1.0\n"
"layout:\n"
" density: spacious\n"
)
from hermes_cli import web_server
results = web_server._discover_user_themes()
assert len(results) == 1
assert results[0]["name"] == "ocean"
assert results[0]["label"] == "Ocean"
assert results[0]["palette"]["background"]["hex"] == "#0a1628"
assert results[0]["layout"]["density"] == "spacious"
# defaults filled in
assert "fontSans" in results[0]["typography"]
def test_ignores_transient_profile_override(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
themes_dir = tmp_path / "dashboard-themes"
themes_dir.mkdir()
(themes_dir / "mine.yaml").write_text("name: mine\n")
other = tmp_path / "other-profile"
other.mkdir()
from hermes_constants import (
reset_hermes_home_override,
set_hermes_home_override,
)
from hermes_cli import web_server
token = set_hermes_home_override(str(other))
try:
results = web_server._discover_user_themes()
finally:
reset_hermes_home_override(token)
assert [r["name"] for r in results] == ["mine"]
class TestThemeBootstrapCSS:
"""Tests for _render_active_theme_bootstrap_css() and its injection
into index.html via _serve_index() — the critical-CSS shim that kills
the default-teal first-paint flash for user YAML themes."""
@staticmethod
def _write_theme(hermes_home, name="ocean"):
themes_dir = hermes_home / "dashboard-themes"
themes_dir.mkdir(exist_ok=True)
(themes_dir / f"{name}.yaml").write_text(
f"name: {name}\n"
"label: Ocean\n"
"palette:\n"
" background:\n"
" hex: \"#0a1628\"\n"
" midground:\n"
" hex: \"#dbe4f0\"\n"
"typography:\n"
" fontSans: \"Inter, sans-serif\"\n"
" baseSize: \"17px\"\n",
encoding="utf-8",
)
def test_user_theme_renders_bundle_vars(self, tmp_path, monkeypatch):
"""Active user theme → style block with ONLY variable names the
bundle actually consumes (layerVars/typographyVars tokens)."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
self._write_theme(tmp_path)
from hermes_cli import web_server
monkeypatch.setattr(
web_server, "load_config", lambda: {"dashboard": {"theme": "ocean"}}
)
css = web_server._render_active_theme_bootstrap_css()
assert css.startswith('<style id="hermes-theme-bootstrap">')
assert css.endswith("</style>")
# Real bundle tokens (web/src/themes/context.tsx + index.css).
assert "--background-base:#0a1628;" in css
assert "--midground-base:#dbe4f0;" in css
assert "--theme-font-sans:Inter, sans-serif;" in css
assert "--theme-base-size:17px;" in css
# Names that do NOT exist in the bundle must not be emitted.
for bogus in ("--color-background", "--color-midground",
"--font-sans:", "--font-base-size"):
assert bogus not in css
# Canvas rule flows through the variables (never goes stale when
# applyTheme() rewrites them as inline styles at runtime).
assert "html,body{background-color:var(--background-base);" in css
assert "font-family:var(--theme-font-sans);" in css
assert "font-size:var(--theme-base-size);" in css
# No baked literal values in the html,body rule.
assert "#0a1628" not in css.split("html,body")[1]
@staticmethod
def _mount_spa_client(tmp_path, monkeypatch):
from fastapi import FastAPI
from starlette.testclient import TestClient
import hermes_cli.web_server as ws
dist = tmp_path / "web_dist"
(dist / "assets").mkdir(parents=True)
(dist / "index.html").write_text(
"<html><head><title>t</title></head><body>SPA</body></html>",
encoding="utf-8",
)
monkeypatch.setattr(ws, "WEB_DIST", dist)
spa_app = FastAPI()
ws.mount_spa(spa_app)
return TestClient(spa_app)
def test_serve_index_injects_bootstrap_for_user_theme(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
self._write_theme(tmp_path)
import hermes_cli.web_server as ws
monkeypatch.setattr(
ws, "load_config", lambda: {"dashboard": {"theme": "ocean"}}
)
client = self._mount_spa_client(tmp_path, monkeypatch)
resp = client.get("/chat")
assert resp.status_code == 200
assert '<style id="hermes-theme-bootstrap">' in resp.text
assert "--background-base:#0a1628;" in resp.text
# Injected inside <head>, before the closing tag.
head = resp.text.split("</head>")[0]
assert "hermes-theme-bootstrap" in head
class TestNormaliseThemeExtensions:
"""Tests for the extended normaliser fields (assets, customCSS,
componentStyles, layoutVariant) — the surfaces themes use to reskin
the dashboard without shipping code."""
def test_custom_css_passthrough_and_capped(self):
from hermes_cli.web_server import _normalise_theme_definition
# Small CSS passes through verbatim.
r = _normalise_theme_definition({
"name": "t",
"customCSS": "body { color: red; }",
})
assert r["customCSS"] == "body { color: red; }"
# 40 KiB of CSS gets clipped to the 32 KiB cap.
huge = "/* x */ " * (40 * 1024 // 8 + 10)
r2 = _normalise_theme_definition({"name": "t", "customCSS": huge})
assert len(r2["customCSS"]) <= 32 * 1024
def test_component_styles_per_bucket(self):
from hermes_cli.web_server import _normalise_theme_definition
r = _normalise_theme_definition({
"name": "t",
"componentStyles": {
"card": {
"clipPath": "polygon(0 0, 100% 0, 100% 100%, 0 100%)",
"boxShadow": "inset 0 0 0 1px red",
"bad prop!": "ignored", # non-alnum prop rejected
},
"header": {"background": "linear-gradient(red, blue)"},
"rogueBucket": {"foo": "bar"}, # not a known bucket — rejected
},
})
assert r["componentStyles"]["card"] == {
"clipPath": "polygon(0 0, 100% 0, 100% 100%, 0 100%)",
"boxShadow": "inset 0 0 0 1px red",
}
assert r["componentStyles"]["header"]["background"].startswith("linear-gradient")
assert "rogueBucket" not in r["componentStyles"]
class TestDeleteSessionEndpoint:
"""Tests for ``DELETE /api/sessions/{session_id}`` — the single-row delete
behind the desktop sidebar's per-session delete.
The desktop optimistically removes the row, then RESTORES it on any error
and surfaces the message. So a 404 on a row that is already gone (reaped by
empty-session hygiene, or removed by a concurrent client — both common amid
/goal + auto-compression churn that leaves transient empty rows) resurrected
a ghost row and showed "session not found". DELETE must be idempotent and
resolve ids like every other session endpoint.
"""
@pytest.fixture(autouse=True)
def _setup_test_client(self, monkeypatch, _isolate_hermes_home):
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"
)
self.auth_client = TestClient(app)
self.auth_client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
def _seed(self, ids):
from hermes_state import SessionDB
db = SessionDB()
try:
for sid in ids:
db.create_session(session_id=sid, source="cli")
finally:
db.close()
def _exists(self, sid) -> bool:
from hermes_state import SessionDB
db = SessionDB()
try:
return db.get_session(sid) is not None
finally:
db.close()
def test_delete_absent_session_is_idempotent(self):
# PREMISE / regression: deleting a row that no longer exists must NOT
# 404 — the desktop would resurrect the ghost row and show
# "session not found". DELETE's contract is "ensure it's gone".
resp = self.auth_client.delete("/api/sessions/never_existed")
assert resp.status_code == 200
assert resp.json().get("ok") is True
class TestBulkDeleteSessionsEndpoint:
"""Tests for ``POST /api/sessions/bulk-delete`` — backs the
dashboard's "Delete N selected" flow on the sessions page.
Locks in four things:
1. Route-ordering: ``/api/sessions/bulk-delete`` must shadow the
templated ``/api/sessions/{session_id}`` route below it (see
the block comment in ``hermes_cli/web_server.py``).
2. Behaviour parity with :meth:`SessionDB.delete_sessions` — real
deleted count, archive/active sessions deleted on explicit
selection.
3. The 500-ID payload cap is enforced.
4. Auth gating (issue #19533 contract).
"""
@pytest.fixture(autouse=True)
def _setup_test_client(self, monkeypatch, _isolate_hermes_home):
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"
)
self.client = TestClient(app)
self.auth_client = TestClient(app)
self.auth_client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
def _seed(self, ids):
from hermes_state import SessionDB
db = SessionDB()
try:
for sid in ids:
db.create_session(session_id=sid, source="cli")
finally:
db.close()
def test_deletes_listed_sessions_only(self):
from hermes_state import SessionDB
self._seed(["a", "b", "c"])
resp = self.auth_client.post(
"/api/sessions/bulk-delete", json={"ids": ["a", "b"]}
)
assert resp.status_code == 200
assert resp.json() == {"ok": True, "deleted": 2}
db = SessionDB()
try:
assert db.get_session("a") is None
assert db.get_session("b") is None
assert db.get_session("c") is not None
finally:
db.close()
def test_route_order_not_shadowed_by_session_id(self):
"""Pin the route-ordering contract: ``POST /api/sessions/bulk-delete``
must hit the bulk handler, not be re-interpreted via the
templated ``/api/sessions/{session_id}`` family. Concretely the
response carries our ``ok`` + ``deleted`` keys."""
resp = self.auth_client.post(
"/api/sessions/bulk-delete", json={"ids": []}
)
assert resp.status_code == 200
body = resp.json()
assert body.get("ok") is True
assert "deleted" in body, (
"If this assertion fails, /api/sessions/bulk-delete is "
"being shadowed by /api/sessions/{session_id} — check "
"registration order in hermes_cli/web_server.py."
)
class TestDeleteEmptySessionsEndpoint:
"""Tests for ``GET /api/sessions/empty/count`` and
``DELETE /api/sessions/empty`` — the bulk-delete endpoints backing
the dashboard's "Delete empty" button.
Locks in three things the implementation has to get right:
1. Route-ordering: the literal ``/api/sessions/empty[/count]`` paths
must shadow the templated ``/api/sessions/{session_id}`` route
above them. A regression here would route ``DELETE /api/sessions/
empty`` to the single-session handler with ``session_id="empty"``
(which 404s instead of bulk-deleting).
2. Behaviour parity with :meth:`SessionDB.delete_empty_sessions`:
active sessions and archived sessions are both preserved.
3. Auth gating: both routes require the session token like every
other ``/api/*`` endpoint (issue #19533 contract).
"""
@pytest.fixture(autouse=True)
def _setup_test_client(self, monkeypatch, _isolate_hermes_home):
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
# Pin the SessionDB to the isolated HERMES_HOME so each test
# starts with a clean state.db.
monkeypatch.setattr(
hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db"
)
self.client = TestClient(app)
self.auth_client = TestClient(app)
self.auth_client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
def _seed(self):
"""Build the standard test corpus:
* ``empty1`` / ``empty2`` — ended, no messages → should delete
* ``hasmsg`` — ended, has one message → must survive
* ``live`` — un-ended, empty → must survive (active)
* ``archived``— ended, empty, archived → must survive
"""
from hermes_state import SessionDB
db = SessionDB()
try:
db.create_session(session_id="empty1", source="cli")
db.end_session("empty1", end_reason="done")
db.create_session(session_id="empty2", source="cli")
db.end_session("empty2", end_reason="done")
db.create_session(session_id="hasmsg", source="cli")
db.append_message("hasmsg", role="user", content="hello")
db.end_session("hasmsg", end_reason="done")
db.create_session(session_id="live", source="cli")
db.create_session(session_id="archived", source="cli")
db.end_session("archived", end_reason="done")
db.set_session_archived("archived", True)
finally:
db.close()
def test_delete_endpoint_requires_auth(self):
"""DELETE /api/sessions/empty must 401 without the session token.
Regression guard for issue #19533 — the bulk-delete is a strictly
destructive primitive, the middleware must gate it even if a
future refactor introduces a non-auth path."""
resp = self.client.delete("/api/sessions/empty")
assert resp.status_code == 401
def test_delete_returns_count_and_removes_only_empties(self):
"""DELETE returns the deleted count and removes only the
empty-ended-unarchived rows — same shape contract as the
DB-level method's unit tests."""
from hermes_state import SessionDB
self._seed()
resp = self.auth_client.delete("/api/sessions/empty")
assert resp.status_code == 200
assert resp.json() == {"ok": True, "deleted": 2}
db = SessionDB()
try:
assert db.get_session("empty1") is None
assert db.get_session("empty2") is None
# Survivors: hasmsg has a message, live is active, archived
# is archived. All three must still be there.
assert db.get_session("hasmsg") is not None
assert db.get_session("live") is not None
assert db.get_session("archived") is not None
# And the count endpoint now reports 0.
assert db.count_empty_sessions() == 0
finally:
db.close()
def test_route_order_empty_not_shadowed_by_session_id(self):
"""Pin the route-ordering contract: ``DELETE /api/sessions/empty``
must hit the bulk handler, not the templated single-session
handler (which would 404 because no session has id 'empty').
Concretely: a request against the bulk path on an EMPTY corpus
returns ``{ok: True, deleted: 0}``. If the templated route were
winning, we'd see 404 ("Session not found") instead.
"""
resp = self.auth_client.delete("/api/sessions/empty")
assert resp.status_code == 200
body = resp.json()
assert "deleted" in body, (
"If this assertion fails, the literal /api/sessions/empty "
"route is being shadowed by the templated /api/sessions/"
"{session_id} route — check registration order in "
"hermes_cli/web_server.py."
)
class TestPluginAPIAuth:
"""Tests that plugin API routes require the session token (issue #19533)."""
@pytest.fixture(autouse=True)
def _setup_test_client(self, monkeypatch, _isolate_hermes_home, _install_example_plugin):
"""Create a TestClient without the session token header.
Pulls in ``_install_example_plugin`` so ``test_plugin_route_allows_auth``
has the ``/api/plugins/example/hello`` endpoint available — the
example plugin is no longer a bundled plugin, so the fixture
installs it into the per-test ``HERMES_HOME``.
"""
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")
self.client = TestClient(app)
self.auth_client = TestClient(app)
self.auth_client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
def test_plugin_route_allows_auth(self):
"""Plugin API routes should work with a valid session token.
Uses ``/api/plugins/example/hello`` from the example-dashboard
test fixture (installed into HERMES_HOME by the class-level
``_install_example_plugin`` fixture) — a stable, side-effect-free
GET that's only loaded for tests. With a valid token the handler
should run (200); without one the middleware should 401 before
the handler is reached.
"""
# Without auth: middleware blocks before reaching the handler.
resp = self.client.get("/api/plugins/example/hello")
assert resp.status_code == 401
# With auth: handler runs.
resp = self.auth_client.get("/api/plugins/example/hello")
assert resp.status_code == 200
def test_plugin_patch_requires_auth(self):
"""Plugin PATCH routes should return 401 without a valid session token.
PATCH is the mutation method most commonly used by the dashboard for
kanban task edits — explicitly cover it so a future middleware
regression that whitelists non-GET methods can't sneak through.
"""
resp = self.client.patch(
"/api/plugins/kanban/tasks/t_fake",
json={"title": "renamed"},
)
assert resp.status_code == 401
def test_non_kanban_plugin_route_requires_auth(self):
"""Auth must be plugin-agnostic, not kanban-specific.
The middleware fix is at the gate level (no per-plugin allowlist),
so any plugin's API surface — kanban, hermes-achievements, future
plugins — must require the session token. Hit a non-kanban plugin
path to lock that in.
"""
# Real plugin path (hermes-achievements is loaded by default).
resp = self.client.get("/api/plugins/hermes-achievements/overview")
assert resp.status_code == 401
# Same for an arbitrary plugin namespace that doesn't even exist —
# the middleware should 401 before routing decides 404, so an
# attacker can't fingerprint plugin names by status codes.
resp = self.client.get("/api/plugins/_definitely_not_a_plugin_/anything")
assert resp.status_code == 401
def test_plugin_websocket_unaffected_by_http_middleware(self):
"""The kanban /events WebSocket has its own ``?token=`` check;
the HTTP middleware change must not start gating WS upgrades.
Starlette doesn't run HTTP middleware on WebSocket upgrades anyway,
but pin the behavior so a future refactor that moves auth into a
shared layer can't silently break the WS auth contract.
"""
from starlette.websockets import WebSocketDisconnect
# Without a token the WS endpoint must close the upgrade itself
# (its own _check_ws_token), NOT 401 from the HTTP middleware.
try:
with self.client.websocket_connect(
"/api/plugins/kanban/events"
):
pass # if we got here without disconnect, the WS accepted us
except WebSocketDisconnect:
pass # expected — WS endpoint rejected via its own check
except Exception:
# The kanban plugin may not be mounted in this test environment,
# in which case the route doesn't exist at all (3xx/4xx during
# upgrade). That's fine for this regression — it only matters
# that the HTTP middleware didn't start intercepting WS upgrades.
pass
class TestDashboardPluginManifestExtensions:
"""Tests for the extended plugin manifest fields (tab.override,
tab.hidden, slots) read by _discover_dashboard_plugins()."""
def _write_plugin(self, tmp_path, name, manifest):
import json
plug_dir = tmp_path / "plugins" / name / "dashboard"
plug_dir.mkdir(parents=True)
(plug_dir / "manifest.json").write_text(json.dumps(manifest))
return plug_dir
def test_override_and_hidden_carried_through(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
self._write_plugin(tmp_path, "skin-home", {
"name": "skin-home",
"label": "Skin Home",
"tab": {"path": "/skin-home", "override": "/", "hidden": True},
"slots": ["sidebar", "header-left"],
"entry": "dist/index.js",
})
from hermes_cli import web_server
# Bust the process-level cache so the test plugin is picked up.
web_server._dashboard_plugins_cache = None
plugins = web_server._get_dashboard_plugins(force_rescan=True)
entry = next(p for p in plugins if p["name"] == "skin-home")
assert entry["tab"]["override"] == "/"
assert entry["tab"]["hidden"] is True
assert entry["slots"] == ["sidebar", "header-left"]
def test_user_plugins_ignore_profile_home_override(self, tmp_path, monkeypatch):
"""Regression: user dashboard extensions are a dashboard-owned asset
(like theme YAML), so they must stay visible after a context-local
HERMES_HOME override scopes a request to another profile."""
from hermes_constants import (
reset_hermes_home_override,
set_hermes_home_override,
)
launch_home = tmp_path / "launch"
launch_home.mkdir()
self._write_plugin(launch_home, "skin-home", {
"name": "skin-home",
"label": "Skin Home",
"tab": {"path": "/skin-home"},
"entry": "dist/index.js",
})
other = tmp_path / "other-profile"
other.mkdir()
monkeypatch.setenv("HERMES_HOME", str(launch_home))
from hermes_cli import web_server
token = set_hermes_home_override(str(other))
try:
plugins = web_server._discover_dashboard_plugins()
finally:
reset_hermes_home_override(token)
assert any(p["name"] == "skin-home" for p in plugins)
# ---------------------------------------------------------------------------
# /api/pty WebSocket — terminal bridge for the dashboard "Chat" tab.
#
# These tests drive the endpoint with a tiny fake command (typically ``cat``
# or ``sh -c 'printf …'``) instead of the real ``hermes --tui`` binary. The
# endpoint resolves its argv through ``_resolve_chat_argv``, so tests
# monkeypatch that hook.
# ---------------------------------------------------------------------------
import sys
skip_on_windows = pytest.mark.skipif(
sys.platform.startswith("win"), reason="PTY bridge is POSIX-only"
)
@skip_on_windows
class TestPtyWebSocket:
@pytest.fixture(autouse=True)
def _setup(self, monkeypatch, _isolate_hermes_home):
from starlette.testclient import TestClient
import hermes_cli.web_server as ws
# Avoid exec'ing the actual TUI in tests: every test below installs
# its own fake argv via ``ws._resolve_chat_argv``.
self.ws_module = ws
monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True)
ws.app.state.pty_active_session_files = {}
self.token = ws._SESSION_TOKEN
self.client = TestClient(ws.app)
def _url(self, token: str | None = None, **params: str) -> str:
tok = token if token is not None else self.token
# TestClient.websocket_connect takes the path; it reconstructs the
# query string, so we pass it inline.
from urllib.parse import urlencode
q = {"token": tok, **params}
return f"/api/pty?{urlencode(q)}"
def test_tui_python_command_uses_child_path(self, tmp_path):
"""Bare Python commands are resolved from the TUI child's PATH."""
import hermes_cli.main as main_mod
command = f"hermes-review-python{Path(sys.executable).suffix}"
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
executable = bin_dir / command
# copy2, not os.link: tmp_path may sit on a different filesystem than
# the venv (tmpfs /tmp vs disk home) where hard links raise EXDEV.
shutil.copy2(sys.executable, executable)
env = {
"HERMES_CWD": str(tmp_path),
"HERMES_PYTHON": command,
"PATH": str(bin_dir),
}
main_mod._apply_tui_python_env(env)
assert env["HERMES_PYTHON"] == command
def test_resolve_chat_argv_async_uses_worker_thread(self, monkeypatch):
captured: dict = {}
def fake_resolve(resume=None, sidecar_url=None, profile=None):
captured["resume"] = resume
captured["sidecar_url"] = sidecar_url
captured["profile"] = profile
return (["node", "dist/entry.js"], "/tmp/ui-tui", {"NODE_ENV": "production"})
async def fake_to_thread(fn, *args, **kwargs):
captured["thread_fn"] = fn
captured["thread_args"] = args
captured["thread_kwargs"] = kwargs
return fn(*args, **kwargs)
monkeypatch.setattr(self.ws_module, "_resolve_chat_argv", fake_resolve)
monkeypatch.setattr(self.ws_module.asyncio, "to_thread", fake_to_thread)
argv, cwd, env = asyncio.run(
self.ws_module._resolve_chat_argv_async(
resume="sess-42",
sidecar_url="ws://127.0.0.1:9119/api/pub?channel=abc",
profile="worker",
)
)
assert callable(captured["thread_fn"])
assert captured["thread_args"] == ()
assert captured["thread_kwargs"] == {
"resume": "sess-42",
"sidecar_url": "ws://127.0.0.1:9119/api/pub?channel=abc",
"profile": "worker",
}
assert argv == ["node", "dist/entry.js"]
assert cwd == "/tmp/ui-tui"
assert env == {"NODE_ENV": "production"}
assert captured["resume"] == "sess-42"
assert captured["sidecar_url"] == "ws://127.0.0.1:9119/api/pub?channel=abc"
assert captured["profile"] == "worker"
def _assert_pty_propagates(self, monkeypatch, raising_resolver, *, profile=None, expect_detail=None):
"""Drive /api/pty with a resolver that raises, and assert the error
propagates through the real _resolve_chat_argv_async -> asyncio.to_thread
-> lock -> re-raise chain into pty_ws's handler: the "Chat unavailable"
notice is sent and the socket closes with code 1011 (the stable
contract — we assert the close code, not the exact notice wording)."""
from starlette.websockets import WebSocketDisconnect
# Patch the REAL resolver so the whole wrapper/to_thread/lock chain runs.
monkeypatch.setattr(self.ws_module, "_resolve_chat_argv", raising_resolver)
url = self._url(profile=profile) if profile else self._url()
with self.client.websocket_connect(url) as conn:
notice = conn.receive_text()
with pytest.raises(WebSocketDisconnect) as exc:
conn.receive_text()
assert "Chat unavailable" in notice
assert exc.value.code == 1011
if expect_detail is not None:
assert expect_detail in notice
def test_unavailable_platform_closes_with_message(self, monkeypatch):
from hermes_cli.pty_bridge import PtyUnavailableError
def _raise(argv, **kwargs):
raise PtyUnavailableError("pty missing for tests")
monkeypatch.setattr(
self.ws_module,
"_resolve_chat_argv",
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
monkeypatch.setattr(ws_mod.PtyBridge, "spawn", classmethod(lambda cls, *a, **k: _raise(*a, **k)))
with self.client.websocket_connect(self._url()) as conn:
# Expect a final text frame with the error message, then close.
msg = conn.receive_text()
assert "pty missing" in msg or "unavailable" in msg.lower() or "pty" in msg.lower()
def test_pub_broadcasts_to_events_subscribers(self):
"""A frame handed to _broadcast_event is sent verbatim to every
subscriber registered on that channel — and not to subscribers on
other channels.
This drives the broadcast unit directly under asyncio rather than
round-tripping through Starlette's TestClient WebSocket portal. The
portal version was flaky under heavy parallel CI load: the broadcast
had to traverse two nested threaded portals within a 10s wall-clock
budget, and a starved ASGI thread occasionally blew that budget even
though the server logic was correct. Testing _broadcast_event with
fake subscribers removes the scheduling surface entirely while
asserting the exact fan-out contract.
"""
import asyncio
from hermes_cli import web_server as ws_mod
class _FakeSub:
def __init__(self):
self.sent: list[str] = []
async def send_text(self, payload: str) -> None:
self.sent.append(payload)
app = ws_mod.app
async def _run():
sub_a1 = _FakeSub()
sub_a2 = _FakeSub()
sub_other = _FakeSub()
frame = '{"type":"tool.start","payload":{"tool_id":"t1"}}'
event_channels, event_lock = ws_mod._get_event_state(app)
# Register two subscribers on the target channel and one on a
# different channel, exactly as the /api/events handler does.
async with event_lock:
event_channels.setdefault("broadcast-test", set()).update(
{sub_a1, sub_a2}
)
event_channels.setdefault("other-channel", set()).add(sub_other)
try:
await ws_mod._broadcast_event(app, "broadcast-test", frame)
finally:
async with event_lock:
event_channels.pop("broadcast-test", None)
event_channels.pop("other-channel", None)
return sub_a1, sub_a2, sub_other, frame
sub_a1, sub_a2, sub_other, frame = asyncio.run(_run())
# Every subscriber on the channel got the frame verbatim, exactly once.
assert sub_a1.sent == [frame]
assert sub_a2.sent == [frame]
# A subscriber on a different channel got nothing.
assert sub_other.sent == []
def test_resolve_chat_argv_injects_gateway_ws_url(monkeypatch):
import hermes_cli.main as cli_main
import hermes_cli.web_server as ws
monkeypatch.setattr(
cli_main,
"_make_tui_argv",
lambda *_args, **_kwargs: (["node", "fake-tui.js"], Path("/tmp")),
)
monkeypatch.setattr(ws.app.state, "bound_host", "127.0.0.1", raising=False)
monkeypatch.setattr(ws.app.state, "bound_port", 9119, raising=False)
_argv, _cwd, env = ws._resolve_chat_argv()
assert env is not None
gateway_url = env.get("HERMES_TUI_GATEWAY_URL", "")
assert gateway_url.startswith("ws://127.0.0.1:9119/api/ws?")
assert "token=" in gateway_url
class TestDashboardPluginStaticAssetAllowlist:
"""``/dashboard-plugins/<name>/<path>`` is unauthenticated by design —
the SPA loads plugin JS via ``<script src>`` and CSS via
``<link href>``, neither of which can attach a custom auth header.
Instead the route restricts file types to the browser-asset
allowlist (JS/CSS/JSON/images/fonts) so that user-installed
plugins shipping a ``plugin_api.py`` backend module don't leak
their Python source to anyone reachable on the loopback port.
Regression test for the dashboard pentest finding filed alongside
the ``web-pentest`` skill (PR #32265 / issue #32267).
"""
@pytest.fixture(autouse=True)
def _setup_test_client(self, monkeypatch, _isolate_hermes_home, _install_example_plugin):
"""Create a TestClient and install the example-dashboard fixture.
The static-asset allowlist tests need a plugin to point at —
they verify that ``/dashboard-plugins/example/manifest.json``
is served while ``plugin_api.py`` and ``__pycache__/*.pyc``
from the same directory are not. Since the example plugin is
no longer bundled, ``_install_example_plugin`` lays it down in
the per-test ``HERMES_HOME`` user-plugins dir.
"""
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
from hermes_cli.web_server import app
self.client = TestClient(app)
def test_python_source_is_404(self):
"""The example plugin's ``plugin_api.py`` must NOT be served as
a static asset, even though the file exists under the plugin's
dashboard directory. Suffix not in the allowlist → 404."""
resp = self.client.get("/dashboard-plugins/example/plugin_api.py")
assert resp.status_code == 404
def test_manifest_json_still_served(self):
"""JSON files remain browser-fetchable — manifests, localized
data, source maps, etc. all sit in this bucket."""
resp = self.client.get("/dashboard-plugins/example/manifest.json")
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("application/json")
# And the body is actually the manifest, not the SPA fallback.
body = resp.json()
assert body.get("name") == "example"
def test_path_traversal_still_blocked(self):
"""The allowlist is on top of the existing ``.resolve()`` /
``is_relative_to()`` check — a ``.js`` named file at an
out-of-base path is still rejected as traversal, not served."""
resp = self.client.get(
"/dashboard-plugins/example/..%2Fplugin_api.py"
)
# 403 traversal-blocked OR 404 (depending on URL decode order)
# — never 200.
assert resp.status_code in (403, 404)
def _fake_httpx_client(*, status: int | None = None, raise_exc: bool = False):
"""Build a drop-in for httpx.Client whose .get() returns a canned status
(or raises a transport error). Patched in for the credential-validate probe
so tests never touch the network."""
class _Resp:
def __init__(self, code):
self.status_code = code
@property
def is_success(self):
return 200 <= self.status_code < 300
class _Client:
def __init__(self, *a, **k):
pass
def __enter__(self):
return self
def __exit__(self, *a):
return False
def get(self, *a, **k):
if raise_exc:
raise RuntimeError("connection refused")
return _Resp(status)
return _Client
class TestValidateProviderCredential:
"""Live-probe credential validation (/api/providers/validate)."""
@pytest.fixture(autouse=True)
def _setup_test_client(self, monkeypatch, _isolate_hermes_home):
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
self.client = TestClient(app)
self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
def _post(self, key, value):
return self.client.post("/api/providers/validate", json={"key": key, "value": value})
def test_network_error_is_unreachable_not_blocking(self, monkeypatch):
monkeypatch.setattr("httpx.Client", _fake_httpx_client(raise_exc=True))
data = self._post("OPENROUTER_API_KEY", "sk-real").json()
assert data["ok"] is False and data["reachable"] is False
def test_local_endpoint_forwards_api_key_as_bearer(self, monkeypatch):
"""A custom endpoint that gates /v1/models behind auth must still
enumerate models: the optional api_key is sent as a Bearer header so the
probe doesn't come back empty (the desktop loop's root cause)."""
captured = {}
class _Resp:
status_code = 200
is_success = True
def json(self):
return {"data": [{"id": "gpt-oss-120b"}]}
class _Client:
def __init__(self, *a, **k):
pass
def __enter__(self):
return self
def __exit__(self, *a):
return False
def get(self, url, *a, headers=None, **k):
captured["url"] = url
captured["headers"] = headers
return _Resp()
monkeypatch.setattr("httpx.Client", _Client)
resp = self.client.post(
"/api/providers/validate",
json={
"key": "OPENAI_BASE_URL",
"value": "https://text.example.com/v1",
"api_key": "sk-secret",
},
)
data = resp.json()
assert data["ok"] is True and data["reachable"] is True
assert data["models"] == ["gpt-oss-120b"]
assert captured["url"] == "https://text.example.com/v1/models"
assert captured["headers"] == {"Authorization": "Bearer sk-secret"}
class TestDesktopCronTicker:
"""The dashboard backend fires cron jobs itself only when desktop-spawned."""
def _client(self):
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
from hermes_cli.web_server import app
return TestClient(app)
def test_ticker_runs_when_desktop(self, monkeypatch, _isolate_hermes_home):
import threading
import cron.scheduler as sched
called = threading.Event()
monkeypatch.setattr(sched, "tick", lambda *a, **k: called.set())
monkeypatch.setenv("HERMES_DESKTOP", "1")
with self._client():
assert called.wait(3.0), "expected cron tick under HERMES_DESKTOP=1"
class TestServeIndexMissingIndex:
"""_serve_index must not raise per-request when index.html vanishes
(partial build, wiped dist) after mount_spa saw an existing dist dir.
It should return the same JSON 404 payload mount_spa emits for a
fully-missing dist."""
@staticmethod
def _client_with_dist(tmp_path, monkeypatch, *, write_index: bool):
from fastapi import FastAPI
from starlette.testclient import TestClient
import hermes_cli.web_server as ws
dist = tmp_path / "web_dist"
(dist / "assets").mkdir(parents=True)
if write_index:
(dist / "index.html").write_text(
"<html><head></head><body>SPA</body></html>", encoding="utf-8"
)
monkeypatch.setattr(ws, "WEB_DIST", dist)
monkeypatch.delenv("HERMES_SERVE_HEADLESS", raising=False)
spa_app = FastAPI()
ws.mount_spa(spa_app)
return TestClient(spa_app), dist
def test_missing_index_inside_existing_dist_returns_json_404(
self, tmp_path, monkeypatch
):
client, _dist = self._client_with_dist(
tmp_path, monkeypatch, write_index=False
)
for route in ("/", "/chat"):
resp = client.get(route)
assert resp.status_code == 404
assert resp.json()["error"] == (
"Frontend not built. Run: cd web && npm run build"
)
def test_index_deleted_after_mount_returns_json_404(self, tmp_path, monkeypatch):
client, dist = self._client_with_dist(tmp_path, monkeypatch, write_index=True)
assert client.get("/chat").status_code == 200 # healthy first
(dist / "index.html").unlink()
resp = client.get("/chat")
assert resp.status_code == 404
assert "Frontend not built" in resp.json()["error"]
# And recovers once the index reappears (e.g. a rebuild finished).
(dist / "index.html").write_text(
"<html><head></head><body>SPA-rebuilt</body></html>", encoding="utf-8"
)
resp = client.get("/chat")
assert resp.status_code == 200
assert "SPA-rebuilt" in resp.text
class TestDashboardComponentHealth:
"""Component-health rollup: error middleware, /api/status components, self-test."""
@pytest.fixture(autouse=True)
def _setup(self, monkeypatch, _isolate_hermes_home):
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
import hermes_state
from hermes_constants import get_hermes_home
import hermes_cli.web_server as ws
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
# Fresh state holder per test so counters don't leak across tests.
monkeypatch.setattr(ws, "DASHBOARD_HEALTH", ws.DashboardHealth())
self.ws = ws
self.client = TestClient(ws.app, raise_server_exceptions=False)
self.client.headers[ws._SESSION_HEADER_NAME] = ws._SESSION_TOKEN
# -- middleware -------------------------------------------------------
# -- /api/status components ------------------------------------------
def test_public_component_payload_carries_no_secret_bearing_fields(self):
"""PUBLIC_API_PATHS contract: counts/enums only — no paths/messages."""
self.ws.DASHBOARD_HEALTH.record_error("RuntimeError", "/api/secret-route?token=abc")
resp = self.client.get("/api/status")
payload = json.dumps(resp.json()["components"])
assert "secret-route" not in payload
assert "token=abc" not in payload
assert "last_error_path" not in payload
assert "last_error_type" not in payload
assert "kaboom" not in payload
# -- self-test ---------------------------------------------------------
def test_selftest_records_failure_on_500(self, monkeypatch):
httpx = pytest.importorskip("httpx")
class _FakeResponse:
status_code = 500
class _FakeClient:
def __init__(self, *args, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
async def get(self, *args, **kwargs):
return _FakeResponse()
monkeypatch.setattr(httpx, "AsyncClient", _FakeClient)
asyncio.run(self.ws._dashboard_selftest_once())
assert self.ws.DASHBOARD_HEALTH.selftest_status == "failing"
assert self.ws.DASHBOARD_HEALTH.selftest_http_status == 500
assert self.ws.DASHBOARD_HEALTH.snapshot()["status"] == "degraded"