From 4fde117f4ba606768a911845ee1652b9690802d9 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:21:52 -0700 Subject: [PATCH] =?UTF-8?q?simplify(compat):=20tests=20=E2=80=94=20repoint?= =?UTF-8?q?=20638=20web=5Fserver.=20references=20(321=20attr,=20217?= =?UTF-8?q?=20monkeypatch/patch.object,=2060=20from-imports,=2040=20patch(?= =?UTF-8?q?)=20strings)=20across=2065=20test=20files=20to=20the=20owning?= =?UTF-8?q?=20modules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/agent/test_curator.py | 4 +- tests/agent/test_review_engine.py | 4 +- tests/cron/test_cron_failure_deliver.py | 2 +- tests/dashboard/test_ws_client_host.py | 17 +- tests/gateway/test_browser_control_cloud.py | 13 +- tests/hermes_cli/test_auth_nous_provider.py | 2 +- .../test_credential_pool_off_loop.py | 5 +- .../test_cron_dashboard_off_loop.py | 7 +- tests/hermes_cli/test_cron_fire_dashboard.py | 112 +++---- ...st_cron_profile_enumeration_lightweight.py | 3 +- .../test_dashboard_admin_endpoints.py | 5 +- tests/hermes_cli/test_dashboard_auth_gate.py | 5 +- .../hermes_cli/test_dashboard_auth_ws_auth.py | 47 +-- .../test_dashboard_spawn_executable.py | 11 +- .../test_desktop_repo_discovery_config.py | 2 +- tests/hermes_cli/test_env_custom_keys.py | 6 +- .../test_external_process_auth_status.py | 8 +- ...ain_model_custom_provider_normalization.py | 4 +- tests/hermes_cli/test_mcp_dashboard_oauth.py | 20 +- tests/hermes_cli/test_mcp_security.py | 3 +- ...est_moa_set_models_preserves_extra_keys.py | 9 +- .../test_model_assignment_env_key_mirror.py | 3 +- .../hermes_cli/test_model_set_secret_copy.py | 2 +- .../test_normalize_main_model_assignment.py | 4 +- tests/hermes_cli/test_nous_policy_surfaces.py | 4 +- .../hermes_cli/test_plugins_hub_perf_guard.py | 44 +-- .../test_project_plugin_rce_bypass.py | 15 +- .../hermes_cli/test_serve_parent_watchdog.py | 2 +- tests/hermes_cli/test_serve_port_in_use.py | 10 +- .../test_session_list_reader_disposable.py | 2 +- tests/hermes_cli/test_setup_hidden_env.py | 6 +- .../test_spawn_gateway_restart_cooldown.py | 51 ++-- .../test_spawn_gateway_restart_reap.py | 12 +- .../hermes_cli/test_system_stats_platform.py | 2 +- .../test_update_receipt_endpoint.py | 11 +- tests/hermes_cli/test_web_oauth_dispatch.py | 66 +++-- ...est_web_routers_tools_install_on_enable.py | 5 +- tests/hermes_cli/test_web_server.py | 274 +++++++++--------- .../test_web_server_approvals_broadcast.py | 3 +- .../test_web_server_boot_handshake.py | 5 +- .../test_web_server_config_offloop.py | 12 +- .../test_web_server_cron_profiles.py | 187 ++++++------ .../test_web_server_executor_isolation.py | 6 +- tests/hermes_cli/test_web_server_files.py | 3 +- .../test_web_server_gateway_topology.py | 57 ++-- .../test_web_server_messaging_profiles.py | 7 +- .../test_web_server_profile_unification.py | 76 ++--- .../test_web_server_pty_idle_backoff.py | 2 +- .../hermes_cli/test_web_server_pty_import.py | 22 +- .../test_web_server_pty_reconnect.py | 17 +- .../test_web_server_session_search.py | 3 +- .../test_web_server_skills_profiles.py | 6 +- .../test_web_server_speak_stream.py | 3 +- tests/hermes_cli/test_whatsapp_onboarding.py | 40 +-- .../hermes_state/test_aux_usage_accounting.py | 6 +- tests/run_agent/test_callable_api_key.py | 6 +- tests/test_code_skew.py | 19 +- tests/test_pty_keepalive_ws.py | 15 +- tests/test_schema_read_probe.py | 2 +- tests/test_session_detail_malformed_db.py | 7 +- tests/test_web_server_sessiondb_eventloop.py | 9 +- .../test_web_server_status_topology_cache.py | 33 ++- .../tui_gateway/test_cold_start_gil_stall.py | 5 +- 63 files changed, 709 insertions(+), 644 deletions(-) diff --git a/tests/agent/test_curator.py b/tests/agent/test_curator.py index c62ef3ae8c..dd4fd55809 100644 --- a/tests/agent/test_curator.py +++ b/tests/agent/test_curator.py @@ -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"], \ diff --git a/tests/agent/test_review_engine.py b/tests/agent/test_review_engine.py index fd024b41ee..547e76f427 100644 --- a/tests/agent/test_review_engine.py +++ b/tests/agent/test_review_engine.py @@ -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']" diff --git a/tests/cron/test_cron_failure_deliver.py b/tests/cron/test_cron_failure_deliver.py index 63628450af..e84d39be08 100644 --- a/tests/cron/test_cron_failure_deliver.py +++ b/tests/cron/test_cron_failure_deliver.py @@ -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 diff --git a/tests/dashboard/test_ws_client_host.py b/tests/dashboard/test_ws_client_host.py index 26e742adcd..f5c1883599 100644 --- a/tests/dashboard/test_ws_client_host.py +++ b/tests/dashboard/test_ws_client_host.py @@ -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 # --------------------------------------------------------------------------- diff --git a/tests/gateway/test_browser_control_cloud.py b/tests/gateway/test_browser_control_cloud.py index c92235a457..050a27a176 100644 --- a/tests/gateway/test_browser_control_cloud.py +++ b/tests/gateway/test_browser_control_cloud.py @@ -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(): diff --git a/tests/hermes_cli/test_auth_nous_provider.py b/tests/hermes_cli/test_auth_nous_provider.py index b093e049b0..54b111a299 100644 --- a/tests/hermes_cli/test_auth_nous_provider.py +++ b/tests/hermes_cli/test_auth_nous_provider.py @@ -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 diff --git a/tests/hermes_cli/test_credential_pool_off_loop.py b/tests/hermes_cli/test_credential_pool_off_loop.py index 6ffe6a6137..5a5ff5b748 100644 --- a/tests/hermes_cli/test_credential_pool_off_loop.py +++ b/tests/hermes_cli/test_credential_pool_off_loop.py @@ -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 diff --git a/tests/hermes_cli/test_cron_dashboard_off_loop.py b/tests/hermes_cli/test_cron_dashboard_off_loop.py index 636aaaff5d..facd42fe06 100644 --- a/tests/hermes_cli/test_cron_dashboard_off_loop.py +++ b/tests/hermes_cli/test_cron_dashboard_off_loop.py @@ -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 diff --git a/tests/hermes_cli/test_cron_fire_dashboard.py b/tests/hermes_cli/test_cron_fire_dashboard.py index d6d406398d..e5736e6ef7 100644 --- a/tests/hermes_cli/test_cron_fire_dashboard.py +++ b/tests/hermes_cli/test_cron_fire_dashboard.py @@ -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// 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), ) diff --git a/tests/hermes_cli/test_cron_profile_enumeration_lightweight.py b/tests/hermes_cli/test_cron_profile_enumeration_lightweight.py index b6de5a2382..04ddb5d9d4 100644 --- a/tests/hermes_cli/test_cron_profile_enumeration_lightweight.py +++ b/tests/hermes_cli/test_cron_profile_enumeration_lightweight.py @@ -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"]) diff --git a/tests/hermes_cli/test_dashboard_admin_endpoints.py b/tests/hermes_cli/test_dashboard_admin_endpoints.py index 3fc75119b4..32506d1ee7 100644 --- a/tests/hermes_cli/test_dashboard_admin_endpoints.py +++ b/tests/hermes_cli/test_dashboard_admin_endpoints.py @@ -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()) diff --git a/tests/hermes_cli/test_dashboard_auth_gate.py b/tests/hermes_cli/test_dashboard_auth_gate.py index 40f1c00089..0b4e520d0f 100644 --- a/tests/hermes_cli/test_dashboard_auth_gate.py +++ b/tests/hermes_cli/test_dashboard_auth_gate.py @@ -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"], }) diff --git a/tests/hermes_cli/test_dashboard_auth_ws_auth.py b/tests/hermes_cli/test_dashboard_auth_ws_auth.py index 4814ca30ef..e7da822a73 100644 --- a/tests/hermes_cli/test_dashboard_auth_ws_auth.py +++ b/tests/hermes_cli/test_dashboard_auth_ws_auth.py @@ -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] diff --git a/tests/hermes_cli/test_dashboard_spawn_executable.py b/tests/hermes_cli/test_dashboard_spawn_executable.py index de1bae8485..6227d9bd9b 100644 --- a/tests/hermes_cli/test_dashboard_spawn_executable.py +++ b/tests/hermes_cli/test_dashboard_spawn_executable.py @@ -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" ) diff --git a/tests/hermes_cli/test_desktop_repo_discovery_config.py b/tests/hermes_cli/test_desktop_repo_discovery_config.py index 40acd79261..a5c711e8a1 100644 --- a/tests/hermes_cli/test_desktop_repo_discovery_config.py +++ b/tests/hermes_cli/test_desktop_repo_discovery_config.py @@ -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(): diff --git a/tests/hermes_cli/test_env_custom_keys.py b/tests/hermes_cli/test_env_custom_keys.py index 530cf5231d..4f57f5335f 100644 --- a/tests/hermes_cli/test_env_custom_keys.py +++ b/tests/hermes_cli/test_env_custom_keys.py @@ -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() diff --git a/tests/hermes_cli/test_external_process_auth_status.py b/tests/hermes_cli/test_external_process_auth_status.py index 221459b10d..bc9ac1df82 100644 --- a/tests/hermes_cli/test_external_process_auth_status.py +++ b/tests/hermes_cli/test_external_process_auth_status.py @@ -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" diff --git a/tests/hermes_cli/test_main_model_custom_provider_normalization.py b/tests/hermes_cli/test_main_model_custom_provider_normalization.py index beb846b169..56ad879dab 100644 --- a/tests/hermes_cli/test_main_model_custom_provider_normalization.py +++ b/tests/hermes_cli/test_main_model_custom_provider_normalization.py @@ -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) diff --git a/tests/hermes_cli/test_mcp_dashboard_oauth.py b/tests/hermes_cli/test_mcp_dashboard_oauth.py index dc3fd4fb61..91f30ec4f0 100644 --- a/tests/hermes_cli/test_mcp_dashboard_oauth.py +++ b/tests/hermes_cli/test_mcp_dashboard_oauth.py @@ -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 diff --git a/tests/hermes_cli/test_mcp_security.py b/tests/hermes_cli/test_mcp_security.py index 1a0bf18b2a..4849360db8 100644 --- a/tests/hermes_cli/test_mcp_security.py +++ b/tests/hermes_cli/test_mcp_security.py @@ -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" diff --git a/tests/hermes_cli/test_moa_set_models_preserves_extra_keys.py b/tests/hermes_cli/test_moa_set_models_preserves_extra_keys.py index 689674c10d..f5b719d0ba 100644 --- a/tests/hermes_cli/test_moa_set_models_preserves_extra_keys.py +++ b/tests/hermes_cli/test_moa_set_models_preserves_extra_keys.py @@ -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) diff --git a/tests/hermes_cli/test_model_assignment_env_key_mirror.py b/tests/hermes_cli/test_model_assignment_env_key_mirror.py index e912db1655..9d869b530c 100644 --- a/tests/hermes_cli/test_model_assignment_env_key_mirror.py +++ b/tests/hermes_cli/test_model_assignment_env_key_mirror.py @@ -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): diff --git a/tests/hermes_cli/test_model_set_secret_copy.py b/tests/hermes_cli/test_model_set_secret_copy.py index dd2712f8a3..587fe2f9ab 100644 --- a/tests/hermes_cli/test_model_set_secret_copy.py +++ b/tests/hermes_cli/test_model_set_secret_copy.py @@ -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, "", "") diff --git a/tests/hermes_cli/test_normalize_main_model_assignment.py b/tests/hermes_cli/test_normalize_main_model_assignment.py index f6673a6208..b84208b4d8 100644 --- a/tests/hermes_cli/test_normalize_main_model_assignment.py +++ b/tests/hermes_cli/test_normalize_main_model_assignment.py @@ -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: diff --git a/tests/hermes_cli/test_nous_policy_surfaces.py b/tests/hermes_cli/test_nous_policy_surfaces.py index f2bc84ea71..d6862b6419 100644 --- a/tests/hermes_cli/test_nous_policy_surfaces.py +++ b/tests/hermes_cli/test_nous_policy_surfaces.py @@ -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 diff --git a/tests/hermes_cli/test_plugins_hub_perf_guard.py b/tests/hermes_cli/test_plugins_hub_perf_guard.py index 370e425e90..817a19dbfd 100644 --- a/tests/hermes_cli/test_plugins_hub_perf_guard.py +++ b/tests/hermes_cli/test_plugins_hub_perf_guard.py @@ -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 diff --git a/tests/hermes_cli/test_project_plugin_rce_bypass.py b/tests/hermes_cli/test_project_plugin_rce_bypass.py index 2e26b81cb8..ff641c8385 100644 --- a/tests/hermes_cli/test_project_plugin_rce_bypass.py +++ b/tests/hermes_cli/test_project_plugin_rce_bypass.py @@ -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 diff --git a/tests/hermes_cli/test_serve_parent_watchdog.py b/tests/hermes_cli/test_serve_parent_watchdog.py index 102fad4f85..08a0c07b23 100644 --- a/tests/hermes_cli/test_serve_parent_watchdog.py +++ b/tests/hermes_cli/test_serve_parent_watchdog.py @@ -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(): diff --git a/tests/hermes_cli/test_serve_port_in_use.py b/tests/hermes_cli/test_serve_port_in_use.py index 4179ce1e2f..aa67816ee8 100644 --- a/tests/hermes_cli/test_serve_port_in_use.py +++ b/tests/hermes_cli/test_serve_port_in_use.py @@ -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 diff --git a/tests/hermes_cli/test_session_list_reader_disposable.py b/tests/hermes_cli/test_session_list_reader_disposable.py index 1109c1b5d8..0ffeb23829 100644 --- a/tests/hermes_cli/test_session_list_reader_disposable.py +++ b/tests/hermes_cli/test_session_list_reader_disposable.py @@ -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 diff --git a/tests/hermes_cli/test_setup_hidden_env.py b/tests/hermes_cli/test_setup_hidden_env.py index 94611fce3e..b1babe9bea 100644 --- a/tests/hermes_cli/test_setup_hidden_env.py +++ b/tests/hermes_cli/test_setup_hidden_env.py @@ -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 ( diff --git a/tests/hermes_cli/test_spawn_gateway_restart_cooldown.py b/tests/hermes_cli/test_spawn_gateway_restart_cooldown.py index 031dbf74ae..190954b8f5 100644 --- a/tests/hermes_cli/test_spawn_gateway_restart_cooldown.py +++ b/tests/hermes_cli/test_spawn_gateway_restart_cooldown.py @@ -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" diff --git a/tests/hermes_cli/test_spawn_gateway_restart_reap.py b/tests/hermes_cli/test_spawn_gateway_restart_reap.py index dc0c8aa74f..9e08390520 100644 --- a/tests/hermes_cli/test_spawn_gateway_restart_reap.py +++ b/tests/hermes_cli/test_spawn_gateway_restart_reap.py @@ -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) diff --git a/tests/hermes_cli/test_system_stats_platform.py b/tests/hermes_cli/test_system_stats_platform.py index 877a29f7d9..94a505c316 100644 --- a/tests/hermes_cli/test_system_stats_platform.py +++ b/tests/hermes_cli/test_system_stats_platform.py @@ -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(): diff --git a/tests/hermes_cli/test_update_receipt_endpoint.py b/tests/hermes_cli/test_update_receipt_endpoint.py index cca062d3ef..bcdc34fd14 100644 --- a/tests/hermes_cli/test_update_receipt_endpoint.py +++ b/tests/hermes_cli/test_update_receipt_endpoint.py @@ -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) diff --git a/tests/hermes_cli/test_web_oauth_dispatch.py b/tests/hermes_cli/test_web_oauth_dispatch.py index 8e2c155591..04b1242652 100644 --- a/tests/hermes_cli/test_web_oauth_dispatch.py +++ b/tests/hermes_cli/test_web_oauth_dispatch.py @@ -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" diff --git a/tests/hermes_cli/test_web_routers_tools_install_on_enable.py b/tests/hermes_cli/test_web_routers_tools_install_on_enable.py index dea6df2922..3c450994c8 100644 --- a/tests/hermes_cli/test_web_routers_tools_install_on_enable.py +++ b/tests/hermes_cli/test_web_routers_tools_install_on_enable.py @@ -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} diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 808a77c4d8..4a237c0556 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -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('") # 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 diff --git a/tests/hermes_cli/test_web_server_approvals_broadcast.py b/tests/hermes_cli/test_web_server_approvals_broadcast.py index cc4244a938..bcb6814d93 100644 --- a/tests/hermes_cli/test_web_server_approvals_broadcast.py +++ b/tests/hermes_cli/test_web_server_approvals_broadcast.py @@ -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", diff --git a/tests/hermes_cli/test_web_server_boot_handshake.py b/tests/hermes_cli/test_web_server_boot_handshake.py index df3d7c5367..0c413f2737 100644 --- a/tests/hermes_cli/test_web_server_boot_handshake.py +++ b/tests/hermes_cli/test_web_server_boot_handshake.py @@ -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()) diff --git a/tests/hermes_cli/test_web_server_config_offloop.py b/tests/hermes_cli/test_web_server_config_offloop.py index 679e7741e5..9d87a9ba2d 100644 --- a/tests/hermes_cli/test_web_server_config_offloop.py +++ b/tests/hermes_cli/test_web_server_config_offloop.py @@ -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() diff --git a/tests/hermes_cli/test_web_server_cron_profiles.py b/tests/hermes_cli/test_web_server_cron_profiles.py index 3a8bda9a76..1be4ee2818 100644 --- a/tests/hermes_cli/test_web_server_cron_profiles.py +++ b/tests/hermes_cli/test_web_server_cron_profiles.py @@ -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", diff --git a/tests/hermes_cli/test_web_server_executor_isolation.py b/tests/hermes_cli/test_web_server_executor_isolation.py index 0fe98cbadc..24111389ec 100644 --- a/tests/hermes_cli/test_web_server_executor_isolation.py +++ b/tests/hermes_cli/test_web_server_executor_isolation.py @@ -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( diff --git a/tests/hermes_cli/test_web_server_files.py b/tests/hermes_cli/test_web_server_files.py index 86ed559605..5157da814d 100644 --- a/tests/hermes_cli/test_web_server_files.py +++ b/tests/hermes_cli/test_web_server_files.py @@ -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), diff --git a/tests/hermes_cli/test_web_server_gateway_topology.py b/tests/hermes_cli/test_web_server_gateway_topology.py index 4cfee0d76d..d519540315 100644 --- a/tests/hermes_cli/test_web_server_gateway_topology.py +++ b/tests/hermes_cli/test_web_server_gateway_topology.py @@ -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 : 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", diff --git a/tests/hermes_cli/test_web_server_messaging_profiles.py b/tests/hermes_cli/test_web_server_messaging_profiles.py index cf5e22f54b..6f787fbc14 100644 --- a/tests/hermes_cli/test_web_server_messaging_profiles.py +++ b/tests/hermes_cli/test_web_server_messaging_profiles.py @@ -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 diff --git a/tests/hermes_cli/test_web_server_profile_unification.py b/tests/hermes_cli/test_web_server_profile_unification.py index 75a3f368f2..86b576fe03 100644 --- a/tests/hermes_cli/test_web_server_profile_unification.py +++ b/tests/hermes_cli/test_web_server_profile_unification.py @@ -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"]) diff --git a/tests/hermes_cli/test_web_server_pty_idle_backoff.py b/tests/hermes_cli/test_web_server_pty_idle_backoff.py index 7a75525981..49712fcf61 100644 --- a/tests/hermes_cli/test_web_server_pty_idle_backoff.py +++ b/tests/hermes_cli/test_web_server_pty_idle_backoff.py @@ -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: diff --git a/tests/hermes_cli/test_web_server_pty_import.py b/tests/hermes_cli/test_web_server_pty_import.py index ff7db6bc95..b8787af7f2 100644 --- a/tests/hermes_cli/test_web_server_pty_import.py +++ b/tests/hermes_cli/test_web_server_pty_import.py @@ -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(): diff --git a/tests/hermes_cli/test_web_server_pty_reconnect.py b/tests/hermes_cli/test_web_server_pty_reconnect.py index 60b6833c30..e6c0f3ae5b 100644 --- a/tests/hermes_cli/test_web_server_pty_reconnect.py +++ b/tests/hermes_cli/test_web_server_pty_reconnect.py @@ -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 diff --git a/tests/hermes_cli/test_web_server_session_search.py b/tests/hermes_cli/test_web_server_session_search.py index a3820b5f67..5a6b8712eb 100644 --- a/tests/hermes_cli/test_web_server_session_search.py +++ b/tests/hermes_cli/test_web_server_session_search.py @@ -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 diff --git a/tests/hermes_cli/test_web_server_skills_profiles.py b/tests/hermes_cli/test_web_server_skills_profiles.py index e5aa4def74..e3ce2408c8 100644 --- a/tests/hermes_cli/test_web_server_skills_profiles.py +++ b/tests/hermes_cli/test_web_server_skills_profiles.py @@ -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"), ) ] diff --git a/tests/hermes_cli/test_web_server_speak_stream.py b/tests/hermes_cli/test_web_server_speak_stream.py index 01220e7e44..33a9e07952 100644 --- a/tests/hermes_cli/test_web_server_speak_stream.py +++ b/tests/hermes_cli/test_web_server_speak_stream.py @@ -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) diff --git a/tests/hermes_cli/test_whatsapp_onboarding.py b/tests/hermes_cli/test_whatsapp_onboarding.py index 0bac1fb1de..97985cc871 100644 --- a/tests/hermes_cli/test_whatsapp_onboarding.py +++ b/tests/hermes_cli/test_whatsapp_onboarding.py @@ -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() diff --git a/tests/hermes_state/test_aux_usage_accounting.py b/tests/hermes_state/test_aux_usage_accounting.py index 0dd4ddd178..d960e0d78d 100644 --- a/tests/hermes_state/test_aux_usage_accounting.py +++ b/tests/hermes_state/test_aux_usage_accounting.py @@ -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( diff --git a/tests/run_agent/test_callable_api_key.py b/tests/run_agent/test_callable_api_key.py index ad0b109da8..8042eb912c 100644 --- a/tests/run_agent/test_callable_api_key.py +++ b/tests/run_agent/test_callable_api_key.py @@ -130,7 +130,7 @@ class TestTruncateTokenCallable: def test_callable_returns_placeholder(self): """Dashboard preview must render the Entra placeholder, NOT ``""``.""" - 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("") == "" diff --git a/tests/test_code_skew.py b/tests/test_code_skew.py index 3ae3bbb6fb..2bde4b5634 100644 --- a/tests/test_code_skew.py +++ b/tests/test_code_skew.py @@ -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] diff --git a/tests/test_pty_keepalive_ws.py b/tests/test_pty_keepalive_ws.py index 9d92f7fa9c..86c85f60f0 100644 --- a/tests/test_pty_keepalive_ws.py +++ b/tests/test_pty_keepalive_ws.py @@ -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, ) diff --git a/tests/test_schema_read_probe.py b/tests/test_schema_read_probe.py index a1c0c83978..4de30263ee 100644 --- a/tests/test_schema_read_probe.py +++ b/tests/test_schema_read_probe.py @@ -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 diff --git a/tests/test_session_detail_malformed_db.py b/tests/test_session_detail_malformed_db.py index 16a3eb12a1..6663bb0d3d 100644 --- a/tests/test_session_detail_malformed_db.py +++ b/tests/test_session_detail_malformed_db.py @@ -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(), ) diff --git a/tests/test_web_server_sessiondb_eventloop.py b/tests/test_web_server_sessiondb_eventloop.py index e5db9c5f37..7666bbcf09 100644 --- a/tests/test_web_server_sessiondb_eventloop.py +++ b/tests/test_web_server_sessiondb_eventloop.py @@ -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"]) ) ) diff --git a/tests/test_web_server_status_topology_cache.py b/tests/test_web_server_status_topology_cache.py index 76c4d0be2e..1a493b1c85 100644 --- a/tests/test_web_server_status_topology_cache.py +++ b/tests/test_web_server_status_topology_cache.py @@ -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() diff --git a/tests/tui_gateway/test_cold_start_gil_stall.py b/tests/tui_gateway/test_cold_start_gil_stall.py index 68bf9ee985..9fc7585efb 100644 --- a/tests/tui_gateway/test_cold_start_gil_stall.py +++ b/tests/tui_gateway/test_cold_start_gil_stall.py @@ -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, (