"""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 ``/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//`` 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= 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("") 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.`` 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('") # 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( "tSPA", 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 '