From 7ee7c1515e7778edc0924e3c674607d359da4893 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:09:46 -0700 Subject: [PATCH 1/4] refactor(web_server): drop dead legacy re-exports, factor auth-gate message and post-bind best-effort steps --- hermes_cli/web_server.py | 868 ++++++++++++--------------------------- 1 file changed, 256 insertions(+), 612 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index e010a6a9ce..b9bda597a2 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -85,7 +85,6 @@ _log = logging.getLogger(__name__) from hermes_cli.web_server_lifecycle import ( # noqa: E402,F401 — re-exported; routers/tests reach these via web_server. - _process_start_marker, PORT_IN_USE_EXIT_CODE, _dashboard_forwarded_allow_ips, _eager_reconcile_own_session_db, @@ -355,27 +354,26 @@ async def _lifespan(app: "FastAPI"): _terminate_desktop_managed_gateway() -def _get_chat_argv_lock(app: "FastAPI") -> asyncio.Lock: - """Return the chat-argv resolution lock from app.state. +def _app_state_default(app: "FastAPI", name: str, factory): + """Return ``app.state.``, lazily creating it for non-``with`` TestClient usages. - Mirrors :func:`_get_event_state`: prefers the lifespan-initialised Lock - (created on the correct event loop) but lazily initialises it for - non-``with`` TestClient usages. + The lifespan normally initialises these on the running event loop (an + asyncio.Lock created at import time binds to whatever loop was active then). """ try: - return app.state.chat_argv_lock + return getattr(app.state, name) except AttributeError: - app.state.chat_argv_lock = asyncio.Lock() - return app.state.chat_argv_lock + value = factory() + setattr(app.state, name, value) + return value + + +def _get_chat_argv_lock(app: "FastAPI") -> asyncio.Lock: + return _app_state_default(app, "chat_argv_lock", asyncio.Lock) def _get_pty_active_session_files(app: "FastAPI") -> dict[str, Path]: - """Return channel -> active-session-file state for dashboard PTYs.""" - try: - return app.state.pty_active_session_files - except AttributeError: - app.state.pty_active_session_files = {} - return app.state.pty_active_session_files + return _app_state_default(app, "pty_active_session_files", dict) app = FastAPI(title="Hermes Agent", version=__version__, lifespan=_lifespan) @@ -807,53 +805,31 @@ async def _plugin_api_runtime_gate(request: Request, call_next): the enabled/disabled check for a request that auth already let through. """ path = request.url.path - if path.startswith("/api/plugins/"): - # Only gate authenticated requests. Unauthenticated ones fall - # through so auth_middleware / the OAuth gate return 401 first and - # this route can't be used as a plugin-name oracle. - _authed = ( - getattr(request.state, "token_authenticated", False) - or getattr(request.app.state, "auth_required", False) - or _has_valid_session_token(request) - or _has_valid_query_token(request, path) - ) - if _authed: - # Extract plugin name from /api/plugins//... - parts = path.split("/") - # parts: ['', 'api', 'plugins', '', ...] - if len(parts) >= 4: - plugin_name = parts[3] - if plugin_name: - try: - from hermes_cli.plugins_cmd import ( - _get_enabled_set, - _get_disabled_set, - ) - enabled_set = _get_enabled_set() - disabled_set = _get_disabled_set() - except Exception: - enabled_set = set() - disabled_set = set() - # Determine plugin source. Check the cached plugin list; - # if not found, assume user plugin (safe default — blocks). - plugins = _get_dashboard_plugins() - plugin = next( - (p for p in plugins if p.get("name") == plugin_name), - None, - ) - source = plugin.get("source") if plugin else "user" - if source == "user": - if plugin_name in disabled_set or plugin_name not in enabled_set: - return JSONResponse( - status_code=404, - content={"detail": "Plugin not found"}, - ) - elif source == "bundled": - if plugin_name in disabled_set: - return JSONResponse( - status_code=404, - content={"detail": "Plugin not found"}, - ) + # parts: ['', 'api', 'plugins', '', ...] + parts = path.split("/") + plugin_name = parts[3] if path.startswith("/api/plugins/") and len(parts) >= 4 else "" + # Only gate authenticated requests. Unauthenticated ones fall through so + # auth_middleware / the OAuth gate return 401 first and this route can't + # be used as a plugin-name oracle. + if plugin_name and ( + getattr(request.state, "token_authenticated", False) + or getattr(request.app.state, "auth_required", False) + or _has_valid_session_token(request) + or _has_valid_query_token(request, path) + ): + try: + from hermes_cli.plugins_cmd import _get_enabled_set, _get_disabled_set + enabled_set = _get_enabled_set() + disabled_set = _get_disabled_set() + except Exception: + enabled_set = set() + disabled_set = set() + # Source from the cached plugin list; unknown => user plugin (safe default — blocks). + plugin = next((p for p in _get_dashboard_plugins() if p.get("name") == plugin_name), None) + source = plugin.get("source") if plugin else "user" + blocked = plugin_name in disabled_set or (source == "user" and plugin_name not in enabled_set) + if blocked and source in ("user", "bundled"): + return JSONResponse(status_code=404, content={"detail": "Plugin not found"}) return await call_next(request) @@ -1046,10 +1022,8 @@ async def _dashboard_selftest_loop() -> None: from hermes_cli.web_server_config import ( # noqa: E402,F401 — re-exported; routers/tests reach these via web_server. CONFIG_SCHEMA, _AUX_TASK_SLOTS, - _SCHEMA_OVERRIDES, _apply_main_model_assignment, _apply_model_assignment_sync, - _build_schema_from_config, _dashboard_code_skew_guard, _denormalize_config_from_web, _memory_provider_options, @@ -1062,88 +1036,16 @@ from hermes_cli.web_server_config import ( # noqa: E402,F401 — re-exported; r from hermes_cli.web_models import ( # noqa: F401 ConfigUpdate, - EnvVarUpdate, - EnvVarDelete, - EnvVarReveal, - MemoryProviderConfigUpdate, - MemoryProviderSetupRequest, - CustomEndpointUpdate, - MessagingPlatformUpdate, - TelegramOnboardingStart, - TelegramOnboardingApply, WhatsAppOnboardingStart, WhatsAppOnboardingApply, - AudioTranscriptionRequest, - ManagedFileUpload, - ChatImageUpload, - ManagedDirectoryCreate, - ManagedFileDelete, - ModelAssignment, MoaModelSlot, - _MoaReferenceControls, MoaPresetPayload, MoaConfigPayload, - FsWriteText, - GitPathBody, - GitFileBody, - GitCommitBody, - GitWorktreeAddBody, - GitWorktreeRemoveBody, - GitBranchSwitchBody, - CuratorPause, - LearningNodeRef, - LearningNodeEdit, - DebugShareRequest, - TTSSpeakRequest, - TTSLeaseRequest, - OAuthSubmitBody, BulkDeleteSessions, - SessionImport, - SessionRename, - SessionPrune, CronJobCreate, CronJobUpdate, AutomationBlueprintInstantiate, MCPServerCreate, - MCPServersReplace, - MCPEnabledToggle, - MCPCatalogInstall, - PairingApprove, - PairingRevoke, - WebhookCreate, - WebhookEnabledToggle, - CredentialPoolAdd, - MemoryProviderSelect, - MemoryReset, - BackupRequest, - ImportRequest, - HookCreate, - HookDelete, - SkillInstallRequest, - SkillUninstallRequest, - SkillsUpdateRequest, - ProfileCreate, - ProfileRename, - ProfileSoulUpdate, - ProfileActiveUpdate, - ProfileDescriptionUpdate, - ProfileModelUpdate, - ProfileDescribeAuto, - SkillToggle, - SkillCreate, - SkillContentUpdate, - ToolsetToggle, - ToolsetProviderSelect, - ToolsetModelSelect, - ToolsetEnvUpdate, - ToolsetPostSetup, - TerminalBackendSelect, - RawConfigUpdate, - ThemeSetBody, - FontSetBody, - _AgentPluginInstallBody, - _PluginProvidersPutBody, - _PluginVisibilityBody, ) @@ -1199,7 +1101,6 @@ from hermes_cli.web_server_gateway import ( # noqa: E402,F401 — re-exported; from hermes_cli.web_server_files import ( # noqa: E402,F401 — re-exported; routers/tests reach these via web_server. - _canonical_path, _dashboard_local_update_managed_externally, _fs_path, _managed_file_entry, @@ -1216,7 +1117,6 @@ from hermes_cli.web_routers import files as _files_routes # noqa: E402 app.include_router(_files_routes.router) from hermes_cli.web_routers.files import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. - get_media, upload_managed_file_stream, ) @@ -1241,27 +1141,6 @@ app.include_router(_git_routes.router) from hermes_cli.web_routers import local_models as _local_models_routes # noqa: E402 app.include_router(_local_models_routes.router) -from hermes_cli.web_routers.git import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. - git_status_route, - git_worktrees_route, - git_branches_route, - git_base_branches_route, - git_review_list_route, - git_review_diff_route, - git_file_diff_route, - git_commit_context_route, - git_rev_parse_route, - git_ship_info_route, - git_stage_route, - git_unstage_route, - git_revert_route, - git_commit_route, - git_push_route, - git_create_pr_route, - git_worktree_add_route, - git_worktree_remove_route, - git_branch_switch_route, -) # Stable install identity for /api/status. One random opaque id per physical @@ -1395,9 +1274,6 @@ app.include_router(_actions_routes.router) from hermes_cli.web_routers import audio as _audio_routes # noqa: E402 app.include_router(_audio_routes.router) -from hermes_cli.web_routers.audio import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. - speak_text, -) # Collapses repeated identical ElevenLabs voice-list failures (the desktop @@ -1428,18 +1304,11 @@ app.include_router(_actions_routes.status_router) from hermes_cli.web_routers import sessions as _sessions_routes # noqa: E402 app.include_router(_sessions_routes.list_router) -from hermes_cli.web_routers.sessions import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. - get_sessions, -) from hermes_cli.web_routers import profiles as _profiles_routes # noqa: E402 app.include_router(_profiles_routes.sessions_router) -from hermes_cli.web_routers.profiles import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. - get_profiles_sessions, - get_profiles_sessions_sidebar, -) app.include_router(_sessions_routes.search_router) @@ -1452,7 +1321,6 @@ from hermes_cli.web_server_memory import ( # noqa: E402,F401 — re-exported; r _coerce_bool, _dependency_importable, _discover_memory_provider_statuses, - _env_lookup, _field_default, _field_is_set, _field_value, @@ -1463,7 +1331,6 @@ from hermes_cli.web_server_memory import ( # noqa: E402,F401 — re-exported; r _memory_provider_setup_manifest, _normalize_memory_provider_name, _normalize_memory_provider_schema, - _read_json_file, _read_memory_provider_existing_values, _require_memory_provider_ready, _run_setup_command, @@ -1478,17 +1345,12 @@ app.include_router(_memory_providers_routes.router) from hermes_cli.web_routers import config_env as _config_env_routes # noqa: E402 app.include_router(_config_env_routes.config_router) -from hermes_cli.web_routers.config_env import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. - get_config, - get_schema, -) from hermes_cli.web_routers import models as _models_routes # noqa: E402 app.include_router(_models_routes.router) from hermes_cli.web_routers.models import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. - get_model_info, get_model_options, get_recommended_default_model, set_moa_models, @@ -1559,7 +1421,6 @@ from hermes_cli.web_server_oauth import ( # noqa: E402,F401 — re-exported; ro _minimax_poller, _nous_poller, _oauth_profile_name, - _oauth_session_profile, _oauth_sessions, _oauth_sessions_lock, _truncate_token, @@ -1574,7 +1435,6 @@ from hermes_cli.web_routers.oauth import ( # noqa: E402,F401 — legacy re-expo _codex_full_login_worker, _new_oauth_session, _resolve_provider_status, - start_oauth_login, ) @@ -1594,15 +1454,11 @@ from hermes_cli.web_server_sessions import ( # noqa: E402,F401 — re-exported; app.include_router(_sessions_routes.manage_router) from hermes_cli.web_routers.sessions import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. bulk_delete_sessions_endpoint, - import_sessions_endpoint, count_empty_sessions_endpoint, delete_empty_sessions_endpoint, - get_session_stats, - get_session_detail, get_session_latest_descendant, get_session_messages, delete_session_endpoint, - rename_session_endpoint, export_session_endpoint, prune_sessions_endpoint, ) @@ -1639,17 +1495,12 @@ from hermes_cli.web_routers import cron as _cron_routes # noqa: E402 app.include_router(_cron_routes.router) from hermes_cli.web_routers.cron import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. list_cron_jobs, - get_cron_job, - list_cron_job_runs, create_cron_job, - get_cron_delivery_targets, update_cron_job, pause_cron_job, resume_cron_job, trigger_cron_job, delete_cron_job, - cron_fire_webhook, - list_cron_blueprints, instantiate_blueprint, _normalize_dashboard_cron_updates, ) @@ -1666,19 +1517,6 @@ from hermes_cli.web_server_mcp import ( # noqa: E402,F401 — re-exported; rout from hermes_cli.web_routers import mcp as _mcp_routes # noqa: E402 app.include_router(_mcp_routes.router) -from hermes_cli.web_routers.mcp import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. - list_mcp_servers, - add_mcp_server, - replace_mcp_servers, - remove_mcp_server, - test_mcp_server, - auth_mcp_server, - mcp_oauth_flow_status, - mcp_oauth_callback, - set_mcp_server_enabled, - list_mcp_catalog, - install_mcp_catalog_entry, -) _ACTION_LOG_FILES.setdefault("computer-use-grant", "action-computer-use-grant.log") @@ -1688,15 +1526,9 @@ from hermes_cli.web_routers import ops as _ops_routes # noqa: E402 app.include_router(_ops_routes.router) from hermes_cli.web_routers.ops import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. - delete_webhook, - list_checkpoints, list_credential_pool, - prune_checkpoints, - run_backup, run_doctor, run_import, - run_security_audit, - start_gateway, ) @@ -1713,62 +1545,17 @@ from hermes_cli.web_routers.ops import ( # noqa: E402,F401 — legacy re-export from hermes_cli.web_routers import skills as _skills_routes # noqa: E402 app.include_router(_skills_routes.hub_router) -from hermes_cli.web_routers.skills import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. - install_skill_hub, - uninstall_skill_hub, - update_skills_hub, - list_skills_hub_sources, - search_skills_hub, - preview_skill_hub, - scan_skill_hub, -) app.include_router(_profiles_routes.router) -from hermes_cli.web_routers.profiles import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. - list_profiles_endpoint, - create_profile_endpoint, - get_active_profile_endpoint, - set_active_profile_endpoint, - get_profile_setup_command, - open_profile_terminal_endpoint, - rename_profile_endpoint, - delete_profile_endpoint, - get_profile_soul, - update_profile_soul, - update_profile_description_endpoint, - update_profile_model_endpoint, - describe_profile_auto_endpoint, -) app.include_router(_skills_routes.router) -from hermes_cli.web_routers.skills import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. - get_skills, - toggle_skill, - get_skill_content, - create_skill, - update_skill_content, -) from hermes_cli.web_routers import tools as _tools_routes # noqa: E402 app.include_router(_tools_routes.router) -from hermes_cli.web_routers.tools import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. - get_toolsets, - toggle_toolset, - get_toolset_config, - get_toolset_models, - select_toolset_model, - select_toolset_provider, - save_toolset_env, - run_toolset_post_setup, - get_terminal_backends, - select_terminal_backend, - get_computer_use_status, - grant_computer_use_permissions, -) from hermes_cli.web_routers import analytics as _analytics_routes # noqa: E402 @@ -1789,7 +1576,6 @@ from hermes_cli.web_server_chat import ( # noqa: E402,F401 — re-exported; rou _LOOPBACK_HOSTS, _PTY_BRIDGE_AVAILABLE, _RESIZE_RE, - _WILDCARD_HOSTS, _active_session_file_for_channel, _build_gateway_ws_url, _build_sidecar_url, @@ -1820,8 +1606,6 @@ from hermes_cli.web_routers.chat_ws import ( # noqa: E402,F401 — legacy re-ex from hermes_cli.web_server_dashboard import ( # noqa: E402,F401 — re-exported; routers/tests reach these via web_server. _BUILTIN_DASHBOARD_THEMES, - _THEME_COMPONENT_BUCKETS, - _THEME_NAMED_ASSET_KEYS, _discover_dashboard_plugins, _discover_user_themes, _invalidate_plugins_hub_cache, @@ -1840,7 +1624,6 @@ from hermes_cli.web_routers import dashboard_ui as _dashboard_ui_routes # noqa: app.include_router(_dashboard_ui_routes.router) from hermes_cli.web_routers.dashboard_ui import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. post_agent_plugin_install, - serve_plugin_asset, ) @@ -1871,6 +1654,91 @@ app.include_router(_dashboard_auth_router) mount_spa(app) +def _no_auth_provider_message(host: str) -> str: + """Actionable SystemExit text for a gated bind with no registered auth provider. + + Names the exact trigger: on a loopback bind the ONLY trigger is + dashboard.public_url, so print the offending URL and the remove-it exit. + Bundled providers expose ``LAST_SKIP_REASON`` so an installed-but- + unconfigured provider is not reported as merely "no providers". + """ + skip_reasons: list[str] = [] + try: + from plugins.dashboard_auth import nous as _nous_plugin + + if _nous_plugin.LAST_SKIP_REASON: + skip_reasons.append(f" • nous: {_nous_plugin.LAST_SKIP_REASON}") + except Exception: + pass + + if host in _LOOPBACK_HOST_VALUES: + public_url = "" + try: + from hermes_cli.dashboard_auth.prefix import resolve_public_url + + public_url = resolve_public_url() + except Exception: + pass + gate_reason = ( + f"dashboard.public_url is set to " + f"{public_url or ''} — an " + f"operator-declared external URL engages the auth gate " + f"even on a loopback bind" + ) + fix_hint = ( + "If this dashboard should be LOCAL-ONLY (no reverse " + "proxy), remove dashboard.public_url from config.yaml " + "(and unset HERMES_DASHBOARD_PUBLIC_URL) to restore the " + "unauthenticated loopback mode.\n" + ) + else: + gate_reason = f"the auth gate engages on non-loopback binds ({host})" + fix_hint = "" + + fix_hint += ( + "Configure an auth provider before exposing the dashboard:\n" + " • Password: set dashboard.basic_auth.username + " + "password_hash in config.yaml\n" + " (hash with: python -c \"from " + "plugins.dashboard_auth.basic import hash_password; " + "print(hash_password('your-password'))\")\n" + " • OAuth: run `hermes dashboard register` (Nous Portal) or " + "install a DashboardAuthProvider plugin.\n" + "There is no unauthenticated public-dashboard option. For " + "local-only use, bind 127.0.0.1 and leave dashboard.public_url " + "unset; a configured external public URL requires auth even " + "when a local reverse proxy reaches a loopback backend." + ) + # Credentials exist but the bundled provider is disabled (#54489). Basic + # auth needs a username AND a credential; a half-configured block is silent. + try: + from hermes_cli.config import load_config as _load_cfg + from hermes_cli.plugins_cmd import _BASIC_AUTH_PLUGIN_KEYS + + cfg = _load_cfg() + ba = (cfg.get("dashboard") or {}).get("basic_auth") or {} + disabled = (cfg.get("plugins") or {}).get("disabled") or [] + has_creds = bool(ba.get("username")) and bool(ba.get("password_hash") or ba.get("password")) + if has_creds and (set(disabled) & _BASIC_AUTH_PLUGIN_KEYS): + fix_hint = ( + "The 'basic' dashboard-auth plugin is in " + "plugins.disabled but dashboard.basic_auth is " + "configured.\n" + "Remove 'basic' from plugins.disabled (or run " + "`hermes plugins enable basic`), then restart the " + "dashboard.\n\n" + ) + fix_hint + except Exception: + pass + msg = ( + f"Refusing to bind dashboard to {host} — {gate_reason}, " + f"but no auth providers are registered.\n\n" + ) + if skip_reasons: + msg += "Bundled providers reported these issues:\n" + "\n".join(skip_reasons) + "\n\n" + return msg + fix_hint + + def _configure_auth_gate( host: str, allow_public: bool, @@ -1880,22 +1748,19 @@ def _configure_auth_gate( """Resolve the trusted public hosts + auth-gate flag onto ``app.state``. Fails closed (``SystemExit`` with an actionable message) when the gate - engages but no dashboard auth provider is registered.""" - # A configured browser-facing URL is also the exact Host/Origin trust - # declaration for reverse-proxy deployments. Resolve it once at startup so - # request middleware never reloads config. Any non-loopback public hostname - # engages the auth gate even when the backend itself remains on loopback; - # otherwise the SPA's local session token would become remotely reachable. + engages but no dashboard auth provider is registered. + """ + # dashboard.public_url is also the exact Host/Origin trust declaration for + # reverse-proxy deployments; resolved once so middleware never reloads + # config. A non-loopback public hostname engages the gate even on a loopback + # backend, else the SPA's local session token becomes remotely reachable. app.state.trusted_public_hosts = _dashboard_public_hosts() - # Stash the auth-gate flag on app.state so middleware / SPA-token injection / - # WS-auth paths can branch on it consistently. It also decides whether to - # refuse startup, log the gate-on banner, and enable uvicorn proxy_headers. + # auth_required drives middleware, SPA-token injection, WS auth, the + # startup refusal, the gate-on banner and uvicorn proxy_headers. if _desktop_loopback_auth_exempt(host, ssh_session_token, ssh_owner_nonce): - # A configured dashboard.public_url describes the operator's PUBLIC - # deployment, not this private Desktop-owned loopback backend (#96490). - # Desktop authenticates with the per-spawn session token; forcing the - # ticket-only gate here broke every Desktop boot while the actual - # public dashboard — a separate non-loopback process — stayed gated. + # public_url describes the operator's PUBLIC deployment, not this + # Desktop-owned loopback backend (#96490), which authenticates with the + # per-spawn session token the ticket-only gate would refuse. app.state.auth_required = should_require_auth(host) _log.info( "Desktop-owned loopback backend: dashboard.public_url does not " @@ -1903,14 +1768,10 @@ def _configure_auth_gate( "keeps its own gate.", ) else: - app.state.auth_required = should_require_dashboard_auth( - host, app.state.trusted_public_hosts - ) + app.state.auth_required = should_require_dashboard_auth(host, app.state.trusted_public_hosts) - # ``--insecure`` no longer disables the auth gate (June 2026 hardening: - # the hermes-0day MCP-persistence campaign abused unauthenticated public - # dashboards). If a caller still passes it, warn that it is now a no-op - # rather than silently changing their expectation of an open bind. + # ``--insecure`` no longer disables the gate (June 2026 hermes-0day + # hardening); warn that it is a no-op rather than silently ignore it. if allow_public and host not in _LOOPBACK_HOST_VALUES: _log.warning( "--insecure no longer bypasses dashboard authentication. A " @@ -1921,115 +1782,10 @@ def _configure_auth_gate( ) if app.state.auth_required: - # The gate engages on every non-loopback bind. Require at least one - # provider to be registered, else fail closed — there is no longer an - # escape hatch that serves the dashboard without authentication. + # No escape hatch serves a gated dashboard without a provider. from hermes_cli.dashboard_auth import list_providers if not list_providers(): - # Surface the *specific* reason any bundled provider declined - # to register (e.g. missing HERMES_DASHBOARD_OAUTH_CLIENT_ID). - # Each provider plugin that ships with Hermes Agent exposes a - # module-level ``LAST_SKIP_REASON`` string for this purpose; - # without it the operator would only see "no providers" which - # is misleading when the provider IS installed but unconfigured. - skip_reasons: list[str] = [] - try: - from plugins.dashboard_auth import nous as _nous_plugin - - if _nous_plugin.LAST_SKIP_REASON: - skip_reasons.append( - f" • nous: {_nous_plugin.LAST_SKIP_REASON}" - ) - except Exception: - pass - - # Name the exact reason the gate engaged. When the bind itself is - # loopback the ONLY trigger is dashboard.public_url — an operator - # (or a stale config.yaml entry) declared external exposure. Say - # so explicitly, print the offending URL, and give the two exits: - # configure auth, or remove public_url to restore local-only mode. - if host in _LOOPBACK_HOST_VALUES: - _public_url_for_msg = "" - try: - from hermes_cli.dashboard_auth.prefix import ( - resolve_public_url as _rpu, - ) - - _public_url_for_msg = _rpu() - except Exception: - pass - _gate_reason = ( - f"dashboard.public_url is set to " - f"{_public_url_for_msg or ''} — an " - f"operator-declared external URL engages the auth gate " - f"even on a loopback bind" - ) - _local_only_hint = ( - "If this dashboard should be LOCAL-ONLY (no reverse " - "proxy), remove dashboard.public_url from config.yaml " - "(and unset HERMES_DASHBOARD_PUBLIC_URL) to restore the " - "unauthenticated loopback mode.\n" - ) - else: - _gate_reason = ( - f"the auth gate engages on non-loopback binds ({host})" - ) - _local_only_hint = "" - - _fix_hint = ( - _local_only_hint - + "Configure an auth provider before exposing the dashboard:\n" - " • Password: set dashboard.basic_auth.username + " - "password_hash in config.yaml\n" - " (hash with: python -c \"from " - "plugins.dashboard_auth.basic import hash_password; " - "print(hash_password('your-password'))\")\n" - " • OAuth: run `hermes dashboard register` (Nous Portal) or " - "install a DashboardAuthProvider plugin.\n" - "There is no unauthenticated public-dashboard option. For " - "local-only use, bind 127.0.0.1 and leave dashboard.public_url " - "unset; a configured external public URL requires auth even " - "when a local reverse proxy reaches a loopback backend." - ) - # Hint when credentials exist but the bundled provider is blocked - # (#54489). - try: - from hermes_cli.config import load_config as _load_cfg - from hermes_cli.plugins_cmd import _BASIC_AUTH_PLUGIN_KEYS - - _cfg = _load_cfg() - _ba = (_cfg.get("dashboard") or {}).get("basic_auth") or {} - _disabled = (_cfg.get("plugins") or {}).get("disabled") or [] - # Basic auth only activates with a username AND a credential - # (plaintext password or password_hash); don't fire the hint on - # a half-configured block. - _has_creds = bool(_ba.get("username")) and bool( - _ba.get("password_hash") or _ba.get("password") - ) - if _has_creds and (set(_disabled) & _BASIC_AUTH_PLUGIN_KEYS): - _fix_hint = ( - "The 'basic' dashboard-auth plugin is in " - "plugins.disabled but dashboard.basic_auth is " - "configured.\n" - "Remove 'basic' from plugins.disabled (or run " - "`hermes plugins enable basic`), then restart the " - "dashboard.\n\n" - ) + _fix_hint - except Exception: - pass - if skip_reasons: - raise SystemExit( - f"Refusing to bind dashboard to {host} — {_gate_reason}, " - f"but no auth providers are registered.\n\n" - f"Bundled providers reported these issues:\n" - + "\n".join(skip_reasons) - + "\n\n" - + _fix_hint - ) - raise SystemExit( - f"Refusing to bind dashboard to {host} — {_gate_reason}, " - f"but no auth providers are registered.\n\n" + _fix_hint - ) + raise SystemExit(_no_auth_provider_message(host)) _log.info( "Dashboard binding to %s with auth gate enabled. Providers: %s", host, @@ -2038,46 +1794,25 @@ def _configure_auth_gate( def _build_uvicorn_server(host: str, port: int): - """Build the uvicorn ``Config`` + ``Server`` for this bind (reads - ``app.state.auth_required``; see the comments for each knob).""" + """Build the uvicorn ``Config`` + ``Server`` for this bind (reads ``app.state.auth_required``). + + uvicorn.Server is driven directly (not uvicorn.run) so startup is split from + the main loop: after startup() the socket is bound and held by uvicorn, so the + OS-assigned port can be read with no pre-bind-then-close TOCTOU. Explicit + taken ports are caught by the #93608 preflight probe; uvicorn's own bind + error stays the fallback for races. + """ import uvicorn - # ── Start uvicorn with direct Server API ───────────────────────── - # We use uvicorn.Server directly (not uvicorn.run) so we can split - # startup from the main loop. After startup() the socket is actually - # bound — we read the OS-assigned port from the live socket, print - # HERMES_DASHBOARD_READY, open the browser, *then* serve. - # - # This eliminates the TOCTOU of the old pre-bind-then-close approach - # (bind port 0 → close → uvicorn rebind): the socket is held by - # uvicorn the entire time, so no other process can steal the port. - # - # For explicit non-zero ports, a taken port is detected by the #93608 - # preflight probe below (BACKEND_PORT_IN_USE sentinel + distinct exit - # code); uvicorn's own bind error remains the fallback for races. - # Loopback binds are the Desktop case: a single local client, no reverse - # proxy in front. uvicorn's ws keepalive ping runs ON the same event loop - # as agent turns, and a single synchronous GIL-holding call on a worker - # thread (e.g. a regex/scrub over a large model output, or a long - # delegate_task subagent turn) can starve that loop for *minutes* — the - # loop cannot process the incoming pong, so uvicorn declares the socket - # dead and closes it, dropping an otherwise-healthy local connection - # (#53773: "event loop stalled 226.3s"; #48445/#50005). A longer timeout - # only raises the threshold — a multi-minute stall sails past any finite - # window. The keepalive ping exists to detect *half-open* connections - # (reverse-proxy 524, dropped tunnels), which cannot happen on loopback: - # there is no network or proxy in the path, and a dead local client tears - # the socket down with a real FIN/RST that starlette surfaces as - # WebSocketDisconnect regardless of the ping. So on loopback the ping - # provides ~no liveness value while actively killing recoverable stalls — - # disable it entirely. Non-loopback binds sit behind a Cloudflare Tunnel - # (idle timeout ~100s) where half-open IS a real failure mode, so keep the - # ping at 20/20 to detect it promptly and stay under the tunnel's idle - # window. - _is_loopback = host in ("127.0.0.1", "localhost", "::1") - # Non-loopback ping cadence is config-driven (dashboard.ws_ping_interval / - # dashboard.ws_ping_timeout, #79635); the 20/20 defaults keep the - # Cloudflare-Tunnel-friendly behaviour when unset or invalid. + # WS keepalive ping runs ON the agent event loop; a GIL-holding worker call + # can starve it for minutes, so uvicorn misses the pong and drops a healthy + # local socket (#53773/#48445/#50005). The ping only detects half-open + # connections (proxy 524, dropped tunnels), impossible on loopback where a + # dead client sends a real FIN/RST -> WebSocketDisconnect. So: no ping on + # loopback; non-loopback sits behind a Cloudflare Tunnel (~100s idle) and + # keeps a config-driven cadence (dashboard.ws_ping_interval/_timeout, + # #79635) defaulting to 20/20. + _is_loopback = host in _LOOPBACK_HOST_VALUES try: _dash_cfg = load_config().get("dashboard") or {} except Exception: @@ -2091,30 +1826,26 @@ def _build_uvicorn_server(host: str, port: int): config = uvicorn.Config( app, host=host, port=port, log_level="warning", - # proxy_headers defaults to False so _ws_client_is_allowed sees - # the real connection peer rather than X-Forwarded-For's rewritten - # value (which would defeat the loopback gate when behind a reverse - # proxy). When the OAuth gate is active we are explicitly running - # behind a TLS terminator (Fly.io) and need X-Forwarded-Proto to - # decide cookie Secure flags, so we flip proxy_headers on for that - # mode. + # Off by default so _ws_client_is_allowed sees the real peer, not + # X-Forwarded-For. Gated mode runs behind a TLS terminator and needs + # X-Forwarded-Proto for cookie Secure flags. proxy_headers=bool(app.state.auth_required), - # Keep uvicorn's loopback-only default unless the operator explicitly - # trusts the address or bounded network of an upstream proxy. This is - # what lets a separate-container TLS terminator supply HTTPS/client - # metadata without accepting spoofed X-Forwarded-* headers from every - # caller. + # Loopback-only unless the operator trusts a bounded upstream proxy, so + # spoofed X-Forwarded-* from arbitrary callers is never honoured. forwarded_allow_ips=_dashboard_forwarded_allow_ips(_dash_cfg), - # Half-open detection for public binds only (see above). Loopback - # disables the protocol ping (None) so an event-loop stall can never - # trigger a false disconnect; a genuinely dead local client is still - # reaped via the WebSocketDisconnect → disconnect/reap path. ws_ping_interval=None if _is_loopback else _ws_ping_setting("ws_ping_interval"), ws_ping_timeout=None if _is_loopback else _ws_ping_setting("ws_ping_timeout"), ws_max_size=_DESKTOP_ATTACHMENT_WS_MAX_BYTES, ) - server = uvicorn.Server(config) - return config, server + return config, uvicorn.Server(config) + + +def _best_effort(what: str, fn) -> None: + """Run a best-effort startup step; any failure (import included) is a debug line.""" + try: + fn() + except Exception as exc: + _log.debug("%s skipped: %s", what, exc) def _on_server_started( @@ -2127,109 +1858,72 @@ def _on_server_started( initial_profile: str, start_mcp_discovery_after_bind: bool, ) -> None: - """Post-bind arming, run on the serving loop right after ``server.startup()``: - reap prior corpses, watchdog, process identity, READY announcement, - browser open, deferred MCP discovery, loop-noise filter, loop heartbeat.""" - # Parent-death watchdog. The desktop spawns us and is supposed to - # SIGTERM us on quit, but a crash / SIGKILL / update handoff that - # exits before reaping leaves us orphaned (ppid→1) yet still - # serving — leaking the whole backend + its MCP child subtree - # (each MCP watchdog is parented to THIS process, so os._exit here - # cascades their teardown). Same pattern as - # Clear corpses left by a previous unclean Desktop exit before we - # stack another backend + MCP tree (EMFILE / missing tabs). - # Parent-death watchdog only protects *this* process going forward. - if os.getenv("HERMES_DESKTOP") == "1": - try: - from hermes_cli.dashboard_procs import ( - _reap_orphaned_desktop_local_serves, - ) + """Post-bind arming on the serving loop right after ``server.startup()``. - _reap_orphaned_desktop_local_serves() - except Exception as exc: - _log.debug("orphan desktop-local serve reap skipped: %s", exc) + Reap prior corpses, parent-death watchdog, process identity, READY + announcement, browser open, deferred MCP discovery, loop-noise filter, + loop heartbeat. + """ + # Clear corpses from a previous unclean Desktop exit (crash/SIGKILL/update + # handoff leaves an orphaned backend + its MCP subtree) before stacking a + # new tree (EMFILE / missing tabs). The watchdog only protects *this* + # process going forward. + def _reap_desktop_serves() -> None: + from hermes_cli.dashboard_procs import _reap_orphaned_desktop_local_serves - # Same sweep for stdio MCP helper children (#61514): ledger- - # identified helpers whose recorded spawner is provably dead are - # corpses from a prior unclean exit — reap them before this - # backend stacks a fresh MCP tree on top. Positive identity only - # (spawn ledger + spawner_is_dead); a helper whose spawner is - # alive or unprovable is never touched. - try: + _reap_orphaned_desktop_local_serves() + + def _reap_mcp_helpers() -> None: from hermes_cli.process_identity import reap_orphaned_mcp_helpers reap_orphaned_mcp_helpers() - except Exception as exc: - _log.debug("orphan MCP helper reap skipped: %s", exc) - # tui_gateway/slash_worker.py::_start_parent_death_watchdog. No-op - # for standalone `hermes serve` (no HERMES_PARENT_PID env). + if os.getenv("HERMES_DESKTOP") == "1": + _best_effort("orphan desktop-local serve reap", _reap_desktop_serves) + # Same sweep for stdio MCP helpers (#61514): positive identity only (spawn + # ledger + spawner provably dead); anything alive or unprovable is untouched. + _best_effort("orphan MCP helper reap", _reap_mcp_helpers) + + # No-op for standalone `hermes serve` (no HERMES_PARENT_PID). _start_parent_death_watchdog() actual_port = _read_bound_port(server, fallback=port) app.state.bound_port = actual_port - # Positive process identity: record (pid, create_time, purpose, - # spawner) in the machine spawn ledger and — on Windows — attach - # to a kill-on-close job so this backend's whole child tree dies - # with it. Both best-effort; failures degrade to legacy behavior. - # Registered AFTER the bind so the entry carries the ACTUAL port - # (ephemeral binds included) — the structured host/port/profile - # is what lets `hermes update` relaunch a manually-started serve - # on its real endpoint instead of dropping it (#63206). - try: - from hermes_cli.process_identity import ( - attach_self_to_kill_on_close_job, - register_self, - ) + # Positive process identity in the machine spawn ledger (+ Windows + # kill-on-close job). Registered AFTER the bind so the entry carries the + # ACTUAL port — what lets `hermes update` relaunch a manually-started serve + # on its real endpoint (#63206). + def _register_identity() -> None: + from hermes_cli.process_identity import attach_self_to_kill_on_close_job, register_self register_self( "serve" if headless else "dashboard", - detail={ - "host": host, - "port": actual_port, - "profile": initial_profile or "", - }, + detail={"host": host, "port": actual_port, "profile": initial_profile or ""}, ) attach_self_to_kill_on_close_job() - except Exception as exc: - _log.debug("process-identity registration skipped: %s", exc) + + _best_effort("process-identity registration", _register_identity) _write_dashboard_ready_file(actual_port) - # Port-discovery sentinel parsed by the desktop spawn. `serve` is a - # plain backend, not a dashboard, so it announces a neutral token; - # `dashboard` keeps the legacy one. The desktop matches either. + # Port-discovery sentinel parsed by the Desktop spawn (matches either + # token). Written to fd 1: tui_gateway.server redirects sys.stdout to + # stderr at import, and the Desktop watches child.stdout (#96282). ready_token = "HERMES_BACKEND_READY" if headless else "HERMES_DASHBOARD_READY" - # tui_gateway.server (imported above for the flush-on-SIGTERM - # handlers, #94724) redirects sys.stdout→sys.stderr at import time - # to keep stray prints off the JSON-RPC protocol stream. fd 1 is - # still the real stdout — and the Desktop spawn watches - # child.stdout for this sentinel — so write to the fd, not to the - # (redirected) sys.stdout, or the desktop times out after 90s - # against a perfectly healthy backend (#96282). _write_machine_sentinel_line(f"{ready_token} port={actual_port}") if headless: - # No SPA, and the JSON-RPC/WS endpoints are auth-gated — don't - # advertise a paste-and-connect URL, just announce the bind. - # flush: on a piped stdout (Desktop spawn) this line is - # block-buffered and can surface MINUTES after the flushed - # READY sentinel above, which reads as a slow boot in - # support bundles when the backend was actually up. + # Auth-gated JSON-RPC/WS only — announce the bind, not a URL. flush: + # a piped stdout otherwise surfaces this minutes after the sentinel. print(f" Hermes backend listening on {host}:{actual_port}", flush=True) else: print(f" Hermes Web UI → http://{host}:{actual_port}") _maybe_open_browser(host, actual_port, open_browser, initial_profile) if start_mcp_discovery_after_bind: - # Deferred from cmd_dashboard for Desktop `serve` (see there). - # Not started at the bind itself either: the ~350ms `mcp` SDK - # import holds the GIL, and at bind time the renderer is doing - # its WebSocket handshake + first hydration reads against this - # loop (measured: starting it here gave back most of the - # READY gain as a slower connect). One second later the shell - # is painted and idle. An agent build inside that second fires - # the deferred start itself (wait_for_mcp_discovery), so its - # bounded join and the late-binding refresh are unchanged. + # Desktop `serve`: the ~350ms `mcp` SDK import holds the GIL while the + # renderer does its WS handshake + first hydration reads, so arm it one + # second later when the shell is painted and idle. An agent build inside + # that second fires the deferred start itself (wait_for_mcp_discovery). try: from hermes_cli.mcp_startup import defer_background_mcp_discovery @@ -2241,28 +1935,18 @@ def _on_server_started( except Exception: _log.debug("Deferred MCP discovery arm failed", exc_info=True) - # Collapse the peer-hangup teardown flood (#50005). When the Desktop - # forcibly closes its WebSocket mid-write, asyncio logs a full - # traceback per pending connection-lost callback — 50+ identical - # WinError 10054 (ConnectionResetError) lines per disconnect on - # Windows. This filter downgrades exactly that class to one debug - # line and passes every other loop error through unchanged. - try: + # Collapse the peer-hangup teardown flood (#50005): 50+ identical WinError + # 10054 tracebacks per Desktop disconnect become one debug line. + def _install_noise_filter() -> None: from tui_gateway.loop_noise import install_loop_noise_filter install_loop_noise_filter(asyncio.get_running_loop()) - except Exception as exc: # pragma: no cover - best-effort - _log.debug("loop noise filter install skipped: %s", exc) - # ── Loop heartbeat watchdog (CF-1) ─────────────────────────── - # Confirm the GIL-pressure hypothesis in production. Re-arm a 2s - # tick and measure the drift between when it *should* fire and - # when it actually does: a healthy loop drifts ~0, but a turn that - # holds the GIL blocks the loop and the next tick fires late by the - # stall duration. We log that so a stalled-loop WS drop is - # diagnosable from the gateway log. Uses loop.time() (monotonic) - # for drift, and call_later (not a task) so it dies with the loop — - # nothing to cancel on shutdown. + _best_effort("loop noise filter install", _install_noise_filter) + + # Loop heartbeat watchdog (CF-1): a 2s call_later tick whose drift equals + # any GIL stall, so a stalled-loop WS drop is diagnosable from the log. + # call_later (not a task) dies with the loop — nothing to cancel. _hb_interval = 2.0 _hb_stall_threshold = 5.0 _hb_loop = asyncio.get_running_loop() @@ -2271,48 +1955,26 @@ def _on_server_started( now = _hb_loop.time() drift = now - expected if drift > _hb_stall_threshold: - _log.warning( - "event loop stalled %.1fs (GIL pressure suspected)", - drift, - ) - _hb_loop.call_later( - _hb_interval, _loop_heartbeat, now + _hb_interval - ) + _log.warning("event loop stalled %.1fs (GIL pressure suspected)", drift) + _hb_loop.call_later(_hb_interval, _loop_heartbeat, now + _hb_interval) - _hb_loop.call_later( - _hb_interval, _loop_heartbeat, _hb_loop.time() + _hb_interval - ) + _hb_loop.call_later(_hb_interval, _loop_heartbeat, _hb_loop.time() + _hb_interval) def _run_serve(serve, config, host: str, port: int) -> None: - """Drive ``serve()`` on the loop uvicorn expects; translate the two known - exits (Ctrl+C -> clean return, probe-to-bind port race -> sentinel + exit code).""" - # On POSIX, keep the long-standing ``asyncio.run(_serve())`` runner — - # Python's default loop there is already a SelectorEventLoop (or uvloop when - # uvicorn[standard] installs it), which is exactly what uvicorn serves on. - # Uvicorn's ``capture_signals()`` restores the original SIGINT handler and - # re-raises the captured signal after a graceful shutdown, which otherwise - # leaks a noisy KeyboardInterrupt traceback for the normal foreground - # dashboard Ctrl+C path. Treat that one signal as a clean user-requested - # shutdown; other serve-time errors still propagate. - # - # On Windows it is broken: ``asyncio.run`` defaults to a ProactorEventLoop, - # but uvicorn's socket-serving stack assumes a SelectorEventLoop on win32 - # (``uvicorn/loops/asyncio.py`` forces it, and ``uvicorn.Server.run`` threads - # ``config.get_loop_factory()`` into its runner for exactly this reason). - # Driving uvicorn on the proactor loop makes ``server.startup()`` bind a - # socket that never accepts — the dashboard / desktop backend prints - # "Skipping web UI build" and then hangs forever with the port LISTENING but - # no TCP handshake completing (#50641). So *only on Windows* we mirror - # uvicorn's own machinery and run on the loop factory it picks. + """Drive ``serve()`` on the loop uvicorn expects. + + POSIX keeps ``asyncio.run`` (already a SelectorEventLoop / uvloop). On + Windows ``asyncio.run`` defaults to a ProactorEventLoop, on which uvicorn + binds a socket that never accepts (#50641), so mirror uvicorn's own runner + + loop factory there (hand-installed selector policy for uvicorn < 0.36). + Ctrl+C -> clean return; probe-to-bind port race -> sentinel + exit code. + """ runner = asyncio.run runner_kwargs: dict = {} if sys.platform == "win32": - # Windows-only path. Resolve the runner + loop factory FIRST (and fall back - # to a hand-installed Windows selector policy only when uvicorn predates the - # loop-factory API, < 0.36). The actual serve call is then OUTSIDE this - # import try/except so genuine serve-time errors (port in use) propagate - # normally instead of being swallowed and double-run. + # Resolved FIRST; the serve call is outside this try so genuine + # serve-time errors (port in use) propagate instead of double-running. try: from uvicorn._compat import asyncio_run as runner @@ -2327,20 +1989,16 @@ def _run_serve(serve, config, host: str, port: int) -> None: except Exception: pass - # Clean Ctrl+C contract on both platforms: ``capture_signals()`` re-raises - # the captured signal after the graceful shutdown has already completed. - # For console Ctrl+C the re-raised SIGINT lands as ``KeyboardInterrupt`` — - # a clean user-requested exit. (Re-raised SIGTERM/SIGBREAK keep their - # default terminate disposition and never reach this except.) + # ``capture_signals()`` re-raises the captured signal after graceful + # shutdown; console Ctrl+C lands as KeyboardInterrupt = clean exit. + # (Re-raised SIGTERM/SIGBREAK keep their terminate disposition.) try: runner(serve(), **runner_kwargs) except KeyboardInterrupt: return except SystemExit as exc: - # Probe-to-bind race (#93608): another process grabbed the port - # between our preflight probe and uvicorn's real bind. uvicorn's - # bind_socket() exits 1 — re-check the bind and translate a - # confirmed conflict into the sentinel + distinct exit code. + # Probe-to-bind race (#93608): uvicorn's bind_socket() exits 1 — re-check + # and translate a confirmed conflict into the sentinel + distinct code. if exc.code == 1 and _port_bind_conflict(host, port): _report_port_in_use(host, port) raise SystemExit(PORT_IN_USE_EXIT_CODE) from None @@ -2360,29 +2018,20 @@ def start_server( ): """Start the web UI server. - ``initial_profile`` (when set) is appended to the auto-opened browser - URL as ``?profile=`` so the SPA's profile switcher preselects it - — used when a profile alias (`` dashboard``) routes to the - machine dashboard. - - ``headless`` is the ``serve`` path: the JSON-RPC/WS backend with no UI - build and no SPA mount (mount_spa() honours ``HERMES_SERVE_HEADLESS``), so - the banner announces the bind rather than a browser URL. - - ``ssh_session_token`` and ``ssh_owner_nonce`` are process-local Desktop SSH - bootstrap state. Neither is persisted or exported to child processes. - - ``start_mcp_discovery_after_bind`` (Desktop ``serve``) defers the - background MCP discovery thread until the ready sentinel has been written, - so its SDK import cannot hold the GIL against the pre-bind import path. + ``initial_profile`` is appended to the auto-opened URL as ``?profile=`` + (profile alias `` dashboard``). ``headless`` is the ``serve`` path: + JSON-RPC/WS backend, no UI build, no SPA mount (``HERMES_SERVE_HEADLESS``). + ``ssh_session_token``/``ssh_owner_nonce`` are process-local Desktop SSH + bootstrap state, never persisted or exported to children. + ``start_mcp_discovery_after_bind`` (Desktop ``serve``) defers MCP discovery + until the ready sentinel is written so its SDK import can't hold the GIL + against the pre-bind path. """ _apply_ssh_session_token(ssh_session_token or "") _apply_ssh_owner_nonce(ssh_owner_nonce) - # Raise RLIMIT_NOFILE for dashboard-mode starts that don't route through - # the `serve` path in main.py (which applies the same floor). Canonical - # policy lives in resource_limits; #81547's motivating leak (iterdir fds) - # is fixed above, this covers legitimate high fd demand. + # Dashboard-mode starts don't route through main.py's `serve` path, which + # applies the same RLIMIT_NOFILE floor (policy in resource_limits, #81547). from hermes_cli.resource_limits import apply_nofile_soft_limit apply_nofile_soft_limit() @@ -2398,18 +2047,16 @@ def start_server( _configure_auth_gate(host, allow_public, ssh_session_token, ssh_owner_nonce) - # Record the bound host so host_header_middleware can validate incoming - # Host headers against it. Defends against DNS rebinding (GHSA-ppp5-vxwm-4cf7). + # host_header_middleware validates Host against this (DNS rebinding, + # GHSA-ppp5-vxwm-4cf7). app.state.bound_host = host config, server = _build_uvicorn_server(host, port) - # Flush-on-kill guard (#94724 item 2): install chaining SIGTERM/SIGINT - # handlers that first persist in-memory session transcripts to state.db - # (bounded, best-effort) before the normal shutdown story runs. Installed - # on the main thread BEFORE uvicorn's capture_signals() so uvicorn saves - # these as the "original" handlers and re-raises into them after its own - # graceful shutdown — kills outside the serve window are covered too. + # Flush-on-kill guard (#94724): chaining SIGTERM/SIGINT handlers persist + # in-memory transcripts to state.db before shutdown. Installed BEFORE + # uvicorn's capture_signals() so uvicorn re-raises into them as the + # "original" handlers — kills outside the serve window are covered too. try: from tui_gateway.server import install_exit_flush_signal_handlers @@ -2417,19 +2064,16 @@ def start_server( except Exception as exc: _log.debug("exit-flush signal handlers not installed: %s", exc) - # ── #93608: machine-readable port-conflict detection ────────────── - # uvicorn's own bind_socket() would catch the EADDRINUSE and exit 1 - # with a bare ERROR line — indistinguishable from "backend broken". - # Probe the exact bind first so a conflict surfaces as the stable - # BACKEND_PORT_IN_USE sentinel + a distinct exit code instead. - # ``--port 0`` (ephemeral) is skipped by the probe and unaffected. + # #93608: uvicorn's bind_socket() would exit 1 with a bare ERROR line, + # indistinguishable from "backend broken". Probe first so a conflict + # surfaces as the BACKEND_PORT_IN_USE sentinel + distinct exit code. + # ``--port 0`` is skipped by the probe. if _port_bind_conflict(host, port): _report_port_in_use(host, port) raise SystemExit(PORT_IN_USE_EXIT_CODE) async def _serve(): - # Split startup from main_loop so we can read the bound port - # after the socket is live (ephemeral port discovery). + # startup split from main_loop so the bound (ephemeral) port is readable. if not config.loaded: config.load() server.lifespan = config.lifespan_class(config) From 64ab698ba3d72992d0fc904e9a672ae0e049907d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:20:36 -0700 Subject: [PATCH 2/4] refactor(web_server): compact middleware/auth helpers and lifespan; merge predicate ladders --- hermes_cli/web_server.py | 574 +++++++++++---------------------------- 1 file changed, 163 insertions(+), 411 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index b9bda597a2..8009e69309 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1,15 +1,9 @@ -""" -Hermes Agent — Web UI server. +"""Hermes Agent — Web UI server: FastAPI app assembly, auth/host middleware, ``start_server``. -FastAPI app construction for the dashboard: lifespan, auth/host middleware, -router mounting (``hermes_cli.web_routers``) and ``start_server``. Route -handlers live in ``web_routers/``; the helpers they call live in the sibling -``web_server_`` modules and are re-imported here so -``web_server.`` stays the single late-binding seam tests monkeypatch. - -Usage: - python -m hermes_cli.main web # Start on http://127.0.0.1:9119 - python -m hermes_cli.main web --port 8080 +Route handlers live in ``web_routers/``; their helpers live in the sibling +``web_server_`` modules and are re-imported here so ``web_server.`` +stays the single late-binding seam tests monkeypatch (``web_deps.late``). +Usage: ``python -m hermes_cli.main web [--port 8080]``. """ from contextlib import asynccontextmanager @@ -103,41 +97,16 @@ from hermes_cli.web_server_lifecycle import ( # noqa: E402,F401 — re-exported ) -# --------------------------------------------------------------------------- -# Per-channel subscriber registry used by /api/pub (PTY-side gateway → dashboard) -# and /api/events (dashboard → browser sidebar). Keyed by an opaque channel id -# the chat tab generates on mount; entries auto-evict when the last subscriber -# drops AND the publisher has disconnected. -# -# State lives on app.state (not module-level globals) so that asyncio.Lock is -# created on the running event loop during lifespan startup. A module-level -# asyncio.Lock() binds to whatever loop was active at import time, which breaks -# when the same module is used across TestClient instances or uvicorn reloads. -# --------------------------------------------------------------------------- - def _start_desktop_cron_ticker(stop_event: "threading.Event", interval: int = 60) -> None: """Tick the cron scheduler from inside the desktop dashboard backend. - The scheduler tick loop normally lives in ``hermes gateway run`` — but the - desktop app spawns a ``hermes dashboard`` backend, not a gateway, so a cron - a user creates in the app would never fire. We run the resolved cron - scheduler provider here (no live adapters; delivery falls back to the - per-platform send path). - - Every local profile's store is ticked, not just this backend's own - (#69377's desktop sibling): the desktop pools per-profile backends and - reaps them after ~10 idle minutes, so a secondary profile's ticker dies - with its backend and that profile's jobs silently stop firing until the - user next opens it ("tasks on the sleeping profile could be idle" — - community report, Aug 2026). The primary backend outlives the pool, so it - owns every profile's tick, exactly like a multiplex gateway. External - providers keep the single-store behavior — their registries are not - profile-scoped (see _notify_cron_provider_for_profile). - - Cross-process safe: the built-in provider's ``cron.scheduler.tick`` takes - the per-store ``cron/.tick.lock`` file lock, so this never double-fires - alongside a real gateway or a live pool backend on the same profile home — - whichever process grabs the lock first wins the tick. + The desktop spawns a ``hermes dashboard`` backend, not a gateway, so without + this a cron created in the app would never fire (no live adapters; delivery + falls back to the per-platform send path). The primary backend outlives the + per-profile pool (reaped after ~10 idle minutes), so it ticks EVERY local + profile's store like a multiplex gateway; external providers keep the + single-store behavior (registries are not profile-scoped). Cross-process + safe: the built-in tick takes the per-store ``cron/.tick.lock``. """ from cron.scheduler_provider import InProcessCronScheduler, resolve_cron_scheduler @@ -151,17 +120,12 @@ def _start_desktop_cron_ticker(stop_event: "threading.Event", interval: int = 60 profile_homes = list(profiles_to_serve(multiplex=True)) if len(profile_homes) > 1: start_kwargs["profile_homes"] = profile_homes - # Stand down, per tick, for any profile whose OWN gateway is - # running: that gateway ticks it with live adapters, and the - # tick-lock race otherwise lets this adapter-less ticker win - # and deliver the job through the standalone path (#100489). - # Evaluated every cycle so a gateway starting/stopping later - # is picked up without a dashboard restart. + # Stand down, per tick, for a profile whose OWN gateway runs: + # it ticks with live adapters, and the tick-lock race would + # otherwise deliver through the standalone path (#100489). from hermes_cli.profiles import _check_gateway_running - start_kwargs["profile_gate"] = ( - lambda _name, home: not _check_gateway_running(Path(home)) - ) + start_kwargs["profile_gate"] = lambda _name, home: not _check_gateway_running(Path(home)) from hermes_logging import enable_profile_log_routing enable_profile_log_routing(profile_homes) @@ -171,8 +135,7 @@ def _start_desktop_cron_ticker(stop_event: "threading.Event", interval: int = 60 [name for name, _home in profile_homes], ) except Exception: - # Fail open to the single-store ticker — the active profile's - # jobs must keep firing even if profile enumeration breaks. + # Fail open to the single-store ticker so the active profile keeps firing. _log.exception("Desktop cron: profile enumeration failed; ticking active profile only") _log.info("Desktop cron scheduler started (provider=%s, interval=%ds)", provider.name, interval) @@ -189,52 +152,37 @@ async def _lifespan(app: "FastAPI"): app.state.event_channels = {} # dict[str, set] app.state.event_lock = asyncio.Lock() app.state.pty_active_session_files = {} # dict[str, Path] - # Serializes chat-argv resolution so concurrent /api/pty connections - # don't trigger overlapping ``npm install`` / ``npm run build`` work. - # On app.state (not a module global) so the Lock binds to the running - # event loop during lifespan startup — see _get_event_state's docstring. + # Serializes chat-argv resolution so concurrent /api/pty connections don't + # overlap ``npm install`` / ``npm run build``. Locks live on app.state (not + # module globals) so they bind to the running loop, not the import-time one. app.state.chat_argv_lock = asyncio.Lock() - # Bring this profile's state.db schema current BEFORE the first - # session-list poll (#79531/#80037). Migrations used to run lazily on - # the first writable open — typically the user's first new session — - # so a store left behind by `hermes update` kept 500ing every - # /api/sessions poll (and the read-probe heal, while it retries per - # poll, can lose repeatedly to lock contention from orphaned sibling - # backends). One writable open here runs _init_schema → - # _reconcile_columns with the full open-time lock patience. Runs in a - # daemon thread so a locked store never delays the server socket (the - # Desktop ready-probe times out at 10s, GH-73083); reads that land - # before it finishes are still covered by the read-probe heal. + # Bring state.db schema current BEFORE the first session-list poll + # (#79531/#80037): a store left behind by `hermes update` otherwise 500s + # every poll while the read-probe heal loses to sibling lock contention. + # Daemon thread so a locked store never delays the socket (Desktop + # ready-probe times out at 10s, GH-73083). threading.Thread( target=_eager_reconcile_own_session_db, daemon=True, name="statedb-eager-reconcile", ).start() - # Import hermes_cli.gateway eagerly *before* the lifespan yield so the - # GIL-heavy .pyc compilation and Defender scan cost is absorbed during - # backend initialisation — before the server socket accepts probes. - # On Windows + Python 3.11 the import does not release the GIL, so - # run_in_executor still froze the event loop for 15-22 s, causing the - # Desktop's 10-second WebSocket ready-probe to time out (GH-73083). + # Import hermes_cli.gateway *before* the yield: on Windows + 3.11 the + # import holds the GIL, so run_in_executor still froze the loop 15-22s and + # the Desktop's 10s ready-probe timed out (GH-73083). _warm_gateway_module() - # Snapshot the checkout revision at boot so risky lazy-import paths (the - # model picker) can detect when `hermes update` replaced the code - # underneath this long-lived process and refuse with a clear "restart - # required" message instead of a stale-module ImportError (#86207). This - # mirrors the gateway's record_boot_fingerprint in gateway/run.py; the - # dashboard is a separate process/unit that the update flow does not - # reliably restart, so it must detect the drift itself. + # Snapshot the checkout revision so lazy-import paths (model picker) can + # refuse with "restart required" after `hermes update` replaced the code + # (#86207); the update flow does not reliably restart the dashboard. from gateway.code_skew import record_boot_fingerprint record_boot_fingerprint() - # Hosted Bot rooms belong to the backend process, not to any connected - # Desktop socket. Recovery may need a contended state.db migration, so keep - # it off the lifespan's pre-yield path: Group Chat startup must degrade on - # its own instead of preventing every dashboard/Desktop feature from booting. + # Hosted Bot rooms belong to the backend process. Recovery may need a + # contended state.db migration, so keep it off the pre-yield path: Group + # Chat must degrade on its own rather than block every Desktop feature. from tui_gateway import methods_groups as _hosted_groups import tui_gateway.server # noqa: F401 @@ -262,25 +210,11 @@ async def _lifespan(app: "FastAPI"): cron_stop: "threading.Event | None" = None cron_thread: "threading.Thread | None" = None if os.getenv("HERMES_DESKTOP") == "1": - # Before forking a fresh gateway, reap any orphan left by a previous - # serve session. Graceful shutdown reaps the managed child, but an - # abnormal exit (crash, SIGKILL, power loss, forced update) reparents - # the old gateway to launchd (PPID=1). It keeps holding the QQ - # WebSocket, and a newly forked gateway then races the same credential, - # splitting messages across parallel session trees (#77276). - # - # The sweep itself still runs unconditionally — a stale-but-present - # registration must not veto the #77276 orphan reap. Protection for - # a healthy standalone gateway (launched via `hermes gateway run`, - # no service supervisor) lives INSIDE the reaper: it probes the - # registration with cleanup_stale=False so the recorded PID always - # joins the exclusion set, even when liveness validation would have - # unlinked the record mid-sweep. That matters most on Windows, where - # every layer of the launcher chain (stub -> venv python -> runtime - # python) carries "gateway run" in its command line, so - # find_gateway_pids() matches processes the pidfile exclusion cannot - # see, and os.kill(SIGTERM) is a hard TerminateProcess — the - # gateway's planned-stop watcher (0.5s poll) has no time to drain. + # Reap an orphaned gateway from an abnormal previous exit (reparented to + # launchd, still holding the platform WebSocket) before forking a fresh + # one that would race the same credential (#77276). Runs + # unconditionally; protection of a healthy standalone gateway lives + # INSIDE the reaper (registration probed with cleanup_stale=False). try: from hermes_cli.gateway import _reap_unsupervised_gateway_orphans @@ -297,38 +231,27 @@ async def _lifespan(app: "FastAPI"): ) cron_thread.start() - # Reap idle/dead keep-alive PTY sessions in the background (30-min TTL). + # Reap idle/dead keep-alive PTY sessions (30-min TTL). pty_reaper_task = asyncio.create_task(run_reaper(PTY_REGISTRY)) - - # Periodic authenticated self-test (feeds the ``dashboard`` component on - # /api/status). The loop exits immediately when httpx is unavailable. + # Periodic authenticated self-test feeding the ``dashboard`` component on /api/status. selftest_task = asyncio.create_task(_dashboard_selftest_loop()) - - # Live auto-archive timer — keeps a backend that stays up for days - # sweeping stale sessions on schedule, independent of list requests. + # Live auto-archive timer, independent of list requests. auto_archive_task = asyncio.create_task(_auto_archive_ticker_loop()) - # Managed local runtime: when the user opted in (local_runtime.enabled, - # set by the Local Models 'Use' action), bring the llama-server back up - # so a restart doesn't strand a llamacpp main model without a backend. - # Off-thread and best-effort: binary check + spawn + health poll must - # not delay the server socket, and failure falls back to configured - # cloud providers exactly like a cold start. + # Managed local runtime (local_runtime.enabled): bring llama-server back so a + # restart doesn't strand a llamacpp main model. Off-thread and best-effort; + # failure falls back to cloud providers like a cold start. Server only — + # models load on first inference (an empty router holds no VRAM). def _boot_local_runtime(): try: from hermes_cli.config import load_config from hermes_cli.local_runtime.bootstrap import ensure_local_runtime - # Server only — models load on first inference, always (residency - # design: downloaded = available; demand loads; idleness - # evicts). An empty router holds no VRAM; warming a model at - # boot would reload gigabytes nobody asked for yet. ensure_local_runtime(load_config()) except Exception as exc: # noqa: BLE001 logging.getLogger(__name__).warning("local runtime boot failed: %s", exc) - threading.Thread(target=_boot_local_runtime, daemon=True, - name="local-runtime-boot").start() + threading.Thread(target=_boot_local_runtime, daemon=True, name="local-runtime-boot").start() try: yield @@ -342,8 +265,7 @@ async def _lifespan(app: "FastAPI"): selftest_task.cancel() auto_archive_task.cancel() await PTY_REGISTRY.close_all() - # Stop the managed llama-server with its parent — a supervisor-less - # orphan would keep VRAM pinned after the app closes. + # Stop the managed llama-server with its parent (an orphan pins VRAM). try: from hermes_cli.local_runtime.bootstrap import shutdown_local_runtime @@ -384,16 +306,9 @@ from hermes_cli.memory_oauth import router as _memory_oauth_router # noqa: E402 app.include_router(_memory_oauth_router) -# --------------------------------------------------------------------------- -# Session token for protecting sensitive endpoints (reveal). -# The desktop shell mints the token and injects it via -# HERMES_DASHBOARD_SESSION_TOKEN so its main process can authenticate the -# /api calls it makes on the user's behalf; otherwise we generate one fresh -# on every server start. Either way it dies when the process exits and is -# injected into the SPA HTML so only the legitimate web UI can use it. -# --------------------------------------------------------------------------- - - +# Session token for sensitive endpoints. The desktop shell mints it via +# HERMES_DASHBOARD_SESSION_TOKEN; otherwise fresh per server start. It dies with +# the process and is injected into the SPA HTML so only the web UI can use it. def _resolve_session_token() -> str: return os.environ.get("HERMES_DASHBOARD_SESSION_TOKEN") or secrets.token_urlsafe(32) @@ -421,14 +336,10 @@ def _apply_ssh_owner_nonce(nonce: Optional[str]) -> None: purelib = sysconfig.get_paths()["purelib"] except (KeyError, OSError): return - # Primary identity: a marker FILE written into site-packages now. - # A replaced venv (rm -rf && recreate — same OR different Python - # version) loses the marker deterministically, while pip installs - # into the live venv leave it untouched (no false stales). A bare - # (dev, ino) snapshot of the directory is NOT sufficient on its - # own: ext4 reuses directory inodes immediately, so the exact - # reported repro (`rm -rf venv && uv venv`) can land on the same - # inode and pass undetected (proven live during salvage). + # Primary identity: a marker FILE in site-packages. A replaced venv + # loses it deterministically; pip installs leave it. A bare (dev, ino) + # snapshot alone is NOT enough: ext4 reuses directory inodes at once, + # so `rm -rf venv && uv venv` can land on the same inode undetected. try: marker = os.path.join(purelib, f".hermes-ssh-runtime-{nonce}") with open(marker, "w", encoding="utf-8") as fh: @@ -444,12 +355,10 @@ def _apply_ssh_owner_nonce(nonce: Optional[str]) -> None: def _ssh_runtime_intact() -> bool: - # Marker file is the deterministic signal when we managed to write one. if _SSH_RUNTIME_MARKER is not None: return os.path.isfile(_SSH_RUNTIME_MARKER) - # Fallback (read-only site-packages): directory identity snapshot. - # Weaker — inode reuse can mask a same-filesystem recreate — but still - # catches cross-device moves and version-bump path changes. + # Fallback (read-only site-packages): directory identity snapshot — weaker + # (inode reuse) but catches cross-device moves and version-bump paths. if _SSH_RUNTIME_PURELIB is None: return True purelib, device, inode = _SSH_RUNTIME_PURELIB @@ -460,26 +369,18 @@ def _ssh_runtime_intact() -> bool: return (st.st_dev, st.st_ino) == (device, inode) -# In-browser Chat tab (/chat, /api/pty, /api/ws, …). Always enabled: the -# desktop app and the dashboard's own Chat tab both drive the agent over the -# `/api/ws` + `/api/pty` WebSockets, so the embedded-chat surface is an -# unconditional part of the dashboard. Kept as a module-level constant (rather -# than inlining ``True`` at every gate) so the WS endpoints and the SPA token -# injection share a single, testable seam. +# In-browser Chat tab (/chat, /api/pty, /api/ws): always enabled. A module +# constant (not an inlined True) so the WS endpoints and SPA token injection +# share one testable seam. _DASHBOARD_EMBEDDED_CHAT_ENABLED = True -# Desktop's file.attach compatibility transport sends a complete base64 data -# URL in one JSON-RPC frame. Uvicorn defaults to 16 MiB, which rejects files at -# the preview ceiling before the dispatcher sees them. Keep the gateway -# finite while allowing the 256 MiB raw Desktop attach cap plus base64/JSON -# overhead. +# Desktop file.attach sends a whole base64 data URL in one JSON-RPC frame; +# uvicorn's 16 MiB default rejects files under the 256 MiB raw attach cap. _DESKTOP_ATTACHMENT_WS_MAX_BYTES = 384 * 1024 * 1024 -# CORS: restrict to localhost origins only. The web UI is intended to run -# locally; binding to 0.0.0.0 with allow_origins=["*"] would let any website -# read/modify config and secrets. - +# CORS: localhost origins only — allow_origins=["*"] on 0.0.0.0 would let any +# website read/modify config and secrets. app.add_middleware( CORSMiddleware, allow_origin_regex=r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$", @@ -487,47 +388,28 @@ app.add_middleware( allow_headers=["*"], ) -# --------------------------------------------------------------------------- -# Endpoints that do NOT require the session token. Everything else under -# /api/ is gated by the auth middleware below. -# -# This list is defined in ``hermes_cli.dashboard_auth.public_paths`` so the -# OAuth gate middleware can honour the same allowlist — keeping the two -# gates in lockstep avoids drift like the wildcard-subdomain regression -# where ``/api/status`` was public under the legacy gate but 401'd under -# the OAuth gate (breaking the portal's liveness probe). -# -# Keep the upstream list minimal — only truly non-sensitive, read-only -# endpoints belong there. -# --------------------------------------------------------------------------- -from hermes_cli.dashboard_auth.public_paths import ( - PUBLIC_API_PATHS as _PUBLIC_API_PATHS, -) +# Endpoints that do NOT require the session token; everything else under /api/ +# is gated below. Shared with the OAuth gate so the two allowlists cannot +# drift (/api/status once 401'd under the OAuth gate, breaking the portal probe). +from hermes_cli.dashboard_auth.public_paths import PUBLIC_API_PATHS as _PUBLIC_API_PATHS def _has_valid_session_token(request: Request) -> bool: """True if the request carries a valid dashboard session token. - The dedicated session header avoids collisions with reverse proxies that - already use ``Authorization`` (for example Caddy ``basic_auth``). We still - accept the legacy Bearer path for backward compatibility with older - dashboard bundles. + The dedicated header avoids collisions with reverse proxies that already use + ``Authorization`` (Caddy ``basic_auth``); the legacy Bearer path stays for + older dashboard bundles. """ session_header = request.headers.get(_SESSION_HEADER_NAME, "") - if session_header and hmac.compare_digest( - session_header.encode(), - _SESSION_TOKEN.encode(), - ): + if session_header and hmac.compare_digest(session_header.encode(), _SESSION_TOKEN.encode()): return True - auth = request.headers.get("authorization", "") - expected = f"Bearer {_SESSION_TOKEN}" - return hmac.compare_digest(auth.encode(), expected.encode()) + return hmac.compare_digest(auth.encode(), f"Bearer {_SESSION_TOKEN}".encode()) -# Routes that may also authenticate via a ``?token=`` query param, for download -# links opened by the OS shell or a new browser tab where the session header -# can't be set. Kept narrow — same query-token tradeoff as the /api/pty WS. +# Routes that may also authenticate via ``?token=`` (download links opened by +# the OS shell / a new tab, where no header can be set). Kept narrow. _QUERY_TOKEN_API_PATHS: frozenset[str] = frozenset({"/api/files/download"}) @@ -541,84 +423,48 @@ def _has_valid_query_token(request: Request, path: str) -> bool: def _require_token(request: Request) -> None: """Authorize a sensitive endpoint, raising 401 if the caller isn't allowed. - Two auth schemes protect the dashboard, exactly one active per bind: - - * **Loopback / ``--insecure`` mode** (``auth_required`` False): the - ephemeral ``_SESSION_TOKEN`` is injected into the SPA HTML and echoed - back via ``X-Hermes-Session-Token`` (or the legacy ``Bearer`` header). - Validate it here. - * **Gated / OAuth mode** (``auth_required`` True): ``_SESSION_TOKEN`` is - NOT injected (the SPA authenticates with a session cookie), so there is - no token to check. The ``gated_auth_middleware`` has already verified the - cookie before the request reached this handler — any non-public ``/api/`` - route it lets through carries a verified ``request.state.session``. The - legacy ``auth_middleware`` likewise short-circuits in this mode. Requiring - the (absent) token here would 401 every cookie-authenticated request, - making plugin install/enable/disable and the other ``_require_token`` - endpoints permanently unreachable behind the gate. Defer to the gate. + Loopback mode (``auth_required`` False): validate the SPA-injected + ``_SESSION_TOKEN``. Gated mode: the token is NOT injected (cookie auth), and + ``gated_auth_middleware`` already 401'd anything without a verified + ``request.state.session`` — requiring the absent token here would make every + ``_require_token`` endpoint unreachable behind the gate, so defer to it. """ if getattr(request.app.state, "auth_required", False): - # Gate is authoritative. It attaches ``request.state.session`` on - # success and 401s otherwise, so a request that reached us is already - # authenticated. Belt-and-braces: confirm the session is present. - if getattr(request.state, "session", None) is not None: - return - raise HTTPException(status_code=401, detail="Unauthorized") - if not _has_valid_session_token(request): + ok = getattr(request.state, "session", None) is not None + else: + ok = _has_valid_session_token(request) + if not ok: raise HTTPException(status_code=401, detail="Unauthorized") -# Accepted Host header values for loopback binds. DNS rebinding attacks -# point a victim browser at an attacker-controlled hostname (evil.test) -# which resolves to 127.0.0.1 after a TTL flip — bypassing same-origin -# checks because the browser now considers evil.test and our dashboard -# "same origin". Validating the Host header at the app layer rejects any -# request whose Host isn't one we bound for. See GHSA-ppp5-vxwm-4cf7. -_LOOPBACK_HOST_VALUES: frozenset = frozenset({ - "localhost", "127.0.0.1", "::1", -}) +# Accepted Host values for loopback binds. DNS rebinding TTL-flips an attacker +# hostname to 127.0.0.1 so the browser treats it as same-origin; validating Host +# at the app layer rejects it. See GHSA-ppp5-vxwm-4cf7. +_LOOPBACK_HOST_VALUES: frozenset = frozenset({"localhost", "127.0.0.1", "::1"}) def _dashboard_public_hosts() -> frozenset[str]: """Return the exact hostname declared by ``dashboard.public_url``. - ``public_url`` is already Hermes' canonical browser-facing URL behind a - reverse proxy. Reusing its validated hostname here keeps OAuth redirects, - HTTP Host validation, and WebSocket Origin validation on one source of - truth. Malformed or unset values fail closed as an empty set. + One source of truth for OAuth redirects, Host and WS Origin validation. + Malformed or unset values fail closed as an empty set. """ from hermes_cli.dashboard_auth.prefix import resolve_public_url public_url = resolve_public_url() - if not public_url: - return frozenset() try: - hostname = urllib.parse.urlparse(public_url).hostname + hostname = urllib.parse.urlparse(public_url).hostname if public_url else None except ValueError: - return frozenset() - if not hostname: - return frozenset() - return frozenset({hostname.lower()}) + hostname = None + return frozenset({hostname.lower()}) if hostname else frozenset() def should_require_auth(host: str, allow_public: bool = False) -> bool: - """Return True iff the dashboard auth gate must be active. + """True iff the auth gate must be active: any non-loopback bind. - Truth table: - host == loopback → False (no auth — local-only, trusted operator) - host != loopback → True (gate engages — OAuth or password required) - - "Loopback" is 127.0.0.1, localhost, ::1. RFC1918 / CGNAT / link-local are - deliberately treated as PUBLIC — a hostile device on the same LAN is exactly - the threat model the gate is designed for. - - ``allow_public`` (the legacy ``--insecure`` escape hatch) NO LONGER disables - the gate. It is accepted for backward-compat with old launch scripts and - desktop shells but is ignored: a non-loopback bind ALWAYS requires an auth - provider (OAuth or the bundled password provider). This closes the - unauthenticated-public-dashboard hole behind the June 2026 ``hermes-0day`` - MCP-persistence campaign, where ``--insecure --host 0.0.0.0`` left the - config/MCP/agent surface open to internet scanners. + RFC1918 / CGNAT / link-local are deliberately PUBLIC — a hostile LAN device + is the threat model. ``allow_public`` (legacy ``--insecure``) is accepted for + old launch scripts but IGNORED since the June 2026 hermes-0day campaign. """ return host not in _LOOPBACK_HOST_VALUES @@ -627,19 +473,14 @@ def should_require_dashboard_auth( host: str, trusted_public_hosts: Optional[frozenset[str]] = None, ) -> bool: - """Return whether the dashboard auth gate must be active. + """Gate required for a non-loopback bind OR a non-loopback ``dashboard.public_url``. - The browser-facing URL is part of the exposure boundary: a non-loopback - ``dashboard.public_url`` requires authentication even when a reverse proxy - reaches a backend bound to loopback. Callers may pass the already-resolved - host set so startup and request validation use the same snapshot. + Callers may pass the already-resolved host set so startup and request + validation share one snapshot. """ if trusted_public_hosts is None: trusted_public_hosts = _dashboard_public_hosts() - return should_require_auth(host) or any( - candidate not in _LOOPBACK_HOST_VALUES - for candidate in trusted_public_hosts - ) + return should_require_auth(host) or any(h not in _LOOPBACK_HOST_VALUES for h in trusted_public_hosts) def _desktop_loopback_auth_exempt( @@ -649,33 +490,18 @@ def _desktop_loopback_auth_exempt( ) -> bool: """True for a Desktop-owned loopback backend (#96490). - A non-loopback ``dashboard.public_url`` engages the ticket-only auth gate - for EVERY ``hermes serve`` on the machine — including the private loopback - backends the Desktop app spawns for itself. Those backends authenticate - with the per-spawn session token (injected via - ``HERMES_DASHBOARD_SESSION_TOKEN`` for local spawns, ``--ssh-session-token - -file``/``--ssh-owner-nonce`` for Desktop SSH), which the gate's WS path - refuses outright — Desktop could not boot with a ``public_url`` configured. - - The public_url describes a DIFFERENT deployment: the actual public - dashboard is a separate process on a non-loopback bind, whose own startup - computes ``should_require_dashboard_auth`` from its host and stays gated. - Exempting this process therefore never opens the public surface. - - Exemption requires ALL of: loopback bind, ``HERMES_DESKTOP=1`` (set by - every Desktop spawn path — local and SSH), and an operator-minted - credential (env token, SSH session token, or owner nonce). A plain - ``hermes serve`` with ``HERMES_DESKTOP=1`` exported but no credential is - NOT exempt. + A non-loopback ``dashboard.public_url`` would otherwise engage the + ticket-only gate for the private loopback backends Desktop spawns, whose + per-spawn session token the gate's WS path refuses — Desktop could not boot. + The public dashboard is a separate non-loopback process that stays gated, so + this never opens the public surface. Requires ALL of: loopback bind, + ``HERMES_DESKTOP=1``, and an operator-minted credential (env token, SSH + session token, or owner nonce). """ - if host not in _LOOPBACK_HOST_VALUES: - return False - if os.environ.get("HERMES_DESKTOP") != "1": - return False - return bool( - os.environ.get("HERMES_DASHBOARD_SESSION_TOKEN") - or ssh_session_token - or ssh_owner_nonce + return ( + host in _LOOPBACK_HOST_VALUES + and os.environ.get("HERMES_DESKTOP") == "1" + and bool(os.environ.get("HERMES_DASHBOARD_SESSION_TOKEN") or ssh_session_token or ssh_owner_nonce) ) @@ -686,11 +512,7 @@ def _host_header_hostname(host_header: str) -> str: malformed IPv6 brackets, and URL syntax so validation always fails closed. """ value = (host_header or "").strip() - if not value: - return "" - if any(char in value for char in ('"', "'", "<", ">", " ", "\n", "\r", "\t")): - return "" - if "://" in value or any(char in value for char in ("/", "?", "#", "@")): + if not value or "://" in value or any(c in value for c in '"\'<> \n\r\t/?#@'): return "" if value.startswith("["): @@ -734,57 +556,33 @@ def _is_accepted_host( host_only = _host_header_hostname(host_header) if not host_only: return False - - if host_only in trusted_public_hosts: + # All-interfaces bind: no Host-layer defence is possible; rely on operator + # network controls. + if host_only in trusted_public_hosts or bound_host in {"0.0.0.0", "::"}: return True - - # 0.0.0.0 bind means operator explicitly opted into all-interfaces - # (requires --insecure per web_server.start_server). No Host-layer - # defence can protect that mode; rely on operator network controls. - if bound_host in {"0.0.0.0", "::"}: - return True - - # Loopback bind: accept the loopback names bound_lc = bound_host.lower() if bound_lc in _LOOPBACK_HOST_VALUES: return host_only in _LOOPBACK_HOST_VALUES - - # Explicit non-loopback bind: require exact host match return host_only == bound_lc @app.middleware("http") async def host_header_middleware(request: Request, call_next): - """Reject requests whose Host header doesn't match the bound interface. - - Defends against DNS rebinding: a victim browser on a localhost - dashboard is tricked into fetching from an attacker hostname that - TTL-flips to 127.0.0.1. CORS and same-origin checks don't help — - the browser now treats the attacker origin as same-origin with the - dashboard. Host-header validation at the app layer catches it. - - See GHSA-ppp5-vxwm-4cf7. - """ - # Store the bound host on app.state so this middleware can read it — - # set by start_server() at listen time. + """Reject requests whose Host header doesn't match the bound interface (DNS rebinding, GHSA-ppp5-vxwm-4cf7).""" + # app.state.bound_host is set by start_server() at listen time. bound_host = getattr(app.state, "bound_host", None) - if bound_host: - host_header = request.headers.get("host", "") - trusted_public_hosts = getattr( - app.state, "trusted_public_hosts", frozenset() + if bound_host and not _is_accepted_host( + request.headers.get("host", ""), bound_host, getattr(app.state, "trusted_public_hosts", frozenset()) + ): + return JSONResponse( + status_code=400, + content={ + "detail": ( + "Invalid Host header. Dashboard requests must use the " + "bound hostname or the configured public hostname." + ), + }, ) - if not _is_accepted_host( - host_header, bound_host, trusted_public_hosts - ): - return JSONResponse( - status_code=400, - content={ - "detail": ( - "Invalid Host header. Dashboard requests must use the " - "bound hostname or the configured public hostname." - ), - }, - ) return await call_next(request) @@ -792,17 +590,11 @@ async def host_header_middleware(request: Request, call_next): async def _plugin_api_runtime_gate(request: Request, call_next): """Block requests to disabled plugin API routes at request time. - :func:`_mount_plugin_api_routes` gates at import time, but if a plugin - is disabled *after* the dashboard is already running, its FastAPI router - remains mounted until restart. This middleware enforces the enabled/ - disabled policy on every request to ``/api/plugins/{name}/...`` so that - runtime config changes take effect immediately. - - Registered BEFORE the auth middlewares (so it executes AFTER them): a - request that hasn't cleared auth must get auth's 401 first, never this - gate's 404 — otherwise an unauthenticated caller could fingerprint which - plugins are installed/enabled by reading the status code. We only reach - the enabled/disabled check for a request that auth already let through. + :func:`_mount_plugin_api_routes` gates at import time; a plugin disabled + while running keeps its router mounted until restart, so enforce on every + ``/api/plugins/{name}/...`` request. Registered BEFORE the auth middlewares + (runs AFTER them): an unauthenticated caller must get auth's 401, never this + 404, or the status code becomes a plugin-name oracle. """ path = request.url.path # parts: ['', 'api', 'plugins', '', ...] @@ -833,40 +625,31 @@ async def _plugin_api_runtime_gate(request: Request, call_next): return await call_next(request) -# --------------------------------------------------------------------------- -# Dashboard OAuth auth gate — engaged only when start_server flags the -# bind as non-loopback-without-insecure. No-op pass-through in loopback -# mode so the legacy auth_middleware (below) handles those binds via -# the injected ``_SESSION_TOKEN``. Registered between host_header and -# auth_middleware so the order is: host check → cookie auth → token auth. -# --------------------------------------------------------------------------- - - @app.middleware("http") async def _dashboard_auth_gate(request: Request, call_next): + """OAuth gate — active only when start_server flags ``auth_required``; pass-through on loopback. + + Registered between host_header and auth_middleware: host check → cookie auth → token auth. + """ from hermes_cli.dashboard_auth.middleware import gated_auth_middleware return await gated_auth_middleware(request, call_next) @app.middleware("http") async def auth_middleware(request: Request, call_next): - """Require the session token on all /api/ routes except the public list.""" - # A request already authenticated by the token-auth seam (a service caller - # presenting a bearer token on a registered token route) carries - # ``token_authenticated`` — never bounce it through the cookie/session gate. - if getattr(request.state, "token_authenticated", False): - return await call_next(request) - # When the OAuth gate is active, cookie-based auth (gated_auth_middleware - # above) is authoritative. The legacy _SESSION_TOKEN path is loopback-only - # and is skipped here so the gate's session attachment isn't overridden. - if getattr(request.app.state, "auth_required", False): - return await call_next(request) + """Require the session token on all /api/ routes except the public list. + + Skipped for requests the token-auth seam already authenticated + (``token_authenticated``) and when the OAuth gate is active — cookie auth is + then authoritative and the loopback-only token path must not override it. + """ path = request.url.path - is_mcp_oauth_callback = path.startswith("/api/mcp/oauth/callback/") if ( - path.startswith("/api/") + not getattr(request.state, "token_authenticated", False) + and not getattr(request.app.state, "auth_required", False) + and path.startswith("/api/") and path not in _PUBLIC_API_PATHS - and not is_mcp_oauth_callback + and not path.startswith("/api/mcp/oauth/callback/") and not _has_valid_session_token(request) and not _has_valid_query_token(request, path) ): @@ -876,36 +659,24 @@ async def auth_middleware(request: Request, call_next): @app.middleware("http") async def _token_auth_seam(request: Request, call_next): - """Outermost auth seam: non-interactive bearer-token auth for opted-in routes. + """Outermost auth seam: bearer-token auth for opted-in routes (registered LAST = runs FIRST). - Registered LAST so it runs FIRST (Starlette middleware is outermost-last). - A registered token route is fully owned here — authenticate by token, - attach the principal + ``token_authenticated`` flag, and let the downstream - cookie/session gates skip enforcement. Non-token routes pass straight - through untouched. + A registered token route is owned here — authenticate, attach the principal + + ``token_authenticated`` so downstream gates skip enforcement. Non-token + routes pass through untouched. """ from hermes_cli.dashboard_auth.token_auth import token_auth_middleware return await token_auth_middleware(request, call_next) -# --------------------------------------------------------------------------- -# Dashboard component health — in-process error/self-test counters that feed -# the ``components`` dict on ``/api/status``. That endpoint is in -# ``PUBLIC_API_PATHS``, so everything exported from here must be counts and -# enums only: no exception messages, no request paths, no tokens. -# --------------------------------------------------------------------------- - _DASHBOARD_HEALTH_WINDOW_SECONDS = 300.0 class DashboardHealth: - """Module-level holder for dashboard-process health signals. + """Dashboard-process health: rolling unhandled-error/5xx window + periodic self-test result. - Tracks unhandled exceptions / 5xx responses seen by the outermost HTTP - middleware (rolling window) and the result of the periodic authenticated - self-test. ``last_error_path`` and ``last_error_type`` are internal - diagnostics for logs/debuggers — :meth:`snapshot` deliberately exports - neither (public-payload no-secrets contract). + Feeds ``components`` on the PUBLIC ``/api/status``, so :meth:`snapshot` + exports counts and enums only — never ``last_error_type``/``last_error_path``. """ def __init__(self, window_seconds: float = _DASHBOARD_HEALTH_WINDOW_SECONDS) -> None: @@ -953,13 +724,7 @@ DASHBOARD_HEALTH = DashboardHealth() @app.middleware("http") async def _dashboard_health_middleware(request: Request, call_next): - """Outermost middleware: count unhandled exceptions and 5xx responses. - - Registered after ``_token_auth_seam`` so it is the outermost layer - (Starlette middleware is outermost-last) — nothing below can raise past - it unseen. Records into :data:`DASHBOARD_HEALTH` and re-raises; never - swallows or alters the response. - """ + """Outermost middleware (registered last): count unhandled exceptions and 5xx; re-raises, never alters.""" try: response = await call_next(request) except Exception as exc: @@ -970,13 +735,8 @@ async def _dashboard_health_middleware(request: Request, call_next): return response -# --------------------------------------------------------------------------- -# Authenticated-route self-test: every minute, make one in-process request -# against a cheap DB-touching authenticated route with the real session -# token. Catches the class of failure where liveness looks fine but every -# authenticated request 500s (e.g. wedged state DB). -# --------------------------------------------------------------------------- - +# Authenticated-route self-test: one in-process request per minute against a +# cheap DB-touching route, catching "liveness fine but every authed request 500s". _DASHBOARD_SELFTEST_INTERVAL_SECONDS = 60.0 _DASHBOARD_SELFTEST_ROUTE = "/api/sessions?limit=1" @@ -986,18 +746,11 @@ async def _dashboard_selftest_once() -> None: try: import httpx except ImportError: - return # optional dependency — skip cleanly, leave status "unknown" + return # optional dependency — leave status "unknown" try: - transport = httpx.ASGITransport(app=app) - # base_url uses a loopback name so the Host-header middleware accepts - # the request on loopback binds. - async with httpx.AsyncClient( - transport=transport, base_url="http://127.0.0.1" - ) as client: - resp = await client.get( - _DASHBOARD_SELFTEST_ROUTE, - headers={_SESSION_HEADER_NAME: _SESSION_TOKEN}, - ) + # Loopback base_url so the Host-header middleware accepts the request. + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://127.0.0.1") as client: + resp = await client.get(_DASHBOARD_SELFTEST_ROUTE, headers={_SESSION_HEADER_NAME: _SESSION_TOKEN}) DASHBOARD_HEALTH.record_selftest(resp.status_code == 200, resp.status_code) except Exception: DASHBOARD_HEALTH.record_selftest(False, None) @@ -1012,8 +765,7 @@ async def _dashboard_selftest_loop() -> None: return while True: await asyncio.sleep(_DASHBOARD_SELFTEST_INTERVAL_SECONDS) - # On OAuth-gated binds the legacy session token is not honoured, so - # the probe would false-alarm 401 — skip until the gate is off. + # OAuth-gated binds don't honour the session token; the probe would false-alarm 401. if getattr(app.state, "auth_required", False): continue await _dashboard_selftest_once() From dacee262f7ad686bbaef0b6489a7b8ccedf4bb34 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:36:43 -0700 Subject: [PATCH 3/4] refactor(web_server): consolidate re-export and router-mount blocks; compact constants prose --- hermes_cli/web_server.py | 706 +++++++++++---------------------------- 1 file changed, 198 insertions(+), 508 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 8009e69309..faae2f3b89 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -771,34 +771,123 @@ async def _dashboard_selftest_loop() -> None: await _dashboard_selftest_once() -from hermes_cli.web_server_config import ( # noqa: E402,F401 — re-exported; routers/tests reach these via web_server. - CONFIG_SCHEMA, - _AUX_TASK_SLOTS, - _apply_main_model_assignment, - _apply_model_assignment_sync, - _dashboard_code_skew_guard, - _denormalize_config_from_web, - _memory_provider_options, - _normalize_config_for_web, - _normalize_main_model_assignment, - _schema_with_dynamic_provider_options, - _timezone_options, +# Helpers extracted into web_server_ modules, re-exported so +# ``web_server.`` stays the late-binding seam (web_deps.late) routers use +# and tests monkeypatch. Every name here has a live web_server. reference. +from hermes_cli.web_server_config import ( # noqa: E402,F401 + CONFIG_SCHEMA, _AUX_TASK_SLOTS, _apply_main_model_assignment, _apply_model_assignment_sync, + _dashboard_code_skew_guard, _denormalize_config_from_web, _memory_provider_options, + _normalize_config_for_web, _normalize_main_model_assignment, + _schema_with_dynamic_provider_options, _timezone_options, +) +from hermes_cli.web_models import ( # noqa: E402,F401 + ConfigUpdate, WhatsAppOnboardingStart, WhatsAppOnboardingApply, MoaModelSlot, MoaPresetPayload, + MoaConfigPayload, BulkDeleteSessions, CronJobCreate, CronJobUpdate, + AutomationBlueprintInstantiate, MCPServerCreate, +) +from hermes_cli.web_server_gateway import ( # noqa: E402,F401 + _ACTION_COMMANDS, _ACTION_IDS, _ACTION_LOG_DIR, _ACTION_LOG_FILES, _ACTION_PROCS, + _ACTION_RESULTS, _TOPOLOGY_CACHE, _TOPOLOGY_CACHE_TTL, _collect_profile_gateway_topology, + _collect_profile_gateway_topology_cached, _dashboard_spawn_executable, _display_system_platform, + _gateway_subcommand, _load_configured_gateway_platforms, _probe_gateway_health, + _profile_gateway_writer_identity, _profile_platform_ports, _restart_gateway_after, + _spawn_hermes_action, _split_text_for_speak_stream, _strip_session_list_rows, + _terminate_desktop_managed_gateway, +) +from hermes_cli.web_server_files import ( # noqa: E402,F401 + _dashboard_local_update_managed_externally, _fs_path, _managed_file_entry, + _managed_response_meta, _path_is_under, _resolve_managed_path, +) +from hermes_cli.web_server_memory import ( # noqa: E402,F401 + _coerce_bool, _dependency_importable, _discover_memory_provider_statuses, _field_default, + _field_is_set, _field_value, _field_visible, _load_memory_provider, _memory_provider_manifest, + _memory_provider_setup_info, _memory_provider_setup_manifest, _normalize_memory_provider_name, + _normalize_memory_provider_schema, _read_memory_provider_existing_values, + _require_memory_provider_ready, _run_setup_command, +) +from hermes_cli.web_server_profiles import ( # noqa: E402,F401 + _profile_cli_args, _hub_action_name, _installed_hub_identifiers, _SKILLS_PROFILE_LOCK, + _TERMINAL_BACKENDS, _approval_mode_of, _aux_task_summary, _aux_usage_rows, + _broadcast_gateway_session_info, _config_profile_scope, _fallback_profile_dicts, + _is_other_profile, _merge_aux_into_by_model, _parse_model_ids, _plugin_terminal_backend_rows, + _profile_scope, _resolve_profile_dir, _write_profile_mcp_servers, +) +from hermes_cli.web_server_messaging import ( # noqa: E402,F401 + _MESSAGING_KEYS_PAGE_KEYS, _TelegramOnboardingPairing, _WhatsAppOnboardingSession, + _build_catalog_entry, _channel_managed_env_keys, _messaging_platform_catalog, + _restart_gateway_after_whatsapp_onboarding, _telegram_onboarding_error_message, + _telegram_onboarding_lock, _telegram_onboarding_pairings, _telegram_onboarding_request_sync, + _whatsapp_onboarding_payload, _whatsapp_onboarding_sessions, _whatsapp_session_path, + _write_platform_enabled, +) +from hermes_cli.web_server_oauth import ( # noqa: E402,F401 + _OAUTH_PROVIDER_CATALOG, _external_process_cli_command, _minimax_poller, _nous_poller, + _oauth_profile_name, _oauth_sessions, _oauth_sessions_lock, _truncate_token, _xai_device_poller, +) +from hermes_cli.web_server_sessions import ( # noqa: E402,F401 + _auto_archive_ticker_loop, _last_auto_archive_check, _maybe_auto_archive_for_profile, + _open_session_db_at_path, _open_session_db_for_profile, _session_db_heal_exhausted, + _session_db_heal_warned, _session_db_read_probe_statements, _session_latest_descendant, +) +from hermes_cli.web_server_cron import ( # noqa: E402,F401 + _call_cron_for_profile, _create_cron_job_sync, _cron_default_profile, _cron_optional_text, + _cron_profile_dicts, _cron_profile_home, _cron_string_list, _find_cron_job_profile, + _fire_cron_job_for_profile, _forward_cron_fire_to_gateway, _gateway_fire_endpoint, + _gateway_intentionally_stopped, _mutate_cron_for_profile, _normalize_dashboard_cron_script, + _notify_cron_provider_for_profile, _raise_if_cron_registration_error, _run_cron_dashboard_io, + _validate_dashboard_cron_context_from, _validate_dashboard_cron_effective_job, +) +from hermes_cli.web_server_mcp import ( # noqa: E402,F401 + _mcp_oauth_flows, _mcp_server_summary, _normalize_mcp_server_create, _run_dashboard_mcp_oauth, +) +from hermes_cli.web_server_chat import ( # noqa: E402,F401 + PTY_REGISTRY, PtyBridge, PtyUnavailableError, _GATEWAY_WS_PROTOCOL, + _GATEWAY_WS_TICKET_PROTOCOL_PREFIX, _LOOPBACK_HOSTS, _PTY_BRIDGE_AVAILABLE, _RESIZE_RE, + _active_session_file_for_channel, _build_gateway_ws_url, _build_sidecar_url, + _get_console_executor, _legacy_pump, _resolve_chat_argv, _resolve_chat_argv_async, + _resolve_client_ws_host, _ws_auth_ok, _ws_auth_reason, _ws_client_is_allowed, _ws_client_reason, + _ws_host_origin_is_allowed, _ws_host_origin_reason, _ws_request_is_allowed, +) +from hermes_cli.web_server_dashboard import ( # noqa: E402,F401 + _BUILTIN_DASHBOARD_THEMES, _discover_dashboard_plugins, _discover_user_themes, + _invalidate_plugins_hub_cache, _merged_plugins_hub, _mount_plugin_api_routes, + _normalise_theme_definition, _render_active_theme_bootstrap_css, _safe_plugin_api_relpath, + _schedule_check_fn_probe, mount_spa, ) -from hermes_cli.web_models import ( # noqa: F401 - ConfigUpdate, - WhatsAppOnboardingStart, - WhatsAppOnboardingApply, - MoaModelSlot, - MoaPresetPayload, - MoaConfigPayload, - BulkDeleteSessions, - CronJobCreate, - CronJobUpdate, - AutomationBlueprintInstantiate, - MCPServerCreate, +# Legacy re-exports of route handlers; tests call these via web_server.. +from hermes_cli.web_routers.files import upload_managed_file_stream # noqa: E402,F401 +from hermes_cli.web_routers.status import get_status, run_dump # noqa: E402,F401 +from hermes_cli.web_routers.sessions import search_sessions # noqa: E402,F401 +from hermes_cli.web_routers.models import ( # noqa: E402,F401 + get_model_options, get_recommended_default_model, set_moa_models, ) +from hermes_cli.web_routers.messaging import ( # noqa: E402,F401 + apply_whatsapp_onboarding, start_whatsapp_onboarding, +) +from hermes_cli.web_routers.oauth import ( # noqa: E402,F401 + _codex_full_login_worker, _new_oauth_session, _resolve_provider_status, +) +from hermes_cli.web_routers.sessions import ( # noqa: E402,F401 + bulk_delete_sessions_endpoint, count_empty_sessions_endpoint, delete_empty_sessions_endpoint, + get_session_latest_descendant, get_session_messages, delete_session_endpoint, + export_session_endpoint, prune_sessions_endpoint, +) +from hermes_cli.web_routers.cron import ( # noqa: E402,F401 + list_cron_jobs, create_cron_job, update_cron_job, pause_cron_job, resume_cron_job, + trigger_cron_job, delete_cron_job, instantiate_blueprint, _normalize_dashboard_cron_updates, +) +from hermes_cli.web_routers.ops import ( # noqa: E402,F401 + list_credential_pool, run_doctor, run_import, +) +from hermes_cli.web_routers.analytics import ( # noqa: E402,F401 + get_models_analytics, get_usage_analytics, +) +from hermes_cli.web_routers.chat_ws import ( # noqa: E402,F401 + _broadcast_event, _get_event_state, pty_ws, +) +from hermes_cli.web_routers.dashboard_ui import post_agent_plugin_install # noqa: E402,F401 _GATEWAY_HEALTH_URL = os.getenv("GATEWAY_HEALTH_URL") @@ -826,85 +915,16 @@ elif _GATEWAY_HEALTH_TIMEOUT > _GATEWAY_HEALTH_TIMEOUT_MAX: _GATEWAY_HEALTH_TIMEOUT = _GATEWAY_HEALTH_TIMEOUT_MAX -from hermes_cli.web_server_gateway import ( # noqa: E402,F401 — re-exported; routers/tests reach these via web_server. - _ACTION_COMMANDS, - _ACTION_IDS, - _ACTION_LOG_DIR, - _ACTION_LOG_FILES, - _ACTION_PROCS, - _ACTION_RESULTS, - _TOPOLOGY_CACHE, - _TOPOLOGY_CACHE_TTL, - _collect_profile_gateway_topology, - _collect_profile_gateway_topology_cached, - _dashboard_spawn_executable, - _display_system_platform, - _gateway_subcommand, - _load_configured_gateway_platforms, - _probe_gateway_health, - _profile_gateway_writer_identity, - _profile_platform_ports, - _restart_gateway_after, - _spawn_hermes_action, - _split_text_for_speak_stream, - _strip_session_list_rows, - _terminate_desktop_managed_gateway, -) - - -from hermes_cli.web_server_files import ( # noqa: E402,F401 — re-exported; routers/tests reach these via web_server. - _dashboard_local_update_managed_externally, - _fs_path, - _managed_file_entry, - _managed_response_meta, - _path_is_under, - _resolve_managed_path, -) - - _MANAGED_FILE_MAX_BYTES = 100 * 1024 * 1024 - - -from hermes_cli.web_routers import files as _files_routes # noqa: E402 - -app.include_router(_files_routes.router) -from hermes_cli.web_routers.files import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. - upload_managed_file_stream, -) - - _FS_DATA_URL_MAX_BYTES = 16 * 1024 * 1024 - - -# Stream uploads to disk in fixed-size chunks. The legacy JSON endpoint above -# buffers the whole file as a base64 data URL in a JSON body, which (a) inflates -# the payload ~33%, (b) holds the entire file (plus its decoded copy) in memory, -# and (c) reliably trips upstream proxy body-size/timeout limits with a 502 on -# large backup archives (NS-501). This multipart endpoint reads the request body -# in 1 MiB chunks straight to a temp file, enforces the size cap as it goes, and -# atomically renames into place — constant memory, no base64 inflation. +# Multipart uploads stream to a temp file in fixed chunks and rename into +# place: constant memory, no base64 inflation, no proxy body-size 502s (NS-501). _UPLOAD_CHUNK_BYTES = 1024 * 1024 - -from hermes_cli.web_routers import git as _git_routes # noqa: E402 - -app.include_router(_git_routes.router) - -from hermes_cli.web_routers import local_models as _local_models_routes # noqa: E402 - -app.include_router(_local_models_routes.router) - - -# Stable install identity for /api/status. One random opaque id per physical -# install, minted on first read and persisted under the ROOT Hermes home -# (get_default_hermes_root()) — NOT the profile-scoped HERMES_HOME — so every -# profile served by the same install reports the same id. Clients (the desktop -# connection registry) use it to recognize that two registered addresses -# (hostname + Tailscale IP, LAN + WAN) are one backend and collapse duplicate -# roster rows. Privacy: uuid4 hex, no hardware/user-derived material; the only -# fact it reveals is "these addresses are the same box", which is the feature. -# It must never change across restarts/updates, so reads are cached for the -# process lifetime and the file is written once, atomically. +# Stable install identity for /api/status: one uuid4 hex per physical install, +# persisted under the ROOT Hermes home (not the profile HERMES_HOME) so every +# profile reports the same id and the desktop can collapse duplicate roster rows +# for one backend. Must never change across restarts, so cached per process. _INSTALL_ID_CACHE: Dict[str, Optional[str]] = {"root": None, "value": None} @@ -913,75 +933,34 @@ def get_install_id() -> Optional[str]: return _shared_get_install_id(cache=_INSTALL_ID_CACHE) -# Serializes read-modify-write cycles over config.yaml for handlers that run -# in worker threads (asyncio.to_thread). config.py's _CONFIG_LOCK covers each -# load_config()/save_config() call individually, not the span between them — -# when these handlers ran on the event loop the loop itself serialized the -# whole cycle, but off-loop two concurrent updates could interleave -# load→mutate→save and silently drop one another's writes. Held only in -# worker threads, so it can never block the event loop. RLock so a locked -# section that calls helpers which also take it can't self-deadlock. +# Serializes config.yaml read-modify-write cycles for handlers on worker threads +# (asyncio.to_thread): config.py's _CONFIG_LOCK covers each load/save call, not +# the span between them, so two off-loop updates could drop each other's writes. +# RLock so nested helpers that also take it can't self-deadlock. _CONFIG_MUTATION_LOCK = threading.RLock() - -from hermes_cli.web_routers import status as _status_routes # noqa: E402 - -app.include_router(_status_routes.router) -from hermes_cli.web_routers.status import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. - get_status, - run_dump, -) - - -# A finished ``gateway-restart`` child does not mean the gateway is back: the -# child exits as soon as it has handed the restart to the supervisor (or to the -# running gateway), while the gateway itself is still stopping and coming up. -# The in-flight reuse in :func:`_spawn_gateway_restart` therefore stops -# coalescing exactly when repeat requests do the most damage, so a stale cached -# frontend that re-fires its restart every few seconds gets a brand new restart -# every time (#89034: 77 restarts, 17 of them inside one minute, killing the -# gateway often enough mid-FTS5-write to corrupt state.db). Suppress repeats -# for a short window after the last spawn as well. -# -# MAINTAINER DECISION: a fixed window, not "until the gateway reports healthy". -# Health-gating is what #89034 asks for, but it cannot be made to fail safe -# here — a gateway that never comes back would leave the restart action -# permanently inert, which is a worse failure than the flood it prevents. A -# fixed window always releases. 10s is above the ~3.5s spacing of the reported -# storm and below the time an operator waits before deliberately retrying. +# A finished ``gateway-restart`` child does not mean the gateway is back (it +# exits once the restart is handed off), so in-flight reuse stops coalescing +# exactly when a stale frontend re-fires every few seconds (#89034: 77 restarts, +# state.db corrupted mid-FTS5-write). MAINTAINER DECISION: a fixed window, not +# "until healthy" — a gateway that never returns must not leave the action +# inert. 10s is above the ~3.5s storm spacing and below an operator's retry. GATEWAY_RESTART_COOLDOWN_SECONDS = 10.0 -# ``(monotonic spawn time, Popen, command)`` for the last gateway restart this -# process started. Deliberately NOT read out of ``_ACTION_PROCS``: entries -# there are reaped once the child exits, and a guard that disappears when the -# child exits is the bug this exists to fix. +# ``(monotonic spawn time, Popen, command)`` of the last restart. Deliberately +# NOT read from ``_ACTION_PROCS``: entries there vanish when the child exits. _LAST_GATEWAY_RESTART: Optional[Tuple[float, subprocess.Popen, Tuple[str, ...]]] = None def _spawn_gateway_restart(profile: Optional[str] = None) -> Tuple[subprocess.Popen, bool]: - """Spawn ``hermes gateway restart``, reusing an in-flight restart. - - Multiple dashboard paths can request a restart in quick succession - (restart button double-click, or a stale cached frontend firing its own - restart after the server already auto-restarted post-onboarding). Two - concurrent ``hermes gateway restart`` children race each other on the - manual kill-and-start path, so reuse the live one instead. - - Reusing only the *live* child is not enough. The child exits as soon as - the restart has been handed off, long before the gateway is back, so a - frontend re-firing every few seconds cleared that guard every time and - kept restarting a gateway that was still coming up (#89034). Requests - within ``GATEWAY_RESTART_COOLDOWN_SECONDS`` of the last spawn for the - same profile are coalesced onto that spawn as well. - - Before spawning, sweep for orphaned gateway processes whose parent has - exited (e.g. desktop-app restarts leaving a reparented gateway child - under launchd/PPID=1). Without this the orphan keeps its platform - connection alive and the fresh gateway stacks a duplicate (#77276). + """Spawn ``hermes gateway restart``, reusing an in-flight or recent restart. + Concurrent children race each other on the kill-and-start path, so a live + child is reused; requests within ``GATEWAY_RESTART_COOLDOWN_SECONDS`` for the + same profile coalesce onto the last spawn too (#89034). Orphaned gateways + are reaped first so the fresh one doesn't stack a duplicate (#77276). Returns ``(proc, reused)``. """ - # Reap orphaned gateways before spawning a new one (#77276). try: from hermes_cli.gateway import _reap_unsupervised_gateway_orphans @@ -1018,28 +997,14 @@ def _spawn_gateway_restart(profile: Optional[str] = None) -> Tuple[subprocess.Po return proc, False -from hermes_cli.web_routers import actions as _actions_routes # noqa: E402 - -app.include_router(_actions_routes.router) - - -from hermes_cli.web_routers import audio as _audio_routes # noqa: E402 - -app.include_router(_audio_routes.router) - - # Collapses repeated identical ElevenLabs voice-list failures (the desktop -# re-polls on every settings open/focus) to a single log line. Re-arms on -# success or when the error signature changes, so a real new failure is seen. +# re-polls on every settings focus) to one log line; re-arms on success or a +# changed signature. _voice_list_last_error: Optional[str] = None def _voice_list_error_logged_once(signature: Optional[str]) -> bool: - """Return True if ``signature`` is new and should be logged now. - - Passing ``None`` clears the latch (call on success). Idempotent per - signature: the same error logs once until it changes. - """ + """True if ``signature`` is new and should be logged now; ``None`` clears the latch.""" global _voice_list_last_error if signature is None: _voice_list_last_error = None @@ -1050,335 +1015,8 @@ def _voice_list_error_logged_once(signature: Optional[str]) -> bool: return True -app.include_router(_actions_routes.status_router) - - -from hermes_cli.web_routers import sessions as _sessions_routes # noqa: E402 - -app.include_router(_sessions_routes.list_router) - - -from hermes_cli.web_routers import profiles as _profiles_routes # noqa: E402 - -app.include_router(_profiles_routes.sessions_router) - - -app.include_router(_sessions_routes.search_router) -from hermes_cli.web_routers.sessions import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. - search_sessions, -) - - -from hermes_cli.web_server_memory import ( # noqa: E402,F401 — re-exported; routers/tests reach these via web_server. - _coerce_bool, - _dependency_importable, - _discover_memory_provider_statuses, - _field_default, - _field_is_set, - _field_value, - _field_visible, - _load_memory_provider, - _memory_provider_manifest, - _memory_provider_setup_info, - _memory_provider_setup_manifest, - _normalize_memory_provider_name, - _normalize_memory_provider_schema, - _read_memory_provider_existing_values, - _require_memory_provider_ready, - _run_setup_command, -) - - -from hermes_cli.web_routers import memory_providers as _memory_providers_routes # noqa: E402 - -app.include_router(_memory_providers_routes.router) - - -from hermes_cli.web_routers import config_env as _config_env_routes # noqa: E402 - -app.include_router(_config_env_routes.config_router) - - -from hermes_cli.web_routers import models as _models_routes # noqa: E402 - -app.include_router(_models_routes.router) -from hermes_cli.web_routers.models import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. - get_model_options, - get_recommended_default_model, - set_moa_models, -) - - -app.include_router(_config_env_routes.router) - - -from hermes_cli.web_server_profiles import ( # noqa: E402,F401 — re-exported; routers/tests reach these via web_server. - _profile_cli_args, - _hub_action_name, - _installed_hub_identifiers, - _SKILLS_PROFILE_LOCK, - _TERMINAL_BACKENDS, - _approval_mode_of, - _aux_task_summary, - _aux_usage_rows, - _broadcast_gateway_session_info, - _config_profile_scope, - _fallback_profile_dicts, - _is_other_profile, - _merge_aux_into_by_model, - _parse_model_ids, - _plugin_terminal_backend_rows, - _profile_scope, - _resolve_profile_dir, - _write_profile_mcp_servers, -) - - -from hermes_cli.web_server_messaging import ( # noqa: E402,F401 — re-exported; routers/tests reach these via web_server. - _MESSAGING_KEYS_PAGE_KEYS, - _TelegramOnboardingPairing, - _WhatsAppOnboardingSession, - _build_catalog_entry, - _channel_managed_env_keys, - _messaging_platform_catalog, - _restart_gateway_after_whatsapp_onboarding, - _telegram_onboarding_error_message, - _telegram_onboarding_lock, - _telegram_onboarding_pairings, - _telegram_onboarding_request_sync, - _whatsapp_onboarding_payload, - _whatsapp_onboarding_sessions, - _whatsapp_session_path, - _write_platform_enabled, -) - - -# Which per-platform knobs the setup UI hides, and why: see -# hermes_cli/setup_hidden_env.py. Shared with the `hermes setup gateway` -# wizard so the surfaces ask for the same things. - - -from hermes_cli.web_routers import messaging as _messaging_routes # noqa: E402 - -app.include_router(_messaging_routes.router) -from hermes_cli.web_routers.messaging import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. - apply_whatsapp_onboarding, - start_whatsapp_onboarding, -) - - -from hermes_cli.web_server_oauth import ( # noqa: E402,F401 — re-exported; routers/tests reach these via web_server. - _OAUTH_PROVIDER_CATALOG, - _external_process_cli_command, - _minimax_poller, - _nous_poller, - _oauth_profile_name, - _oauth_sessions, - _oauth_sessions_lock, - _truncate_token, - _xai_device_poller, -) - - -from hermes_cli.web_routers import oauth as _oauth_routes # noqa: E402 - -app.include_router(_oauth_routes.router) -from hermes_cli.web_routers.oauth import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. - _codex_full_login_worker, - _new_oauth_session, - _resolve_provider_status, -) - - -from hermes_cli.web_server_sessions import ( # noqa: E402,F401 — re-exported; routers/tests reach these via web_server. - _auto_archive_ticker_loop, - _last_auto_archive_check, - _maybe_auto_archive_for_profile, - _open_session_db_at_path, - _open_session_db_for_profile, - _session_db_heal_exhausted, - _session_db_heal_warned, - _session_db_read_probe_statements, - _session_latest_descendant, -) - - -app.include_router(_sessions_routes.manage_router) -from hermes_cli.web_routers.sessions import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. - bulk_delete_sessions_endpoint, - count_empty_sessions_endpoint, - delete_empty_sessions_endpoint, - get_session_latest_descendant, - get_session_messages, - delete_session_endpoint, - export_session_endpoint, - prune_sessions_endpoint, -) - - -app.include_router(_status_routes.logs_router) - - -from hermes_cli.web_server_cron import ( # noqa: E402,F401 — re-exported; routers/tests reach these via web_server. - _call_cron_for_profile, - _create_cron_job_sync, - _cron_default_profile, - _cron_optional_text, - _cron_profile_dicts, - _cron_profile_home, - _cron_string_list, - _find_cron_job_profile, - _fire_cron_job_for_profile, - _forward_cron_fire_to_gateway, - _gateway_fire_endpoint, - _gateway_intentionally_stopped, - _mutate_cron_for_profile, - _normalize_dashboard_cron_script, - _notify_cron_provider_for_profile, - _raise_if_cron_registration_error, - _run_cron_dashboard_io, - _validate_dashboard_cron_context_from, - _validate_dashboard_cron_effective_job, -) - - -from hermes_cli.web_routers import cron as _cron_routes # noqa: E402 - -app.include_router(_cron_routes.router) -from hermes_cli.web_routers.cron import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. - list_cron_jobs, - create_cron_job, - update_cron_job, - pause_cron_job, - resume_cron_job, - trigger_cron_job, - delete_cron_job, - instantiate_blueprint, - _normalize_dashboard_cron_updates, -) - - -from hermes_cli.web_server_mcp import ( # noqa: E402,F401 — re-exported; routers/tests reach these via web_server. - _mcp_oauth_flows, - _mcp_server_summary, - _normalize_mcp_server_create, - _run_dashboard_mcp_oauth, -) - - -from hermes_cli.web_routers import mcp as _mcp_routes # noqa: E402 - -app.include_router(_mcp_routes.router) - - _ACTION_LOG_FILES.setdefault("computer-use-grant", "action-computer-use-grant.log") - -from hermes_cli.web_routers import ops as _ops_routes # noqa: E402 - -app.include_router(_ops_routes.router) -from hermes_cli.web_routers.ops import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. - list_credential_pool, - run_doctor, - run_import, -) - - -# --------------------------------------------------------------------------- -# Skills hub endpoints — search / install / uninstall / update. -# -# Search and install touch the network (GitHub, hub sources) and run the same -# complex source-router pipeline the CLI uses, so they're spawned as background -# actions whose logs the dashboard tails. The already-installed skill list + -# enable/disable toggle live in the existing /api/skills endpoints. -# --------------------------------------------------------------------------- - - -from hermes_cli.web_routers import skills as _skills_routes # noqa: E402 - -app.include_router(_skills_routes.hub_router) - - -app.include_router(_profiles_routes.router) - - -app.include_router(_skills_routes.router) - - -from hermes_cli.web_routers import tools as _tools_routes # noqa: E402 - -app.include_router(_tools_routes.router) - - -from hermes_cli.web_routers import analytics as _analytics_routes # noqa: E402 - -app.include_router(_analytics_routes.router) -from hermes_cli.web_routers.analytics import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. - get_models_analytics, - get_usage_analytics, -) - - -from hermes_cli.web_server_chat import ( # noqa: E402,F401 — re-exported; routers/tests reach these via web_server. - PTY_REGISTRY, - PtyBridge, - PtyUnavailableError, - _GATEWAY_WS_PROTOCOL, - _GATEWAY_WS_TICKET_PROTOCOL_PREFIX, - _LOOPBACK_HOSTS, - _PTY_BRIDGE_AVAILABLE, - _RESIZE_RE, - _active_session_file_for_channel, - _build_gateway_ws_url, - _build_sidecar_url, - _get_console_executor, - _legacy_pump, - _resolve_chat_argv, - _resolve_chat_argv_async, - _resolve_client_ws_host, - _ws_auth_ok, - _ws_auth_reason, - _ws_client_is_allowed, - _ws_client_reason, - _ws_host_origin_is_allowed, - _ws_host_origin_reason, - _ws_request_is_allowed, -) - - -from hermes_cli.web_routers import chat_ws as _chat_ws_routes # noqa: E402 - -app.include_router(_chat_ws_routes.router) -from hermes_cli.web_routers.chat_ws import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. - _broadcast_event, - _get_event_state, - pty_ws, -) - - -from hermes_cli.web_server_dashboard import ( # noqa: E402,F401 — re-exported; routers/tests reach these via web_server. - _BUILTIN_DASHBOARD_THEMES, - _discover_dashboard_plugins, - _discover_user_themes, - _invalidate_plugins_hub_cache, - _merged_plugins_hub, - _mount_plugin_api_routes, - _normalise_theme_definition, - _render_active_theme_bootstrap_css, - _safe_plugin_api_relpath, - _schedule_check_fn_probe, - mount_spa, -) - - -from hermes_cli.web_routers import dashboard_ui as _dashboard_ui_routes # noqa: E402 - -app.include_router(_dashboard_ui_routes.router) -from hermes_cli.web_routers.dashboard_ui import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. - post_agent_plugin_install, -) - - # Cache discovered plugins per-process (refresh on explicit re-scan). _dashboard_plugins_cache: Optional[list] = None @@ -1393,16 +1031,68 @@ def _get_dashboard_plugins(force_rescan: bool = False) -> list: return _dashboard_plugins_cache -# Mount plugin API routes before the SPA catch-all. +# Router mounting. ORDER IS ROUTE-MATCHING ORDER: literal paths must land before +# templated siblings (e.g. /api/sessions/bulk-delete before /api/sessions/{id}). +from hermes_cli.web_routers import ( # noqa: E402 + files as _files_routes, + git as _git_routes, + local_models as _local_models_routes, + status as _status_routes, + actions as _actions_routes, + audio as _audio_routes, + sessions as _sessions_routes, + profiles as _profiles_routes, + memory_providers as _memory_providers_routes, + config_env as _config_env_routes, + models as _models_routes, + messaging as _messaging_routes, + oauth as _oauth_routes, + cron as _cron_routes, + mcp as _mcp_routes, + ops as _ops_routes, + skills as _skills_routes, + tools as _tools_routes, + analytics as _analytics_routes, + chat_ws as _chat_ws_routes, + dashboard_ui as _dashboard_ui_routes, +) + +app.include_router(_files_routes.router) +app.include_router(_git_routes.router) +app.include_router(_local_models_routes.router) +app.include_router(_status_routes.router) +app.include_router(_actions_routes.router) +app.include_router(_audio_routes.router) +app.include_router(_actions_routes.status_router) +app.include_router(_sessions_routes.list_router) +app.include_router(_profiles_routes.sessions_router) +app.include_router(_sessions_routes.search_router) +app.include_router(_memory_providers_routes.router) +app.include_router(_config_env_routes.config_router) +app.include_router(_models_routes.router) +app.include_router(_config_env_routes.router) +app.include_router(_messaging_routes.router) +app.include_router(_oauth_routes.router) +app.include_router(_sessions_routes.manage_router) +app.include_router(_status_routes.logs_router) +app.include_router(_cron_routes.router) +app.include_router(_mcp_routes.router) +app.include_router(_ops_routes.router) +app.include_router(_skills_routes.hub_router) +app.include_router(_profiles_routes.router) +app.include_router(_skills_routes.router) +app.include_router(_tools_routes.router) +app.include_router(_analytics_routes.router) +app.include_router(_chat_ws_routes.router) +app.include_router(_dashboard_ui_routes.router) + +# Plugin API routes and the dashboard auth routes (/login, /auth/*, /api/auth/*) +# mount before the SPA catch-all so /{full_path:path} doesn't swallow them. Auth +# routes are always mounted — the gate middleware decides enforcement. _mount_plugin_api_routes() - -# Mount the dashboard auth routes (/login, /auth/*, /api/auth/*) before the -# SPA catch-all so /{full_path:path} doesn't swallow them. These are -# always mounted — the gate middleware decides whether to enforce auth, -# not whether the routes exist. from hermes_cli.dashboard_auth.routes import router as _dashboard_auth_router # noqa: E402 -app.include_router(_dashboard_auth_router) +app.include_router(_dashboard_auth_router) mount_spa(app) From 39f1b618fcfc40efeb0aba43702d5f2593171ba0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:58:22 -0700 Subject: [PATCH 4/4] refactor(web_server): compact lifecycle/files/mcp helper docstrings and call layouts --- hermes_cli/web_server_files.py | 51 ++---- hermes_cli/web_server_lifecycle.py | 242 ++++++++--------------------- hermes_cli/web_server_mcp.py | 94 +++-------- 3 files changed, 104 insertions(+), 283 deletions(-) diff --git a/hermes_cli/web_server_files.py b/hermes_cli/web_server_files.py index b2610fafd3..dbb4807d85 100644 --- a/hermes_cli/web_server_files.py +++ b/hermes_cli/web_server_files.py @@ -1,8 +1,7 @@ -"""Managed-files policy for the dashboard file browser: root resolution, path canonicalisation/containment, entry metadata. +"""Managed-files policy for the dashboard file browser: root resolution, path containment, entry metadata. -Split out of ``hermes_cli.web_server``; every externally used name is re-imported -there, so ``web_server.`` keeps resolving (and monkeypatching) as before. -Helpers that tests patch on ``web_server`` are reached lazily through it. +Split out of ``hermes_cli.web_server``, which re-imports every externally used +name so ``web_server.`` keeps resolving (and monkeypatching) as before. """ import mimetypes @@ -93,19 +92,12 @@ def _default_hermes_root_is_opt_data() -> bool: def _dashboard_local_update_managed_externally() -> bool: - """Return true when the dashboard should not offer ``hermes update``. + """True when the dashboard should not offer ``hermes update``. - Containerized dashboards are updated by the outer launcher/image, not by an - in-browser local update action. Keep this dashboard capability separate - from install-method detection: manual git/pip installs inside containers can - still behave like their actual install method in the CLI. - - However, when the install method is ``git`` (a bind-mounted checkout inside - a container — e.g. the hermes-webui image sharing the Hermes source tree), - the dashboard's ``hermes update`` button is the correct update path and - should not be suppressed. Other containerized install methods remain - externally managed unless their apply path is proven safe inside the - running container filesystem. + Containerized dashboards are updated by the outer launcher/image — except a + ``git`` install (bind-mounted checkout, e.g. the hermes-webui image), where + the update button is the correct path. pip stays blocked in containers: its + apply path mutates the running container filesystem. """ from hermes_cli.web_server import PROJECT_ROOT, detect_install_method if _default_hermes_root_is_opt_data(): @@ -117,14 +109,8 @@ def _dashboard_local_update_managed_externally() -> bool: return False except Exception: return False - # We are inside a container, but the install may still be self-managed. - # If the install method is git, the dashboard update button works against - # the mounted checkout and should be offered. Keep pip blocked inside - # containers: its apply path mutates the running container filesystem and - # is not the bind-mounted checkout case this gate is meant to recover. try: - method = detect_install_method(PROJECT_ROOT) - if method == "git": + if detect_install_method(PROJECT_ROOT) == "git": return False except Exception: pass @@ -137,11 +123,9 @@ def _managed_files_policy(request: Request, *, create_root: bool = True) -> Mana root = _ensure_managed_root(raw_forced_root) if create_root else _canonical_path(Path(raw_forced_root)) return ManagedFilesPolicy(default_path=root, locked_root=root, can_change_path=False) - # Remote/OAuth access does not imply a hosted container. Users can expose a - # local dashboard through the auth gate (for example a macOS launchd install) - # and still expect the Files page to browse their local home directory. Lock - # to /opt/data only when the installation's Hermes root is actually /opt/data - # (the container/hosted layout) or when HERMES_DASHBOARD_FILES_ROOT is set. + # Remote/OAuth access does not imply a hosted container (a gated macOS launchd + # install still browses its home). Lock to /opt/data only when the Hermes + # root actually IS /opt/data or HERMES_DASHBOARD_FILES_ROOT is set. if _default_hermes_root_is_opt_data(): root = _ensure_managed_root(_HOSTED_MANAGED_FILES_ROOT) if create_root else _HOSTED_MANAGED_FILES_ROOT return ManagedFilesPolicy(default_path=root, locked_root=root, can_change_path=False) @@ -151,10 +135,7 @@ def _managed_files_policy(request: Request, *, create_root: bool = True) -> Mana def _resolve_managed_path( - raw_path: str | None, - request: Request, - *, - for_write: bool = False, + raw_path: str | None, request: Request, *, for_write: bool = False ) -> tuple[ManagedFilesPolicy, Path, str]: policy = _managed_files_policy(request) text = _path_text(raw_path) @@ -190,11 +171,7 @@ def _resolve_managed_path( def _managed_response_meta(policy: ManagedFilesPolicy) -> Dict[str, Any]: locked_root = str(policy.locked_root) if policy.locked_root is not None else None - return { - "root": locked_root, - "locked_root": locked_root, - "can_change_path": policy.can_change_path, - } + return {"root": locked_root, "locked_root": locked_root, "can_change_path": policy.can_change_path} def _managed_file_entry(policy: ManagedFilesPolicy, target: Path) -> Dict[str, Any]: diff --git a/hermes_cli/web_server_lifecycle.py b/hermes_cli/web_server_lifecycle.py index 5aef159867..a91c313c96 100644 --- a/hermes_cli/web_server_lifecycle.py +++ b/hermes_cli/web_server_lifecycle.py @@ -1,8 +1,7 @@ -"""Serve-process lifecycle helpers: parent start markers and death watchdog, port-conflict preflight, ready-file/sentinel announcement, browser auto-open, forwarded-IP resolution. +"""Serve-process lifecycle: parent death watchdog, port-conflict preflight, READY announcement, browser open, trusted proxies. -Split out of ``hermes_cli.web_server``; every externally used name is re-imported -there, so ``web_server.`` keeps resolving (and monkeypatching) as before. -Helpers that tests patch on ``web_server`` are reached lazily through it. +Split out of ``hermes_cli.web_server``, which re-imports every externally used +name so ``web_server.`` keeps resolving (and monkeypatching) as before. """ import logging @@ -27,8 +26,8 @@ _log = logging.getLogger("hermes_cli.web_server") def _process_start_marker(pid: int) -> str: """Return a cross-runtime marker for the current incarnation of ``pid``. - ``ProcessLookupError`` means the process is absent. Other failures are left - distinct so callers can fail safe rather than killing a healthy backend. + ``ProcessLookupError`` means the process is absent; other failures stay + distinct so callers fail safe rather than killing a healthy backend. """ if sys.platform == "linux": try: @@ -36,8 +35,8 @@ def _process_start_marker(pid: int) -> str: except FileNotFoundError as exc: raise ProcessLookupError(pid) from exc - # The command in field 2 may contain spaces or parentheses. Splitting - # after its final ')' leaves field 3 at index zero and field 22 at 19. + # Field 2 (comm) may contain spaces/parens; split after its final ')' + # so field 3 is index 0 and field 22 (starttime) is index 19. fields = stat_line.rsplit(")", 1)[1].strip().split() if len(fields) < 20 or not fields[19].isdigit(): raise OSError(f"invalid /proc stat data for PID {pid}") @@ -51,13 +50,7 @@ def _process_start_marker(pid: int) -> str: kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] kernel32.OpenProcess.restype = wintypes.HANDLE - kernel32.GetProcessTimes.argtypes = [ - wintypes.HANDLE, - ctypes.POINTER(wintypes.FILETIME), - ctypes.POINTER(wintypes.FILETIME), - ctypes.POINTER(wintypes.FILETIME), - ctypes.POINTER(wintypes.FILETIME), - ] + kernel32.GetProcessTimes.argtypes = [wintypes.HANDLE] + [ctypes.POINTER(wintypes.FILETIME)] * 4 kernel32.GetProcessTimes.restype = wintypes.BOOL kernel32.CloseHandle.argtypes = [wintypes.HANDLE] kernel32.CloseHandle.restype = wintypes.BOOL @@ -68,32 +61,19 @@ def _process_start_marker(pid: int) -> str: raise ProcessLookupError(pid) raise OSError(error, f"OpenProcess failed for PID {pid}") - creation = wintypes.FILETIME() - exit_time = wintypes.FILETIME() - kernel = wintypes.FILETIME() - user = wintypes.FILETIME() + creation, exit_time, kernel, user = (wintypes.FILETIME() for _ in range(4)) try: if not kernel32.GetProcessTimes( - handle, - ctypes.byref(creation), - ctypes.byref(exit_time), - ctypes.byref(kernel), - ctypes.byref(user), + handle, ctypes.byref(creation), ctypes.byref(exit_time), ctypes.byref(kernel), ctypes.byref(user) ): - error = ctypes.get_last_error() - raise OSError(error, f"GetProcessTimes failed for PID {pid}") + raise OSError(ctypes.get_last_error(), f"GetProcessTimes failed for PID {pid}") finally: kernel32.CloseHandle(handle) filetime = (creation.dwHighDateTime << 32) | creation.dwLowDateTime return f"win:{filetime + 504911232000000000}" - result = subprocess.run( - ["ps", "-p", str(pid), "-o", "lstart="], - capture_output=True, - text=True, - check=False, - ) + result = subprocess.run(["ps", "-p", str(pid), "-o", "lstart="], capture_output=True, text=True, check=False) marker = result.stdout.strip() if result.returncode == 0 and marker: return f"ps:{marker}" @@ -112,12 +92,11 @@ def _valid_parent_start_marker(marker: str) -> bool: def _parent_start_markers_match(actual: str, expected: str) -> bool: - """Compare parent markers across Desktop protocol generations. + """Compare parent markers across Desktop generations. - Older Windows Desktop builds send .NET ticks (``win:``). New builds use - Electron's native process creation time in Unix milliseconds (``winms:``) - so startup does not need to launch PowerShell. The backend still reads the - exact FILETIME and normalizes it only when the expected marker is ``winms``. + Old Windows Desktop sends .NET ticks (``win:``); new builds send Electron's + creation time in Unix ms (``winms:``) to avoid launching PowerShell. The + backend reads the exact FILETIME and normalizes only for ``winms``. """ if actual == expected: return True @@ -138,32 +117,18 @@ def _parent_start_markers_match(actual: str, expected: str) -> bool: def _warm_gateway_module() -> None: """Pre-import heavy modules so the event loop is not stalled on first use. - On a cold Windows install, importing these module chains triggers .pyc - compilation and Defender real-time scans that can stall the event loop - for 15-30s. The original fix (pre-#60800) only warmed - ``hermes_cli.gateway``. But the first WS connection and its initial - RPC burst (``setup.status``, ``setup.runtime_check``, - ``gateway.ready``→``resolve_skin``) pull in several *other* heavy - chains that were still imported on the loop thread, contributing to - the ~14s cold-start stall (#60800). Warm them all here so the cost - is paid in a worker thread while the server socket is already open. + Cold Windows installs pay .pyc compilation + Defender scans (15-30s) on + these chains; the first WS RPC burst (setup.status, setup.runtime_check, + gateway.ready→resolve_skin, model.options) pulled them in on the loop + thread (#60800). Warm them all off-loop while the socket is already open. """ for mod in ( "hermes_cli.gateway", - # setup.status / setup.runtime_check resolve provider auth state, - # which imports copilot_auth (→ subprocess module) and scans - # credential files. First import is noticeably slow on Windows. - "hermes_cli.auth", + "hermes_cli.auth", # provider auth state → copilot_auth → subprocess "hermes_cli.copilot_auth", "hermes_cli.runtime_provider", - # resolve_skin() reads config + initialises the skin engine. - # Even though handle_ws now calls it via asyncio.to_thread - # (see tui_gateway/ws.py), warming it here avoids the first-call - # import cost inside that thread. - "hermes_cli.skin_engine", - # model.options / picker context — parses provider catalogs and - # the models.dev cache on first use. - "hermes_cli.inventory", + "hermes_cli.skin_engine", # resolve_skin() config + skin engine init + "hermes_cli.inventory", # provider catalogs + models.dev cache "hermes_cli.model_switch", ): try: @@ -184,12 +149,9 @@ def _resolve_restart_drain_timeout() -> float: def _eager_reconcile_own_session_db() -> None: """One writable open of this process's own state.db at startup. - ``SessionDB.__init__`` runs ``_init_schema`` → ``_reconcile_columns``, - bringing a store left behind by `hermes update` current before the - dashboard's first session-list poll, with the open-time lock patience - (jittered retries) absorbing transient contention. Never raises: a - store this cannot fix is still served through the read-probe heal in - :func:`_open_session_db_at_path`, which retries on every poll. + ``SessionDB.__init__`` runs ``_init_schema`` → ``_reconcile_columns`` with + open-time lock patience. Never raises: an unfixable store still gets the + per-poll read-probe heal in :func:`_open_session_db_at_path`. """ try: from hermes_state import SessionDB, _default_db_path @@ -203,25 +165,17 @@ def _eager_reconcile_own_session_db() -> None: def _read_bound_port(server: "uvicorn.Server", fallback: int) -> int: - """Read the OS-assigned port from a live uvicorn server socket. - - After ``server.startup()`` the socket is bound. Returns the actual - port so ephemeral (port-0) discovery works without a pre-bind TOCTOU. - Falls back to *fallback* if the socket list is empty (shouldn't happen - but guards against uvicorn internals changing). - """ + """Read the OS-assigned port from the live uvicorn socket (ephemeral port-0 discovery).""" if server.servers and server.servers[0].sockets: return server.servers[0].sockets[0].getsockname()[1] return fallback def _write_dashboard_ready_file(actual_port: int) -> None: - """Optionally publish the dashboard port through an atomic ready file. + """Publish the port through an atomic ready file when ``HERMES_DESKTOP_READY_FILE`` is set. - Windows Desktop can launch dashboard backends with ``pythonw.exe`` to avoid - console flashes. That path cannot rely on stdout for the port announcement, - so Electron passes ``HERMES_DESKTOP_READY_FILE`` and waits for this JSON. - Normal CLI/dashboard launches still use the stdout READY line below. + Windows Desktop launches via ``pythonw.exe`` (no console flash) cannot use + stdout for the port announcement, so Electron waits for this JSON instead. """ target = os.environ.get("HERMES_DESKTOP_READY_FILE") if not target: @@ -233,12 +187,7 @@ def _write_dashboard_ready_file(actual_port: int) -> None: path.parent.mkdir(parents=True, exist_ok=True) payload = json.dumps({"port": int(actual_port)}, separators=(",", ":")) with tempfile.NamedTemporaryFile( - "w", - encoding="utf-8", - dir=str(path.parent), - prefix=f"{path.name}.", - suffix=".tmp", - delete=False, + "w", encoding="utf-8", dir=str(path.parent), prefix=f"{path.name}.", suffix=".tmp", delete=False ) as fh: fh.write(payload) fh.flush() @@ -254,26 +203,18 @@ def _write_dashboard_ready_file(actual_port: int) -> None: _log.warning("Failed to write dashboard ready file %r: %s", target, exc) -def _maybe_open_browser( - host: str, actual_port: int, open_browser: bool, initial_profile: str -) -> None: +def _maybe_open_browser(host: str, actual_port: int, open_browser: bool, initial_profile: str) -> None: """Open the dashboard URL in the user's browser if appropriate. - Skips on headless Linux (no ``DISPLAY`` / ``WAYLAND_DISPLAY``) to avoid - TUI browsers (links, lynx) that would SIGHUP the server process. - Maps ``0.0.0.0`` / ``::`` binds to ``127.0.0.1`` so the browser opens - a reachable URL. + Skips headless Linux (no DISPLAY/WAYLAND_DISPLAY) so a TUI browser can't + SIGHUP the server; maps ``0.0.0.0``/``::`` binds to ``127.0.0.1``. """ if not open_browser: return import webbrowser - _has_display = ( - sys.platform != "linux" - or bool(os.environ.get("DISPLAY")) - or bool(os.environ.get("WAYLAND_DISPLAY")) - ) + _has_display = sys.platform != "linux" or bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")) if not _has_display: _log.debug( "Skipping browser-open: no DISPLAY or WAYLAND_DISPLAY detected " @@ -306,23 +247,15 @@ def _is_serve_orphaned( ) -> bool: """True when the exact Desktop process that owns this backend is gone. - ``HERMES_PARENT_PID`` is the Electron Desktop PID, not necessarily this - Python process's immediate PPID. On Windows the venv ``hermes.exe`` launcher - introduces one or more shim processes, so comparing ``os.getppid()`` to the - Electron PID incorrectly treats a healthy backend as orphaned and exits 0. - - New Desktop versions also provide the owner's process-start marker. This - prevents a recycled PID from keeping an orphan alive. Older versions remain - compatible through the PID-only probe. Any inconclusive probe failure is - fail-safe: keep serving rather than killing a backend whose owner could not - be conclusively shown to be dead. + ``HERMES_PARENT_PID`` is the Electron PID, not necessarily our PPID (the + Windows ``hermes.exe`` launcher adds shims), so never compare getppid(). + The start marker (newer Desktops) defeats PID recycling; PID-only probing + stays for older ones. Any inconclusive failure keeps serving (fail-safe). """ try: if expected_start_marker is not None: probe = process_start_marker or _process_start_marker - return not _parent_start_markers_match( - probe(int(desktop_pid)), expected_start_marker - ) + return not _parent_start_markers_match(probe(int(desktop_pid)), expected_start_marker) if pid_exists is None: from gateway.status import _pid_exists @@ -338,11 +271,9 @@ def _is_serve_orphaned( def _start_parent_death_watchdog() -> None: """Exit when the exact desktop parent that spawned this backend dies. - The desktop passes its PID and, in newer versions, its process-start marker - plus a per-spawn nonce. The marker distinguishes a live owner from PID reuse; - the nonce makes partial/mixed-version identity plumbing fail safe. Legacy - Desktop versions that provide only ``HERMES_PARENT_PID`` retain PID-only - tracking. + Desktop passes its PID and, in newer versions, a start marker (defeats PID + reuse) plus a per-spawn nonce (makes mixed-version plumbing fail safe: + marker without nonce or vice versa disables the watchdog). """ raw_pid = os.environ.get("HERMES_PARENT_PID") start_marker = os.environ.get("HERMES_PARENT_START_MARKER") @@ -356,14 +287,9 @@ def _start_parent_death_watchdog() -> None: return has_marker = start_marker is not None - has_nonce = nonce is not None - if has_marker != has_nonce: + if has_marker != (nonce is not None): return - if has_marker and ( - not _valid_parent_start_marker(start_marker or "") - or not nonce - or nonce != nonce.strip() - ): + if has_marker and (not _valid_parent_start_marker(start_marker or "") or not nonce or nonce != nonce.strip()): return try: @@ -379,22 +305,12 @@ def _start_parent_death_watchdog() -> None: threading.Thread(target=_loop, daemon=True, name="serve-parent-watchdog").start() -# ── Port-conflict sentinel (#93608) ───────────────────────────────────────── -# When the requested port is already bound, uvicorn's ``bind_socket()`` -# catches the OSError itself and does ``logger.error(exc); sys.exit(1)`` — a -# bare ERROR line plus the same exit 1 as any real backend crash. The desktop -# spawn (and any script wrapping ``hermes serve``) cannot tell "port occupied" -# from "backend broken". So we probe the exact bind before handing the socket -# to uvicorn and, on conflict, emit ONE machine-readable stdout sentinel plus -# a human hint, then exit with a distinct code. -# -# 75 == BSD ``EX_TEMPFAIL`` (sysexits.h) — the codebase's existing convention -# for "transient environmental condition, not a code failure" (see -# gateway/restart.py and kanban_db.py's quota-wall sentinel). +# Port-conflict sentinel (#93608): uvicorn's bind_socket() turns EADDRINUSE into +# a bare ERROR + exit 1, indistinguishable from a crash for the Desktop spawn. +# We probe the exact bind first and emit ONE machine-readable stdout line plus a +# distinct exit code. 75 == BSD EX_TEMPFAIL, the codebase's "transient +# environmental condition" convention (gateway/restart.py, kanban_db.py). PORT_IN_USE_EXIT_CODE = 75 - -# One line, stable format, parsed by machines — mirrors the shape of the -# HERMES_BACKEND_READY sentinel (which is NOT changed by any of this). _PORT_IN_USE_SENTINEL = "BACKEND_PORT_IN_USE port={port}" @@ -402,19 +318,16 @@ def _is_addr_in_use_error(exc: OSError) -> bool: """True when ``exc`` is the platform's address-in-use bind failure.""" import errno - codes = {errno.EADDRINUSE, 98, 48, 10048} # POSIX, Linux, macOS, WinSock - if exc.errno in codes: - return True - return getattr(exc, "winerror", None) == 10048 # WSAEADDRINUSE + # POSIX, Linux, macOS, WinSock; WSAEADDRINUSE also surfaces as winerror. + return exc.errno in {errno.EADDRINUSE, 98, 48, 10048} or getattr(exc, "winerror", None) == 10048 def _port_bind_conflict(host: str, port: int) -> bool: """Probe whether binding ``host:port`` would fail with EADDRINUSE. - ``port == 0`` (ephemeral) can never conflict — the kernel picks a free - port — so the probe is skipped and ``--port 0`` behaves exactly as - before. Any probe error other than address-in-use returns ``False`` so - uvicorn surfaces it with its normal diagnostics (bad host, EACCES, …). + ``port == 0`` (ephemeral) never conflicts, so it is skipped. Any probe + error other than address-in-use returns ``False`` so uvicorn surfaces it + with its normal diagnostics (bad host, EACCES, …). """ if not port: return False @@ -426,21 +339,15 @@ def _port_bind_conflict(host: str, port: int) -> bool: except OSError: return False try: - import sys as _sys_mod - _exclusive = getattr(_socket, "SO_EXCLUSIVEADDRUSE", None) - if _sys_mod.platform == "win32" and _exclusive is not None: - # Windows: SO_REUSEADDR means "bind over anyone" — a probe (or - # uvicorn bind) with it SUCCEEDS on top of a live LISTEN socket, - # so it can never detect a conflict. SO_EXCLUSIVEADDRUSE makes - # the probe fail with WSAEADDRINUSE exactly when another socket - # holds the port (the reporter's 10048 shape in #93608). + if sys.platform == "win32" and _exclusive is not None: + # Windows SO_REUSEADDR binds over a live LISTEN socket and can never + # detect a conflict; SO_EXCLUSIVEADDRUSE fails with 10048 exactly + # when another socket holds the port (#93608). probe.setsockopt(_socket.SOL_SOCKET, _exclusive, 1) else: - # POSIX: match uvicorn's bind flags (uvicorn/config.py - # bind_socket) so the probe conflicts exactly when uvicorn's own - # bind would: SO_REUSEADDR lets TIME_WAIT remnants pass while a - # live LISTEN socket still fails. + # Match uvicorn's own bind flags so the probe conflicts exactly when + # its bind would: TIME_WAIT remnants pass, a live LISTEN fails. probe.setsockopt(_socket.SOL_SOCKET, _socket.SO_REUSEADDR, 1) probe.bind((host, port)) except OSError as exc: @@ -455,20 +362,11 @@ def _port_bind_conflict(host: str, port: int) -> bool: def _write_machine_sentinel_line(line: str) -> None: """Write a machine-parsed sentinel line to the REAL stdout (fd 1). - The serve startup path imports ``tui_gateway.server`` (flush-on-SIGTERM - handlers, #94724) which redirects ``sys.stdout`` to ``sys.stderr`` at - import time to keep stray prints off the JSON-RPC protocol stream. Any - machine-readable sentinel printed after that import via ``print()`` lands - on stderr — invisible to consumers that parse the child's stdout pipe - (the Desktop spawn, scripts). fd 1 is untouched by the Python-level - redirect, so write there. - - Best-effort by design: if fd 1 is unwritable (closed; invalid under - pythonw.exe), fall back to ``print()`` for human visibility only — the - redirected stream can't reach stdout-parsing consumers, and pythonw - Desktop spawns rely on ``_write_dashboard_ready_file()`` (the - HERMES_DESKTOP_READY_FILE channel) for port discovery instead. Never - raises: a sentinel-delivery failure must not kill a healthy serve. + ``tui_gateway.server`` redirects ``sys.stdout`` to stderr at import (#94724), + so a ``print()`` sentinel never reaches the Desktop's stdout pipe; fd 1 is + untouched. If fd 1 is unwritable (pythonw.exe) fall back to ``print()`` for + humans only — pythonw spawns discover the port via the ready file. Never + raises. """ try: os.write(1, (line + "\n").encode()) @@ -496,12 +394,10 @@ _DEFAULT_DASHBOARD_FORWARDED_ALLOW_IPS = ("127.0.0.1", "::1") def _dashboard_forwarded_allow_ips(dashboard_config: dict[str, Any]) -> list[str]: - """Return the bounded proxy addresses uvicorn may trust. + """Return the bounded proxy addresses uvicorn may trust: loopback plus valid config entries. - Uvicorn's default trusts loopback. Preserve that behavior and extend it - only with explicit IP addresses or CIDR networks from config. Invalid or - unbounded entries fail closed instead of turning arbitrary client-supplied - forwarding headers into request metadata. + Invalid or unbounded (/0, '*') ``dashboard.trusted_proxies`` entries fail + closed so client-supplied forwarding headers never become request metadata. """ configured = dashboard_config.get("trusted_proxies", []) if configured in (None, ""): diff --git a/hermes_cli/web_server_mcp.py b/hermes_cli/web_server_mcp.py index 47002fef0a..5308a35e1a 100644 --- a/hermes_cli/web_server_mcp.py +++ b/hermes_cli/web_server_mcp.py @@ -1,8 +1,9 @@ -"""MCP server dashboard helpers: create-payload normalisation, env redaction/summary, and the dashboard-driven MCP OAuth worker. +"""MCP dashboard helpers: create-payload normalisation, env redaction/summary, dashboard-driven MCP OAuth worker. -Split out of ``hermes_cli.web_server``; every externally used name is re-imported -there, so ``web_server.`` keeps resolving (and monkeypatching) as before. -Helpers that tests patch on ``web_server`` are reached lazily through it. +Split out of ``hermes_cli.web_server``, which re-imports every externally used +name so ``web_server.`` keeps resolving (and monkeypatching) as before. +Wraps the same config layer the CLI uses (hermes_cli.mcp_config); stdio ``env`` +secrets are redacted on read. """ import threading @@ -15,38 +16,14 @@ from hermes_cli.config import redact_key from hermes_cli.web_models import MCPServerCreate -# --------------------------------------------------------------------------- -# Automation Blueprints — parameterized automation blueprints. The dashboard renders the -# slot schema as a form; submitting instantiates a real cron job via the same -# create_job path. See cron/blueprint_catalog.py for the single source of truth. -# --------------------------------------------------------------------------- - - -# --------------------------------------------------------------------------- -# MCP server endpoints — list / add / remove / test. -# -# Wraps the same config data layer the CLI uses (hermes_cli.mcp_config), so -# servers managed here show up under `hermes mcp list` and vice versa. Secrets -# in stdio `env` blocks are redacted on read; the agent picks them up from -# config.yaml at session start exactly as with CLI-added servers. -# --------------------------------------------------------------------------- - - -def _normalize_mcp_server_create( - body: MCPServerCreate, -) -> tuple[str, Dict[str, Any], Optional[str]]: +def _normalize_mcp_server_create(body: MCPServerCreate) -> tuple[str, Dict[str, Any], Optional[str]]: """Validate a Dashboard MCP create request and build its safe config. - The returned config never contains the submitted Bearer token. Callers - persist the token with the shared Bearer helper only after they enter the - intended profile scope. Keeping this conversion shared makes the - standalone MCP page and the Profile Builder enforce the same - transport/auth contract. + The returned config never contains the Bearer token; callers persist it via + the shared Bearer helper once inside the intended profile scope. Shared by + the MCP page and the Profile Builder so both enforce one transport/auth contract. """ - from hermes_cli.mcp_config import ( - _bearer_auth_headers, - _strip_bearer_prefix, - ) + from hermes_cli.mcp_config import _bearer_auth_headers, _strip_bearer_prefix from hermes_cli.mcp_security import validate_mcp_server_entry name = (body.name or "").strip() @@ -56,11 +33,7 @@ def _normalize_mcp_server_create( url = (body.url or "").strip() command = (body.command or "").strip() auth = (body.auth or "none").strip().lower() - bearer_token = ( - body.bearer_token.get_secret_value() - if body.bearer_token is not None - else None - ) + bearer_token = body.bearer_token.get_secret_value() if body.bearer_token is not None else None if bool(url) == bool(command): raise ValueError("Provide exactly one of URL (HTTP/SSE) or command (stdio)") @@ -72,9 +45,7 @@ def _normalize_mcp_server_create( if body.args: raise ValueError("Arguments are only supported for stdio MCP servers") if body.env: - raise ValueError( - "Environment variables are only supported for stdio MCP servers" - ) + raise ValueError("Environment variables are only supported for stdio MCP servers") if auth == "header": normalized = _strip_bearer_prefix(bearer_token) if bearer_token else "" if not normalized or normalized.lower() == "bearer": @@ -88,9 +59,7 @@ def _normalize_mcp_server_create( server_config["auth"] = "oauth" else: if auth != "none" or body.bearer_token is not None: - raise ValueError( - "HTTP authentication is not supported for stdio MCP servers" - ) + raise ValueError("HTTP authentication is not supported for stdio MCP servers") server_config["command"] = command if body.args: server_config["args"] = list(body.args) @@ -118,9 +87,7 @@ def _mcp_server_summary(name: str, cfg: Dict[str, Any]) -> Dict[str, Any]: transport = "http" if cfg.get("url") else ("stdio" if cfg.get("command") else "unknown") auth = cfg.get("auth") headers = cfg.get("headers") or {} - if not auth and isinstance(headers, dict) and any( - str(key).lower() == "authorization" for key in headers - ): + if not auth and isinstance(headers, dict) and any(str(key).lower() == "authorization" for key in headers): auth = "header" return { "name": name, @@ -149,17 +116,9 @@ def _mcp_oauth_transaction(flow) -> threading.Lock: def _run_dashboard_mcp_oauth(flow, cfg: dict) -> None: """Run the normal MCP probe with dashboard redirect/callback handlers.""" - from hermes_cli.mcp_config import ( - _oauth_tokens_present, - _probe_single_server, - _save_mcp_server, - ) + from hermes_cli.mcp_config import _oauth_tokens_present, _probe_single_server, _save_mcp_server try: - from agent.secret_scope import ( - build_profile_secret_scope, - reset_secret_scope, - set_secret_scope, - ) + from agent.secret_scope import build_profile_secret_scope, reset_secret_scope, set_secret_scope from hermes_constants import reset_hermes_home_override, set_hermes_home_override from tools.mcp_dashboard_oauth import dashboard_oauth_flow from tools.mcp_oauth import HermesTokenStorage, force_interactive_oauth @@ -175,10 +134,7 @@ def _run_dashboard_mcp_oauth(flow, cfg: dict) -> None: backup = storage.snapshot() previous_entry = None try: - previous_entry = manager.remove( - flow.server_name, - hermes_home=flow.hermes_home, - ) + previous_entry = manager.remove(flow.server_name, hermes_home=flow.hermes_home) tools = _probe_single_server( flow.server_name, cfg, @@ -198,28 +154,20 @@ def _run_dashboard_mcp_oauth(flow, cfg: dict) -> None: reconnect_mcp_server(flow.server_name) except Exception: storage.restore(backup, only_if_absent=True) - manager.restore_entry( - flow.server_name, - previous_entry, - hermes_home=flow.hermes_home, - ) + manager.restore_entry(flow.server_name, previous_entry, hermes_home=flow.hermes_home) raise finally: reset_secret_scope(secret_token) reset_hermes_home_override(home_token) except Exception as exc: msg = str(exc) - # Providers that gate RFC 7591 registration to pre-approved clients - # (Figma's MCP catalog, etc.) 403 the register call before any - # authorization URL exists — surface what's actually happening - # instead of a bare "403 Forbidden". + # Providers gating RFC 7591 registration to pre-approved clients 403 the + # register call before any auth URL exists; say so, not "403 Forbidden". try: from tools.mcp_oauth import humanize_oauth_registration_error humanized = humanize_oauth_registration_error( - flow.server_name, - exc, - server_url=cfg.get("url") if isinstance(cfg, dict) else None, + flow.server_name, exc, server_url=cfg.get("url") if isinstance(cfg, dict) else None ) if humanized: msg = humanized