mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
* 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>
675 lines
26 KiB
Python
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
|