simplify(compat): tests — repoint 638 web_server.<name> references (321 attr, 217 monkeypatch/patch.object, 60 from-imports, 40 patch() strings) across 65 test files to the owning modules

This commit is contained in:
Teknium
2026-09-03 14:21:52 -07:00
parent 08bd8aea74
commit 4fde117f4b
63 changed files with 709 additions and 644 deletions
+2 -2
View File
@@ -716,8 +716,8 @@ def test_curator_slot_is_canonical_aux_task():
specifically so the unification doesn't silently regress.
"""
from hermes_cli.config import DEFAULT_CONFIG
from hermes_cli.main import _AUX_TASKS
from hermes_cli.web_server import _AUX_TASK_SLOTS
from hermes_cli.main_provider_setup import _AUX_TASKS
from hermes_cli.web_server_config import _AUX_TASK_SLOTS
# 1. DEFAULT_CONFIG.auxiliary — schema source
assert "curator" in DEFAULT_CONFIG["auxiliary"], \
+2 -2
View File
@@ -483,8 +483,8 @@ def test_review_registered_in_every_aux_surface():
mirror _AUX_TASK_SLOTS by convention (shared "Must match" comments).
"""
from hermes_cli.config import DEFAULT_CONFIG
from hermes_cli.main import _AUX_TASKS
from hermes_cli.web_server import _AUX_TASK_SLOTS
from hermes_cli.main_provider_setup import _AUX_TASKS
from hermes_cli.web_server_config import _AUX_TASK_SLOTS
assert "review" in DEFAULT_CONFIG["auxiliary"], \
"review missing from DEFAULT_CONFIG['auxiliary']"
+1 -1
View File
@@ -451,7 +451,7 @@ class TestPreflightAndDashboardLanes:
"""The dashboard update lane normalizes failure_deliver like
deliver: text stripped, empty clears (None) instead of
coalescing to a target."""
from hermes_cli.web_server import _normalize_dashboard_cron_updates
from hermes_cli.web_routers.cron import _normalize_dashboard_cron_updates
out = _normalize_dashboard_cron_updates(
{"failure_deliver": " slack:D0ALERTS "}, tmp_path
+9 -8
View File
@@ -26,6 +26,7 @@ import os
import pytest
from hermes_cli import web_server
import hermes_cli.web_server_chat as _web_server_chat
# ---------------------------------------------------------------------------
@@ -81,20 +82,20 @@ def _netloc(ws_url: str) -> str:
class TestResolveClientWsHost:
def test_wildcard_ipv4_uses_loopback(self, saved_app_state, clear_ws_host_env):
_set_bound(saved_app_state, "0.0.0.0")
assert web_server._resolve_client_ws_host() == "127.0.0.1"
assert _web_server_chat._resolve_client_ws_host() == "127.0.0.1"
def test_wildcard_ipv6_uses_loopback(self, saved_app_state, clear_ws_host_env):
_set_bound(saved_app_state, "::")
assert web_server._resolve_client_ws_host() == "127.0.0.1"
assert _web_server_chat._resolve_client_ws_host() == "127.0.0.1"
def test_loopback_bind_unchanged(self, saved_app_state, clear_ws_host_env):
_set_bound(saved_app_state, "127.0.0.1")
assert web_server._resolve_client_ws_host() == "127.0.0.1"
assert _web_server_chat._resolve_client_ws_host() == "127.0.0.1"
def test_public_dns_bind_preserved(self, saved_app_state, clear_ws_host_env):
_set_bound(saved_app_state, "fly-app.example.dev")
assert web_server._resolve_client_ws_host() == "fly-app.example.dev"
assert _web_server_chat._resolve_client_ws_host() == "fly-app.example.dev"
@@ -107,7 +108,7 @@ class TestResolveClientWsHost:
an intent. Treat whitespace-only as absent and fall through."""
monkeypatch.setenv("HERMES_DASHBOARD_WS_HOST", " ")
_set_bound(saved_app_state, "0.0.0.0")
assert web_server._resolve_client_ws_host() == "127.0.0.1"
assert _web_server_chat._resolve_client_ws_host() == "127.0.0.1"
def test_bind_host_unchanged_after_wildcard_resolution(
@@ -117,7 +118,7 @@ class TestResolveClientWsHost:
``app.state`` (used by the listener and host-header middleware) is
NOT mutated."""
_set_bound(saved_app_state, "0.0.0.0")
web_server._resolve_client_ws_host()
_web_server_chat._resolve_client_ws_host()
assert web_server.app.state.bound_host == "0.0.0.0"
@@ -132,7 +133,7 @@ class TestGatewayWsUrlHost:
self, saved_app_state, clear_ws_host_env
):
_set_bound(saved_app_state, "::", port=9119)
url = web_server._build_gateway_ws_url()
url = _web_server_chat._build_gateway_ws_url()
assert url is not None
assert url.startswith("ws://127.0.0.1:9119/api/ws")
# The ``::`` must not leak into the client URL.
@@ -162,7 +163,7 @@ class TestSidecarUrlHost:
):
web_server.app.state.bound_host = None
web_server.app.state.bound_port = None
assert web_server._build_sidecar_url("ch-1") is None
assert _web_server_chat._build_sidecar_url("ch-1") is None
# ---------------------------------------------------------------------------
+7 -6
View File
@@ -9,6 +9,7 @@ from gateway.browser_control_broker import (
get_browser_control_broker,
)
from hermes_cli import web_server
import hermes_cli.web_server_chat as _web_server_chat
from hermes_cli.dashboard_auth.ws_tickets import _reset_for_tests, mint_ticket
from tui_gateway import server
from tui_gateway.ws import WSTransport
@@ -29,8 +30,8 @@ def _fake_ticket_subprotocol_ws(ticket):
query_params={},
headers={
"sec-websocket-protocol": (
f"{web_server._GATEWAY_WS_PROTOCOL}, "
f"{web_server._GATEWAY_WS_TICKET_PROTOCOL_PREFIX}{ticket}"
f"{_web_server_chat._GATEWAY_WS_PROTOCOL}, "
f"{_web_server_chat._GATEWAY_WS_TICKET_PROTOCOL_PREFIX}{ticket}"
)
},
client=SimpleNamespace(host="203.0.113.7"),
@@ -54,12 +55,12 @@ def test_dashboard_ticket_identity_is_carried_forward_without_trusting_rpc_param
ticket = mint_ticket(user_id="user-fixture", provider="provider-fixture")
ws = _fake_ticket_ws(ticket)
assert web_server._ws_auth_ok(ws) is True
assert _web_server_chat._ws_auth_ok(ws) is True
assert ws._hermes_auth_identity == {
"user_id": "user-fixture",
"provider": "provider-fixture",
}
assert web_server._ws_auth_ok(_fake_ticket_ws(ticket)) is False
assert _web_server_chat._ws_auth_ok(_fake_ticket_ws(ticket)) is False
def test_dashboard_ticket_subprotocol_carries_the_same_server_identity(gated_dashboard):
@@ -67,12 +68,12 @@ def test_dashboard_ticket_subprotocol_carries_the_same_server_identity(gated_das
ticket = mint_ticket(user_id="subprotocol-user", provider="provider-fixture")
ws = _fake_ticket_subprotocol_ws(ticket)
assert web_server._ws_auth_ok(ws) is True
assert _web_server_chat._ws_auth_ok(ws) is True
assert ws._hermes_auth_identity == {
"user_id": "subprotocol-user",
"provider": "provider-fixture",
}
assert ws._hermes_ws_subprotocol == web_server._GATEWAY_WS_PROTOCOL
assert ws._hermes_ws_subprotocol == _web_server_chat._GATEWAY_WS_PROTOCOL
def test_ws_transport_records_only_server_authenticated_identity():
+1 -1
View File
@@ -1099,7 +1099,7 @@ class TestNousDeviceAuthTimeoutMessage:
def test_poll_for_token_timeout_raises_actionable_message():
"""The poll deadline must raise the CAPTCHA-aware guidance at the SOURCE,
so both the CLI login and the dashboard poller (web_server._nous_poller,
so both the CLI login and the dashboard poller (web_server_oauth._nous_poller,
which surfaces str(e) to the UI) inherit it."""
import httpx
import pytest
@@ -17,6 +17,7 @@ from unittest.mock import patch
import pytest
from hermes_cli import copilot_auth
import hermes_cli.web_routers.ops as _rt_ops
# ---------------------------------------------------------------------------
@@ -189,7 +190,7 @@ async def test_list_credential_pool_runs_off_event_loop(monkeypatch):
return {}
monkeypatch.setattr(auth_mod, "read_credential_pool", fake_read_pool)
result = await web_server.list_credential_pool()
result = await _rt_ops.list_credential_pool()
assert result == {"providers": []}
assert seen["thread"] != loop_thread
@@ -219,7 +220,7 @@ async def test_list_credential_pool_keeps_loop_responsive(monkeypatch):
last = now
t = asyncio.create_task(ticker())
await web_server.list_credential_pool()
await _rt_ops.list_credential_pool()
stop.set()
await t
# 0.25 s threshold vs a 0.5 s blocking read: a regression (read on the
@@ -13,6 +13,7 @@ import pytest
from starlette.testclient import TestClient
from hermes_cli import web_server
import hermes_cli.web_server_cron as _web_server_cron
@pytest.fixture()
@@ -37,7 +38,7 @@ def test_cron_fire_profile_lookup_off_loop(monkeypatch, loop_probe):
probe("find")
return None
monkeypatch.setattr(web_server, "_find_cron_job_profile", fake_find)
monkeypatch.setattr(_web_server_cron, "_find_cron_job_profile", fake_find)
import plugins.cron_providers.chronos.verify as chv
monkeypatch.setattr(chv, "get_fire_verifier", lambda: (lambda **kw: {"sub": "t"}))
@@ -62,7 +63,7 @@ def test_blueprint_instantiate_create_job_off_loop(monkeypatch, loop_probe):
probe("call")
return {"id": "bp-job-1", "kwargs_seen": sorted(kwargs.keys())}
monkeypatch.setattr(web_server, "_call_cron_for_profile", fake_call)
monkeypatch.setattr(_web_server_cron, "_call_cron_for_profile", fake_call)
monkeypatch.setattr(web_server, "_has_valid_session_token", lambda req: True)
import cron.blueprint_catalog as bc
@@ -104,7 +105,7 @@ def test_blueprint_instantiate_reports_saved_but_unregistered(monkeypatch):
def fail_call(profile, fn, *args, **kwargs):
raise failure
monkeypatch.setattr(web_server, "_call_cron_for_profile", fail_call)
monkeypatch.setattr(_web_server_cron, "_call_cron_for_profile", fail_call)
monkeypatch.setattr(web_server, "_has_valid_session_token", lambda req: True)
import cron.blueprint_catalog as bc
+57 -55
View File
@@ -18,6 +18,8 @@ import pytest
from starlette.testclient import TestClient
from hermes_cli import web_server
import hermes_cli.config as _cfg_mod
import hermes_cli.web_server_cron as _web_server_cron
from hermes_cli.dashboard_auth.public_paths import PUBLIC_API_PATHS
@@ -60,8 +62,8 @@ def test_bad_token_401(monkeypatch):
"plugins.cron_providers.chronos.verify.get_fire_verifier",
lambda: (lambda **kw: None), # verification fails
)
monkeypatch.setattr(web_server, "_find_cron_job_profile", lambda jid: "default")
monkeypatch.setattr(web_server, "_fire_cron_job_for_profile",
monkeypatch.setattr(_web_server_cron, "_find_cron_job_profile", lambda jid: "default")
monkeypatch.setattr(_web_server_cron, "_fire_cron_job_for_profile",
lambda p, j: fired.append((p, j)))
client, pa, ph = _client(auth_required=True)
@@ -99,7 +101,7 @@ def test_unknown_job_200_gone(monkeypatch):
"plugins.cron_providers.chronos.verify.get_fire_verifier",
lambda: (lambda **kw: {"purpose": "cron_fire"}),
)
monkeypatch.setattr(web_server, "_find_cron_job_profile", lambda jid: None)
monkeypatch.setattr(_web_server_cron, "_find_cron_job_profile", lambda jid: None)
client, pa, ph = _client(auth_required=False)
try:
resp = client.post("/api/cron/fire",
@@ -128,9 +130,9 @@ def test_valid_fire_forwards_to_gateway(monkeypatch):
"plugins.cron_providers.chronos.verify.get_fire_verifier",
lambda: (lambda **kw: {"purpose": "cron_fire"}),
)
monkeypatch.setattr(web_server, "_find_cron_job_profile", lambda jid: "default")
monkeypatch.setattr(web_server, "_forward_cron_fire_to_gateway", fake_forward)
monkeypatch.setattr(web_server, "_fire_cron_job_for_profile",
monkeypatch.setattr(_web_server_cron, "_find_cron_job_profile", lambda jid: "default")
monkeypatch.setattr(_web_server_cron, "_forward_cron_fire_to_gateway", fake_forward)
monkeypatch.setattr(_web_server_cron, "_fire_cron_job_for_profile",
lambda p, j: executed.append((p, j)))
client, pa, ph = _client(auth_required=False)
@@ -161,9 +163,9 @@ def test_gateway_unreachable_503_for_nas_retry(monkeypatch):
"plugins.cron_providers.chronos.verify.get_fire_verifier",
lambda: (lambda **kw: {"purpose": "cron_fire"}),
)
monkeypatch.setattr(web_server, "_find_cron_job_profile", lambda jid: "default")
monkeypatch.setattr(web_server, "_forward_cron_fire_to_gateway", fake_forward)
monkeypatch.setattr(web_server, "_fire_cron_job_for_profile",
monkeypatch.setattr(_web_server_cron, "_find_cron_job_profile", lambda jid: "default")
monkeypatch.setattr(_web_server_cron, "_forward_cron_fire_to_gateway", fake_forward)
monkeypatch.setattr(_web_server_cron, "_fire_cron_job_for_profile",
lambda p, j: executed.append((p, j)))
client, pa, ph = _client(auth_required=False)
@@ -189,8 +191,8 @@ def test_gateway_error_status_passes_through(monkeypatch):
"plugins.cron_providers.chronos.verify.get_fire_verifier",
lambda: (lambda **kw: {"purpose": "cron_fire"}),
)
monkeypatch.setattr(web_server, "_find_cron_job_profile", lambda jid: "default")
monkeypatch.setattr(web_server, "_forward_cron_fire_to_gateway", fake_forward)
monkeypatch.setattr(_web_server_cron, "_find_cron_job_profile", lambda jid: "default")
monkeypatch.setattr(_web_server_cron, "_forward_cron_fire_to_gateway", fake_forward)
client, pa, ph = _client(auth_required=False)
try:
@@ -209,8 +211,8 @@ def test_gateway_error_status_passes_through(monkeypatch):
def test_fire_endpoint_default_port(tmp_path, monkeypatch):
monkeypatch.delenv("API_SERVER_PORT", raising=False)
monkeypatch.delenv("GATEWAY_MULTIPLEX_PROFILES", raising=False)
monkeypatch.setattr(web_server, "load_config", lambda: {})
url = web_server._gateway_fire_endpoint("default", tmp_path)
monkeypatch.setattr(_cfg_mod, "load_config", lambda: {})
url = _web_server_cron._gateway_fire_endpoint("default", tmp_path)
assert url == "http://127.0.0.1:8642/api/cron/fire"
@@ -220,11 +222,11 @@ def test_fire_endpoint_config_yaml_port_wins(tmp_path, monkeypatch):
monkeypatch.setenv("API_SERVER_PORT", "9999")
monkeypatch.delenv("GATEWAY_MULTIPLEX_PROFILES", raising=False)
monkeypatch.setattr(
web_server,
_cfg_mod,
"load_config",
lambda: {"platforms": {"api_server": {"extra": {"port": 8700}}}},
)
url = web_server._gateway_fire_endpoint("default", tmp_path)
url = _web_server_cron._gateway_fire_endpoint("default", tmp_path)
assert url == "http://127.0.0.1:8700/api/cron/fire"
@@ -233,10 +235,10 @@ def test_fire_endpoint_profile_env_port(tmp_path, monkeypatch):
dashboard process env (per-profile-gateway topology)."""
monkeypatch.setenv("API_SERVER_PORT", "9999") # dashboard process env
monkeypatch.delenv("GATEWAY_MULTIPLEX_PROFILES", raising=False)
monkeypatch.setattr(web_server, "load_config", lambda: {})
monkeypatch.setattr(web_server, "_cron_default_profile", lambda: "default")
monkeypatch.setattr(_cfg_mod, "load_config", lambda: {})
monkeypatch.setattr(_web_server_cron, "_cron_default_profile", lambda: "default")
(tmp_path / ".env").write_text("API_SERVER_PORT=8701\n", encoding="utf-8")
url = web_server._gateway_fire_endpoint("worker_alpha", tmp_path)
url = _web_server_cron._gateway_fire_endpoint("worker_alpha", tmp_path)
assert url == "http://127.0.0.1:8701/api/cron/fire"
@@ -245,8 +247,8 @@ def test_fire_endpoint_multiplex_profile_prefix(tmp_path, monkeypatch):
gateway's port with the /p/<profile>/ prefix mirror."""
monkeypatch.delenv("API_SERVER_PORT", raising=False)
monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", "1")
monkeypatch.setattr(web_server, "load_config", lambda: {})
url = web_server._gateway_fire_endpoint("worker_alpha", tmp_path)
monkeypatch.setattr(_cfg_mod, "load_config", lambda: {})
url = _web_server_cron._gateway_fire_endpoint("worker_alpha", tmp_path)
assert url == "http://127.0.0.1:8642/p/worker_alpha/api/cron/fire"
@@ -272,15 +274,15 @@ def test_fire_endpoint_multiplex_reads_port_from_default_listener(tmp_path, monk
monkeypatch.setenv("HERMES_HOME", str(default_home))
monkeypatch.delenv("API_SERVER_PORT", raising=False)
monkeypatch.delenv("GATEWAY_MULTIPLEX_PROFILES", raising=False)
monkeypatch.setattr(web_server, "_cron_default_profile", lambda: "default")
monkeypatch.setattr(_web_server_cron, "_cron_default_profile", lambda: "default")
url = web_server._gateway_fire_endpoint("worker_alpha", worker_home)
url = _web_server_cron._gateway_fire_endpoint("worker_alpha", worker_home)
assert url == "http://127.0.0.1:8650/p/worker_alpha/api/cron/fire"
# The GATEWAY_MULTIPLEX_PROFILES env override is still honored (parity
# with gateway/config.py): forcing it off restores per-profile routing.
monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", "0")
assert web_server._gateway_fire_endpoint("worker_alpha", worker_home) == (
assert _web_server_cron._gateway_fire_endpoint("worker_alpha", worker_home) == (
"http://127.0.0.1:8702/api/cron/fire"
)
@@ -300,10 +302,10 @@ def test_gateway_unreachable_503_carries_retry_after(monkeypatch):
"plugins.cron_providers.chronos.verify.get_fire_verifier",
lambda: (lambda **kw: {"purpose": "cron_fire"}),
)
monkeypatch.setattr(web_server, "_find_cron_job_profile", lambda jid: "default")
monkeypatch.setattr(web_server, "_forward_cron_fire_to_gateway", fake_forward)
monkeypatch.setattr(_web_server_cron, "_find_cron_job_profile", lambda jid: "default")
monkeypatch.setattr(_web_server_cron, "_forward_cron_fire_to_gateway", fake_forward)
monkeypatch.setattr(
web_server, "_gateway_intentionally_stopped", lambda p: False
_web_server_cron, "_gateway_intentionally_stopped", lambda p: False
)
client, pa, ph = _client(auth_required=False)
@@ -333,12 +335,12 @@ def test_gateway_intentionally_stopped_drops_with_200(monkeypatch):
"plugins.cron_providers.chronos.verify.get_fire_verifier",
lambda: (lambda **kw: {"purpose": "cron_fire"}),
)
monkeypatch.setattr(web_server, "_find_cron_job_profile", lambda jid: "default")
monkeypatch.setattr(web_server, "_forward_cron_fire_to_gateway", fake_forward)
monkeypatch.setattr(_web_server_cron, "_find_cron_job_profile", lambda jid: "default")
monkeypatch.setattr(_web_server_cron, "_forward_cron_fire_to_gateway", fake_forward)
monkeypatch.setattr(
web_server, "_gateway_intentionally_stopped", lambda p: True
_web_server_cron, "_gateway_intentionally_stopped", lambda p: True
)
monkeypatch.setattr(web_server, "_fire_cron_job_for_profile",
monkeypatch.setattr(_web_server_cron, "_fire_cron_job_for_profile",
lambda p, j: executed.append((p, j)))
client, pa, ph = _client(auth_required=False)
@@ -371,9 +373,9 @@ def test_stopped_check_only_consulted_when_gateway_unreachable(monkeypatch):
"plugins.cron_providers.chronos.verify.get_fire_verifier",
lambda: (lambda **kw: {"purpose": "cron_fire"}),
)
monkeypatch.setattr(web_server, "_find_cron_job_profile", lambda jid: "default")
monkeypatch.setattr(web_server, "_forward_cron_fire_to_gateway", fake_forward)
monkeypatch.setattr(web_server, "_gateway_intentionally_stopped", fake_stopped)
monkeypatch.setattr(_web_server_cron, "_find_cron_job_profile", lambda jid: "default")
monkeypatch.setattr(_web_server_cron, "_forward_cron_fire_to_gateway", fake_forward)
monkeypatch.setattr(_web_server_cron, "_gateway_intentionally_stopped", fake_stopped)
client, pa, ph = _client(auth_required=False)
try:
@@ -399,8 +401,8 @@ def test_gateway_own_503_also_carries_retry_after(monkeypatch):
"plugins.cron_providers.chronos.verify.get_fire_verifier",
lambda: (lambda **kw: {"purpose": "cron_fire"}),
)
monkeypatch.setattr(web_server, "_find_cron_job_profile", lambda jid: "default")
monkeypatch.setattr(web_server, "_forward_cron_fire_to_gateway", fake_forward)
monkeypatch.setattr(_web_server_cron, "_find_cron_job_profile", lambda jid: "default")
monkeypatch.setattr(_web_server_cron, "_forward_cron_fire_to_gateway", fake_forward)
client, pa, ph = _client(auth_required=False)
try:
@@ -420,7 +422,7 @@ def test_gateway_own_503_also_carries_retry_after(monkeypatch):
def _stopped_check_home(monkeypatch, tmp_path):
"""Point the profile resolver at tmp_path so the check reads our file."""
monkeypatch.setattr(
web_server, "_cron_profile_home", lambda p: ("default", tmp_path)
_web_server_cron, "_cron_profile_home", lambda p: ("default", tmp_path)
)
@@ -429,7 +431,7 @@ def test_intentionally_stopped_true_on_desired_state_stopped(tmp_path, monkeypat
(tmp_path / "gateway_state.json").write_text(
'{"gateway_state":"stopped","desired_state":"stopped"}', encoding="utf-8"
)
assert web_server._gateway_intentionally_stopped("default") is True
assert _web_server_cron._gateway_intentionally_stopped("default") is True
def test_intentionally_stopped_false_when_desired_running(tmp_path, monkeypatch):
@@ -440,7 +442,7 @@ def test_intentionally_stopped_false_when_desired_running(tmp_path, monkeypatch)
'{"gateway_state":"startup_failed","desired_state":"running"}',
encoding="utf-8",
)
assert web_server._gateway_intentionally_stopped("default") is False
assert _web_server_cron._gateway_intentionally_stopped("default") is False
def test_intentionally_stopped_false_on_legacy_file_without_desired_state(
@@ -453,16 +455,16 @@ def test_intentionally_stopped_false_on_legacy_file_without_desired_state(
(tmp_path / "gateway_state.json").write_text(
'{"gateway_state":"stopped"}', encoding="utf-8"
)
assert web_server._gateway_intentionally_stopped("default") is False
assert _web_server_cron._gateway_intentionally_stopped("default") is False
def test_intentionally_stopped_false_on_missing_or_bad_file(tmp_path, monkeypatch):
_stopped_check_home(monkeypatch, tmp_path)
assert web_server._gateway_intentionally_stopped("default") is False
assert _web_server_cron._gateway_intentionally_stopped("default") is False
(tmp_path / "gateway_state.json").write_text("{not json", encoding="utf-8")
assert web_server._gateway_intentionally_stopped("default") is False
assert _web_server_cron._gateway_intentionally_stopped("default") is False
(tmp_path / "gateway_state.json").write_text('["list"]', encoding="utf-8")
assert web_server._gateway_intentionally_stopped("default") is False
assert _web_server_cron._gateway_intentionally_stopped("default") is False
def test_intentionally_stopped_false_when_profile_resolution_fails(monkeypatch):
@@ -471,8 +473,8 @@ def test_intentionally_stopped_false_when_profile_resolution_fails(monkeypatch):
def boom(profile):
raise RuntimeError("no such profile")
monkeypatch.setattr(web_server, "_cron_profile_home", boom)
assert web_server._gateway_intentionally_stopped("ghost") is False
monkeypatch.setattr(_web_server_cron, "_cron_profile_home", boom)
assert _web_server_cron._gateway_intentionally_stopped("ghost") is False
# ── last_fire_error stamp on forward failure (missed-fire visibility) ─────
@@ -495,10 +497,10 @@ def test_forward_failure_stamps_last_fire_error(monkeypatch):
"plugins.cron_providers.chronos.verify.get_fire_verifier",
lambda: (lambda **kw: {"purpose": "cron_fire"}),
)
monkeypatch.setattr(web_server, "_find_cron_job_profile", lambda jid: "default")
monkeypatch.setattr(web_server, "_forward_cron_fire_to_gateway", fake_forward)
monkeypatch.setattr(web_server, "_gateway_intentionally_stopped", lambda p: False)
monkeypatch.setattr(web_server, "_call_cron_for_profile", fake_call_cron)
monkeypatch.setattr(_web_server_cron, "_find_cron_job_profile", lambda jid: "default")
monkeypatch.setattr(_web_server_cron, "_forward_cron_fire_to_gateway", fake_forward)
monkeypatch.setattr(_web_server_cron, "_gateway_intentionally_stopped", lambda p: False)
monkeypatch.setattr(_web_server_cron, "_call_cron_for_profile", fake_call_cron)
client, pa, ph = _client(auth_required=False)
try:
@@ -531,10 +533,10 @@ def test_forward_failure_stamp_error_never_breaks_retry_contract(monkeypatch):
"plugins.cron_providers.chronos.verify.get_fire_verifier",
lambda: (lambda **kw: {"purpose": "cron_fire"}),
)
monkeypatch.setattr(web_server, "_find_cron_job_profile", lambda jid: "default")
monkeypatch.setattr(web_server, "_forward_cron_fire_to_gateway", fake_forward)
monkeypatch.setattr(web_server, "_gateway_intentionally_stopped", lambda p: False)
monkeypatch.setattr(web_server, "_call_cron_for_profile", boom)
monkeypatch.setattr(_web_server_cron, "_find_cron_job_profile", lambda jid: "default")
monkeypatch.setattr(_web_server_cron, "_forward_cron_fire_to_gateway", fake_forward)
monkeypatch.setattr(_web_server_cron, "_gateway_intentionally_stopped", lambda p: False)
monkeypatch.setattr(_web_server_cron, "_call_cron_for_profile", boom)
client, pa, ph = _client(auth_required=False)
try:
@@ -559,10 +561,10 @@ def test_reachable_gateway_does_not_stamp(monkeypatch):
"plugins.cron_providers.chronos.verify.get_fire_verifier",
lambda: (lambda **kw: {"purpose": "cron_fire"}),
)
monkeypatch.setattr(web_server, "_find_cron_job_profile", lambda jid: "default")
monkeypatch.setattr(web_server, "_forward_cron_fire_to_gateway", fake_forward)
monkeypatch.setattr(_web_server_cron, "_find_cron_job_profile", lambda jid: "default")
monkeypatch.setattr(_web_server_cron, "_forward_cron_fire_to_gateway", fake_forward)
monkeypatch.setattr(
web_server, "_call_cron_for_profile",
_web_server_cron, "_call_cron_for_profile",
lambda *a, **k: stamped.append(a),
)
@@ -6,6 +6,7 @@ from pathlib import Path
from unittest import mock
from hermes_cli import web_server
import hermes_cli.web_server_cron as _web_server_cron
class CronProfileEnumerationTests(unittest.TestCase):
@@ -25,7 +26,7 @@ class CronProfileEnumerationTests(unittest.TestCase):
side_effect=AssertionError("full profile scan is forbidden"),
),
):
result = web_server._cron_profile_dicts()
result = _web_server_cron._cron_profile_dicts()
lightweight.assert_called_once_with(multiplex=True)
self.assertEqual([item["name"] for item in result], ["default", "coder-01"])
@@ -11,7 +11,6 @@ import pytest
import hermes_cli.config as _cfg_mod
import hermes_cli.web_server_files as _web_server_files
import hermes_cli.web_server_gateway as _web_server_gateway
import hermes_cli.web_server_lifecycle as _web_server_lifecycle
def _client():
@@ -1065,7 +1064,7 @@ def test_desktop_lifespan_reaps_orphan_gateways_on_startup(
monkeypatch.setenv("HERMES_DESKTOP", "1")
# Keep the lifespan cheap: don't re-import the gateway module or spin up the
# real cron scheduler thread.
monkeypatch.setattr(_web_server_lifecycle, "_warm_gateway_module", lambda: None)
monkeypatch.setattr(ws, "_warm_gateway_module", lambda: None)
monkeypatch.setattr(ws, "_start_desktop_cron_ticker", lambda *_args: None)
# web_server imports the reaper lazily from hermes_cli.gateway, so patch it
# on that module.
@@ -1094,7 +1093,7 @@ def test_desktop_lifespan_terminates_managed_gateway_restart(monkeypatch):
calls.append("terminate")
monkeypatch.setenv("HERMES_DESKTOP", "1")
monkeypatch.setattr(_web_server_lifecycle, "_warm_gateway_module", lambda: None)
monkeypatch.setattr(ws, "_warm_gateway_module", lambda: None)
monkeypatch.setattr(ws, "_start_desktop_cron_ticker", lambda *_args: None)
monkeypatch.setitem(_web_server_gateway._ACTION_PROCS, "gateway-restart", _FakeRunningProc())
+3 -2
View File
@@ -7,6 +7,7 @@ import asyncio
import logging
import pytest
import hermes_cli.web_server_lifecycle as _web_server_lifecycle
# Phase 5 / Phase 6: these tests mutate ``web_server.app.state.auth_required``
# at module level. Run them in the same xdist worker so they don't race
@@ -274,7 +275,7 @@ def test_start_server_passes_bounded_trusted_proxy_networks(monkeypatch, caplog)
def test_trusted_proxy_allowlist_rejects_unbounded_entries(caplog):
"""Wildcard and whole-address-space trust must fail closed."""
trusted = web_server._dashboard_forwarded_allow_ips({
trusted = _web_server_lifecycle._dashboard_forwarded_allow_ips({
"trusted_proxies": ["*", "0.0.0.0/0", "::/0", "172.18.0.7"],
})
@@ -288,7 +289,7 @@ def test_trusted_container_proxy_controls_https_detection():
from starlette.requests import Request
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
trusted = web_server._dashboard_forwarded_allow_ips({
trusted = _web_server_lifecycle._dashboard_forwarded_allow_ips({
"trusted_proxies": ["172.18.0.0/16"],
})
+24 -23
View File
@@ -20,6 +20,7 @@ import pytest
from fastapi.testclient import TestClient
from hermes_cli import web_server
import hermes_cli.web_server_chat as _web_server_chat
from hermes_cli.dashboard_auth import clear_providers, register_provider
from hermes_cli.dashboard_auth.ws_tickets import (
_reset_for_tests,
@@ -205,7 +206,7 @@ class TestWsAuthOkLoopback:
def test_correct_token_accepted(self, loopback_app):
ws = _fake_ws(query={"token": web_server._SESSION_TOKEN})
assert web_server._ws_auth_ok(ws) is True
assert _web_server_chat._ws_auth_ok(ws) is True
class TestWsAuthOkGated:
@@ -216,27 +217,27 @@ class TestWsAuthOkGated:
ticket = mint_ticket(user_id="u1", provider="stub")
ws_one = _fake_ws(query={"ticket": ticket})
ws_two = _fake_ws(query={"ticket": ticket})
assert web_server._ws_auth_ok(ws_one) is True
assert _web_server_chat._ws_auth_ok(ws_one) is True
# Single-use — second consumption fails.
assert web_server._ws_auth_ok(ws_two) is False
assert _web_server_chat._ws_auth_ok(ws_two) is False
def test_ticket_subprotocol_is_single_use_and_selects_only_the_public_protocol(self, gated_app):
ticket = mint_ticket(user_id="subprotocol-user", provider="stub")
protocols = (
web_server._GATEWAY_WS_PROTOCOL,
f"{web_server._GATEWAY_WS_TICKET_PROTOCOL_PREFIX}{ticket}",
_web_server_chat._GATEWAY_WS_PROTOCOL,
f"{_web_server_chat._GATEWAY_WS_TICKET_PROTOCOL_PREFIX}{ticket}",
)
ws_one = _fake_ws(query={}, path="/api/ws", protocols=protocols)
ws_two = _fake_ws(query={}, path="/api/ws", protocols=protocols)
assert web_server._ws_auth_ok(ws_one) is True
assert _web_server_chat._ws_auth_ok(ws_one) is True
assert ws_one._hermes_auth_identity == {
"user_id": "subprotocol-user",
"provider": "stub",
}
assert ws_one._hermes_ws_subprotocol == web_server._GATEWAY_WS_PROTOCOL
assert ws_one._hermes_ws_subprotocol == _web_server_chat._GATEWAY_WS_PROTOCOL
assert ticket not in ws_one._hermes_ws_subprotocol
assert web_server._ws_auth_ok(ws_two) is False
assert _web_server_chat._ws_auth_ok(ws_two) is False
def test_ticket_subprotocol_rejects_missing_public_protocol_or_ambiguous_tickets(self, gated_app):
first = mint_ticket(user_id="u1", provider="stub")
@@ -245,7 +246,7 @@ class TestWsAuthOkGated:
path="/api/ws",
protocols=(f"hermes-gateway-ticket.{first}",),
)
assert web_server._ws_auth_ok(missing_public) is False
assert _web_server_chat._ws_auth_ok(missing_public) is False
second = mint_ticket(user_id="u2", provider="stub")
ambiguous = _fake_ws(
@@ -257,7 +258,7 @@ class TestWsAuthOkGated:
f"hermes-gateway-ticket.{second}",
),
)
assert web_server._ws_auth_ok(ambiguous) is False
assert _web_server_chat._ws_auth_ok(ambiguous) is False
def test_legacy_token_rejected_in_gated_mode(self, gated_app):
@@ -265,7 +266,7 @@ class TestWsAuthOkGated:
even when someone has access to the in-process value of
_SESSION_TOKEN (e.g. a leaked log line)."""
ws = _fake_ws(query={"token": web_server._SESSION_TOKEN})
assert web_server._ws_auth_ok(ws) is False
assert _web_server_chat._ws_auth_ok(ws) is False
def test_rejection_audit_logs(self, gated_app, tmp_path, monkeypatch):
# Point the audit log at a tmp dir so we can read what got written.
@@ -278,7 +279,7 @@ class TestWsAuthOkGated:
monkeypatch.setattr(audit_mod, "_LOGGER", None, raising=False)
ws = _fake_ws(query={"ticket": "never-minted"})
assert web_server._ws_auth_ok(ws) is False
assert _web_server_chat._ws_auth_ok(ws) is False
log_file = tmp_path / "logs" / "dashboard-auth.log"
# The audit module may write asynchronously through stdlib logging,
@@ -317,7 +318,7 @@ class TestWsRequestIsAllowedGated:
guessing it."""
ws = _fake_ws(query={}, client_host="192.168.1.42")
ws.headers = {"host": "127.0.0.1:8080"}
assert web_server._ws_request_is_allowed(ws) is False
assert _web_server_chat._ws_request_is_allowed(ws) is False
def test_non_loopback_peer_allowed_in_insecure_public_mode(self, insecure_public_app):
@@ -333,7 +334,7 @@ class TestWsRequestIsAllowedGated:
"host": "192.168.0.222:9120",
"origin": "http://192.168.0.222:9120",
}
assert web_server._ws_request_is_allowed(ws) is True
assert _web_server_chat._ws_request_is_allowed(ws) is True
def test_peer_allowed_on_explicit_non_loopback_bind(self, insecure_explicit_host_app):
"""`--host 100.64.0.10 --insecure` (Tailscale/LAN IP) is an explicit
@@ -348,7 +349,7 @@ class TestWsRequestIsAllowedGated:
"host": "100.64.0.10:9119",
"origin": "http://100.64.0.10:9119",
}
assert web_server._ws_request_is_allowed(ws) is True
assert _web_server_chat._ws_request_is_allowed(ws) is True
@@ -375,7 +376,7 @@ class TestWsRequestIsAllowedGated:
"host": "192.168.0.222:9120",
"origin": "http://192.168.0.222:9120",
}
assert web_server._ws_client_is_allowed(ws) is True
assert _web_server_chat._ws_client_is_allowed(ws) is True
class TestWsHostOriginGuardOrigins:
@@ -408,7 +409,7 @@ class TestWsHostOriginGuardOrigins:
non-gated mode the legacy session token remains the auth boundary.
"""
ws = self._ws(origin="file://", host="100.64.0.10:9119")
assert web_server._ws_host_origin_is_allowed(ws) is True
assert _web_server_chat._ws_host_origin_is_allowed(ws) is True
@@ -419,19 +420,19 @@ class TestWsHostOriginGuardOrigins:
# gated bind: a cross-site http origin whose netloc doesn't match the
# bound host is rejected. Real browser DNS-rebinding defence unchanged.
ws = self._ws(origin="https://evil.test", host="fly-app.fly.dev")
assert web_server._ws_host_origin_is_allowed(ws) is False
assert _web_server_chat._ws_host_origin_is_allowed(ws) is False
class TestSidecarUrl:
def test_loopback_uses_session_token(self, loopback_app):
url = web_server._build_sidecar_url("ch-1")
url = _web_server_chat._build_sidecar_url("ch-1")
assert url is not None
assert f"token={web_server._SESSION_TOKEN}" in url
assert "ticket=" not in url
def test_gated_uses_internal_credential(self, gated_app):
url = web_server._build_sidecar_url("ch-1")
url = _web_server_chat._build_sidecar_url("ch-1")
assert url is not None
assert "token=" not in url
assert "ticket=" not in url
@@ -448,7 +449,7 @@ class TestSidecarUrl:
def test_no_bound_host_returns_none(self, gated_app):
web_server.app.state.bound_host = None
try:
assert web_server._build_sidecar_url("ch") is None
assert _web_server_chat._build_sidecar_url("ch") is None
finally:
web_server.app.state.bound_host = "fly-app.fly.dev"
@@ -466,8 +467,8 @@ class TestGatewayWsUrl:
def test_gated_credential_matches_sidecar(self, gated_app):
"""Both server-internal builders share one process credential, so a
single value authenticates /api/ws and /api/pub alike."""
gw = web_server._build_gateway_ws_url()
sc = web_server._build_sidecar_url("ch-1")
gw = _web_server_chat._build_gateway_ws_url()
sc = _web_server_chat._build_sidecar_url("ch-1")
assert gw is not None and sc is not None
gw_cred = gw.split("internal=")[1].split("&")[0]
sc_cred = sc.split("internal=")[1].split("&")[0]
@@ -15,6 +15,7 @@ from pathlib import Path
from unittest.mock import patch
import hermes_cli.web_server as web_server
import hermes_cli.web_server_gateway as _web_server_gateway
class TestDashboardSpawnExecutable:
@@ -28,7 +29,7 @@ class TestDashboardSpawnExecutable:
patch.object(web_server, "PROJECT_ROOT", tmp_path),
patch.object(sys, "executable", str(fake_venv)),
):
assert web_server._dashboard_spawn_executable() == str(fake_venv)
assert _web_server_gateway._dashboard_spawn_executable() == str(fake_venv)
def test_base_interpreter_replaced_by_venv_python(self, tmp_path):
"""sys.executable pointing at the dependency-less uv base
@@ -42,7 +43,7 @@ class TestDashboardSpawnExecutable:
patch.object(web_server, "PROJECT_ROOT", tmp_path),
patch.object(sys, "executable", str(base_interp)),
):
chosen = web_server._dashboard_spawn_executable()
chosen = _web_server_gateway._dashboard_spawn_executable()
assert chosen == str(fake_venv)
def test_windows_layout_resolved(self, tmp_path):
@@ -55,7 +56,7 @@ class TestDashboardSpawnExecutable:
patch.object(web_server, "PROJECT_ROOT", tmp_path),
patch.object(sys, "executable", str(base_interp)),
):
chosen = web_server._dashboard_spawn_executable()
chosen = _web_server_gateway._dashboard_spawn_executable()
assert Path(chosen).name == "python.exe"
assert "Scripts" in chosen
@@ -66,7 +67,7 @@ class TestDashboardSpawnExecutable:
patch.object(web_server, "PROJECT_ROOT", tmp_path),
patch.object(sys, "executable", str(base_interp)),
):
assert web_server._dashboard_spawn_executable() == str(base_interp)
assert _web_server_gateway._dashboard_spawn_executable() == str(base_interp)
def test_venv_symlink_to_base_is_still_preferred_unresolved(self, tmp_path):
"""The Linux-standard layout: venv/bin/python is a SYMLINK to the
@@ -84,7 +85,7 @@ class TestDashboardSpawnExecutable:
patch.object(web_server, "PROJECT_ROOT", tmp_path),
patch.object(sys, "executable", str(base)),
):
chosen = web_server._dashboard_spawn_executable()
chosen = _web_server_gateway._dashboard_spawn_executable()
assert chosen == str(venv_py), (
"must return the unresolved venv path, not the symlink target"
)
@@ -1,5 +1,5 @@
from hermes_cli.config import DEFAULT_CONFIG
from hermes_cli.web_server import CONFIG_SCHEMA
from hermes_cli.web_server_config import CONFIG_SCHEMA
def test_desktop_repo_discovery_defaults_preserve_existing_behavior():
+4 -2
View File
@@ -11,6 +11,8 @@ NOT mislabelled custom.
from fastapi.testclient import TestClient
import hermes_cli.web_server as web_server
import hermes_cli.config as _cfg_mod
import hermes_cli.web_server_messaging as _web_server_messaging
from hermes_cli.web_server import _SESSION_TOKEN, app
client = TestClient(app)
@@ -19,10 +21,10 @@ HEADERS = {"X-Hermes-Session-Token": _SESSION_TOKEN}
def _env_rows(monkeypatch, env_on_disk):
"""Drive GET /api/env with a controlled on-disk env mapping."""
monkeypatch.setattr(web_server, "load_env", lambda: dict(env_on_disk))
monkeypatch.setattr(_cfg_mod, "load_env", lambda: dict(env_on_disk))
# Channel-managed key detection reads real config; force empty so the test
# is hermetic and the custom-key path is exercised directly.
monkeypatch.setattr(web_server, "_channel_managed_env_keys", lambda: set())
monkeypatch.setattr(_web_server_messaging, "_channel_managed_env_keys", lambda: set())
resp = client.get("/api/env", headers=HEADERS)
assert resp.status_code == 200
return resp.json()
@@ -204,7 +204,7 @@ def test_explicit_filter_drops_unverified_external_process_row(tmp_path, monkeyp
def test_catalog_sign_in_command_is_a_valid_copilot_invocation():
from hermes_cli.web_server import _OAUTH_PROVIDER_CATALOG
from hermes_cli.web_server_oauth import _OAUTH_PROVIDER_CATALOG
entry = next(e for e in _OAUTH_PROVIDER_CATALOG if e["id"] == "copilot-acp")
# `copilot /login` is not a valid invocation — slash-commands only exist
@@ -214,7 +214,7 @@ def test_catalog_sign_in_command_is_a_valid_copilot_invocation():
def test_cli_command_reflects_configured_executable(tmp_path, monkeypatch, _clean_copilot_env):
from hermes_cli.web_server import _external_process_cli_command
from hermes_cli.web_server_oauth import _external_process_cli_command
fake = tmp_path / ("copilot.exe" if os.name == "nt" else "copilot")
fake.write_text("", encoding="utf-8")
@@ -227,13 +227,13 @@ def test_cli_command_reflects_configured_executable(tmp_path, monkeypatch, _clea
def test_cli_command_untouched_for_non_external_providers(_clean_copilot_env):
from hermes_cli.web_server import _external_process_cli_command
from hermes_cli.web_server_oauth import _external_process_cli_command
assert _external_process_cli_command("nous", "hermes auth add nous") == "hermes auth add nous"
def test_cli_command_default_when_no_override(monkeypatch, _clean_copilot_env):
from hermes_cli.web_server import _external_process_cli_command
from hermes_cli.web_server_oauth import _external_process_cli_command
assert _external_process_cli_command("copilot-acp", "copilot login") == "copilot login"
@@ -2,11 +2,11 @@
from unittest.mock import patch
from hermes_cli.web_server import _normalize_main_model_assignment
from hermes_cli.web_server_config import _normalize_main_model_assignment
def _normalize(config, provider, model="vendor/model-a"):
with patch("hermes_cli.web_server.load_config", return_value=config):
with patch("hermes_cli.config.load_config", return_value=config):
return _normalize_main_model_assignment(provider, model)
+11 -9
View File
@@ -3,6 +3,8 @@
from unittest.mock import patch
import pytest
import hermes_cli.web_server_mcp as _web_server_mcp
import hermes_cli.web_server_profiles as _web_server_profiles
def _client():
@@ -19,10 +21,10 @@ def _client():
def _clear_flows():
from hermes_cli import web_server
web_server._mcp_oauth_flows.clear()
_web_server_mcp._mcp_oauth_flows.clear()
web_server.app.state.auth_required = False
yield
web_server._mcp_oauth_flows.clear()
_web_server_mcp._mcp_oauth_flows.clear()
web_server.app.state.auth_required = False
@@ -40,7 +42,7 @@ def test_hosted_auth_start_returns_public_authorization_url(monkeypatch):
asyncio.run(flow.publish_authorization_url("https://idp.example/authorize?state=s1"))
monkeypatch.setattr(web_server, "_run_dashboard_mcp_oauth", fake_worker)
monkeypatch.setattr(_web_server_mcp, "_run_dashboard_mcp_oauth", fake_worker)
with patch(
"hermes_cli.dashboard_auth.prefix.resolve_public_url",
return_value="https://agent.example",
@@ -51,7 +53,7 @@ def test_hosted_auth_start_returns_public_authorization_url(monkeypatch):
body = response.json()
assert body["status"] == "authorization_required"
assert body["authorization_url"] == "https://idp.example/authorize?state=s1"
flow = web_server._mcp_oauth_flows[body["flow_id"]]
flow = _web_server_mcp._mcp_oauth_flows[body["flow_id"]]
assert flow.redirect_uri == "https://agent.example/api/mcp/oauth/callback/reports"
@@ -75,7 +77,7 @@ def test_hosted_callback_bypasses_gated_cookie_auth(monkeypatch):
"https://idp.example/authorize?state=expected"
)
)
web_server._mcp_oauth_flows[flow.flow_id] = flow
_web_server_mcp._mcp_oauth_flows[flow.flow_id] = flow
monkeypatch.setattr(web_server.app.state, "auth_required", True, raising=False)
response = TestClient(web_server.app).get(
@@ -92,7 +94,7 @@ def test_hosted_auth_allows_same_server_name_in_different_profiles(tmp_path, mon
profile_home = tmp_path / "profiles" / "work"
profile_home.mkdir(parents=True)
monkeypatch.setattr(web_server, "_resolve_profile_dir", lambda _name: profile_home)
monkeypatch.setattr(_web_server_profiles, "_resolve_profile_dir", lambda _name: profile_home)
existing = DashboardOAuthFlow(
flow_id="existing-default",
@@ -101,7 +103,7 @@ def test_hosted_auth_allows_same_server_name_in_different_profiles(tmp_path, mon
hermes_home=str(tmp_path / "default"),
redirect_uri="https://agent.example/callback/existing",
)
web_server._mcp_oauth_flows[existing.flow_id] = existing
_web_server_mcp._mcp_oauth_flows[existing.flow_id] = existing
def fake_worker(flow, cfg):
import asyncio
@@ -109,7 +111,7 @@ def test_hosted_auth_allows_same_server_name_in_different_profiles(tmp_path, mon
asyncio.run(flow.publish_authorization_url("https://idp.example/authorize?state=work"))
with patch("hermes_cli.mcp_config._get_mcp_servers", return_value={"reports": {"url": "https://mcp.example"}}), \
patch.object(web_server, "_run_dashboard_mcp_oauth", fake_worker):
patch.object(_web_server_mcp, "_run_dashboard_mcp_oauth", fake_worker):
response = _client().post("/api/mcp/servers/reports/auth?profile=work")
assert response.status_code != 409
@@ -131,7 +133,7 @@ def test_flow_status_does_not_expose_authorization_code():
flow.authorization_url = "https://idp.example/authorize"
flow.status = "approved"
flow._callback = ("secret-code", "secret-state")
web_server._mcp_oauth_flows[flow.flow_id] = flow
_web_server_mcp._mcp_oauth_flows[flow.flow_id] = flow
response = _client().get("/api/mcp/oauth/flows/flow-status")
assert response.status_code == 200
+2 -1
View File
@@ -151,7 +151,8 @@ def test_migration_disables_existing_dangerous_entry(tmp_path):
def test_profile_mcp_write_skips_dangerous_entry(tmp_path):
from hermes_cli.config import load_config
from hermes_cli.web_server import MCPServerCreate, _write_profile_mcp_servers
from hermes_cli.web_models import MCPServerCreate
from hermes_cli.web_server_profiles import _write_profile_mcp_servers
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
profile_dir = tmp_path / "profile"
@@ -9,7 +9,8 @@ from __future__ import annotations
from unittest.mock import patch
from hermes_cli.web_server import MoaConfigPayload, MoaModelSlot, MoaPresetPayload, set_moa_models
from hermes_cli.web_models import MoaConfigPayload, MoaModelSlot, MoaPresetPayload
from hermes_cli.web_routers.models import set_moa_models
def _base_payload(**overrides) -> MoaConfigPayload:
@@ -66,9 +67,9 @@ class TestSetMoaModelsPreservesUndeclaredKeys:
payload = _base_payload()
with (
patch("hermes_cli.web_server.load_config", side_effect=fake_load_config),
patch("hermes_cli.web_server.save_config", side_effect=fake_save_config),
patch("hermes_cli.web_server._profile_scope"),
patch("hermes_cli.config.load_config", side_effect=fake_load_config),
patch("hermes_cli.config.save_config", side_effect=fake_save_config),
patch("hermes_cli.web_server_profiles._profile_scope"),
):
set_moa_models(payload)
@@ -14,6 +14,7 @@ import os
import pytest
import yaml
import hermes_cli.web_server_config as _web_server_config
@pytest.fixture()
@@ -39,7 +40,7 @@ def _write_config(home, providers):
def _apply(provider, model="local/model"):
import hermes_cli.web_server as ws
return ws._apply_model_assignment_sync("main", provider, model, "", "")
return _web_server_config._apply_model_assignment_sync("main", provider, model, "", "")
def _raw_model_cfg(home):
@@ -39,7 +39,7 @@ def _write_config(home, body: str) -> None:
def _apply(provider="myprov", model="test-model"):
from hermes_cli.web_server import _apply_model_assignment_sync
from hermes_cli.web_server_config import _apply_model_assignment_sync
return _apply_model_assignment_sync("main", provider, model, "", "")
@@ -22,14 +22,14 @@ prefix and reassigned to openrouter, even though it isn't in
from unittest.mock import patch
from hermes_cli.web_server import _normalize_main_model_assignment
from hermes_cli.web_server_config import _normalize_main_model_assignment
def _no_custom_providers_configured():
"""Patch load_config so resolve_user_provider/resolve_custom_provider
both come up empty, forcing execution into the fallback path under
test -- independent of whatever config.yaml happens to be on disk."""
return patch("hermes_cli.web_server.load_config", return_value={})
return patch("hermes_cli.config.load_config", return_value={})
class TestUnresolvedNamedCustomProviderIsNotTreatedAsStrayVendorPrefix:
@@ -133,7 +133,7 @@ class TestRecommendedDefaultEndpoint:
def _call(self, monkeypatch):
import hermes_cli.auth as auth_mod
from hermes_cli.web_server import get_recommended_default_model
from hermes_cli.web_routers.models import get_recommended_default_model
# Blocked first, so an unfiltered list would make it the silent
# default — otherwise this passes whether or not the filter runs.
@@ -209,7 +209,7 @@ class TestNousPrefetch:
auth_mod, "_load_auth_store",
lambda *a, **k: {"providers": {"nous": {"access_token": "tok"}}},
)
slugs = model_switch_providers._collect_authed_provider_slugs({}, {"nous": list(CURATED)}, [])
slugs = ms._collect_authed_provider_slugs({}, {"nous": list(CURATED)}, [])
assert "nous" not in slugs
+24 -20
View File
@@ -5,6 +5,10 @@ from pathlib import Path
from types import SimpleNamespace
from hermes_cli import web_server
import hermes_cli.config as _cfg_mod
import hermes_cli.web_routers.dashboard_ui as _rt_dashboard_ui
import hermes_cli.web_server_dashboard as _web_server_dashboard
import hermes_cli.web_server_memory as _web_server_memory
from hermes_cli import plugins_cmd
from tools import registry as tools_registry
@@ -14,9 +18,9 @@ _PLUGIN_ROW = [("demo", "1.0.0", "demo plugin", "user", "/tmp/demo-plugin", "dem
def _patch_minimal_hub_dependencies(monkeypatch, *, check_fn, discover_all_plugins=None):
monkeypatch.setattr(web_server, "_get_dashboard_plugins", lambda force_rescan=False: [])
monkeypatch.setattr(web_server, "_discover_memory_provider_statuses", lambda: [])
monkeypatch.setattr(web_server, "get_hermes_home", lambda: Path("/tmp/hermes-home"))
monkeypatch.setattr(web_server, "load_config", lambda: {"dashboard": {"hidden_plugins": []}})
monkeypatch.setattr(_web_server_memory, "_discover_memory_provider_statuses", lambda: [])
monkeypatch.setattr(_cfg_mod, "get_hermes_home", lambda: Path("/tmp/hermes-home"))
monkeypatch.setattr(_cfg_mod, "load_config", lambda: {"dashboard": {"hidden_plugins": []}})
monkeypatch.setattr(
plugins_cmd,
@@ -40,7 +44,7 @@ def _patch_minimal_hub_dependencies(monkeypatch, *, check_fn, discover_all_plugi
def test_plugins_hub_does_not_probe_cold_check_fns(monkeypatch):
tools_registry.invalidate_check_fn_cache()
web_server._invalidate_plugins_hub_cache()
_web_server_dashboard._invalidate_plugins_hub_cache()
calls = {"count": 0, "threads": set()}
@@ -51,7 +55,7 @@ def test_plugins_hub_does_not_probe_cold_check_fns(monkeypatch):
_patch_minimal_hub_dependencies(monkeypatch, check_fn=check_fn)
payload = web_server._merged_plugins_hub(force_refresh=True)
payload = _web_server_dashboard._merged_plugins_hub(force_refresh=True)
# The request path itself must never execute the probe: the cold verdict
# is unknown, so the payload reports no auth requirement. Any probing
@@ -63,7 +67,7 @@ def test_plugins_hub_does_not_probe_cold_check_fns(monkeypatch):
def test_plugins_hub_cold_cache_schedules_background_probe(monkeypatch):
tools_registry.invalidate_check_fn_cache()
web_server._invalidate_plugins_hub_cache()
_web_server_dashboard._invalidate_plugins_hub_cache()
probe_ran = threading.Event()
@@ -74,18 +78,18 @@ def test_plugins_hub_cold_cache_schedules_background_probe(monkeypatch):
_patch_minimal_hub_dependencies(monkeypatch, check_fn=check_fn)
scheduled: list = []
real_schedule = web_server._schedule_check_fn_probe
real_schedule = _web_server_dashboard._schedule_check_fn_probe
def tracking_schedule(fn):
thread = real_schedule(fn)
scheduled.append(thread)
return thread
monkeypatch.setattr(web_server, "_schedule_check_fn_probe", tracking_schedule)
monkeypatch.setattr(_web_server_dashboard, "_schedule_check_fn_probe", tracking_schedule)
# Cold cache → the fetch schedules a background probe and reports the
# verdict as unknown (auth_required stays False for now).
payload = web_server._merged_plugins_hub(force_refresh=True)
payload = _web_server_dashboard._merged_plugins_hub(force_refresh=True)
assert payload["plugins"][0]["auth_required"] is False
assert scheduled and scheduled[0] is not None
@@ -94,7 +98,7 @@ def test_plugins_hub_cold_cache_schedules_background_probe(monkeypatch):
# Once the TTL cache refreshes, the probed False verdict surfaces as an
# auth requirement.
refreshed = web_server._merged_plugins_hub(force_refresh=True)
refreshed = _web_server_dashboard._merged_plugins_hub(force_refresh=True)
assert refreshed["plugins"][0]["auth_required"] is True
assert refreshed["plugins"][0]["auth_command"] == "hermes auth demo"
@@ -102,7 +106,7 @@ def test_plugins_hub_cold_cache_schedules_background_probe(monkeypatch):
def test_plugins_hub_uses_cached_failed_check_fn_verdict(monkeypatch):
tools_registry.invalidate_check_fn_cache()
web_server._invalidate_plugins_hub_cache()
_web_server_dashboard._invalidate_plugins_hub_cache()
def check_fn():
return False
@@ -110,7 +114,7 @@ def test_plugins_hub_uses_cached_failed_check_fn_verdict(monkeypatch):
assert tools_registry._check_fn_cached(check_fn) is False
_patch_minimal_hub_dependencies(monkeypatch, check_fn=check_fn)
payload = web_server._merged_plugins_hub(force_refresh=True)
payload = _web_server_dashboard._merged_plugins_hub(force_refresh=True)
assert payload["plugins"][0]["auth_required"] is True
assert payload["plugins"][0]["auth_command"] == "hermes auth demo"
@@ -119,7 +123,7 @@ def test_plugins_hub_uses_cached_failed_check_fn_verdict(monkeypatch):
def test_plugins_hub_short_ttl_cache_collapses_duplicate_fetches(monkeypatch):
tools_registry.invalidate_check_fn_cache()
web_server._invalidate_plugins_hub_cache()
_web_server_dashboard._invalidate_plugins_hub_cache()
calls = {"discover": 0}
@@ -133,8 +137,8 @@ def test_plugins_hub_short_ttl_cache_collapses_duplicate_fetches(monkeypatch):
discover_all_plugins=discover_all_plugins,
)
first = web_server._merged_plugins_hub(force_refresh=True)
second = web_server._merged_plugins_hub()
first = _web_server_dashboard._merged_plugins_hub(force_refresh=True)
second = _web_server_dashboard._merged_plugins_hub()
assert calls["discover"] == 1
assert first is second
@@ -146,7 +150,7 @@ def test_plugin_install_endpoint_invalidates_hub_cache(monkeypatch):
from hermes_cli.web_models import _AgentPluginInstallBody
tools_registry.invalidate_check_fn_cache()
web_server._invalidate_plugins_hub_cache()
_web_server_dashboard._invalidate_plugins_hub_cache()
calls = {"discover": 0}
@@ -161,8 +165,8 @@ def test_plugin_install_endpoint_invalidates_hub_cache(monkeypatch):
)
# Prime the TTL cache; a plain fetch must be served from it.
web_server._merged_plugins_hub(force_refresh=True)
web_server._merged_plugins_hub()
_web_server_dashboard._merged_plugins_hub(force_refresh=True)
_web_server_dashboard._merged_plugins_hub()
assert calls["discover"] == 1
# Simulate a successful install through the endpoint; its invalidation
@@ -173,10 +177,10 @@ def test_plugin_install_endpoint_invalidates_hub_cache(monkeypatch):
)
asyncio.run(
web_server.post_agent_plugin_install(
_rt_dashboard_ui.post_agent_plugin_install(
object(), _AgentPluginInstallBody(identifier="demo")
)
)
web_server._merged_plugins_hub()
_web_server_dashboard._merged_plugins_hub()
assert calls["discover"] == 2
@@ -4,13 +4,13 @@ server's dashboard plugin loader.
Two primitives combined into the original advisory chain:
1. ``hermes_cli.web_server._discover_dashboard_plugins`` opted into
1. ``hermes_cli.web_server_dashboard._discover_dashboard_plugins`` opted into
the untrusted ``./.hermes/plugins/`` source via
``os.environ.get("HERMES_ENABLE_PROJECT_PLUGINS")`` — truthy for
any non-empty string, so ``=0`` / ``=false`` / ``=no`` (all of
which the agent loader treats as off, and which operators set to
*disable* project plugins) silently *enabled* the source.
2. ``hermes_cli.web_server._mount_plugin_api_routes`` then imported
2. ``hermes_cli.web_server_dashboard._mount_plugin_api_routes`` then imported
each plugin's manifest ``api`` field as a Python module via
``importlib.util.spec_from_file_location``. The field was used
raw, with no path-traversal check, so a single manifest line
@@ -38,6 +38,7 @@ from unittest.mock import patch
import pytest
from hermes_cli import web_server
import hermes_cli.web_server_dashboard as _web_server_dashboard
@pytest.fixture(autouse=True)
@@ -135,7 +136,7 @@ class TestApiPathSanitizer:
def test_simple_relative_path_accepted(self, tmp_path):
d = self._dashboard_dir(tmp_path)
(d / "api.py").write_text("router = None\n")
assert web_server._safe_plugin_api_relpath("api.py", dashboard_dir=d) == "api.py"
assert _web_server_dashboard._safe_plugin_api_relpath("api.py", dashboard_dir=d) == "api.py"
@pytest.mark.parametrize("payload", [
@@ -146,7 +147,7 @@ class TestApiPathSanitizer:
])
def test_traversal_rejected(self, tmp_path, payload):
d = self._dashboard_dir(tmp_path)
assert web_server._safe_plugin_api_relpath(payload, dashboard_dir=d) is None
assert _web_server_dashboard._safe_plugin_api_relpath(payload, dashboard_dir=d) is None
@@ -231,7 +232,7 @@ class TestMountApiRoutesRefusesUntrusted:
plugin = self._payload_plugin(tmp_path, source="project")
web_server._dashboard_plugins_cache = [plugin]
with patch("importlib.util.spec_from_file_location") as spec:
web_server._mount_plugin_api_routes()
_web_server_dashboard._mount_plugin_api_routes()
assert spec.call_count == 0, (
"project-source plugin's api file was imported — "
"GHSA-5qr3-c538-wm9j defence-in-depth regression"
@@ -246,7 +247,7 @@ class TestMountApiRoutesRefusesUntrusted:
api_file="../../../tmp/evil.py")
web_server._dashboard_plugins_cache = [plugin]
with patch("importlib.util.spec_from_file_location") as spec:
web_server._mount_plugin_api_routes()
_web_server_dashboard._mount_plugin_api_routes()
assert spec.call_count == 0
@@ -289,7 +290,7 @@ class TestEndToEndPocBlocked:
with patch("importlib.util.spec_from_file_location") as spec:
plugins = web_server._get_dashboard_plugins(force_rescan=True)
web_server._mount_plugin_api_routes()
_web_server_dashboard._mount_plugin_api_routes()
# The project source must stay disabled because ``0`` is no
# longer truthy. Even if the operator *had* opted in, the
@@ -1,6 +1,6 @@
"""Regression tests for Desktop-owned ``hermes serve`` lifecycle tracking."""
from hermes_cli.web_server import _is_serve_orphaned, _valid_parent_start_marker
from hermes_cli.web_server_lifecycle import _is_serve_orphaned, _valid_parent_start_marker
def test_parent_watchdog_tracks_recorded_desktop_pid_not_immediate_ppid():
+5 -5
View File
@@ -39,7 +39,7 @@ pytestmark = pytest.mark.skipif(
def test_probe_detects_held_socket():
from hermes_cli.web_server import _port_bind_conflict
from hermes_cli.web_server_lifecycle import _port_bind_conflict
holder = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
holder.bind(("127.0.0.1", 0))
@@ -52,7 +52,7 @@ def test_probe_detects_held_socket():
def test_probe_free_port_is_clean():
from hermes_cli.web_server import _port_bind_conflict
from hermes_cli.web_server_lifecycle import _port_bind_conflict
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
probe.bind(("127.0.0.1", 0))
@@ -62,7 +62,7 @@ def test_probe_free_port_is_clean():
def test_probe_skips_ephemeral_port_zero():
from hermes_cli.web_server import _port_bind_conflict
from hermes_cli.web_server_lifecycle import _port_bind_conflict
# port 0 can never conflict — must short-circuit False, never bind.
assert _port_bind_conflict("127.0.0.1", 0) is False
@@ -71,7 +71,7 @@ def test_probe_skips_ephemeral_port_zero():
def test_addr_in_use_error_classification():
import errno
from hermes_cli.web_server import _is_addr_in_use_error
from hermes_cli.web_server_lifecycle import _is_addr_in_use_error
assert _is_addr_in_use_error(OSError(errno.EADDRINUSE, "in use")) is True
assert _is_addr_in_use_error(OSError(98, "linux")) is True
@@ -80,7 +80,7 @@ def test_addr_in_use_error_classification():
def test_exit_code_is_distinct_tempfail():
from hermes_cli.web_server import PORT_IN_USE_EXIT_CODE
from hermes_cli.web_server_lifecycle import PORT_IN_USE_EXIT_CODE
assert PORT_IN_USE_EXIT_CODE == 75 # EX_TEMPFAIL — repo convention
assert PORT_IN_USE_EXIT_CODE != 1
@@ -27,7 +27,7 @@ from __future__ import annotations
import shutil
from hermes_cli.sqlite_safe_read import LiveConnectionError, offline_file_access
from hermes_cli.web_server import _open_session_db_at_path
from hermes_cli.web_server_sessions import _open_session_db_at_path
from hermes_state import SessionDB, _db_fingerprint
+3 -3
View File
@@ -42,7 +42,7 @@ class TestChannelCards:
def test_discord_card_asks_for_token_and_allowlist_only(self):
"""The reported bug: the Discord card asked five questions for a
one-credential platform."""
from hermes_cli.web_server import _build_catalog_entry
from hermes_cli.web_server_messaging import _build_catalog_entry
assert set(_build_catalog_entry("discord")["env_vars"]) == {
"DISCORD_BOT_TOKEN",
@@ -50,7 +50,7 @@ class TestChannelCards:
}
def test_no_card_shows_a_hidden_knob(self):
from hermes_cli.web_server import _messaging_platform_catalog
from hermes_cli.web_server_messaging import _messaging_platform_catalog
for entry in _messaging_platform_catalog():
for key in entry["env_vars"]:
@@ -60,7 +60,7 @@ class TestChannelCards:
def test_hidden_knobs_move_to_the_keys_page_not_into_a_void(self):
"""Keys hides what a Channels card owns. Dropping these from the card
must hand them back to Keys, not orphan them from every surface."""
from hermes_cli.web_server import _channel_managed_env_keys
from hermes_cli.web_server_messaging import _channel_managed_env_keys
managed = _channel_managed_env_keys()
for key in (
@@ -13,6 +13,7 @@ import subprocess
from unittest.mock import MagicMock, patch
import pytest
import hermes_cli.web_server_gateway as _web_server_gateway
@pytest.fixture(autouse=True)
@@ -37,11 +38,11 @@ class TestRepeatRestartWithinCooldown:
"""Repeats within the window ride the previous spawn."""
@patch(
"hermes_cli.web_server._gateway_subcommand",
"hermes_cli.web_server_gateway._gateway_subcommand",
return_value=["gateway", "restart"],
)
@patch("hermes_cli.web_server._spawn_hermes_action")
@patch("hermes_cli.web_server._ACTION_PROCS", {})
@patch("hermes_cli.web_server_gateway._spawn_hermes_action")
@patch("hermes_cli.web_server_gateway._ACTION_PROCS", {})
def test_second_request_after_the_child_exits_is_coalesced(
self, mock_spawn, mock_subcmd
):
@@ -64,11 +65,11 @@ class TestRepeatRestartWithinCooldown:
assert second is proc and second_reused is True
@patch(
"hermes_cli.web_server._gateway_subcommand",
"hermes_cli.web_server_gateway._gateway_subcommand",
return_value=["gateway", "restart"],
)
@patch("hermes_cli.web_server._spawn_hermes_action")
@patch("hermes_cli.web_server._ACTION_PROCS", {})
@patch("hermes_cli.web_server_gateway._spawn_hermes_action")
@patch("hermes_cli.web_server_gateway._ACTION_PROCS", {})
def test_a_storm_of_requests_produces_exactly_one_restart(
self, mock_spawn, mock_subcmd
):
@@ -92,11 +93,11 @@ class TestRepeatRestartWithinCooldown:
assert mock_spawn.call_count == 1
@patch(
"hermes_cli.web_server._gateway_subcommand",
"hermes_cli.web_server_gateway._gateway_subcommand",
return_value=["gateway", "restart"],
)
@patch("hermes_cli.web_server._spawn_hermes_action")
@patch("hermes_cli.web_server._ACTION_PROCS", {})
@patch("hermes_cli.web_server_gateway._spawn_hermes_action")
@patch("hermes_cli.web_server_gateway._ACTION_PROCS", {})
def test_cooldown_survives_the_action_table_being_cleared(
self, mock_spawn, mock_subcmd
):
@@ -114,8 +115,8 @@ class TestRepeatRestartWithinCooldown:
"hermes_cli.web_server.time.monotonic", side_effect=[100.0, 102.0]
):
_spawn_gateway_restart()
web_server._ACTION_PROCS.clear()
web_server._ACTION_COMMANDS.clear()
_web_server_gateway._ACTION_PROCS.clear()
_web_server_gateway._ACTION_COMMANDS.clear()
_, reused = _spawn_gateway_restart()
assert mock_spawn.call_count == 1
@@ -126,11 +127,11 @@ class TestCooldownReleases:
"""The window always expires; it never wedges the restart action."""
@patch(
"hermes_cli.web_server._gateway_subcommand",
"hermes_cli.web_server_gateway._gateway_subcommand",
return_value=["gateway", "restart"],
)
@patch("hermes_cli.web_server._spawn_hermes_action")
@patch("hermes_cli.web_server._ACTION_PROCS", {})
@patch("hermes_cli.web_server_gateway._spawn_hermes_action")
@patch("hermes_cli.web_server_gateway._ACTION_PROCS", {})
def test_request_after_the_window_starts_a_real_restart(
self, mock_spawn, mock_subcmd
):
@@ -149,8 +150,8 @@ class TestCooldownReleases:
assert reused is False
assert second.pid == 2
@patch("hermes_cli.web_server._spawn_hermes_action")
@patch("hermes_cli.web_server._ACTION_PROCS", {})
@patch("hermes_cli.web_server_gateway._spawn_hermes_action")
@patch("hermes_cli.web_server_gateway._ACTION_PROCS", {})
def test_a_different_profile_is_never_coalesced(self, mock_spawn):
"""Two profiles are two services; one's restart is not the other's."""
from hermes_cli.web_server import _spawn_gateway_restart
@@ -161,7 +162,7 @@ class TestCooldownReleases:
"hermes_cli.web_server.time.monotonic",
side_effect=[100.0, 101.0],
), patch(
"hermes_cli.web_server._gateway_subcommand",
"hermes_cli.web_server_gateway._gateway_subcommand",
side_effect=[["gateway", "restart"], ["-p", "coder", "gateway", "restart"]],
):
_spawn_gateway_restart()
@@ -176,10 +177,10 @@ class TestExistingBehaviourIsPreserved:
"""Regression guards on the pre-existing in-flight reuse."""
@patch(
"hermes_cli.web_server._gateway_subcommand",
"hermes_cli.web_server_gateway._gateway_subcommand",
return_value=["gateway", "restart"],
)
@patch("hermes_cli.web_server._spawn_hermes_action")
@patch("hermes_cli.web_server_gateway._spawn_hermes_action")
def test_live_child_is_still_reused_without_consulting_the_clock(
self, mock_spawn, mock_subcmd
):
@@ -190,9 +191,9 @@ class TestExistingBehaviourIsPreserved:
live.pid = 7
with patch(
"hermes_cli.web_server._ACTION_PROCS", {"gateway-restart": live}
"hermes_cli.web_server_gateway._ACTION_PROCS", {"gateway-restart": live}
), patch(
"hermes_cli.web_server._ACTION_COMMANDS",
"hermes_cli.web_server_gateway._ACTION_COMMANDS",
{"gateway-restart": ("gateway", "restart")},
), patch(
"hermes_cli.gateway._reap_unsupervised_gateway_orphans"
@@ -204,10 +205,10 @@ class TestExistingBehaviourIsPreserved:
mock_spawn.assert_not_called()
@patch(
"hermes_cli.web_server._gateway_subcommand",
"hermes_cli.web_server_gateway._gateway_subcommand",
return_value=["gateway", "restart"],
)
@patch("hermes_cli.web_server._spawn_hermes_action")
@patch("hermes_cli.web_server_gateway._spawn_hermes_action")
def test_live_child_for_another_profile_still_raises(self, mock_spawn, mock_subcmd):
from hermes_cli.web_server import _spawn_gateway_restart
@@ -215,9 +216,9 @@ class TestExistingBehaviourIsPreserved:
live.poll.return_value = None
with patch(
"hermes_cli.web_server._ACTION_PROCS", {"gateway-restart": live}
"hermes_cli.web_server_gateway._ACTION_PROCS", {"gateway-restart": live}
), patch(
"hermes_cli.web_server._ACTION_COMMANDS",
"hermes_cli.web_server_gateway._ACTION_COMMANDS",
{"gateway-restart": ("-p", "coder", "gateway", "restart")},
), patch(
"hermes_cli.gateway._reap_unsupervised_gateway_orphans"
@@ -25,9 +25,9 @@ def reset_restart_cooldown():
class TestSpawnGatewayRestartReapsOrphans:
"""_spawn_gateway_restart must reap orphaned gateways before spawning."""
@patch("hermes_cli.web_server._gateway_subcommand", return_value=["gateway", "restart"])
@patch("hermes_cli.web_server._spawn_hermes_action")
@patch("hermes_cli.web_server._ACTION_PROCS", {})
@patch("hermes_cli.web_server_gateway._gateway_subcommand", return_value=["gateway", "restart"])
@patch("hermes_cli.web_server_gateway._spawn_hermes_action")
@patch("hermes_cli.web_server_gateway._ACTION_PROCS", {})
def test_reap_called_before_spawn(self, mock_spawn, mock_subcmd):
"""Orphan reap runs before the new gateway process is spawned."""
mock_proc = MagicMock(spec=subprocess.Popen)
@@ -46,9 +46,9 @@ class TestSpawnGatewayRestartReapsOrphans:
assert proc is mock_proc
assert reused is False
@patch("hermes_cli.web_server._gateway_subcommand", return_value=["gateway", "restart"])
@patch("hermes_cli.web_server._spawn_hermes_action")
@patch("hermes_cli.web_server._ACTION_PROCS", {})
@patch("hermes_cli.web_server_gateway._gateway_subcommand", return_value=["gateway", "restart"])
@patch("hermes_cli.web_server_gateway._spawn_hermes_action")
@patch("hermes_cli.web_server_gateway._ACTION_PROCS", {})
def test_reap_failure_does_not_block_spawn(self, mock_spawn, mock_subcmd):
"""If reap raises, the restart still proceeds."""
mock_proc = MagicMock(spec=subprocess.Popen)
@@ -1,4 +1,4 @@
from hermes_cli.web_server import _display_system_platform
from hermes_cli.web_server_gateway import _display_system_platform
def test_windows_11_build_displays_as_windows_11():
@@ -16,6 +16,7 @@ from pathlib import Path
import pytest
import hermes_cli.web_server as web_server
import hermes_cli.web_server_gateway as _web_server_gateway
@pytest.fixture()
@@ -88,12 +89,12 @@ class TestUpdateReceiptEndpoint:
class TestUpdateStatusReadsReceipt:
def _clear_registries(self, monkeypatch, tmp_path):
monkeypatch.setattr(web_server, "_ACTION_LOG_DIR", tmp_path / "actions")
monkeypatch.setattr(_web_server_gateway, "_ACTION_LOG_DIR", tmp_path / "actions")
(tmp_path / "actions").mkdir(exist_ok=True)
monkeypatch.setattr(web_server, "_ACTION_PROCS", {})
monkeypatch.setattr(web_server, "_ACTION_RESULTS", {})
monkeypatch.setattr(web_server, "_ACTION_COMMANDS", {})
monkeypatch.setattr(web_server, "_ACTION_IDS", {})
monkeypatch.setattr(_web_server_gateway, "_ACTION_PROCS", {})
monkeypatch.setattr(_web_server_gateway, "_ACTION_RESULTS", {})
monkeypatch.setattr(_web_server_gateway, "_ACTION_COMMANDS", {})
monkeypatch.setattr(_web_server_gateway, "_ACTION_IDS", {})
def test_status_attaches_receipt_summary(self, client, tmp_path, monkeypatch):
_write_receipt(tmp_path, monkeypatch)
+34 -32
View File
@@ -32,6 +32,8 @@ import pytest
from fastapi.testclient import TestClient
from hermes_cli.web_server import _SESSION_TOKEN, app
import hermes_cli.web_routers.oauth as _rt_oauth
import hermes_cli.web_server_oauth as _web_server_oauth
client = TestClient(app)
HEADERS = {"X-Hermes-Session-Token": _SESSION_TOKEN}
@@ -87,7 +89,7 @@ def test_minimax_login_does_not_launch_anthropic_flow():
"hermes_cli.auth._minimax_pkce_pair",
return_value=("verifier-stub", "challenge-stub", "stub-state"),
), patch(
"hermes_cli.web_server._minimax_poller",
"hermes_cli.web_server_oauth._minimax_poller",
return_value=None,
):
resp = client.post(
@@ -131,7 +133,7 @@ def test_oauth_provider_status_uses_profile_query(tmp_path, monkeypatch):
"docs_url": "https://example.com",
"status_fn": fake_status,
},)
monkeypatch.setattr(ws, "_OAUTH_PROVIDER_CATALOG", fake_catalog)
monkeypatch.setattr(_web_server_oauth, "_OAUTH_PROVIDER_CATALOG", fake_catalog)
resp = client.get("/api/providers/oauth?profile=coder", headers=HEADERS)
@@ -157,7 +159,7 @@ def test_oauth_start_stores_profile_for_background_completion(tmp_path, monkeypa
"hermes_cli.auth._minimax_pkce_pair",
return_value=("verifier-stub", "challenge-stub", "stub-state"),
), patch(
"hermes_cli.web_server._minimax_poller",
"hermes_cli.web_server_oauth._minimax_poller",
return_value=None,
):
resp = client.post(
@@ -168,9 +170,9 @@ def test_oauth_start_stores_profile_for_background_completion(tmp_path, monkeypa
assert resp.status_code == 200, resp.text
session_id = resp.json()["session_id"]
try:
assert ws._oauth_sessions[session_id]["profile"] == "coder"
assert _web_server_oauth._oauth_sessions[session_id]["profile"] == "coder"
finally:
ws._oauth_sessions.pop(session_id, None)
_web_server_oauth._oauth_sessions.pop(session_id, None)
def test_oauth_session_cannot_be_polled_or_cancelled_from_another_profile(
@@ -181,7 +183,7 @@ def test_oauth_session_cannot_be_polled_or_cancelled_from_another_profile(
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "profiles" / "worker").mkdir(parents=True)
session_id, _session = ws._new_oauth_session(
session_id, _session = _rt_oauth._new_oauth_session(
"xai-oauth", "device_code", profile="worker"
)
try:
@@ -198,7 +200,7 @@ def test_oauth_session_cannot_be_polled_or_cancelled_from_another_profile(
)
assert cancel_resp.status_code == 400, cancel_resp.text
assert "profile" in cancel_resp.text.lower()
assert session_id in ws._oauth_sessions
assert session_id in _web_server_oauth._oauth_sessions
correct_poll = client.get(
f"/api/providers/oauth/xai-oauth/poll/{session_id}?profile=worker",
@@ -212,7 +214,7 @@ def test_oauth_session_cannot_be_polled_or_cancelled_from_another_profile(
)
assert correct_cancel.status_code == 200, correct_cancel.text
finally:
ws._oauth_sessions.pop(session_id, None)
_web_server_oauth._oauth_sessions.pop(session_id, None)
@@ -220,7 +222,7 @@ def test_oauth_session_cannot_be_polled_or_cancelled_from_another_profile(
def test_codex_dashboard_start_rewords_device_authorization_error(monkeypatch):
from hermes_cli import web_server as ws
before_sessions = set(ws._oauth_sessions)
before_sessions = set(_web_server_oauth._oauth_sessions)
class _Resp:
status_code = 400
@@ -263,8 +265,8 @@ def test_codex_dashboard_start_rewords_device_authorization_error(monkeypatch):
assert "click Login again" in detail
assert "hermes auth" not in detail
finally:
for sid in set(ws._oauth_sessions) - before_sessions:
ws._oauth_sessions.pop(sid, None)
for sid in set(_web_server_oauth._oauth_sessions) - before_sessions:
_web_server_oauth._oauth_sessions.pop(sid, None)
def test_codex_dashboard_worker_stops_polling_after_cancel(tmp_path, monkeypatch):
@@ -319,7 +321,7 @@ def test_codex_dashboard_worker_stops_polling_after_cancel(tmp_path, monkeypatch
monkeypatch.setattr(httpx, "Client", _Client)
monkeypatch.setattr(auth_mod, "_save_codex_tokens", lambda tokens: saved.append(tokens))
sid, _ = ws._new_oauth_session("openai-codex", "device_code", profile="coder")
sid, _ = _rt_oauth._new_oauth_session("openai-codex", "device_code", profile="coder")
def fake_sleep(_interval):
# Simulate a real concurrent DELETE /api/providers/oauth/sessions/{sid}
@@ -333,12 +335,12 @@ def test_codex_dashboard_worker_stops_polling_after_cancel(tmp_path, monkeypatch
monkeypatch.setattr(ws.time, "sleep", fake_sleep)
try:
ws._codex_full_login_worker(sid)
_rt_oauth._codex_full_login_worker(sid)
assert saved == []
assert sid not in ws._oauth_sessions
assert sid not in _web_server_oauth._oauth_sessions
finally:
ws._oauth_sessions.pop(sid, None)
_web_server_oauth._oauth_sessions.pop(sid, None)
def test_codex_worker_final_save_is_atomic_with_cancel_delete(tmp_path, monkeypatch):
@@ -425,9 +427,9 @@ def test_codex_worker_final_save_is_atomic_with_cancel_delete(tmp_path, monkeypa
monkeypatch.setattr(auth_mod, "_save_codex_tokens", fake_save)
monkeypatch.setattr(ws.time, "sleep", lambda *_a, **_k: None)
sid, _ = ws._new_oauth_session("openai-codex", "device_code", profile="coder")
sid, _ = _rt_oauth._new_oauth_session("openai-codex", "device_code", profile="coder")
ws._codex_full_login_worker(sid)
_rt_oauth._codex_full_login_worker(sid)
# The lock is released now (worker returned), so the DELETE thread can
# finally complete.
@@ -443,7 +445,7 @@ def test_codex_worker_final_save_is_atomic_with_cancel_delete(tmp_path, monkeypa
# DELETE arrived after the point of no return (save already committed),
# so this is the legitimate too-late-to-cancel outcome: token saved,
# session subsequently removed by the now-unblocked DELETE.
assert sid not in ws._oauth_sessions
assert sid not in _web_server_oauth._oauth_sessions
def test_cancel_oauth_session_marks_dict_cancelled_before_popping(tmp_path, monkeypatch):
@@ -457,7 +459,7 @@ def test_cancel_oauth_session_marks_dict_cancelled_before_popping(tmp_path, monk
_make_profile_home(tmp_path, monkeypatch, profile="coder")
session_id = "cancel-flag-test"
ws._oauth_sessions[session_id] = {
_web_server_oauth._oauth_sessions[session_id] = {
"session_id": session_id,
"provider": "openai-codex",
"flow": "device_code",
@@ -466,7 +468,7 @@ def test_cancel_oauth_session_marks_dict_cancelled_before_popping(tmp_path, monk
"status": "pending",
"error_message": None,
}
worker_ref = ws._oauth_sessions[session_id]
worker_ref = _web_server_oauth._oauth_sessions[session_id]
resp = client.delete(
f"/api/providers/oauth/sessions/{session_id}?profile=coder",
@@ -475,7 +477,7 @@ def test_cancel_oauth_session_marks_dict_cancelled_before_popping(tmp_path, monk
assert resp.status_code == 200, resp.text
assert resp.json() == {"ok": True, "session_id": session_id}
assert session_id not in ws._oauth_sessions
assert session_id not in _web_server_oauth._oauth_sessions
assert worker_ref["cancelled"] is True
@@ -484,7 +486,7 @@ def test_nous_dashboard_poller_preserves_effective_scope_when_token_omits_scope(
from hermes_cli import web_server as ws
session_id = "nous-effective-scope-test"
ws._oauth_sessions[session_id] = {
_web_server_oauth._oauth_sessions[session_id] = {
"session_id": session_id,
"provider": "nous",
"flow": "device_code",
@@ -522,11 +524,11 @@ def test_nous_dashboard_poller_preserves_effective_scope_when_token_omits_scope(
monkeypatch.setattr(auth_mod, "persist_nous_credentials", lambda state: None)
try:
ws._nous_poller(session_id)
_web_server_oauth._nous_poller(session_id)
assert captured_state["scope"] == auth_mod.DEFAULT_NOUS_SCOPE
assert ws._oauth_sessions[session_id]["status"] == "approved"
assert _web_server_oauth._oauth_sessions[session_id]["status"] == "approved"
finally:
ws._oauth_sessions.pop(session_id, None)
_web_server_oauth._oauth_sessions.pop(session_id, None)
@@ -551,7 +553,7 @@ def test_anthropic_dashboard_oauth_is_removed_and_external():
assert providers["anthropic"]["flow"] == "external"
assert providers["anthropic"]["cli_command"] == "hermes auth add anthropic"
before_sessions = set(ws._oauth_sessions)
before_sessions = set(_web_server_oauth._oauth_sessions)
start_resp = client.post(
"/api/providers/oauth/anthropic/start",
headers=HEADERS,
@@ -567,7 +569,7 @@ def test_anthropic_dashboard_oauth_is_removed_and_external():
)
assert submit_resp.status_code == 400, submit_resp.text
assert "not supported" in submit_resp.text
assert set(ws._oauth_sessions) == before_sessions
assert set(_web_server_oauth._oauth_sessions) == before_sessions
def test_accounts_offers_every_oauth_provider_from_catalog():
@@ -704,7 +706,7 @@ def test_xai_dashboard_poller_seeds_single_entry_and_clears_suppression(tmp_path
)
session_id = "xai-dashboard-dedupe-test"
ws._oauth_sessions[session_id] = {
_web_server_oauth._oauth_sessions[session_id] = {
"session_id": session_id,
"provider": "xai-oauth",
"flow": "device_code",
@@ -716,10 +718,10 @@ def test_xai_dashboard_poller_seeds_single_entry_and_clears_suppression(tmp_path
"expires_at": time.time() + 600,
}
try:
ws._xai_device_poller(session_id)
assert ws._oauth_sessions[session_id]["status"] == "approved"
_web_server_oauth._xai_device_poller(session_id)
assert _web_server_oauth._oauth_sessions[session_id]["status"] == "approved"
finally:
ws._oauth_sessions.pop(session_id, None)
_web_server_oauth._oauth_sessions.pop(session_id, None)
# The interactive dashboard login cleared the suppression marker.
assert auth_mod.is_source_suppressed("xai-oauth", "device_code") is False
@@ -764,7 +766,7 @@ def test_status_falls_through_to_generic_dispatcher_for_catalog_only_provider():
"has_refresh_token": True,
}
with patch("hermes_cli.auth.get_auth_status", return_value=fake_status):
out = ws._resolve_provider_status("some-future-oauth", None)
out = _rt_oauth._resolve_provider_status("some-future-oauth", None)
assert out["logged_in"] is True
assert out["source"] == "some-future-oauth"
@@ -10,6 +10,7 @@ the binary.
"""
import pytest
import hermes_cli.web_server_gateway as _web_server_gateway
class TestToggleToolsetInstallOnEnable:
@@ -42,7 +43,7 @@ class TestToggleToolsetInstallOnEnable:
calls.append((tuple(subcommand), name))
return _FakeProc()
monkeypatch.setattr(web_server, "_spawn_hermes_action", _fake_spawn)
monkeypatch.setattr(_web_server_gateway, "_spawn_hermes_action", _fake_spawn)
return calls
def test_enable_computer_use_spawns_cua_install_when_binary_missing(
@@ -127,7 +128,7 @@ class TestToggleToolsetInstallOnEnable:
def _boom(subcommand, name, **kwargs):
raise RuntimeError("spawn exploded")
monkeypatch.setattr(web_server, "_spawn_hermes_action", _boom)
monkeypatch.setattr(_web_server_gateway, "_spawn_hermes_action", _boom)
resp = self.client.put(
"/api/tools/toolsets/computer_use", json={"enabled": True}
+141 -133
View File
@@ -21,6 +21,19 @@ from hermes_cli.config import (
OPTIONAL_ENV_VARS,
DEFAULT_CONFIG,
)
import gateway.status as _gw_status
import hermes_cli.config as _cfg_mod
import hermes_cli.web_routers.chat_ws as _rt_chat_ws
import hermes_cli.web_routers.status as _rt_status
import hermes_cli.web_server_chat as _web_server_chat
import hermes_cli.web_server_config as _web_server_config
import hermes_cli.web_server_dashboard as _web_server_dashboard
import hermes_cli.web_server_files as _web_server_files
import hermes_cli.web_server_gateway as _web_server_gateway
import hermes_cli.web_server_lifecycle as _web_server_lifecycle
import hermes_cli.web_server_memory as _web_server_memory
import hermes_cli.web_server_messaging as _web_server_messaging
import hermes_cli.web_server_sessions as _web_server_sessions
# ---------------------------------------------------------------------------
@@ -105,7 +118,7 @@ def _install_example_plugin(_isolate_hermes_home):
# route reorder below.
web_server._dashboard_plugins_cache = None
web_server._get_dashboard_plugins(force_rescan=True)
web_server._mount_plugin_api_routes()
_web_server_dashboard._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
@@ -268,7 +281,7 @@ class TestWebServerEndpoints:
from hermes_constants import get_hermes_home
from hermes_state import SessionDB
web_server._last_auto_archive_check.clear()
_web_server_sessions._last_auto_archive_check.clear()
db_path = get_hermes_home() / "state.db"
wal_path = Path(f"{db_path}-wal")
writer = SessionDB(db_path=db_path)
@@ -328,7 +341,7 @@ class TestWebServerEndpoints:
def boom(*_args, **_kwargs):
raise sqlite3.OperationalError("disk I/O error")
monkeypatch.setattr(web_server, "_open_session_db_for_profile", boom)
monkeypatch.setattr(_web_server_sessions, "_open_session_db_for_profile", boom)
assert self.client.get("/api/sessions?limit=1&offset=0").status_code == 503
def test_get_sessions_non_transient_operational_error_is_500(self, monkeypatch):
@@ -339,7 +352,7 @@ class TestWebServerEndpoints:
def boom(*_args, **_kwargs):
raise sqlite3.OperationalError("no such table: sessions")
monkeypatch.setattr(web_server, "_open_session_db_for_profile", boom)
monkeypatch.setattr(_web_server_sessions, "_open_session_db_for_profile", boom)
assert self.client.get("/api/sessions?limit=1&offset=0").status_code == 500
def test_get_status_loads_gateway_config_off_event_loop(self, monkeypatch):
@@ -368,7 +381,7 @@ class TestWebServerEndpoints:
async def _run():
event_loop_thread = threading.get_ident()
await web_server.get_status()
await _rt_status.get_status()
return event_loop_thread
event_loop_thread = asyncio.run(_run())
@@ -402,7 +415,7 @@ class TestWebServerEndpoints:
}
)
save_config(config)
web_server._last_auto_archive_check.clear()
_web_server_sessions._last_auto_archive_check.clear()
response = self.client.get("/api/sessions?limit=50&offset=0")
@@ -534,7 +547,7 @@ class TestWebServerEndpoints:
finally:
legacy.close()
web_server._eager_reconcile_own_session_db()
_web_server_lifecycle._eager_reconcile_own_session_db()
healed = sqlite3.connect(str(db_path))
try:
@@ -566,7 +579,7 @@ class TestWebServerEndpoints:
monkeypatch.setattr(hermes_state, "SessionDB", boom)
# Must swallow — reads fall back to the per-poll probe heal.
web_server._eager_reconcile_own_session_db()
_web_server_lifecycle._eager_reconcile_own_session_db()
def test_heal_gives_up_when_reconcile_cannot_fix_the_store(self, monkeypatch):
"""A probe failure reconciliation can't cure must not retry forever.
@@ -591,12 +604,12 @@ class TestWebServerEndpoints:
# A column no SCHEMA_SQL declares: the heal's writable reconcile
# cannot add it, so the re-probe keeps failing.
monkeypatch.setattr(
web_server,
_web_server_sessions,
"_session_db_read_probe_statements",
lambda: ('SELECT "sessions"."not_a_real_column" FROM "sessions" LIMIT 0',),
)
monkeypatch.setattr(web_server, "_session_db_heal_exhausted", set())
monkeypatch.setattr(web_server, "_session_db_heal_warned", set())
monkeypatch.setattr(_web_server_sessions, "_session_db_heal_exhausted", set())
monkeypatch.setattr(_web_server_sessions, "_session_db_heal_warned", set())
writable_opens = []
@@ -615,16 +628,16 @@ class TestWebServerEndpoints:
# First open: probe fails -> one writable heal -> re-probe fails ->
# exhausted. Still returns a usable read-only handle.
db = web_server._open_session_db_for_profile(None, read_only=True)
db = _web_server_sessions._open_session_db_for_profile(None, read_only=True)
try:
assert db.list_sessions_rich(limit=10, compact_rows=True)
finally:
db.close()
assert len(writable_opens) == 1
assert str(db_path) in web_server._session_db_heal_exhausted
assert str(db_path) in _web_server_sessions._session_db_heal_exhausted
# Second open: probe skipped, NO further writable opens.
db = web_server._open_session_db_for_profile(None, read_only=True)
db = _web_server_sessions._open_session_db_for_profile(None, read_only=True)
try:
assert db.list_sessions_rich(limit=10, compact_rows=True)
finally:
@@ -651,7 +664,7 @@ class TestWebServerEndpoints:
monkeypatch.setattr(hermes_state, "SessionDB", corrupt_open)
with pytest.raises(sqlite3.DatabaseError, match="disk image is malformed"):
web_server._open_session_db_at_path(db_path, read_only=True)
_web_server_sessions._open_session_db_at_path(db_path, read_only=True)
assert opens == [True]
@@ -680,7 +693,7 @@ class TestWebServerEndpoints:
monkeypatch.setattr(hermes_state, "SessionDB", scripted_open)
db = web_server._open_session_db_at_path(db_path, read_only=True)
db = _web_server_sessions._open_session_db_at_path(db_path, read_only=True)
assert isinstance(db, _OkDB)
assert opens == [True, False, True]
@@ -747,10 +760,10 @@ class TestWebServerEndpoints:
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(_gw_status, "get_running_pid_cached", _pid)
monkeypatch.setattr(_gw_status, "get_running_pid", _pid)
monkeypatch.setattr(_gw_status, "read_runtime_status", _runtime)
monkeypatch.setattr(_gw_status, "get_runtime_status_running_pid", _runtime_pid)
monkeypatch.setattr(web_server, "_GATEWAY_HEALTH_URL", None)
resp = self.client.get("/api/messaging/platforms?profile=worker")
@@ -892,7 +905,7 @@ class TestWebServerEndpoints:
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)
monkeypatch.setattr(_web_server_memory, "_dependency_importable", lambda dep: False)
installed = []
@@ -1229,16 +1242,16 @@ class TestWebServerEndpoints:
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_files, "_dashboard_local_update_managed_externally", lambda: False)
# The shared admission gate (#91277 Phase 3) resolves the install
# method through hermes_cli.config directly.
monkeypatch.setattr(
"hermes_cli.config.detect_install_method", lambda *_a, **_k: "docker"
)
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)
monkeypatch.setattr(_cfg_mod, "detect_install_method", lambda _root: "docker")
monkeypatch.setattr(_web_server_gateway, "_spawn_hermes_action", fail_spawn)
_web_server_gateway._ACTION_PROCS.pop("hermes-update", None)
_web_server_gateway._ACTION_RESULTS.pop("hermes-update", None)
resp = self.client.post("/api/hermes/update")
@@ -1269,17 +1282,17 @@ class TestWebServerEndpoints:
spawned = True
raise AssertionError("APT-managed update guard should not spawn hermes update")
monkeypatch.setattr(web_server, "_dashboard_local_update_managed_externally", lambda: False)
monkeypatch.setattr(_web_server_files, "_dashboard_local_update_managed_externally", lambda: False)
# The shared admission gate (#91277 Phase 3) resolves the install
# method through hermes_cli.config directly, so patch it there (the
# web_server module alias only feeds the /update/check endpoint).
monkeypatch.setattr(
"hermes_cli.config.detect_install_method", lambda *_a, **_k: "apt"
)
monkeypatch.setattr(web_server, "detect_install_method", lambda _root: "apt")
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)
monkeypatch.setattr(_cfg_mod, "detect_install_method", lambda _root: "apt")
monkeypatch.setattr(_web_server_gateway, "_spawn_hermes_action", fail_spawn)
_web_server_gateway._ACTION_PROCS.pop("hermes-update", None)
_web_server_gateway._ACTION_RESULTS.pop("hermes-update", None)
resp = self.client.post("/api/hermes/update")
@@ -1314,11 +1327,11 @@ class TestWebServerEndpoints:
f"=== hermes-update completed {action_id} ===\n",
encoding="utf-8",
)
monkeypatch.setattr(web_server, "_ACTION_LOG_DIR", tmp_path)
monkeypatch.setattr(web_server, "_ACTION_PROCS", {})
monkeypatch.setattr(web_server, "_ACTION_RESULTS", {})
monkeypatch.setattr(web_server, "_ACTION_COMMANDS", {})
monkeypatch.setattr(web_server, "_ACTION_IDS", {})
monkeypatch.setattr(_web_server_gateway, "_ACTION_LOG_DIR", tmp_path)
monkeypatch.setattr(_web_server_gateway, "_ACTION_PROCS", {})
monkeypatch.setattr(_web_server_gateway, "_ACTION_RESULTS", {})
monkeypatch.setattr(_web_server_gateway, "_ACTION_COMMANDS", {})
monkeypatch.setattr(_web_server_gateway, "_ACTION_IDS", {})
status = self.client.get("/api/actions/hermes-update/status?lines=2000")
@@ -1341,12 +1354,12 @@ class TestWebServerEndpoints:
calls.append((subcommand, name, env_overrides))
return Proc()
monkeypatch.setattr(web_server, "_dashboard_local_update_managed_externally", lambda: False)
monkeypatch.setattr(web_server, "detect_install_method", lambda _root: "git")
monkeypatch.setattr(_web_server_files, "_dashboard_local_update_managed_externally", lambda: False)
monkeypatch.setattr(_cfg_mod, "detect_install_method", lambda _root: "git")
monkeypatch.setattr(web_server.secrets, "token_hex", lambda _size: "a" * 32)
monkeypatch.setattr(web_server, "_spawn_hermes_action", fake_spawn)
web_server._ACTION_PROCS.pop("hermes-update", None)
web_server._ACTION_RESULTS.pop("hermes-update", None)
monkeypatch.setattr(_web_server_gateway, "_spawn_hermes_action", fake_spawn)
_web_server_gateway._ACTION_PROCS.pop("hermes-update", None)
_web_server_gateway._ACTION_RESULTS.pop("hermes-update", None)
resp = self.client.post("/api/hermes/update")
@@ -1370,21 +1383,21 @@ class TestWebServerEndpoints:
def poll(self):
return None
monkeypatch.setattr(web_server, "_dashboard_local_update_managed_externally", lambda: False)
monkeypatch.setattr(web_server, "detect_install_method", lambda _root: "git")
monkeypatch.setattr(_web_server_files, "_dashboard_local_update_managed_externally", lambda: False)
monkeypatch.setattr(_cfg_mod, "detect_install_method", lambda _root: "git")
monkeypatch.setattr(
web_server,
_web_server_gateway,
"_spawn_hermes_action",
lambda *_args, **_kwargs: pytest.fail("must not spawn a duplicate update"),
)
web_server._ACTION_PROCS["hermes-update"] = Proc()
web_server._ACTION_IDS["hermes-update"] = "b" * 32
_web_server_gateway._ACTION_PROCS["hermes-update"] = Proc()
_web_server_gateway._ACTION_IDS["hermes-update"] = "b" * 32
try:
resp = self.client.post("/api/hermes/update")
finally:
web_server._ACTION_PROCS.pop("hermes-update", None)
web_server._ACTION_IDS.pop("hermes-update", None)
_web_server_gateway._ACTION_PROCS.pop("hermes-update", None)
_web_server_gateway._ACTION_IDS.pop("hermes-update", None)
assert resp.status_code == 200
assert resp.json() == {
@@ -1422,11 +1435,7 @@ class TestWebServerEndpoints:
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,
)
from hermes_cli.web_server_messaging 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"]
@@ -1580,8 +1589,8 @@ class TestWebServerEndpoints:
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()
with _web_server_messaging._telegram_onboarding_lock:
_web_server_messaging._telegram_onboarding_pairings.clear()
def fake_request(method, path, *, body=None, bearer_token=None):
if method == "POST":
@@ -1603,15 +1612,15 @@ class TestWebServerEndpoints:
"token": "123456:SECRET",
}
monkeypatch.setattr(ws, "_telegram_onboarding_request_sync", fake_request)
ws._ACTION_PROCS.pop("gateway-restart", None)
monkeypatch.setattr(_web_server_messaging, "_telegram_onboarding_request_sync", fake_request)
_web_server_gateway._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)
monkeypatch.setattr(_web_server_gateway, "_spawn_hermes_action", fail_spawn_action)
start = self.client.post("/api/messaging/telegram/onboarding/start", json={})
assert start.status_code == 200
@@ -1669,7 +1678,7 @@ class TestWebServerEndpoints:
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
from hermes_cli.web_server_profiles import _parse_model_ids
class FakeResp:
def __init__(self, payload, ok=True):
@@ -2408,7 +2417,7 @@ class TestBuildSchemaFromConfig:
def test_overrides_applied(self):
from hermes_cli.web_server import CONFIG_SCHEMA
from hermes_cli.web_server_config import CONFIG_SCHEMA
# terminal.backend should be a select with options
if "terminal.backend" in CONFIG_SCHEMA:
entry = CONFIG_SCHEMA["terminal.backend"]
@@ -2433,7 +2442,7 @@ class TestBuildSchemaFromConfig:
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
from hermes_cli.web_server_config import CONFIG_SCHEMA, _timezone_options
entry = CONFIG_SCHEMA["timezone"]
assert entry["type"] == "select"
@@ -2456,19 +2465,19 @@ class TestBuildSchemaFromConfig:
"""
from hermes_cli import web_server
monkeypatch.setattr(web_server, "load_config", lambda: {"memory": {"provider": "honcho"}})
monkeypatch.setattr(_cfg_mod, "load_config", lambda: {"memory": {"provider": "honcho"}})
monkeypatch.setattr(
web_server,
_web_server_config,
"_memory_provider_options",
lambda: ["", "honcho", "hindsight", "freshly_installed"],
)
fields = web_server._schema_with_dynamic_provider_options()
fields = _web_server_config._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"]
assert _web_server_config.CONFIG_SCHEMA["memory.provider"] is not fields["memory.provider"]
@@ -2478,7 +2487,7 @@ class TestBuildSchemaFromConfig:
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 hermes_cli.web_server_config import CONFIG_SCHEMA
from collections import Counter
cats = Counter(e["category"] for e in CONFIG_SCHEMA.values())
for cat, count in cats.items():
@@ -2964,7 +2973,7 @@ class TestNewEndpoints:
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)
monkeypatch.setattr(shutil, "which", lambda name: None)
config = load_config()
config.setdefault("terminal", {})
config["terminal"]["ssh_host"] = "devbox.example.com"
@@ -3146,7 +3155,7 @@ class TestModelContextLength:
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
from hermes_cli.web_server_config import _normalize_config_for_web
cfg = {
"model": {
@@ -3161,7 +3170,7 @@ class TestModelContextLength:
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
from hermes_cli.web_server_config import _normalize_config_for_web
result = _normalize_config_for_web({"model": "anthropic/claude-sonnet-4"})
assert result["model"] == "anthropic/claude-sonnet-4"
@@ -3170,7 +3179,7 @@ class TestModelContextLength:
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.web_server_config import _denormalize_config_from_web
from hermes_cli.config import save_config
# Set up disk config with model as a dict
@@ -3190,7 +3199,7 @@ class TestModelContextLength:
"""The Settings autosave now sends a diff, not the full draft: editing
only the Context Window control must not omit ``model`` and thereby
drop the context_length edit on the floor (#89597 review)."""
from hermes_cli.web_server import _denormalize_config_from_web
from hermes_cli.web_server_config import _denormalize_config_from_web
from hermes_cli.config import save_config
save_config({
@@ -3212,7 +3221,7 @@ class TestModelContextLength:
to the diff-omission bug rather than the separate, pre-existing (and
intentional, see ``_apply_main_model_assignment``) behavior where a
real provider switch drops the context_length override."""
from hermes_cli.web_server import _denormalize_config_from_web
from hermes_cli.web_server_config import _denormalize_config_from_web
from hermes_cli.config import save_config
save_config({
@@ -3232,7 +3241,7 @@ class TestDenormalizeProviderSwitch:
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.web_server_config import _denormalize_config_from_web
from hermes_cli.config import save_config
save_config({
@@ -3255,7 +3264,7 @@ class TestDenormalizeProviderSwitch:
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.web_server_config import _denormalize_config_from_web
from hermes_cli.config import save_config
save_config({"model": {"default": "llama3.2", "provider": "ollama-local"}})
@@ -3275,13 +3284,13 @@ class TestModelContextLengthSchema:
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
from hermes_cli.web_server_config 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
from hermes_cli.web_server_config import CONFIG_SCHEMA
entry = CONFIG_SCHEMA["model_context_length"]
assert entry["type"] == "number"
assert "category" in entry
@@ -3303,7 +3312,7 @@ class TestModelInfoEndpoint:
def test_model_info_with_dict_config(self, monkeypatch):
import hermes_cli.web_server as ws
monkeypatch.setattr(ws, "load_config", lambda: {
monkeypatch.setattr(_cfg_mod, "load_config", lambda: {
"model": {
"default": "anthropic/claude-opus-4.6",
"provider": "openrouter",
@@ -3326,7 +3335,7 @@ class TestModelInfoEndpoint:
"""Endpoint should return zeros on import/resolution errors, not 500."""
import hermes_cli.web_server as ws
monkeypatch.setattr(ws, "load_config", lambda: {
monkeypatch.setattr(_cfg_mod, "load_config", lambda: {
"model": "some/obscure-model"
})
@@ -3361,7 +3370,7 @@ class TestProbeGatewayHealth:
monkeypatch.setattr(ws.urllib.request, "urlopen", mock_urlopen)
alive, body = ws._probe_gateway_health()
alive, body = _web_server_gateway._probe_gateway_health()
assert alive is False
assert body is None
@@ -3390,7 +3399,7 @@ class TestProbeGatewayHealth:
return mock_resp
monkeypatch.setattr(ws.urllib.request, "urlopen", mock_urlopen)
alive, body = ws._probe_gateway_health()
alive, body = _web_server_gateway._probe_gateway_health()
assert alive is True
assert body["status"] == "ok"
assert call_count[0] == 2
@@ -3414,10 +3423,10 @@ class TestStatusRemoteGateway:
"""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(_gw_status, "get_running_pid_cached", lambda: None)
monkeypatch.setattr(_gw_status, "read_runtime_status", lambda: None)
monkeypatch.setattr(ws, "_GATEWAY_HEALTH_URL", "http://gw:8642")
monkeypatch.setattr(ws, "_probe_gateway_health", lambda: (True, {
monkeypatch.setattr(_web_server_gateway, "_probe_gateway_health", lambda: (True, {
"status": "ok",
"gateway_state": "running",
"platforms": {"telegram": {"state": "connected"}},
@@ -3437,20 +3446,20 @@ class TestStatusRemoteGateway:
"""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: {
monkeypatch.setattr(_gw_status, "get_running_pid_cached", lambda: 1234)
monkeypatch.setattr(_gw_status, "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
original = _web_server_gateway._probe_gateway_health
def track_probe():
probe_called[0] = True
return original()
monkeypatch.setattr(ws, "_probe_gateway_health", track_probe)
monkeypatch.setattr(_web_server_gateway, "_probe_gateway_health", track_probe)
resp = self.client.get("/api/status")
assert resp.status_code == 200
@@ -3461,10 +3470,10 @@ class TestStatusRemoteGateway:
"""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(_gw_status, "get_running_pid_cached", lambda: None)
monkeypatch.setattr(_gw_status, "read_runtime_status", lambda: None)
monkeypatch.setattr(ws, "_GATEWAY_HEALTH_URL", "http://gw:8642")
monkeypatch.setattr(ws, "_probe_gateway_health", lambda: (True, {
monkeypatch.setattr(_web_server_gateway, "_probe_gateway_health", lambda: (True, {
"status": "ok",
}))
@@ -3506,8 +3515,8 @@ class TestStatusInstallId:
import hermes_cli.web_server as ws
from hermes_constants import get_default_hermes_root
monkeypatch.setattr(ws, "get_running_pid_cached", lambda: None)
monkeypatch.setattr(ws, "read_runtime_status", lambda: None)
monkeypatch.setattr(_gw_status, "get_running_pid_cached", lambda: None)
monkeypatch.setattr(_gw_status, "read_runtime_status", lambda: None)
first = self.client.get("/api/status")
assert first.status_code == 200
@@ -3595,8 +3604,8 @@ class TestGatewayBusyReadout:
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: {
monkeypatch.setattr(_gw_status, "get_running_pid_cached", lambda: 1234)
monkeypatch.setattr(_gw_status, "read_runtime_status", lambda: {
"gateway_state": "draining",
"platforms": {},
"active_agents": 3,
@@ -3612,8 +3621,8 @@ class TestGatewayBusyReadout:
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: {
monkeypatch.setattr(_gw_status, "get_running_pid_cached", lambda: 1234)
monkeypatch.setattr(_gw_status, "read_runtime_status", lambda: {
"gateway_state": "running",
"platforms": {},
"active_agents": "garbage",
@@ -3718,8 +3727,8 @@ class TestGatewayUpdatedAtContract:
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: {
monkeypatch.setattr(_gw_status, "get_running_pid_cached", lambda: 1234)
monkeypatch.setattr(_gw_status, "read_runtime_status", lambda: {
"gateway_state": "running",
"platforms": {},
"active_agents": 0,
@@ -3739,10 +3748,10 @@ class TestGatewayUpdatedAtContract:
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(_gw_status, "get_running_pid_cached", lambda: None)
monkeypatch.setattr(_gw_status, "read_runtime_status", lambda: None)
monkeypatch.setattr(ws, "_GATEWAY_HEALTH_URL", "http://gw:8642")
monkeypatch.setattr(ws, "_probe_gateway_health", lambda: (True, {
monkeypatch.setattr(_web_server_gateway, "_probe_gateway_health", lambda: (True, {
"status": "ok",
"gateway_state": "running",
"platforms": {},
@@ -3769,14 +3778,14 @@ class TestNormaliseThemeDefinition:
def test_rejects_non_dict(self):
from hermes_cli.web_server import _normalise_theme_definition
from hermes_cli.web_server_dashboard 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
from hermes_cli.web_server_dashboard import _normalise_theme_definition
result = _normalise_theme_definition({
"name": "loose",
"colors": {"background": "#000000", "midground": "#ffffff"},
@@ -3798,7 +3807,7 @@ class TestDiscoverUserThemes:
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() == []
assert _web_server_dashboard._discover_user_themes() == []
def test_loads_and_normalises_yaml(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
@@ -3815,7 +3824,7 @@ class TestDiscoverUserThemes:
" density: spacious\n"
)
from hermes_cli import web_server
results = web_server._discover_user_themes()
results = _web_server_dashboard._discover_user_themes()
assert len(results) == 1
assert results[0]["name"] == "ocean"
assert results[0]["label"] == "Ocean"
@@ -3842,7 +3851,7 @@ class TestDiscoverUserThemes:
token = set_hermes_home_override(str(other))
try:
results = web_server._discover_user_themes()
results = _web_server_dashboard._discover_user_themes()
finally:
reset_hermes_home_override(token)
@@ -3879,9 +3888,9 @@ class TestThemeBootstrapCSS:
self._write_theme(tmp_path)
from hermes_cli import web_server
monkeypatch.setattr(
web_server, "load_config", lambda: {"dashboard": {"theme": "ocean"}}
_cfg_mod, "load_config", lambda: {"dashboard": {"theme": "ocean"}}
)
css = web_server._render_active_theme_bootstrap_css()
css = _web_server_dashboard._render_active_theme_bootstrap_css()
assert css.startswith('<style id="hermes-theme-bootstrap">')
assert css.endswith("</style>")
# Real bundle tokens (web/src/themes/context.tsx + index.css).
@@ -3920,7 +3929,7 @@ class TestThemeBootstrapCSS:
)
monkeypatch.setattr(ws, "WEB_DIST", dist)
spa_app = FastAPI()
ws.mount_spa(spa_app)
_web_server_dashboard.mount_spa(spa_app)
return TestClient(spa_app)
def test_serve_index_injects_bootstrap_for_user_theme(self, tmp_path, monkeypatch):
@@ -3928,7 +3937,7 @@ class TestThemeBootstrapCSS:
self._write_theme(tmp_path)
import hermes_cli.web_server as ws
monkeypatch.setattr(
ws, "load_config", lambda: {"dashboard": {"theme": "ocean"}}
_cfg_mod, "load_config", lambda: {"dashboard": {"theme": "ocean"}}
)
client = self._mount_spa_client(tmp_path, monkeypatch)
resp = client.get("/chat")
@@ -3952,7 +3961,7 @@ class TestNormaliseThemeExtensions:
def test_custom_css_passthrough_and_capped(self):
from hermes_cli.web_server import _normalise_theme_definition
from hermes_cli.web_server_dashboard import _normalise_theme_definition
# Small CSS passes through verbatim.
r = _normalise_theme_definition({
"name": "t",
@@ -3967,7 +3976,7 @@ class TestNormaliseThemeExtensions:
def test_component_styles_per_bucket(self):
from hermes_cli.web_server import _normalise_theme_definition
from hermes_cli.web_server_dashboard import _normalise_theme_definition
r = _normalise_theme_definition({
"name": "t",
"componentStyles": {
@@ -4416,7 +4425,7 @@ class TestDashboardPluginManifestExtensions:
from hermes_cli import web_server
token = set_hermes_home_override(str(other))
try:
plugins = web_server._discover_dashboard_plugins()
plugins = _web_server_dashboard._discover_dashboard_plugins()
finally:
reset_hermes_home_override(token)
assert any(p["name"] == "skin-home" for p in plugins)
@@ -4437,7 +4446,7 @@ class TestDashboardPluginManifestExtensions:
monkeypatch.setenv("HERMES_HOME", str(profile_home))
from hermes_cli import web_server
plugins = web_server._discover_dashboard_plugins()
plugins = _web_server_dashboard._discover_dashboard_plugins()
assert any(p["name"] == "meeting-intelligence" for p in plugins)
def test_profile_local_plugin_wins_over_root_plugin(self, tmp_path, monkeypatch):
@@ -4461,7 +4470,7 @@ class TestDashboardPluginManifestExtensions:
monkeypatch.setenv("HERMES_HOME", str(profile_home))
from hermes_cli import web_server
plugins = web_server._discover_dashboard_plugins()
plugins = _web_server_dashboard._discover_dashboard_plugins()
entries = [p for p in plugins if p["name"] == "dupe"]
assert len(entries) == 1
assert entries[0]["tab"]["path"] == "/from-profile"
@@ -4479,6 +4488,7 @@ class TestDashboardPluginManifestExtensions:
# ---------------------------------------------------------------------------
import sys
from hermes_cli import main_tui_launch
skip_on_windows = pytest.mark.skipif(
@@ -4495,7 +4505,7 @@ class TestPtyWebSocket:
import hermes_cli.web_server as ws
# Avoid exec'ing the actual TUI in tests: every test below installs
# its own fake argv via ``ws._resolve_chat_argv``.
# its own fake argv via ``web_server_chat._resolve_chat_argv``.
self.ws_module = ws
monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True)
ws.app.state.pty_active_session_files = {}
@@ -4530,7 +4540,7 @@ class TestPtyWebSocket:
"PATH": str(bin_dir),
}
main_mod._apply_tui_python_env(env)
main_tui_launch._apply_tui_python_env(env)
assert env["HERMES_PYTHON"] == command
@@ -4553,11 +4563,11 @@ class TestPtyWebSocket:
captured["thread_kwargs"] = kwargs
return fn(*args, **kwargs)
monkeypatch.setattr(self.ws_module, "_resolve_chat_argv", fake_resolve)
monkeypatch.setattr(self.ws_module.asyncio, "to_thread", fake_to_thread)
monkeypatch.setattr(_web_server_chat, "_resolve_chat_argv", fake_resolve)
monkeypatch.setattr(_web_server_chat.asyncio, "to_thread", fake_to_thread)
argv, cwd, env = asyncio.run(
self.ws_module._resolve_chat_argv_async(
_web_server_chat._resolve_chat_argv_async(
resume="sess-42",
sidecar_url="ws://127.0.0.1:9119/api/pub?channel=abc",
profile="worker",
@@ -4588,7 +4598,7 @@ class TestPtyWebSocket:
from starlette.websockets import WebSocketDisconnect
# Patch the REAL resolver so the whole wrapper/to_thread/lock chain runs.
monkeypatch.setattr(self.ws_module, "_resolve_chat_argv", raising_resolver)
monkeypatch.setattr(_web_server_chat, "_resolve_chat_argv", raising_resolver)
url = self._url(profile=profile) if profile else self._url()
with self.client.websocket_connect(url) as conn:
@@ -4612,14 +4622,12 @@ class TestPtyWebSocket:
raise PtyUnavailableError("pty missing for tests")
monkeypatch.setattr(
self.ws_module,
_web_server_chat,
"_resolve_chat_argv",
lambda resume=None, sidecar_url=None, profile=None: (["/bin/cat"], None, None),
)
# Patch PtyBridge.spawn at the web_server module's binding.
import hermes_cli.web_server as ws_mod
monkeypatch.setattr(ws_mod.PtyBridge, "spawn", classmethod(lambda cls, *a, **k: _raise(*a, **k)))
# Patch PtyBridge.spawn at the web_server_chat module's binding.
monkeypatch.setattr(_web_server_chat.PtyBridge, "spawn", classmethod(lambda cls, *a, **k: _raise(*a, **k)))
with self.client.websocket_connect(self._url()) as conn:
# Expect a final text frame with the error message, then close.
@@ -4660,7 +4668,7 @@ class TestPtyWebSocket:
sub_other = _FakeSub()
frame = '{"type":"tool.start","payload":{"tool_id":"t1"}}'
event_channels, event_lock = ws_mod._get_event_state(app)
event_channels, event_lock = _rt_chat_ws._get_event_state(app)
# Register two subscribers on the target channel and one on a
# different channel, exactly as the /api/events handler does.
async with event_lock:
@@ -4669,7 +4677,7 @@ class TestPtyWebSocket:
)
event_channels.setdefault("other-channel", set()).add(sub_other)
try:
await ws_mod._broadcast_event(app, "broadcast-test", frame)
await _rt_chat_ws._broadcast_event(app, "broadcast-test", frame)
finally:
async with event_lock:
event_channels.pop("broadcast-test", None)
@@ -4698,7 +4706,7 @@ def test_resolve_chat_argv_injects_gateway_ws_url(monkeypatch):
monkeypatch.setattr(ws.app.state, "bound_host", "127.0.0.1", raising=False)
monkeypatch.setattr(ws.app.state, "bound_port", 9119, raising=False)
_argv, _cwd, env = ws._resolve_chat_argv()
_argv, _cwd, env = _web_server_chat._resolve_chat_argv()
assert env is not None
gateway_url = env.get("HERMES_TUI_GATEWAY_URL", "")
@@ -5026,7 +5034,7 @@ class TestServeIndexMissingIndex:
monkeypatch.setattr(ws, "WEB_DIST", dist)
monkeypatch.delenv("HERMES_SERVE_HEADLESS", raising=False)
spa_app = FastAPI()
ws.mount_spa(spa_app)
_web_server_dashboard.mount_spa(spa_app)
return TestClient(spa_app), dist
def test_missing_index_inside_existing_dist_returns_json_404(
@@ -5079,7 +5087,7 @@ class TestHeadlessServeTokenPage:
monkeypatch.setenv("HERMES_SERVE_HEADLESS", "1")
spa_app = FastAPI()
spa_app.state.auth_required = gated
ws.mount_spa(spa_app)
_web_server_dashboard.mount_spa(spa_app)
return TestClient(spa_app), ws
def test_root_serves_token_page_when_not_gated(self, monkeypatch):
@@ -5147,7 +5155,7 @@ class TestHashedAssetCacheHeaders:
monkeypatch.setattr(ws, "WEB_DIST", dist)
monkeypatch.delenv("HERMES_SERVE_HEADLESS", raising=False)
spa_app = FastAPI()
ws.mount_spa(spa_app)
_web_server_dashboard.mount_spa(spa_app)
return TestClient(spa_app)
def test_hashed_js_asset_is_immutable(self, tmp_path, monkeypatch):
@@ -5353,7 +5361,7 @@ def test_mount_spa_dynamic_web_dist_recheck(tmp_path, monkeypatch):
dist = tmp_path / "web_dist"
monkeypatch.setattr(web_server, "WEB_DIST", dist)
web_server.mount_spa(app)
_web_server_dashboard.mount_spa(app)
client = TestClient(app)
# 1. missing build -> 404
@@ -14,6 +14,7 @@ same contract.
import types
import pytest
import hermes_cli.web_server_profiles as _web_server_profiles
@pytest.fixture
@@ -117,7 +118,7 @@ class TestApprovalsSaveBroadcast:
profile_dir = tmp_path / "profiles" / "other"
profile_dir.mkdir(parents=True)
monkeypatch.setattr(web_server, "_resolve_profile_dir", lambda name: profile_dir)
monkeypatch.setattr(_web_server_profiles, "_resolve_profile_dir", lambda name: profile_dir)
resp = client.put(
"/api/config",
@@ -32,6 +32,7 @@ from unittest.mock import patch
import pytest
import hermes_cli.web_server as web_server_mod
import hermes_cli.web_server_lifecycle as _web_server_lifecycle
SLOW_SECONDS = 1 # represents the Defender worst-case (scaled down for CI speed)
@@ -149,7 +150,7 @@ def test_get_status_does_not_block_event_loop():
tg.create_task(_version())
with patch.object(
web_server_mod, "_resolve_restart_drain_timeout", _make_slow_drain(SLOW_SECONDS)
_web_server_lifecycle, "_resolve_restart_drain_timeout", _make_slow_drain(SLOW_SECONDS)
):
asyncio.run(_run())
@@ -204,7 +205,7 @@ def test_concurrent_status_probes_all_respond():
responses.append(r.status_code)
with patch.object(
web_server_mod, "_resolve_restart_drain_timeout", _make_slow_drain(SLOW_SECONDS)
_web_server_lifecycle, "_resolve_restart_drain_timeout", _make_slow_drain(SLOW_SECONDS)
):
asyncio.run(_run())
@@ -12,6 +12,8 @@ import threading
import time
import pytest
import hermes_cli.config as _cfg_mod
import hermes_cli.web_server_profiles as _web_server_profiles
class TestGetConfigOffLoop:
@@ -51,7 +53,7 @@ class TestGetConfigOffLoop:
acquired = threading.Event()
def _holder():
with web_server._SKILLS_PROFILE_LOCK:
with _web_server_profiles._SKILLS_PROFILE_LOCK:
acquired.set()
release.wait(hold_s)
@@ -131,7 +133,7 @@ class TestRouterOffLoop:
acquired = threading.Event()
def _holder():
with web_server._SKILLS_PROFILE_LOCK:
with _web_server_profiles._SKILLS_PROFILE_LOCK:
acquired.set()
release.wait(hold_s)
@@ -220,7 +222,7 @@ class TestConfigMutationLock:
# Widen the race window: make save_config slow so an unserialized
# interleave is near-certain, not just possible.
real_save = web_server.save_config
real_save = _cfg_mod.save_config
def _slow_save(cfg, **kwargs):
time.sleep(0.15)
@@ -228,7 +230,7 @@ class TestConfigMutationLock:
threads = []
try:
web_server.save_config = _slow_save
_cfg_mod.save_config = _slow_save
threads = [
threading.Thread(target=_put_theme),
threading.Thread(target=_put_font),
@@ -238,7 +240,7 @@ class TestConfigMutationLock:
finally:
for t in threads:
t.join()
web_server.save_config = real_save
_cfg_mod.save_config = real_save
assert all(code == 200 for _, code in results), results
cfg = load_config()
@@ -7,6 +7,9 @@ import threading
import pytest
from fastapi import HTTPException
import hermes_cli.web_models as _web_models
import hermes_cli.web_routers.cron as _rt_cron
import hermes_cli.web_server_cron as _web_server_cron
@pytest.fixture()
@@ -71,7 +74,7 @@ def test_fire_cron_job_scopes_store_and_runtime_home_together(
outer_token = set_hermes_home_override(default_home)
try:
assert web_server._fire_cron_job_for_profile("worker_alpha", "worker-job") is True
assert _web_server_cron._fire_cron_job_for_profile("worker_alpha", "worker-job") is True
assert captured == {
"job_id": "worker-job",
"runtime_home": worker_home,
@@ -113,7 +116,7 @@ def test_create_registers_scheduler_inside_target_profile(
lambda: RecordingProvider(),
)
job = web_server._call_cron_for_profile(
job = _web_server_cron._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="managed by named profile",
@@ -144,11 +147,11 @@ def test_dashboard_create_reports_saved_but_unregistered(
def fail_create(*args, **kwargs):
raise failure
monkeypatch.setattr(web_server, "_call_cron_for_profile", fail_create)
monkeypatch.setattr(_web_server_cron, "_call_cron_for_profile", fail_create)
with pytest.raises(HTTPException) as exc_info:
web_server._create_cron_job_sync(
web_server.CronJobCreate(
_web_server_cron._create_cron_job_sync(
_web_models.CronJobCreate(
prompt="managed by named profile",
schedule="every 1h",
name="named-profile-job",
@@ -185,7 +188,7 @@ def test_notify_cron_provider_scopes_store_and_runtime_home_together(
worker_home = isolated_profiles["worker_alpha"]
monkeypatch.setattr(scheduler, "_hermes_home", None)
monkeypatch.setattr(
web_server,
_web_server_cron,
"_cron_profile_dicts",
lambda: [{"name": "worker_alpha"}],
)
@@ -203,7 +206,7 @@ def test_notify_cron_provider_scopes_store_and_runtime_home_together(
outer_token = set_hermes_home_override(default_home)
try:
web_server._notify_cron_provider_for_profile("worker_alpha")
_web_server_cron._notify_cron_provider_for_profile("worker_alpha")
assert captured == {
"runtime_home": worker_home,
"jobs_file": worker_home / "cron" / "jobs.json",
@@ -235,7 +238,7 @@ def test_notify_cron_provider_failure_is_best_effort(
lambda: FailNotifyProvider(),
)
created = web_server._mutate_cron_for_profile(
created = _web_server_cron._mutate_cron_for_profile(
"worker_alpha",
"create_job",
prompt="survives provider failure",
@@ -260,7 +263,7 @@ def test_external_provider_reconcile_fails_closed_with_multiple_profiles(
monkeypatch.setattr(scheduler, "_hermes_home", None)
monkeypatch.setattr(
web_server,
_web_server_cron,
"_cron_profile_dicts",
lambda: [{"name": "default"}, {"name": "worker_alpha"}],
)
@@ -282,7 +285,7 @@ def test_external_provider_reconcile_fails_closed_with_multiple_profiles(
lambda: ExternalProvider(),
)
created = web_server._mutate_cron_for_profile(
created = _web_server_cron._mutate_cron_for_profile(
"worker_alpha",
"create_job",
prompt="must not disarm siblings",
@@ -309,7 +312,7 @@ def test_builtin_provider_hook_still_fires_with_multiple_profiles(
monkeypatch.setattr(scheduler, "_hermes_home", None)
monkeypatch.setattr(
web_server,
_web_server_cron,
"_cron_profile_dicts",
lambda: [{"name": "default"}, {"name": "worker_alpha"}],
)
@@ -324,7 +327,7 @@ def test_builtin_provider_hook_still_fires_with_multiple_profiles(
lambda: BuiltinProbe(),
)
created = web_server._mutate_cron_for_profile(
created = _web_server_cron._mutate_cron_for_profile(
"worker_alpha",
"create_job",
prompt="builtin notify",
@@ -405,7 +408,7 @@ def test_profile_call_cannot_retarget_ticker_store_mid_write(
ticker_future = pool.submit(run_ticker_write)
assert ticker_loaded.wait(5), "ticker did not load the default store"
profile_future = pool.submit(
web_server._call_cron_for_profile,
_web_server_cron._call_cron_for_profile,
"worker_alpha",
"_hold_profile_call",
)
@@ -429,7 +432,7 @@ def test_profile_call_cannot_retarget_ticker_store_mid_write(
async def test_cron_mutation_without_profile_finds_named_profile_job(isolated_profiles):
from hermes_cli import web_server
worker_job = web_server._call_cron_for_profile(
worker_job = _web_server_cron._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="managed by named profile",
@@ -437,12 +440,12 @@ async def test_cron_mutation_without_profile_finds_named_profile_job(isolated_pr
name="named-profile-job",
)
paused = await web_server.pause_cron_job(worker_job["id"])
paused = await _rt_cron.pause_cron_job(worker_job["id"])
assert paused["profile"] == "worker_alpha"
assert paused["enabled"] is False
default_jobs = await web_server.list_cron_jobs(profile="default")
worker_jobs = await web_server.list_cron_jobs(profile="worker_alpha")
default_jobs = await _rt_cron.list_cron_jobs(profile="default")
worker_jobs = await _rt_cron.list_cron_jobs(profile="worker_alpha")
assert default_jobs == []
assert len(worker_jobs) == 1
@@ -459,27 +462,27 @@ async def test_dashboard_cron_mutations_notify_selected_profile_provider(
notified_profiles = []
monkeypatch.setattr(
web_server,
_web_server_cron,
"_notify_cron_provider_for_profile",
notified_profiles.append,
)
created = await web_server.create_cron_job(
web_server.CronJobCreate(
created = await _rt_cron.create_cron_job(
_web_models.CronJobCreate(
prompt="managed by named profile",
schedule="every 1h",
name="provider-notify-job",
),
profile="worker_alpha",
)
await web_server.update_cron_job(
await _rt_cron.update_cron_job(
created["id"],
web_server.CronJobUpdate(updates={"name": "provider-notify-job-updated"}),
_web_models.CronJobUpdate(updates={"name": "provider-notify-job-updated"}),
profile="worker_alpha",
)
await web_server.pause_cron_job(created["id"], profile="worker_alpha")
await web_server.resume_cron_job(created["id"], profile="worker_alpha")
await web_server.delete_cron_job(created["id"], profile="worker_alpha")
await _rt_cron.pause_cron_job(created["id"], profile="worker_alpha")
await _rt_cron.resume_cron_job(created["id"], profile="worker_alpha")
await _rt_cron.delete_cron_job(created["id"], profile="worker_alpha")
assert notified_profiles == ["worker_alpha"] * 5
@@ -493,13 +496,13 @@ async def test_blueprint_instantiation_notifies_selected_profile_provider(
notified_profiles = []
monkeypatch.setattr(
web_server,
_web_server_cron,
"_notify_cron_provider_for_profile",
notified_profiles.append,
)
created = await web_server.instantiate_blueprint(
web_server.AutomationBlueprintInstantiate(
created = await _rt_cron.instantiate_blueprint(
_web_models.AutomationBlueprintInstantiate(
blueprint="morning-brief",
values={"time": "07:30", "deliver": "local"},
),
@@ -518,14 +521,14 @@ async def test_trigger_cron_job_fires_only_selected_job_and_returns_refreshed_st
from cron import jobs as cron_jobs
from hermes_cli import web_server
selected = web_server._call_cron_for_profile(
selected = _web_server_cron._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="run immediately",
schedule="every 1h",
name="selected-trigger-job",
)
sibling = web_server._call_cron_for_profile(
sibling = _web_server_cron._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="leave scheduled",
@@ -558,7 +561,7 @@ async def test_trigger_cron_job_fires_only_selected_job_and_returns_refreshed_st
),
)
triggered = await web_server.trigger_cron_job(
triggered = await _rt_cron.trigger_cron_job(
selected["id"],
profile="worker_alpha",
)
@@ -572,7 +575,7 @@ async def test_trigger_cron_job_fires_only_selected_job_and_returns_refreshed_st
]
assert triggered["last_status"] == "ok"
assert triggered["last_run_at"] is not None
untouched = web_server._call_cron_for_profile(
untouched = _web_server_cron._call_cron_for_profile(
"worker_alpha",
"get_job",
sibling["id"],
@@ -587,7 +590,7 @@ async def test_trigger_cron_job_reports_lost_claim_as_conflict(
):
from hermes_cli import web_server
job = web_server._call_cron_for_profile(
job = _web_server_cron._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="already running",
@@ -605,7 +608,7 @@ async def test_trigger_cron_job_reports_lost_claim_as_conflict(
)
with pytest.raises(HTTPException) as exc:
await web_server.trigger_cron_job(job["id"], profile="worker_alpha")
await _rt_cron.trigger_cron_job(job["id"], profile="worker_alpha")
assert exc.value.status_code == 409
assert "already running" in exc.value.detail
@@ -619,14 +622,14 @@ async def test_trigger_cron_job_forces_paused_job_atomically(
from cron import jobs as cron_jobs
from hermes_cli import web_server
job = web_server._call_cron_for_profile(
job = _web_server_cron._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="resume me",
schedule="every 1h",
name="paused-trigger-job",
)
web_server._call_cron_for_profile("worker_alpha", "pause_job", job["id"])
_web_server_cron._call_cron_for_profile("worker_alpha", "pause_job", job["id"])
observed = {}
class ForceProvider:
@@ -641,7 +644,7 @@ async def test_trigger_cron_job_forces_paused_job_atomically(
lambda: ForceProvider(),
)
triggered = await web_server.trigger_cron_job(
triggered = await _rt_cron.trigger_cron_job(
job["id"],
profile="worker_alpha",
)
@@ -660,14 +663,14 @@ async def test_trigger_paused_job_rejects_legacy_provider_without_mutating_job(
from fastapi import HTTPException
from hermes_cli import web_server
job = web_server._call_cron_for_profile(
job = _web_server_cron._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="stay paused",
schedule="every 1h",
name="legacy-paused-trigger-job",
)
web_server._call_cron_for_profile("worker_alpha", "pause_job", job["id"])
_web_server_cron._call_cron_for_profile("worker_alpha", "pause_job", job["id"])
calls = []
class LegacyProvider:
@@ -681,12 +684,12 @@ async def test_trigger_paused_job_rejects_legacy_provider_without_mutating_job(
)
with pytest.raises(HTTPException) as exc:
await web_server.trigger_cron_job(job["id"], profile="worker_alpha")
await _rt_cron.trigger_cron_job(job["id"], profile="worker_alpha")
assert exc.value.status_code == 409
assert "forced" in exc.value.detail.lower()
assert calls == []
persisted = web_server._call_cron_for_profile(
persisted = _web_server_cron._call_cron_for_profile(
"worker_alpha",
"get_job",
job["id"],
@@ -703,7 +706,7 @@ async def test_trigger_cron_job_returns_refreshed_execution_failure(
from cron import jobs as cron_jobs
from hermes_cli import web_server
job = web_server._call_cron_for_profile(
job = _web_server_cron._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="fail visibly",
@@ -721,7 +724,7 @@ async def test_trigger_cron_job_returns_refreshed_execution_failure(
lambda: FailedProvider(),
)
triggered = await web_server.trigger_cron_job(
triggered = await _rt_cron.trigger_cron_job(
job["id"],
profile="worker_alpha",
)
@@ -738,7 +741,7 @@ async def test_trigger_cron_job_returns_completed_snapshot_for_retained_oneshot(
from cron import jobs as cron_jobs
from hermes_cli import web_server
job = web_server._call_cron_for_profile(
job = _web_server_cron._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="run once",
@@ -756,7 +759,7 @@ async def test_trigger_cron_job_returns_completed_snapshot_for_retained_oneshot(
lambda: SuccessfulProvider(),
)
triggered = await web_server.trigger_cron_job(
triggered = await _rt_cron.trigger_cron_job(
job["id"],
profile="worker_alpha",
)
@@ -768,7 +771,7 @@ async def test_trigger_cron_job_returns_completed_snapshot_for_retained_oneshot(
# record, not a synthetic pre-removal snapshot.
assert triggered["last_status"] == "ok"
assert triggered["last_run_at"] is not None
retained = web_server._call_cron_for_profile(
retained = _web_server_cron._call_cron_for_profile(
"worker_alpha",
"get_job",
job["id"],
@@ -781,7 +784,7 @@ async def test_trigger_cron_job_returns_completed_snapshot_for_retained_oneshot(
async def test_cron_profile_scan_runs_off_event_loop(isolated_profiles, monkeypatch):
from hermes_cli import web_server
worker_job = web_server._call_cron_for_profile(
worker_job = _web_server_cron._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="managed by named profile",
@@ -792,8 +795,8 @@ async def test_cron_profile_scan_runs_off_event_loop(isolated_profiles, monkeypa
event_loop_thread = threading.get_ident()
profile_scan_threads = SimpleQueue()
worker_threads = SimpleQueue()
original_profile_dicts = web_server._cron_profile_dicts
original_find = web_server._find_cron_job_profile
original_profile_dicts = _web_server_cron._cron_profile_dicts
original_find = _web_server_cron._find_cron_job_profile
def tracking_profile_dicts():
profile_scan_threads.put(threading.get_ident())
@@ -803,11 +806,11 @@ async def test_cron_profile_scan_runs_off_event_loop(isolated_profiles, monkeypa
worker_threads.put(threading.get_ident())
return original_find(job_id)
monkeypatch.setattr(web_server, "_cron_profile_dicts", tracking_profile_dicts)
monkeypatch.setattr(web_server, "_find_cron_job_profile", tracking_find)
monkeypatch.setattr(_web_server_cron, "_cron_profile_dicts", tracking_profile_dicts)
monkeypatch.setattr(_web_server_cron, "_find_cron_job_profile", tracking_find)
jobs = await web_server.list_cron_jobs(profile="all")
paused = await web_server.pause_cron_job(worker_job["id"])
jobs = await _rt_cron.list_cron_jobs(profile="all")
paused = await _rt_cron.pause_cron_job(worker_job["id"])
assert any(job["id"] == worker_job["id"] for job in jobs)
assert paused["profile"] == "worker_alpha"
@@ -827,7 +830,7 @@ async def test_cron_dashboard_io_rejects_async_callables():
return "nope"
with pytest.raises(TypeError, match="only accepts sync callables"):
await web_server._run_cron_dashboard_io(async_callable)
await _web_server_cron._run_cron_dashboard_io(async_callable)
@@ -838,7 +841,7 @@ async def test_update_cron_job_normalizes_dashboard_core_fields(isolated_profile
scripts_dir = isolated_profiles["worker_alpha"] / "scripts"
scripts_dir.mkdir()
(scripts_dir / "collect.py").write_text("print('ok')\n", encoding="utf-8")
job = web_server._call_cron_for_profile(
job = _web_server_cron._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="managed by named profile",
@@ -846,9 +849,9 @@ async def test_update_cron_job_normalizes_dashboard_core_fields(isolated_profile
name="normalizes-dashboard-fields",
)
updated = await web_server.update_cron_job(
updated = await _rt_cron.update_cron_job(
job["id"],
web_server.CronJobUpdate(
_web_models.CronJobUpdate(
updates={
"base_url": "https://example.invalid/v1/",
"script": str(scripts_dir / "collect.py"),
@@ -875,8 +878,8 @@ async def test_create_cron_job_rejects_script_outside_profile_scripts(
outside.write_text("print('nope')\n", encoding="utf-8")
with pytest.raises(HTTPException) as exc:
await web_server.create_cron_job(
web_server.CronJobCreate(
await _rt_cron.create_cron_job(
_web_models.CronJobCreate(
schedule="every 1h",
script=str(outside),
no_agent=True,
@@ -893,8 +896,8 @@ async def test_create_cron_job_rejects_empty_agent_job(isolated_profiles):
from hermes_cli import web_server
with pytest.raises(HTTPException) as exc:
await web_server.create_cron_job(
web_server.CronJobCreate(schedule="every 1h"),
await _rt_cron.create_cron_job(
_web_models.CronJobCreate(schedule="every 1h"),
profile="worker_alpha",
)
@@ -910,17 +913,17 @@ async def test_update_cron_job_no_agent_reuses_existing_script(isolated_profiles
scripts_dir.mkdir()
(scripts_dir / "collect.py").write_text("print('ok')\n", encoding="utf-8")
job = await web_server.create_cron_job(
web_server.CronJobCreate(
job = await _rt_cron.create_cron_job(
_web_models.CronJobCreate(
schedule="every 1h",
script=str(scripts_dir / "collect.py"),
),
profile="worker_alpha",
)
updated = await web_server.update_cron_job(
updated = await _rt_cron.update_cron_job(
job["id"],
web_server.CronJobUpdate(updates={"no_agent": True}),
_web_models.CronJobUpdate(updates={"no_agent": True}),
profile="worker_alpha",
)
@@ -933,8 +936,8 @@ async def test_dashboard_cron_rejects_missing_context_from(isolated_profiles):
from hermes_cli import web_server
with pytest.raises(HTTPException) as create_exc:
await web_server.create_cron_job(
web_server.CronJobCreate(
await _rt_cron.create_cron_job(
_web_models.CronJobCreate(
prompt="process missing upstream",
schedule="every 1h",
context_from=["missing-job-id"],
@@ -945,7 +948,7 @@ async def test_dashboard_cron_rejects_missing_context_from(isolated_profiles):
assert create_exc.value.status_code == 400
assert "missing-job-id" in create_exc.value.detail
job = web_server._call_cron_for_profile(
job = _web_server_cron._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="managed by named profile",
@@ -954,9 +957,9 @@ async def test_dashboard_cron_rejects_missing_context_from(isolated_profiles):
)
with pytest.raises(HTTPException) as update_exc:
await web_server.update_cron_job(
await _rt_cron.update_cron_job(
job["id"],
web_server.CronJobUpdate(
_web_models.CronJobUpdate(
updates={
"context_from": ["missing-job-id"],
}
@@ -986,7 +989,7 @@ async def test_dashboard_cron_noop_inference_fields_keep_existing_snapshots(
lambda **kwargs: {"provider": current_provider["name"]},
)
job = web_server._call_cron_for_profile(
job = _web_server_cron._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="managed by named profile",
@@ -1003,9 +1006,9 @@ async def test_dashboard_cron_noop_inference_fields_keep_existing_snapshots(
encoding="utf-8",
)
updated = await web_server.update_cron_job(
updated = await _rt_cron.update_cron_job(
job["id"],
web_server.CronJobUpdate(
_web_models.CronJobUpdate(
updates={
"name": "dashboard-edit-job-renamed",
"provider": None,
@@ -1038,7 +1041,7 @@ async def test_update_cron_job_clears_snapshots_for_no_agent(
scripts_dir.mkdir()
(scripts_dir / "collect.py").write_text("print('ok')\n", encoding="utf-8")
job = web_server._call_cron_for_profile(
job = _web_server_cron._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="managed by named profile",
@@ -1049,9 +1052,9 @@ async def test_update_cron_job_clears_snapshots_for_no_agent(
assert job["provider_snapshot"] == "worker-provider"
assert job["model_snapshot"] == "test-model"
updated = await web_server.update_cron_job(
updated = await _rt_cron.update_cron_job(
job["id"],
web_server.CronJobUpdate(
_web_models.CronJobUpdate(
updates={
"script": str(scripts_dir / "collect.py"),
"no_agent": True,
@@ -1072,11 +1075,11 @@ async def test_update_cron_job_rejects_id_mutation(isolated_profiles, monkeypatc
notified_profiles = []
monkeypatch.setattr(
web_server,
_web_server_cron,
"_notify_cron_provider_for_profile",
notified_profiles.append,
)
worker_job = web_server._call_cron_for_profile(
worker_job = _web_server_cron._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="managed by named profile",
@@ -1085,16 +1088,16 @@ async def test_update_cron_job_rejects_id_mutation(isolated_profiles, monkeypatc
)
with pytest.raises(HTTPException) as exc:
await web_server.update_cron_job(
await _rt_cron.update_cron_job(
worker_job["id"],
web_server.CronJobUpdate(updates={"id": "../escape"}),
_web_models.CronJobUpdate(updates={"id": "../escape"}),
profile="worker_alpha",
)
assert exc.value.status_code == 400
assert "id" in exc.value.detail
assert notified_profiles == []
worker_jobs = await web_server.list_cron_jobs(profile="worker_alpha")
worker_jobs = await _rt_cron.list_cron_jobs(profile="worker_alpha")
assert [job["id"] for job in worker_jobs] == [worker_job["id"]]
@@ -1102,14 +1105,14 @@ async def test_update_cron_job_rejects_id_mutation(isolated_profiles, monkeypatc
async def test_cron_delete_with_profile_deletes_only_target_profile(isolated_profiles):
from hermes_cli import web_server
default_job = web_server._call_cron_for_profile(
default_job = _web_server_cron._call_cron_for_profile(
"default",
"create_job",
prompt="same-ish default",
schedule="every 1h",
name="shared-name",
)
worker_job = web_server._call_cron_for_profile(
worker_job = _web_server_cron._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="same-ish worker",
@@ -1117,11 +1120,11 @@ async def test_cron_delete_with_profile_deletes_only_target_profile(isolated_pro
name="shared-name-worker",
)
deleted = await web_server.delete_cron_job(worker_job["id"], profile="worker_alpha")
deleted = await _rt_cron.delete_cron_job(worker_job["id"], profile="worker_alpha")
assert deleted == {"ok": True}
remaining_default = await web_server.list_cron_jobs(profile="default")
remaining_worker = await web_server.list_cron_jobs(profile="worker_alpha")
remaining_default = await _rt_cron.list_cron_jobs(profile="default")
remaining_worker = await _rt_cron.list_cron_jobs(profile="worker_alpha")
assert [job["id"] for job in remaining_default] == [default_job["id"]]
assert remaining_worker == []
@@ -1131,11 +1134,11 @@ async def test_cron_profile_validation_errors(isolated_profiles):
from hermes_cli import web_server
with pytest.raises(HTTPException) as bad_name:
await web_server.list_cron_jobs(profile="../bad")
await _rt_cron.list_cron_jobs(profile="../bad")
assert bad_name.value.status_code == 400
with pytest.raises(HTTPException) as missing:
await web_server.list_cron_jobs(profile="missing_profile")
await _rt_cron.list_cron_jobs(profile="missing_profile")
assert missing.value.status_code == 404
@@ -1152,8 +1155,8 @@ async def test_create_cron_job_without_profile_uses_backend_own_profile(
"HERMES_HOME", str(isolated_profiles["worker_alpha"])
)
job = await web_server.create_cron_job(
web_server.CronJobCreate(
job = await _rt_cron.create_cron_job(
_web_models.CronJobCreate(
prompt="runs in my own profile",
schedule="every 1h",
name="own-profile-job",
@@ -1176,8 +1179,8 @@ async def test_create_cron_job_without_profile_defaults_when_unscoped(
monkeypatch.setenv("HERMES_HOME", str(isolated_profiles["default"]))
job = await web_server.create_cron_job(
web_server.CronJobCreate(
job = await _rt_cron.create_cron_job(
_web_models.CronJobCreate(
prompt="runs in default",
schedule="every 1h",
name="default-job",
@@ -12,6 +12,8 @@ import concurrent.futures
import threading
import pytest
import hermes_cli.web_server_gateway as _web_server_gateway
import hermes_cli.web_server_lifecycle as _web_server_lifecycle
def _occupy_default_executor(loop: asyncio.AbstractEventLoop):
@@ -108,7 +110,7 @@ def test_status_route_survives_default_executor_starvation(monkeypatch):
from hermes_cli import web_server
monkeypatch.setattr(
web_server,
_web_server_gateway,
"_collect_profile_gateway_topology_cached",
lambda: {
"profiles": ["default"],
@@ -117,7 +119,7 @@ def test_status_route_survives_default_executor_starvation(monkeypatch):
"profile_platforms": {},
},
)
monkeypatch.setattr(web_server, "_resolve_restart_drain_timeout", lambda: 30.0)
monkeypatch.setattr(_web_server_lifecycle, "_resolve_restart_drain_timeout", lambda: 30.0)
monkeypatch.setattr(web_server, "get_install_id", lambda: None)
response = asyncio.run(
+2 -1
View File
@@ -6,6 +6,7 @@ import pytest
from starlette.testclient import TestClient
from hermes_cli import web_server
import hermes_cli.web_routers.files as _rt_files
def _client_with_app_state():
@@ -249,7 +250,7 @@ def test_stream_upload_cleans_temp_on_cancellation(forced_files_client):
with pytest.raises(asyncio.CancelledError):
asyncio.run(
web_server.upload_managed_file_stream(
_rt_files.upload_managed_file_stream(
request=request,
file=_AbortingUpload(),
path=str(target),
@@ -8,10 +8,9 @@ gateway detection, and per-platform port resolution.
import pytest
from hermes_cli import web_server
from hermes_cli.web_server import (
_collect_profile_gateway_topology,
_profile_platform_ports,
)
import gateway.status as _gw_status
import hermes_cli.web_server_gateway as _web_server_gateway
from hermes_cli.web_server_gateway import _collect_profile_gateway_topology, _profile_platform_ports
# ---------------------------------------------------------------------------
@@ -111,7 +110,7 @@ class TestCollectProfileGatewayTopology:
)
identities = {tmp_path / "d": (100, 111), tmp_path / "c": (200, 222)}
monkeypatch.setattr(
web_server,
_web_server_gateway,
"_profile_gateway_writer_identity",
lambda home, runtime: identities.get(home),
)
@@ -180,7 +179,7 @@ class TestCollectProfileGatewayTopology:
monkeypatch, homes, running={"coder"}, runtimes=runtimes
)
monkeypatch.setattr(
web_server,
_web_server_gateway,
"_profile_gateway_writer_identity",
lambda home, runtime: (200, 222),
)
@@ -204,7 +203,7 @@ class TestCollectProfileGatewayTopology:
}
_patch_topology(monkeypatch, homes, running={"coder"}, runtimes=runtimes)
monkeypatch.setattr(
web_server,
_web_server_gateway,
"_profile_gateway_writer_identity",
lambda home, runtime: None,
)
@@ -253,7 +252,7 @@ class TestStatusEndpointTopology:
def test_status_includes_full_topology_on_loopback(self, monkeypatch):
monkeypatch.setattr(
web_server, "_collect_profile_gateway_topology",
_web_server_gateway, "_collect_profile_gateway_topology",
lambda: {
"profiles": ["default", "coder"],
"gateway_mode": "single",
@@ -269,9 +268,9 @@ class TestStatusEndpointTopology:
assert data["gateways"] == [{"profile": "default", "ports": {}}]
def test_status_preserves_secondary_profile_platform_errors(self, monkeypatch):
monkeypatch.setattr(web_server, "get_running_pid_cached", lambda: 123)
monkeypatch.setattr(_gw_status, "get_running_pid_cached", lambda: 123)
monkeypatch.setattr(
web_server,
_gw_status,
"read_runtime_status",
lambda path=None: {
"gateway_state": "running",
@@ -286,7 +285,7 @@ class TestStatusEndpointTopology:
},
)
monkeypatch.setattr(
web_server,
_web_server_gateway,
"_load_configured_gateway_platforms",
lambda: {"telegram"},
)
@@ -299,9 +298,9 @@ class TestStatusEndpointTopology:
assert platforms["reviewer:discord"]["error_code"] == "duplicate_credential"
def test_status_rejects_malformed_namespaced_platform_key(self, monkeypatch):
monkeypatch.setattr(web_server, "get_running_pid_cached", lambda: 123)
monkeypatch.setattr(_gw_status, "get_running_pid_cached", lambda: 123)
monkeypatch.setattr(
web_server,
_gw_status,
"read_runtime_status",
lambda path=None: {
"gateway_state": "running",
@@ -311,7 +310,7 @@ class TestStatusEndpointTopology:
},
)
monkeypatch.setattr(
web_server,
_web_server_gateway,
"_load_configured_gateway_platforms",
lambda: {"telegram"},
)
@@ -327,9 +326,9 @@ class TestStatusEndpointTopology:
# validated against the key grammar. A config-load failure must not
# let malformed keys from a process-local JSON file reach the public
# endpoint.
monkeypatch.setattr(web_server, "get_running_pid_cached", lambda: 123)
monkeypatch.setattr(_gw_status, "get_running_pid_cached", lambda: 123)
monkeypatch.setattr(
web_server,
_gw_status,
"read_runtime_status",
lambda path=None: {
"gateway_state": "running",
@@ -346,7 +345,7 @@ class TestStatusEndpointTopology:
raise RuntimeError("config unreadable")
monkeypatch.setattr(
web_server, "_load_configured_gateway_platforms", _boom
_web_server_gateway, "_load_configured_gateway_platforms", _boom
)
resp = self.client.get("/api/status")
@@ -363,9 +362,9 @@ class TestStatusEndpointTopology:
# names are lowercased directory names; the Platform enum accepts
# them). A valid ``reviewer:foo-bar`` fatal entry must survive the
# public filter.
monkeypatch.setattr(web_server, "get_running_pid_cached", lambda: 123)
monkeypatch.setattr(_gw_status, "get_running_pid_cached", lambda: 123)
monkeypatch.setattr(
web_server,
_gw_status,
"read_runtime_status",
lambda path=None: {
"gateway_state": "running",
@@ -378,7 +377,7 @@ class TestStatusEndpointTopology:
},
)
monkeypatch.setattr(
web_server,
_web_server_gateway,
"_load_configured_gateway_platforms",
lambda: {"telegram"},
)
@@ -396,9 +395,9 @@ class TestStatusEndpointTopology:
# them in as <profile>:<platform> so NAS health monitoring sees them.
import hermes_cli.profiles as profiles_mod
monkeypatch.setattr(web_server, "get_running_pid_cached", lambda: 123)
monkeypatch.setattr(_gw_status, "get_running_pid_cached", lambda: 123)
monkeypatch.setattr(
web_server,
_gw_status,
"read_runtime_status",
lambda path=None: {
"gateway_state": "running",
@@ -412,7 +411,7 @@ class TestStatusEndpointTopology:
},
)
monkeypatch.setattr(
web_server,
_web_server_gateway,
"_load_configured_gateway_platforms",
lambda: {"telegram"},
)
@@ -420,7 +419,7 @@ class TestStatusEndpointTopology:
profiles_mod, "get_active_profile_name", lambda: "default"
)
monkeypatch.setattr(
web_server, "_collect_profile_gateway_topology",
_web_server_gateway, "_collect_profile_gateway_topology",
lambda: {
"profiles": ["default", "lead-gen-outreach"],
"gateway_mode": "multiple",
@@ -472,9 +471,9 @@ class TestStatusEndpointTopology:
# profile's failures into it would misattribute state.
import hermes_cli.profiles as profiles_mod
monkeypatch.setattr(web_server, "get_running_pid_cached", lambda: 123)
monkeypatch.setattr(_gw_status, "get_running_pid_cached", lambda: 123)
monkeypatch.setattr(
web_server,
_gw_status,
"read_runtime_status",
lambda path=None: {
"gateway_state": "running",
@@ -482,7 +481,7 @@ class TestStatusEndpointTopology:
},
)
monkeypatch.setattr(
web_server,
_web_server_gateway,
"_load_configured_gateway_platforms",
lambda: {"telegram"},
)
@@ -490,7 +489,7 @@ class TestStatusEndpointTopology:
profiles_mod, "get_active_profile_name", lambda: "default"
)
monkeypatch.setattr(
web_server, "_collect_profile_gateway_topology",
_web_server_gateway, "_collect_profile_gateway_topology",
lambda: {
"profiles": ["default", "coder"],
"gateway_mode": "multiple",
@@ -512,7 +511,7 @@ class TestStatusEndpointTopology:
# Hermes Cloud Portal reads /api/status over the network (a gated bind)
# to render the profile list, so they must survive the auth gate.
monkeypatch.setattr(
web_server, "_collect_profile_gateway_topology",
_web_server_gateway, "_collect_profile_gateway_topology",
lambda: {
"profiles": ["default", "coder"],
"gateway_mode": "multiplex",
@@ -9,6 +9,7 @@ profile's HERMES_HOME, and the dashboard's own profile stays untouched.
"""
import pytest
import yaml
import gateway.status as _gw_status
_VALID_WORKER_BOT_TOKEN = "123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZ_1234"
@@ -100,12 +101,12 @@ class TestProfileScopedMessagingReads:
yaml.safe_dump({"platforms": {"telegram": {"enabled": True}}}),
encoding="utf-8",
)
monkeypatch.setattr(web_server, "get_running_pid", lambda *a, **k: None)
monkeypatch.setattr(_gw_status, "get_running_pid", lambda *a, **k: None)
monkeypatch.setattr(
web_server, "get_running_pid_cached", lambda *a, **k: None
_gw_status, "get_running_pid_cached", lambda *a, **k: None
)
monkeypatch.setattr(
web_server,
_gw_status,
"read_runtime_status",
# Accepts path= : the profile-scoped read now passes the
# profile's own gateway_state.json explicitly rather than
@@ -11,6 +11,12 @@ from contextlib import contextmanager
import pytest
import yaml
import gateway.status as _gw_status
import hermes_cli.config as _cfg_mod
import hermes_cli.web_server_chat as _web_server_chat
import hermes_cli.web_server_gateway as _web_server_gateway
import hermes_cli.web_server_messaging as _web_server_messaging
import hermes_cli.web_server_profiles as _web_server_profiles
@pytest.fixture
@@ -378,9 +384,9 @@ class TestProfileScopedModel:
yield object()
monkeypatch.setattr(
web_server, "_config_profile_scope", _recording_config_scope
_web_server_profiles, "_config_profile_scope", _recording_config_scope
)
monkeypatch.setattr(web_server, "_profile_scope", _recording_profile_scope)
monkeypatch.setattr(_web_server_profiles, "_profile_scope", _recording_profile_scope)
monkeypatch.setattr(
"hermes_cli.inventory.load_picker_context", lambda: object()
)
@@ -417,7 +423,7 @@ class TestProfileScopedPostSetup:
pid = 777
monkeypatch.setattr(
web_server,
_web_server_gateway,
"_spawn_hermes_action",
lambda subcommand, name: calls.append(list(subcommand)) or _FakeProc(),
)
@@ -445,7 +451,7 @@ class TestProfileScopedPostSetup:
pid = 777
monkeypatch.setattr(
web_server,
_web_server_gateway,
"_spawn_hermes_action",
lambda subcommand, name: calls.append(list(subcommand)) or _FakeProc(),
)
@@ -477,12 +483,12 @@ class TestProfileScopedGateway:
seen_homes.append(str(get_hermes_home()))
return None
monkeypatch.setattr(web_server, "check_config_version", lambda: (1, 1))
monkeypatch.setattr(_cfg_mod, "check_config_version", lambda: (1, 1))
# get_status probes via the TTL-cached wrapper (PR #53511 salvage);
# patch the cached name so the fake still intercepts the probe.
monkeypatch.setattr(web_server, "get_running_pid_cached", fake_get_running_pid)
monkeypatch.setattr(_gw_status, "get_running_pid_cached", fake_get_running_pid)
monkeypatch.setattr(
web_server,
_gw_status,
"read_runtime_status",
lambda *a, **k: {"gateway_state": "startup_failed", "platforms": {}},
)
@@ -514,13 +520,13 @@ class TestProfileScopedGateway:
"exit_reason": None,
"updated_at": "2026-06-17T00:00:00+00:00",
}
monkeypatch.setattr(web_server, "check_config_version", lambda: (1, 1))
monkeypatch.setattr(_cfg_mod, "check_config_version", lambda: (1, 1))
monkeypatch.setattr(
web_server, "get_running_pid_cached", lambda *a, **k: None
_gw_status, "get_running_pid_cached", lambda *a, **k: None
)
monkeypatch.setattr(web_server, "read_runtime_status", lambda *a, **k: runtime)
monkeypatch.setattr(_gw_status, "read_runtime_status", lambda *a, **k: runtime)
monkeypatch.setattr(
web_server,
_gw_status,
"get_runtime_status_running_pid",
lambda payload, **k: 4242,
)
@@ -568,15 +574,15 @@ class TestProfileScopedGateway:
"exit_reason": "telegram: token rejected",
"updated_at": "2026-06-17T00:00:00+00:00",
}
monkeypatch.setattr(web_server, "check_config_version", lambda: (1, 1))
monkeypatch.setattr(_cfg_mod, "check_config_version", lambda: (1, 1))
monkeypatch.setattr(
web_server, "get_running_pid_cached", lambda *a, **k: None
_gw_status, "get_running_pid_cached", lambda *a, **k: None
)
monkeypatch.setattr(web_server, "read_runtime_status", lambda *a, **k: runtime)
monkeypatch.setattr(_gw_status, "read_runtime_status", lambda *a, **k: runtime)
# Bare platform keys are checked against the configured set (fail
# closed) — mirror a host that actually has telegram configured.
monkeypatch.setattr(
web_server, "_load_configured_gateway_platforms", lambda: {"telegram"}
_web_server_gateway, "_load_configured_gateway_platforms", lambda: {"telegram"}
)
monkeypatch.setattr(web_server, "_GATEWAY_HEALTH_URL", None)
@@ -604,11 +610,11 @@ class TestProfileScopedGateway:
"exit_reason": None,
"updated_at": "2026-06-17T00:00:00+00:00",
}
monkeypatch.setattr(web_server, "check_config_version", lambda: (1, 1))
monkeypatch.setattr(_cfg_mod, "check_config_version", lambda: (1, 1))
monkeypatch.setattr(
web_server, "get_running_pid_cached", lambda *a, **k: None
_gw_status, "get_running_pid_cached", lambda *a, **k: None
)
monkeypatch.setattr(web_server, "read_runtime_status", lambda *a, **k: runtime)
monkeypatch.setattr(_gw_status, "read_runtime_status", lambda *a, **k: runtime)
monkeypatch.setattr(web_server, "_GATEWAY_HEALTH_URL", None)
resp = client.get("/api/status", params={"profile": "worker_beta"})
@@ -626,10 +632,10 @@ class TestProfileScopedTelegramOnboarding:
import time
import hermes_cli.web_server as web_server
with web_server._telegram_onboarding_lock:
web_server._telegram_onboarding_pairings.clear()
web_server._telegram_onboarding_pairings["pair-worker"] = (
web_server._TelegramOnboardingPairing(
with _web_server_messaging._telegram_onboarding_lock:
_web_server_messaging._telegram_onboarding_pairings.clear()
_web_server_messaging._telegram_onboarding_pairings["pair-worker"] = (
_web_server_messaging._TelegramOnboardingPairing(
poll_token="poll-secret",
expires_at="2027-05-18T00:00:00.000Z",
expires_at_ts=time.time() + 600,
@@ -645,12 +651,12 @@ class TestProfileScopedTelegramOnboarding:
pid = 889
monkeypatch.setattr(
web_server,
_web_server_gateway,
"_spawn_hermes_action",
lambda subcommand, name: calls.append((list(subcommand), name)) or _FakeProc(),
)
web_server._ACTION_PROCS.pop("gateway-restart", None)
web_server._ACTION_COMMANDS.pop("gateway-restart", None)
_web_server_gateway._ACTION_PROCS.pop("gateway-restart", None)
_web_server_gateway._ACTION_COMMANDS.pop("gateway-restart", None)
resp = client.post(
"/api/messaging/telegram/onboarding/pair-worker/apply",
@@ -682,11 +688,11 @@ class TestProfileScopedChatPty:
import hermes_cli.web_server as web_server
monkeypatch.setattr(
"hermes_cli.main._make_tui_argv",
"hermes_cli.main_tui_launch._make_tui_argv",
lambda root, tui_dev=False: (["cat"], None),
raising=False,
)
argv, cwd, env = web_server._resolve_chat_argv(profile="worker_beta")
argv, cwd, env = _web_server_chat._resolve_chat_argv(profile="worker_beta")
assert env is not None
assert env["HERMES_HOME"] == str(isolated_profiles["worker_beta"])
# Scoped chat must NOT attach to the dashboard's in-memory gateway.
@@ -714,12 +720,12 @@ class TestProfileScopedChatPty:
monkeypatch.setenv("TERMINAL_DOCKER_IMAGE", "launch-profile-image")
monkeypatch.setenv("TERMINAL_SSH_USER", "operator-user")
monkeypatch.setattr(
"hermes_cli.main._make_tui_argv",
"hermes_cli.main_tui_launch._make_tui_argv",
lambda root, tui_dev=False: (["cat"], None),
raising=False,
)
_argv, _cwd, env = web_server._resolve_chat_argv(profile="worker_beta")
_argv, _cwd, env = _web_server_chat._resolve_chat_argv(profile="worker_beta")
assert env is not None
assert env["HERMES_HOME"] == str(isolated_profiles["worker_beta"])
@@ -741,12 +747,12 @@ class TestProfileScopedChatPty:
monkeypatch.setenv("TERMINAL_ENV", "docker")
monkeypatch.setenv("TERMINAL_SSH_USER", "operator-user")
monkeypatch.setattr(
"hermes_cli.main._make_tui_argv",
"hermes_cli.main_tui_launch._make_tui_argv",
lambda root, tui_dev=False: (["cat"], None),
raising=False,
)
_argv, _cwd, env = web_server._resolve_chat_argv()
_argv, _cwd, env = _web_server_chat._resolve_chat_argv()
assert env is not None
assert env["TERMINAL_ENV"] == "docker"
@@ -769,12 +775,12 @@ class TestProfileScopedChatPty:
monkeypatch.setenv("TERMINAL_ENV", "docker")
monkeypatch.setenv("TERMINAL_CWD", "/operator/work")
monkeypatch.setattr(
"hermes_cli.main._make_tui_argv",
"hermes_cli.main_tui_launch._make_tui_argv",
lambda root, tui_dev=False: (["cat"], None),
raising=False,
)
_argv, _cwd, env = web_server._resolve_chat_argv(profile="worker_beta")
_argv, _cwd, env = _web_server_chat._resolve_chat_argv(profile="worker_beta")
assert env is not None
assert env["TERMINAL_ENV"] == "ssh"
@@ -794,7 +800,7 @@ class TestProfileScopedChatPty:
)
monkeypatch.setenv("TERMINAL_ENV", "docker")
monkeypatch.setattr(
"hermes_cli.main._make_tui_argv",
"hermes_cli.main_tui_launch._make_tui_argv",
lambda root, tui_dev=False: (["cat"], None),
raising=False,
)
@@ -805,7 +811,7 @@ class TestProfileScopedChatPty:
)
with caplog.at_level(logging.WARNING, logger=web_server._log.name):
_argv, _cwd, env = web_server._resolve_chat_argv(profile="worker_beta")
_argv, _cwd, env = _web_server_chat._resolve_chat_argv(profile="worker_beta")
assert env is not None
assert env["HERMES_HOME"] == str(isolated_profiles["worker_beta"])
@@ -7,7 +7,7 @@ from unittest.mock import patch
import pytest
from hermes_cli.web_server import _legacy_pump
from hermes_cli.web_server_chat import _legacy_pump
class _FakeBridge:
+11 -11
View File
@@ -1,6 +1,6 @@
"""Test the platform-branched PTY bridge import in hermes_cli.web_server.
"""Test the platform-branched PTY bridge import in hermes_cli.web_server_chat.
The /api/pty WebSocket handler in web_server.py picks its bridge at import
The /api/pty WebSocket handler picks its bridge at import
time via ``sys.platform.startswith("win")`` — Windows gets the ConPTY
backend, POSIX gets the fcntl/termios one. Both branches must:
@@ -21,18 +21,18 @@ import sys
import pytest
from hermes_cli import web_server
import hermes_cli.web_server_chat as _web_server_chat
def test_web_server_exposes_pty_bridge_symbols():
"""The two symbols /api/pty consumes must always exist."""
assert hasattr(web_server, "PtyBridge")
assert hasattr(web_server, "PtyUnavailableError")
assert hasattr(web_server, "_PTY_BRIDGE_AVAILABLE")
assert hasattr(_web_server_chat, "PtyBridge")
assert hasattr(_web_server_chat, "PtyUnavailableError")
assert hasattr(_web_server_chat, "_PTY_BRIDGE_AVAILABLE")
# PtyUnavailableError is always an exception class — either the real
# one from the platform bridge, or the local fallback class.
assert isinstance(web_server.PtyUnavailableError, type)
assert issubclass(web_server.PtyUnavailableError, BaseException)
assert isinstance(_web_server_chat.PtyUnavailableError, type)
assert issubclass(_web_server_chat.PtyUnavailableError, BaseException)
@pytest.mark.skipif(sys.platform.startswith("win"), reason="POSIX-only")
@@ -41,9 +41,9 @@ def test_web_server_uses_posix_pty_bridge_on_posix():
from hermes_cli.pty_bridge import PtyBridge as PosixBridge
from hermes_cli.pty_bridge import PtyUnavailableError as PosixErr
assert web_server.PtyBridge is PosixBridge
assert web_server._PTY_BRIDGE_AVAILABLE is True
assert web_server.PtyUnavailableError is PosixErr
assert _web_server_chat.PtyBridge is PosixBridge
assert _web_server_chat._PTY_BRIDGE_AVAILABLE is True
assert _web_server_chat.PtyUnavailableError is PosixErr
def test_pty_bridge_import_block_is_platform_branched():
@@ -6,6 +6,7 @@ from pathlib import Path
from urllib.parse import urlencode
import pytest
import hermes_cli.web_server_chat as _web_server_chat
pytestmark = pytest.mark.skipif(
@@ -45,7 +46,7 @@ def pty_client(monkeypatch, _isolate_hermes_home):
import hermes_cli.web_server as ws
monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True)
monkeypatch.setattr(ws.PtyBridge, "spawn", _OneFrameBridge.spawn)
monkeypatch.setattr(_web_server_chat.PtyBridge, "spawn", _OneFrameBridge.spawn)
ws.app.state.pty_active_session_files = {}
client = TestClient(ws.app)
@@ -64,7 +65,7 @@ def test_fresh_param_ignores_channel_active_session_file(pty_client, monkeypatch
"""Explicit fresh starts must not resurrect the prior channel session."""
ws, client, token = pty_client
channel = "fresh-chan"
active_file = ws._active_session_file_for_channel(ws.app, channel)
active_file = _web_server_chat._active_session_file_for_channel(ws.app, channel)
active_file.write_text(json.dumps({"session_id": "sess-old"}), encoding="utf-8")
captured = {}
@@ -73,7 +74,7 @@ def test_fresh_param_ignores_channel_active_session_file(pty_client, monkeypatch
captured["resume"] = resume
return (["fake-hermes-tui"], None, None)
monkeypatch.setattr(ws, "_resolve_chat_argv", fake_resolve)
monkeypatch.setattr(_web_server_chat, "_resolve_chat_argv", fake_resolve)
with client.websocket_connect(_url(token, channel=channel, fresh="1")) as conn:
assert conn.receive_bytes() == b"ready"
@@ -94,11 +95,11 @@ def test_active_session_fallback_sends_resume_control_message(pty_client, monkey
"""
ws, client, token = pty_client
channel = "implicit-resume-chan"
active_file = ws._active_session_file_for_channel(ws.app, channel)
active_file = _web_server_chat._active_session_file_for_channel(ws.app, channel)
active_file.write_text(json.dumps({"session_id": "sess-old"}), encoding="utf-8")
monkeypatch.setattr(
ws, "_resolve_chat_argv", lambda **kw: (["fake-hermes-tui"], None, None)
_web_server_chat, "_resolve_chat_argv", lambda **kw: (["fake-hermes-tui"], None, None)
)
with client.websocket_connect(_url(token, channel=channel)) as conn:
@@ -112,7 +113,7 @@ def test_explicit_resume_sends_no_control_message(pty_client, monkeypatch):
channel = "explicit-resume-chan"
monkeypatch.setattr(
ws, "_resolve_chat_argv", lambda **kw: (["fake-hermes-tui"], None, None)
_web_server_chat, "_resolve_chat_argv", lambda **kw: (["fake-hermes-tui"], None, None)
)
with client.websocket_connect(
@@ -141,9 +142,9 @@ def test_child_eof_closes_socket_and_bridge(pty_client, monkeypatch):
bridges.append(b)
return b
monkeypatch.setattr(ws.PtyBridge, "spawn", _RecordingBridge.spawn)
monkeypatch.setattr(_web_server_chat.PtyBridge, "spawn", _RecordingBridge.spawn)
monkeypatch.setattr(
ws, "_resolve_chat_argv", lambda **kw: (["fake-hermes-tui"], None, None)
_web_server_chat, "_resolve_chat_argv", lambda **kw: (["fake-hermes-tui"], None, None)
)
# The client never sends a disconnect of its own — it only reads the one
@@ -1,6 +1,7 @@
import asyncio
from hermes_cli import web_server
import hermes_cli.web_routers.sessions as _rt_sessions
class _FakeSessionDB:
@@ -110,7 +111,7 @@ def test_desktop_session_search_merges_id_matches_before_content_matches(monkeyp
_FakeSessionDB.requested_fields = None
monkeypatch.setattr("hermes_state.SessionDB", _FakeSessionDB)
response = asyncio.run(web_server.search_sessions(q="20260603", limit=2))
response = asyncio.run(_rt_sessions.search_sessions(q="20260603", limit=2))
assert _FakeSessionDB.requested_fields is not None
assert "context" not in _FakeSessionDB.requested_fields
@@ -9,6 +9,8 @@ profile's HERMES_HOME, and the dashboard's own profile stays untouched.
"""
import pytest
import yaml
import hermes_cli.web_server_gateway as _web_server_gateway
import hermes_cli.web_server_profiles as _web_server_profiles
def _write_skill(skills_dir, name, description="test skill"):
@@ -109,7 +111,7 @@ class TestProfileScopedHubActions:
calls.append((list(subcommand), name))
return _FakeProc()
monkeypatch.setattr(web_server, "_spawn_hermes_action", _fake_spawn)
monkeypatch.setattr(_web_server_gateway, "_spawn_hermes_action", _fake_spawn)
resp = client.post(
"/api/skills/hub/install",
json={"identifier": "official/demo", "profile": "worker_alpha"},
@@ -118,7 +120,7 @@ class TestProfileScopedHubActions:
assert calls == [
(
["-p", "worker_alpha", "skills", "install", "official/demo", "--yes"],
web_server._hub_action_name("install", "official/demo"),
_web_server_profiles._hub_action_name("install", "official/demo"),
)
]
@@ -11,6 +11,7 @@ from starlette.testclient import TestClient
from starlette.websockets import WebSocketDisconnect
from hermes_cli import web_server
import hermes_cli.web_server_gateway as _web_server_gateway
@pytest.fixture
@@ -113,7 +114,7 @@ def test_long_text_is_split_across_provider_requests(stream_client, monkeypatch)
def test_split_text_respects_cap_and_preserves_content():
text = "Alpha beta. Gamma delta epsilon. Zeta eta theta iota kappa."
pieces = web_server._split_text_for_speak_stream(text, 30)
pieces = _web_server_gateway._split_text_for_speak_stream(text, 30)
assert pieces
assert all(len(piece) <= 30 for piece in pieces)
joined = " ".join(pieces)
+22 -18
View File
@@ -1,5 +1,9 @@
import asyncio
import time
import hermes_cli.config as _cfg_mod
import hermes_cli.web_models as _web_models
import hermes_cli.web_routers.messaging as _rt_messaging
import hermes_cli.web_server_messaging as _web_server_messaging
class _FakeProc:
@@ -36,16 +40,16 @@ def test_apply_whatsapp_onboarding_saves_pairing_policy(monkeypatch):
removed = []
enabled = []
monkeypatch.setattr(ws, "save_env_value", lambda key, value: saved.setdefault(key, value))
monkeypatch.setattr(ws, "remove_env_value", lambda key: removed.append(key))
monkeypatch.setattr(ws, "_write_platform_enabled", lambda platform, value: enabled.append((platform, value)))
monkeypatch.setattr(_cfg_mod, "save_env_value", lambda key, value: saved.setdefault(key, value))
monkeypatch.setattr(_cfg_mod, "remove_env_value", lambda key: removed.append(key))
monkeypatch.setattr(_web_server_messaging, "_write_platform_enabled", lambda platform, value: enabled.append((platform, value)))
monkeypatch.setattr(
ws,
_web_server_messaging,
"_restart_gateway_after_whatsapp_onboarding",
lambda profile=None: {"restart_started": True, "restart_pid": 12345},
)
record = ws._WhatsAppOnboardingSession(
record = _web_server_messaging._WhatsAppOnboardingSession(
proc=None,
mode="bot",
allowed_users="",
@@ -54,13 +58,13 @@ def test_apply_whatsapp_onboarding_saves_pairing_policy(monkeypatch):
expires_at_ts=time.time() + 600,
status="connected",
)
ws._whatsapp_onboarding_sessions.clear()
ws._whatsapp_onboarding_sessions["pairing"] = record
_web_server_messaging._whatsapp_onboarding_sessions.clear()
_web_server_messaging._whatsapp_onboarding_sessions["pairing"] = record
result = asyncio.run(
ws.apply_whatsapp_onboarding(
_rt_messaging.apply_whatsapp_onboarding(
"pairing",
ws.WhatsAppOnboardingApply(mode="bot", allowed_users=""),
_web_models.WhatsAppOnboardingApply(mode="bot", allowed_users=""),
)
)
@@ -70,7 +74,7 @@ def test_apply_whatsapp_onboarding_saves_pairing_policy(monkeypatch):
assert saved["WHATSAPP_ENABLED"] == "true"
assert "WHATSAPP_ALLOWED_USERS" not in removed
assert enabled == [("whatsapp", True)]
assert "pairing" not in ws._whatsapp_onboarding_sessions
assert "pairing" not in _web_server_messaging._whatsapp_onboarding_sessions
def test_start_whatsapp_onboarding_existing_creds_returns_linked_account(monkeypatch, tmp_path):
@@ -84,7 +88,7 @@ def test_start_whatsapp_onboarding_existing_creds_returns_linked_account(monkeyp
)
old_proc = _FakeProc(returncode=1)
old_record = ws._WhatsAppOnboardingSession(
old_record = _web_server_messaging._WhatsAppOnboardingSession(
proc=old_proc,
mode="bot",
allowed_users="",
@@ -92,14 +96,14 @@ def test_start_whatsapp_onboarding_existing_creds_returns_linked_account(monkeyp
expires_at="2099-01-01T00:00:00Z",
expires_at_ts=time.time() + 600,
)
ws._whatsapp_onboarding_sessions.clear()
ws._whatsapp_onboarding_sessions["old"] = old_record
monkeypatch.setattr(ws, "_whatsapp_session_path", lambda: session_dir)
_web_server_messaging._whatsapp_onboarding_sessions.clear()
_web_server_messaging._whatsapp_onboarding_sessions["old"] = old_record
monkeypatch.setattr(_web_server_messaging, "_whatsapp_session_path", lambda: session_dir)
monkeypatch.setattr(ws.secrets, "token_urlsafe", lambda size: "existing-creds")
result = asyncio.run(
ws.start_whatsapp_onboarding(
ws.WhatsAppOnboardingStart(mode="self-chat", allowed_users="")
_rt_messaging.start_whatsapp_onboarding(
_web_models.WhatsAppOnboardingStart(mode="self-chat", allowed_users="")
)
)
@@ -111,8 +115,8 @@ def test_start_whatsapp_onboarding_existing_creds_returns_linked_account(monkeyp
assert result["account_phone"] == "15551234567"
assert old_record.status == "cancelled"
assert old_proc.terminated is True
assert ws._whatsapp_onboarding_sessions["existing-creds"].account_phone == "15551234567"
ws._whatsapp_onboarding_sessions.clear()
assert _web_server_messaging._whatsapp_onboarding_sessions["existing-creds"].account_phone == "15551234567"
_web_server_messaging._whatsapp_onboarding_sessions.clear()
@@ -234,11 +234,7 @@ class TestAmbientAccountingContext:
class TestAnalyticsAuxRows:
def test_aux_usage_rows_and_merge(self, db):
from hermes_cli.web_server import (
_aux_task_summary,
_aux_usage_rows,
_merge_aux_into_by_model,
)
from hermes_cli.web_server_profiles import _aux_task_summary, _aux_usage_rows, _merge_aux_into_by_model
db.create_session("s1", source="cli")
db.update_token_counts(
+3 -3
View File
@@ -130,7 +130,7 @@ class TestTruncateTokenCallable:
def test_callable_returns_placeholder(self):
"""Dashboard preview must render the Entra placeholder, NOT
``"<function ...>"``."""
from hermes_cli.web_server import _truncate_token
from hermes_cli.web_server_oauth import _truncate_token
invoked = {"count": 0}
@@ -144,13 +144,13 @@ class TestTruncateTokenCallable:
assert invoked["count"] == 0
def test_string_jwt_still_truncated_to_signature_tail(self):
from hermes_cli.web_server import _truncate_token
from hermes_cli.web_server_oauth import _truncate_token
# JWT shape: header.payload.signature → only signature tail shown.
out = _truncate_token("aaaa.bbbb.cccccccsig", visible=4)
assert out == "…csig"
def test_empty_returns_empty(self):
from hermes_cli.web_server import _truncate_token
from hermes_cli.web_server_oauth import _truncate_token
assert _truncate_token(None) == ""
assert _truncate_token("") == ""
+11 -8
View File
@@ -3,7 +3,7 @@
Companion to ``tests/test_stale_utils_module_import.py``: that test proves the
crash; these prove the guard that turns it into a clear "restart the gateway"
message before a model switch can hit it. The dashboard mirror (#86207) lives
here too: ``web_server._dashboard_code_skew_guard`` and the
here too: ``web_server_config._dashboard_code_skew_guard`` and the
``/api/model/options`` 503 guard, which protect the Models page from the same
stale-module ImportError after ``hermes update``.
"""
@@ -14,6 +14,9 @@ import contextlib
import pytest
from gateway import code_skew
import hermes_cli.web_routers.models as _rt_models
import hermes_cli.web_server_config as _web_server_config
import hermes_cli.web_server_profiles as _web_server_profiles
@pytest.fixture(autouse=True)
@@ -76,14 +79,14 @@ class TestDashboardCodeSkewGuard:
from hermes_cli import web_server
monkeypatch.setattr(code_skew, "detect_code_skew", lambda: None)
assert web_server._dashboard_code_skew_guard() is None
assert _web_server_config._dashboard_code_skew_guard() is None
def test_dashboard_guard_message_names_revs_and_restart(self, monkeypatch):
from hermes_cli import web_server
monkeypatch.delenv("HERMES_SERVE_HEADLESS", raising=False)
monkeypatch.setattr(code_skew, "detect_code_skew", lambda: ("abc1234567", "def4567890"))
msg = web_server._dashboard_code_skew_guard()
msg = _web_server_config._dashboard_code_skew_guard()
assert msg is not None
assert "abc1234567" in msg
assert "def4567890" in msg
@@ -96,7 +99,7 @@ class TestDashboardCodeSkewGuard:
monkeypatch.setenv("HERMES_SERVE_HEADLESS", "1")
monkeypatch.setattr(code_skew, "detect_code_skew", lambda: ("abc1234567", "def4567890"))
msg = web_server._dashboard_code_skew_guard()
msg = _web_server_config._dashboard_code_skew_guard()
assert msg is not None
assert "Desktop-owned backend" in msg
assert "systemctl" not in msg
@@ -126,7 +129,7 @@ class TestModelOptionsSkewGuard:
)
with pytest.raises(HTTPException) as excinfo:
asyncio.run(web_server.get_model_options())
asyncio.run(_rt_models.get_model_options())
assert excinfo.value.status_code == 503
assert "restart" in str(excinfo.value.detail).lower()
@@ -141,9 +144,9 @@ class TestModelOptionsSkewGuard:
async def _fake_run_in_threadpool(func):
return func()
monkeypatch.setattr(web_server, "run_in_threadpool", _fake_run_in_threadpool)
monkeypatch.setattr(_rt_models, "run_in_threadpool", _fake_run_in_threadpool)
monkeypatch.setattr(
web_server, "_profile_scope", lambda profile: contextlib.nullcontext()
_web_server_profiles, "_profile_scope", lambda profile: contextlib.nullcontext()
)
monkeypatch.setattr("hermes_cli.inventory.load_picker_context", lambda: {})
@@ -154,7 +157,7 @@ class TestModelOptionsSkewGuard:
lambda *a, **k: payload_calls.append(1) or expected,
)
result = asyncio.run(web_server.get_model_options())
result = asyncio.run(_rt_models.get_model_options())
assert result == expected
assert payload_calls == [1]
+8 -7
View File
@@ -3,6 +3,7 @@ import json
import pytest
from hermes_cli import web_server
import hermes_cli.web_server_chat as _web_server_chat
class FakeBridge:
@@ -37,22 +38,22 @@ def pty_keepalive_harness(monkeypatch):
spawned.bridges.append(b)
return b
monkeypatch.setattr(web_server.PtyBridge, "spawn", staticmethod(fake_spawn))
monkeypatch.setattr(web_server, "_ws_auth_reason", lambda ws: (None, "test"))
monkeypatch.setattr(web_server, "_ws_host_origin_reason", lambda ws: None)
monkeypatch.setattr(web_server, "_ws_client_reason", lambda ws: None)
monkeypatch.setattr(_web_server_chat.PtyBridge, "spawn", staticmethod(fake_spawn))
monkeypatch.setattr(_web_server_chat, "_ws_auth_reason", lambda ws: (None, "test"))
monkeypatch.setattr(_web_server_chat, "_ws_host_origin_reason", lambda ws: None)
monkeypatch.setattr(_web_server_chat, "_ws_client_reason", lambda ws: None)
async def fake_argv(**kw):
resume = "child" if kw.get("resume") == "parent" else kw.get("resume")
env = {"HERMES_TUI_RESUME": resume} if resume else {}
return (["x", resume or "fresh"], "/tmp", env)
monkeypatch.setattr(web_server, "_resolve_chat_argv_async", fake_argv)
monkeypatch.setattr(_web_server_chat, "_resolve_chat_argv_async", fake_argv)
try:
yield spawned
finally:
web_server.PTY_REGISTRY._sessions.clear()
_web_server_chat.PTY_REGISTRY._sessions.clear()
@pytest.mark.asyncio
@@ -105,7 +106,7 @@ async def test_attach_token_reuses_default_chat_after_active_session_fallback(
active_session_file = tmp_path / "active-session.json"
monkeypatch.setattr(
web_server,
_web_server_chat,
"_active_session_file_for_channel",
lambda app, channel: active_session_file,
)
+1 -1
View File
@@ -2,7 +2,7 @@
Read-only SessionDB opens skip _reconcile_columns() by design, so dashboard
read paths heal stale stores via a probe-then-writable-reopen dance in
``hermes_cli.web_server._open_session_db_at_path``. These tests pin the
``hermes_cli.web_server_sessions._open_session_db_at_path``. These tests pin the
probe's contract: it is DERIVED from SCHEMA_SQL (any column added there is
covered automatically — the previous hand-written probe went stale within
days) and it must fail at prepare time on a store missing any declared
+4 -3
View File
@@ -17,6 +17,7 @@ import pytest
from fastapi import HTTPException
from hermes_cli import web_server
import hermes_cli.web_server_sessions as _web_server_sessions
from hermes_cli.web_routers import sessions as sessions_router
@@ -40,7 +41,7 @@ class _MalformedDB:
def malformed_db(monkeypatch):
db = _MalformedDB()
monkeypatch.setattr(
web_server, "_open_session_db_for_profile", lambda profile, *, read_only: db
_web_server_sessions, "_open_session_db_for_profile", lambda profile, *, read_only: db
)
return db
@@ -135,7 +136,7 @@ class _EmptyDB:
@pytest.mark.asyncio
async def test_absent_session_is_still_404(monkeypatch):
monkeypatch.setattr(
web_server,
_web_server_sessions,
"_open_session_db_for_profile",
lambda profile, *, read_only: _EmptyDB(),
)
@@ -155,7 +156,7 @@ class _OtherErrorDB(_EmptyDB):
@pytest.mark.asyncio
async def test_non_corruption_database_error_is_not_swallowed(monkeypatch):
monkeypatch.setattr(
web_server,
_web_server_sessions,
"_open_session_db_for_profile",
lambda profile, *, read_only: _OtherErrorDB(),
)
+6 -3
View File
@@ -4,6 +4,9 @@ import threading
from pathlib import Path
from hermes_cli import web_server
import hermes_cli.web_models as _web_models
import hermes_cli.web_routers.sessions as _rt_sessions
import hermes_cli.web_server_sessions as _web_server_sessions
from hermes_cli import web_server_sessions
from hermes_cli.web_routers import analytics as web_analytics
from hermes_cli.web_routers import sessions as web_sessions
@@ -110,11 +113,11 @@ def test_bulk_delete_sessiondb_work_runs_off_event_loop(monkeypatch):
db_modes.append(read_only)
return _DB()
monkeypatch.setattr(web_server, "_open_session_db_for_profile", _open_db)
monkeypatch.setattr(_web_server_sessions, "_open_session_db_for_profile", _open_db)
result = asyncio.run(
web_server.bulk_delete_sessions_endpoint(
web_server.BulkDeleteSessions(ids=["one", "two"])
_rt_sessions.bulk_delete_sessions_endpoint(
_web_models.BulkDeleteSessions(ids=["one", "two"])
)
)
+17 -16
View File
@@ -13,12 +13,13 @@ import threading
import time
from hermes_cli import web_server
import hermes_cli.web_server_gateway as _web_server_gateway
def _reset_cache():
web_server._TOPOLOGY_CACHE["ts"] = 0.0
web_server._TOPOLOGY_CACHE["data"] = None
web_server._TOPOLOGY_CACHE["fn"] = None
_web_server_gateway._TOPOLOGY_CACHE["ts"] = 0.0
_web_server_gateway._TOPOLOGY_CACHE["data"] = None
_web_server_gateway._TOPOLOGY_CACHE["fn"] = None
def _fake_topology(calls, delay=0.0):
@@ -34,12 +35,12 @@ def _fake_topology(calls, delay=0.0):
def test_topology_cache_returns_cached_result_within_ttl(monkeypatch):
calls = []
monkeypatch.setattr(
web_server, "_collect_profile_gateway_topology", _fake_topology(calls)
_web_server_gateway, "_collect_profile_gateway_topology", _fake_topology(calls)
)
_reset_cache()
try:
first = web_server._collect_profile_gateway_topology_cached()
second = web_server._collect_profile_gateway_topology_cached()
first = _web_server_gateway._collect_profile_gateway_topology_cached()
second = _web_server_gateway._collect_profile_gateway_topology_cached()
finally:
_reset_cache()
@@ -50,14 +51,14 @@ def test_topology_cache_returns_cached_result_within_ttl(monkeypatch):
def test_topology_cache_rescans_after_ttl(monkeypatch):
calls = []
monkeypatch.setattr(
web_server, "_collect_profile_gateway_topology", _fake_topology(calls)
_web_server_gateway, "_collect_profile_gateway_topology", _fake_topology(calls)
)
_reset_cache()
try:
web_server._collect_profile_gateway_topology_cached()
_web_server_gateway._collect_profile_gateway_topology_cached()
# Age the cache entry past the TTL instead of sleeping through it.
web_server._TOPOLOGY_CACHE["ts"] -= web_server._TOPOLOGY_CACHE_TTL + 1.0
web_server._collect_profile_gateway_topology_cached()
_web_server_gateway._TOPOLOGY_CACHE["ts"] -= _web_server_gateway._TOPOLOGY_CACHE_TTL + 1.0
_web_server_gateway._collect_profile_gateway_topology_cached()
finally:
_reset_cache()
@@ -69,7 +70,7 @@ def test_topology_cache_collapses_concurrent_scans(monkeypatch):
is exactly the GIL storm the cache exists to prevent."""
calls = []
monkeypatch.setattr(
web_server,
_web_server_gateway,
"_collect_profile_gateway_topology",
_fake_topology(calls, delay=0.05),
)
@@ -79,7 +80,7 @@ def test_topology_cache_collapses_concurrent_scans(monkeypatch):
threads = [
threading.Thread(
target=lambda: results.append(
web_server._collect_profile_gateway_topology_cached()
_web_server_gateway._collect_profile_gateway_topology_cached()
)
)
for _ in range(8)
@@ -100,15 +101,15 @@ def test_topology_cache_misses_when_collector_is_swapped(monkeypatch):
collector never leaks across the swap."""
calls_a, calls_b = [], []
monkeypatch.setattr(
web_server, "_collect_profile_gateway_topology", _fake_topology(calls_a)
_web_server_gateway, "_collect_profile_gateway_topology", _fake_topology(calls_a)
)
_reset_cache()
try:
first = web_server._collect_profile_gateway_topology_cached()
first = _web_server_gateway._collect_profile_gateway_topology_cached()
monkeypatch.setattr(
web_server, "_collect_profile_gateway_topology", _fake_topology(calls_b)
_web_server_gateway, "_collect_profile_gateway_topology", _fake_topology(calls_b)
)
second = web_server._collect_profile_gateway_topology_cached()
second = _web_server_gateway._collect_profile_gateway_topology_cached()
finally:
_reset_cache()
@@ -8,7 +8,7 @@ between ``HERMES_BACKEND_READY`` and the first prompt. Three fixes:
subprocess when a Copilot env var is explicitly set (even if invalid).
2. ``tui_gateway.ws.handle_ws`` runs ``resolve_skin()`` via
``asyncio.to_thread`` so the loop is not blocked by config/skin init.
3. ``web_server._warm_gateway_module`` pre-imports the heavy module
3. ``web_server_lifecycle._warm_gateway_module`` pre-imports the heavy module
chains that the first WS connection + RPC burst would otherwise
import on the loop thread.
"""
@@ -19,6 +19,7 @@ import sys
from unittest.mock import patch, MagicMock
import pytest
import hermes_cli.web_server_lifecycle as _web_server_lifecycle
# ─── Fix 1: copilot_auth skips gh CLI when env var is set ──────────────
@@ -187,7 +188,7 @@ def test_warm_gateway_module_imports_cold_start_chains():
"hermes_cli.model_switch",
}
web_server_mod._warm_gateway_module()
_web_server_lifecycle._warm_gateway_module()
missing = required - set(sys.modules)
assert not missing, (