hermes-agent/tests/hermes_cli/test_web_server.py
Teknium e2a9b5369f
feat: web UI dashboard for managing Hermes Agent (#8756)
* feat: web UI dashboard for managing Hermes Agent (salvage of #8204/#7621)

Adds an embedded web UI dashboard accessible via `hermes web`:
- Status page: agent version, active sessions, gateway status, connected platforms
- Config editor: schema-driven form with tabbed categories, import/export, reset
- API Keys page: set, clear, and view redacted values with category grouping
- Sessions, Skills, Cron, Logs, and Analytics pages

Backend:
- hermes_cli/web_server.py: FastAPI server with REST endpoints
- hermes_cli/config.py: reload_env() utility for hot-reloading .env
- hermes_cli/main.py: `hermes web` subcommand (--port, --host, --no-open)
- cli.py / commands.py: /reload slash command for .env hot-reload
- pyproject.toml: [web] optional dependency extra (fastapi + uvicorn)
- Both update paths (git + zip) auto-build web frontend when npm available

Frontend:
- Vite + React + TypeScript + Tailwind v4 SPA in web/
- shadcn/ui-style components, Nous design language
- Auto-refresh status page, toast notifications, masked password inputs

Security:
- Path traversal guard (resolve().is_relative_to()) on SPA file serving
- CORS localhost-only via allow_origin_regex
- Generic error messages (no internal leak), SessionDB handles closed properly

Tests: 47 tests covering reload_env, redact_key, API endpoints, schema
generation, path traversal, category merging, internal key stripping,
and full config round-trip.

Original work by @austinpickett (PR #1813), salvaged by @kshitijk4poor
(PR #7621#8204), re-salvaged onto current main with stale-branch
regressions removed.

* fix(web): clean up status page cards, always rebuild on `hermes web`

- Remove config version migration alert banner from status page
- Remove config version card (internal noise, not surfaced in TUI)
- Reorder status cards: Agent → Gateway → Active Sessions (3-col grid)
- `hermes web` now always rebuilds from source before serving,
  preventing stale web_dist when editing frontend files

* feat(web): full-text search across session messages

- Add GET /api/sessions/search endpoint backed by FTS5
- Auto-append prefix wildcards so partial words match (e.g. 'nimb' → 'nimby')
- Debounced search (300ms) with spinner in the search icon slot
- Search results show FTS5 snippets with highlighted match delimiters
- Expanding a search hit auto-scrolls to the first matching message
- Matching messages get a warning ring + 'match' badge
- Inline term highlighting within Markdown (text, bold, italic, headings, lists)
- Clear button (x) on search input for quick reset

---------

Co-authored-by: emozilla <emozilla@nousresearch.com>
2026-04-12 22:26:28 -07:00

675 lines
26 KiB
Python

"""Tests for hermes_cli.web_server and related config utilities."""
import os
import json
import tempfile
from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
from hermes_cli.config import (
DEFAULT_CONFIG,
reload_env,
redact_key,
_EXTRA_ENV_KEYS,
OPTIONAL_ENV_VARS,
)
# ---------------------------------------------------------------------------
# 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("hermes_cli.config.get_env_path", return_value=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_updates_changed_vars(self, tmp_path):
"""reload_env() updates vars whose value changed on disk."""
env_file = tmp_path / ".env"
env_file.write_text("TEST_RELOAD_VAR=old_value\n")
with patch("hermes_cli.config.get_env_path", return_value=env_file):
os.environ["TEST_RELOAD_VAR"] = "old_value"
# Now change the file
env_file.write_text("TEST_RELOAD_VAR=new_value\n")
count = reload_env()
assert count >= 1
assert os.environ.get("TEST_RELOAD_VAR") == "new_value"
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("hermes_cli.config.get_env_path", return_value=env_file):
os.environ[known_key] = "stale_value"
count = reload_env()
assert known_key not in os.environ
assert count >= 1
def test_does_not_remove_unknown_vars(self, tmp_path):
"""reload_env() preserves non-Hermes env vars even when absent from .env."""
env_file = tmp_path / ".env"
env_file.write_text("")
with patch("hermes_cli.config.get_env_path", return_value=env_file):
os.environ["MY_CUSTOM_UNRELATED_VAR"] = "keep_me"
reload_env()
assert os.environ.get("MY_CUSTOM_UNRELATED_VAR") == "keep_me"
os.environ.pop("MY_CUSTOM_UNRELATED_VAR", None)
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
# web_server tests (FastAPI endpoints)
# ---------------------------------------------------------------------------
class TestWebServerEndpoints:
"""Test the FastAPI REST endpoints using Starlette TestClient."""
@pytest.fixture(autouse=True)
def _setup_test_client(self):
"""Create a TestClient — import is deferred to avoid requiring fastapi."""
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_get_status(self):
resp = self.client.get("/api/status")
assert resp.status_code == 200
data = resp.json()
assert "version" in data
assert "hermes_home" in data
assert "active_sessions" in data
def test_get_status_filters_unconfigured_gateway_platforms(self, monkeypatch):
import gateway.config as gateway_config
import hermes_cli.web_server as web_server
class _Platform:
def __init__(self, value):
self.value = value
class _GatewayConfig:
def get_connected_platforms(self):
return [_Platform("telegram")]
monkeypatch.setattr(web_server, "get_running_pid", lambda: 1234)
monkeypatch.setattr(
web_server,
"read_runtime_status",
lambda: {
"gateway_state": "running",
"updated_at": "2026-04-12T00:00:00+00:00",
"platforms": {
"telegram": {"state": "connected", "updated_at": "2026-04-12T00:00:00+00:00"},
"whatsapp": {"state": "retrying", "updated_at": "2026-04-12T00:00:00+00:00"},
"feishu": {"state": "connected", "updated_at": "2026-04-12T00:00:00+00:00"},
},
},
)
monkeypatch.setattr(web_server, "check_config_version", lambda: (1, 1))
monkeypatch.setattr(gateway_config, "load_gateway_config", lambda: _GatewayConfig())
resp = self.client.get("/api/status")
assert resp.status_code == 200
assert resp.json()["gateway_platforms"] == {
"telegram": {"state": "connected", "updated_at": "2026-04-12T00:00:00+00:00"},
}
def test_get_status_hides_stale_platforms_when_gateway_not_running(self, monkeypatch):
import gateway.config as gateway_config
import hermes_cli.web_server as web_server
class _GatewayConfig:
def get_connected_platforms(self):
return []
monkeypatch.setattr(web_server, "get_running_pid", lambda: None)
monkeypatch.setattr(
web_server,
"read_runtime_status",
lambda: {
"gateway_state": "startup_failed",
"updated_at": "2026-04-12T00:00:00+00:00",
"platforms": {
"whatsapp": {"state": "retrying", "updated_at": "2026-04-12T00:00:00+00:00"},
"feishu": {"state": "connected", "updated_at": "2026-04-12T00:00:00+00:00"},
},
},
)
monkeypatch.setattr(web_server, "check_config_version", lambda: (1, 1))
monkeypatch.setattr(gateway_config, "load_gateway_config", lambda: _GatewayConfig())
resp = self.client.get("/api/status")
assert resp.status_code == 200
assert resp.json()["gateway_state"] == "startup_failed"
assert resp.json()["gateway_platforms"] == {}
def test_get_config_schema(self):
resp = self.client.get("/api/config/schema")
assert resp.status_code == 200
data = resp.json()
assert "fields" in data
assert "category_order" in data
schema = data["fields"]
assert len(schema) > 100 # Should have 150+ fields
assert "model" in schema
# Verify category_order is a non-empty list
assert isinstance(data["category_order"], list)
assert len(data["category_order"]) > 0
assert "general" in data["category_order"]
def test_get_config_defaults(self):
resp = self.client.get("/api/config/defaults")
assert resp.status_code == 200
defaults = resp.json()
assert "model" in defaults
def test_get_env_vars(self):
resp = self.client.get("/api/env")
assert resp.status_code == 200
data = resp.json()
# Should contain known env var names
assert any(k.endswith("_API_KEY") or k.endswith("_TOKEN") for k in data.keys())
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_TOKEN
save_env_value("TEST_REVEAL_KEY", "super-secret-value-12345")
resp = self.client.post(
"/api/env/reveal",
json={"key": "TEST_REVEAL_KEY"},
headers={"Authorization": f"Bearer {_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_not_found(self):
"""POST /api/env/reveal should 404 for unknown keys."""
from hermes_cli.web_server import _SESSION_TOKEN
resp = self.client.post(
"/api/env/reveal",
json={"key": "NONEXISTENT_KEY_XYZ"},
headers={"Authorization": f"Bearer {_SESSION_TOKEN}"},
)
assert resp.status_code == 404
def test_reveal_env_var_no_token(self, tmp_path):
"""POST /api/env/reveal without token should return 401."""
from hermes_cli.config import save_env_value
save_env_value("TEST_REVEAL_NOAUTH", "secret-value")
resp = self.client.post(
"/api/env/reveal",
json={"key": "TEST_REVEAL_NOAUTH"},
)
assert resp.status_code == 401
def test_reveal_env_var_bad_token(self, tmp_path):
"""POST /api/env/reveal with wrong token should return 401."""
from hermes_cli.config import save_env_value
save_env_value("TEST_REVEAL_BADAUTH", "secret-value")
resp = self.client.post(
"/api/env/reveal",
json={"key": "TEST_REVEAL_BADAUTH"},
headers={"Authorization": "Bearer wrong-token-here"},
)
assert resp.status_code == 401
def test_session_token_endpoint(self):
"""GET /api/auth/session-token should return a token."""
from hermes_cli.web_server import _SESSION_TOKEN
resp = self.client.get("/api/auth/session-token")
assert resp.status_code == 200
assert resp.json()["token"] == _SESSION_TOKEN
def test_path_traversal_blocked(self):
"""Verify URL-encoded path traversal is blocked."""
# %2e%2e = ..
resp = self.client.get("/%2e%2e/%2e%2e/etc/passwd")
# Should return 200 with index.html (SPA fallback), not the actual file
assert resp.status_code in (200, 404)
if resp.status_code == 200:
# Should be the SPA fallback, not the system file
assert "root:" not in resp.text
def test_path_traversal_dotdot_blocked(self):
"""Direct .. path traversal via encoded sequences."""
resp = self.client.get("/%2e%2e/hermes_cli/web_server.py")
assert resp.status_code in (200, 404)
if resp.status_code == 200:
assert "FastAPI" not in resp.text # Should not serve the actual source
# ---------------------------------------------------------------------------
# _build_schema_from_config tests
# ---------------------------------------------------------------------------
class TestBuildSchemaFromConfig:
def test_produces_expected_field_count(self):
from hermes_cli.web_server import CONFIG_SCHEMA
# DEFAULT_CONFIG has ~150+ leaf fields
assert len(CONFIG_SCHEMA) > 100
def test_schema_entries_have_required_fields(self):
from hermes_cli.web_server import CONFIG_SCHEMA
for key, entry in list(CONFIG_SCHEMA.items())[:10]:
assert "type" in entry, f"Missing type for {key}"
assert "category" in entry, f"Missing category for {key}"
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"]
def test_empty_prefix_produces_correct_keys(self):
from hermes_cli.web_server import _build_schema_from_config
test_config = {"model": "test", "nested": {"key": "val"}}
schema = _build_schema_from_config(test_config)
assert "model" in schema
assert "nested.key" in schema
def test_top_level_scalars_get_general_category(self):
"""Top-level scalar fields should be in 'general' category."""
from hermes_cli.web_server import CONFIG_SCHEMA
assert CONFIG_SCHEMA["model"]["category"] == "general"
def test_nested_keys_get_parent_category(self):
"""Nested fields should use the top-level parent as their category."""
from hermes_cli.web_server import CONFIG_SCHEMA
if "agent.max_turns" in CONFIG_SCHEMA:
assert CONFIG_SCHEMA["agent.max_turns"]["category"] == "agent"
def test_category_merge_applied(self):
"""Small categories should be merged into larger ones."""
from hermes_cli.web_server import CONFIG_SCHEMA
categories = {e["category"] for e in CONFIG_SCHEMA.values()}
# These should be merged away
assert "privacy" not in categories # merged into security
assert "context" not in categories # merged into agent
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
self.client = TestClient(app)
def test_get_config_no_internal_keys(self):
"""GET /api/config should not expose _config_version or _model_meta."""
config = self.client.get("/api/config").json()
internal = [k for k in config if k.startswith("_")]
assert not internal, f"Internal keys leaked to frontend: {internal}"
def test_get_config_model_is_string(self):
"""GET /api/config should normalize model dict to a string."""
config = self.client.get("/api/config").json()
assert isinstance(config.get("model"), str), \
f"model should be string, got {type(config.get('model'))}"
def test_round_trip_preserves_model_subkeys(self):
"""Save and reload should not lose model.provider, model.base_url, etc."""
from hermes_cli.config import load_config, save_config
# Set up a config with model as a dict (the common user config form)
save_config({
"model": {
"default": "anthropic/claude-sonnet-4",
"provider": "openrouter",
"base_url": "https://openrouter.ai/api/v1",
"api_mode": "openai",
}
})
before = load_config()
assert isinstance(before.get("model"), dict)
original_keys = set(before["model"].keys())
# GET → PUT unchanged
web_config = self.client.get("/api/config").json()
assert isinstance(web_config.get("model"), str), "GET should normalize model to string"
self.client.put("/api/config", json={"config": web_config})
after = load_config()
assert isinstance(after.get("model"), dict), "model should still be a dict after save"
assert set(after["model"].keys()) >= original_keys, \
f"Lost model subkeys: {original_keys - set(after['model'].keys())}"
def test_edit_model_name_preserved(self):
"""Changing the model string should update model.default on disk."""
from hermes_cli.config import load_config
web_config = self.client.get("/api/config").json()
original_model = web_config["model"]
# Change model
web_config["model"] = "test/editing-model"
self.client.put("/api/config", json={"config": web_config})
after = load_config()
if isinstance(after.get("model"), dict):
assert after["model"]["default"] == "test/editing-model"
else:
assert after["model"] == "test/editing-model"
# Restore
web_config["model"] = original_model
self.client.put("/api/config", json={"config": web_config})
def test_edit_nested_value(self):
"""Editing a nested config value should persist correctly."""
from hermes_cli.config import load_config
web_config = self.client.get("/api/config").json()
original_turns = web_config.get("agent", {}).get("max_turns")
# Change max_turns
if "agent" not in web_config:
web_config["agent"] = {}
web_config["agent"]["max_turns"] = 42
self.client.put("/api/config", json={"config": web_config})
after = load_config()
assert after.get("agent", {}).get("max_turns") == 42
# Restore
web_config["agent"]["max_turns"] = original_turns
self.client.put("/api/config", json={"config": web_config})
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, f"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):
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_get_logs_default(self):
resp = self.client.get("/api/logs")
assert resp.status_code == 200
data = resp.json()
assert "file" in data
assert "lines" in data
assert isinstance(data["lines"], list)
def test_get_logs_invalid_file(self):
resp = self.client.get("/api/logs?file=nonexistent")
assert resp.status_code == 400
def test_cron_list(self):
resp = self.client.get("/api/cron/jobs")
assert resp.status_code == 200
assert isinstance(resp.json(), list)
def test_cron_job_not_found(self):
resp = self.client.get("/api/cron/jobs/nonexistent-id")
assert resp.status_code == 404
def test_skills_list(self):
resp = self.client.get("/api/skills")
assert resp.status_code == 200
skills = resp.json()
assert isinstance(skills, list)
if skills:
assert "name" in skills[0]
assert "enabled" in skills[0]
def test_skills_list_includes_disabled_skills(self, monkeypatch):
import tools.skills_tool as skills_tool
import hermes_cli.skills_config as skills_config
import hermes_cli.web_server as web_server
def _fake_find_all_skills(*, skip_disabled=False):
if skip_disabled:
return [
{"name": "active-skill", "description": "active", "category": "demo"},
{"name": "disabled-skill", "description": "disabled", "category": "demo"},
]
return [
{"name": "active-skill", "description": "active", "category": "demo"},
]
monkeypatch.setattr(skills_tool, "_find_all_skills", _fake_find_all_skills)
monkeypatch.setattr(skills_config, "get_disabled_skills", lambda config: {"disabled-skill"})
monkeypatch.setattr(web_server, "load_config", lambda: {"skills": {"disabled": ["disabled-skill"]}})
resp = self.client.get("/api/skills")
assert resp.status_code == 200
assert resp.json() == [
{
"name": "active-skill",
"description": "active",
"category": "demo",
"enabled": True,
},
{
"name": "disabled-skill",
"description": "disabled",
"category": "demo",
"enabled": False,
},
]
def test_toolsets_list(self):
resp = self.client.get("/api/tools/toolsets")
assert resp.status_code == 200
toolsets = resp.json()
assert isinstance(toolsets, list)
if toolsets:
assert "name" in toolsets[0]
assert "label" in toolsets[0]
assert "enabled" in toolsets[0]
def test_toolsets_list_matches_cli_enabled_state(self, monkeypatch):
import hermes_cli.tools_config as tools_config
import toolsets as toolsets_module
import hermes_cli.web_server as web_server
monkeypatch.setattr(
tools_config,
"_get_effective_configurable_toolsets",
lambda: [
("web", "🔍 Web Search & Scraping", "web_search, web_extract"),
("skills", "📚 Skills", "list, view, manage"),
("memory", "💾 Memory", "persistent memory across sessions"),
],
)
monkeypatch.setattr(
tools_config,
"_get_platform_tools",
lambda config, platform, include_default_mcp_servers=False: {"web", "skills"},
)
monkeypatch.setattr(
tools_config,
"_toolset_has_keys",
lambda ts_key, config=None: ts_key != "web",
)
monkeypatch.setattr(
toolsets_module,
"resolve_toolset",
lambda name: {
"web": ["web_search", "web_extract"],
"skills": ["skills_list", "skill_view"],
"memory": ["memory_read"],
}[name],
)
monkeypatch.setattr(web_server, "load_config", lambda: {"platform_toolsets": {"cli": ["web", "skills"]}})
resp = self.client.get("/api/tools/toolsets")
assert resp.status_code == 200
assert resp.json() == [
{
"name": "web",
"label": "🔍 Web Search & Scraping",
"description": "web_search, web_extract",
"enabled": True,
"available": True,
"configured": False,
"tools": ["web_extract", "web_search"],
},
{
"name": "skills",
"label": "📚 Skills",
"description": "list, view, manage",
"enabled": True,
"available": True,
"configured": True,
"tools": ["skill_view", "skills_list"],
},
{
"name": "memory",
"label": "💾 Memory",
"description": "persistent memory across sessions",
"enabled": False,
"available": False,
"configured": True,
"tools": ["memory_read"],
},
]
def test_config_raw_get(self):
resp = self.client.get("/api/config/raw")
assert resp.status_code == 200
assert "yaml" in resp.json()
def test_config_raw_put_valid(self):
resp = self.client.put(
"/api/config/raw",
json={"yaml_text": "model: test\ntoolsets:\n - all\n"},
)
assert resp.status_code == 200
assert resp.json()["ok"] is True
def test_config_raw_put_invalid(self):
resp = self.client.put(
"/api/config/raw",
json={"yaml_text": "- this is a list not a dict"},
)
assert resp.status_code == 400
def test_analytics_usage(self):
resp = self.client.get("/api/analytics/usage?days=7")
assert resp.status_code == 200
data = resp.json()
assert "daily" in data
assert "by_model" in data
assert "totals" in data
assert isinstance(data["daily"], list)
assert "total_sessions" in data["totals"]
def test_session_token_endpoint(self):
from hermes_cli.web_server import _SESSION_TOKEN
resp = self.client.get("/api/auth/session-token")
assert resp.status_code == 200
assert resp.json()["token"] == _SESSION_TOKEN