From 3ca0d4fbfde0cd2d78bb0b5d1f44e98688b0b2fe Mon Sep 17 00:00:00 2001 From: andrexibiza <84248988+andrexibiza@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:21:08 -0500 Subject: [PATCH 1/5] refactor(web): extract WhatsApp onboarding into web_routers/whatsapp_onboarding (web_server.py god-file slice R4-C3) Signed-off-by: andrexibiza <84248988+andrexibiza@users.noreply.github.com> --- hermes_cli/web_routers/whatsapp_onboarding.py | 529 ++++++++++++++++++ hermes_cli/web_server.py | 511 +---------------- tests/web_server_whatsapp_seam.py | 98 ++++ 3 files changed, 645 insertions(+), 493 deletions(-) create mode 100644 hermes_cli/web_routers/whatsapp_onboarding.py create mode 100644 tests/web_server_whatsapp_seam.py diff --git a/hermes_cli/web_routers/whatsapp_onboarding.py b/hermes_cli/web_routers/whatsapp_onboarding.py new file mode 100644 index 0000000000000..226e957e7591b --- /dev/null +++ b/hermes_cli/web_routers/whatsapp_onboarding.py @@ -0,0 +1,529 @@ +"""WhatsApp onboarding routes for the dashboard. + +Extracted from hermes_cli/web_server.py (god-file slice R4-C3, epic #78791): +the WhatsApp bridge onboarding cluster (spawn/watch/apply/cancel + session +lifecycle). Cross-cluster helpers resolve through web_deps.late(). +""" + +from datetime import datetime, timezone +import json +import logging +import re +import secrets +import subprocess +import threading +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, HTTPException + +from hermes_cli.web_deps import late +from hermes_cli.web_models import WhatsAppOnboardingApply, WhatsAppOnboardingStart + +save_env_value = late("save_env_value") +remove_env_value = late("remove_env_value") + +_log = logging.getLogger("hermes_cli.web_server") + +router = APIRouter() + +_config_profile_scope = late("_config_profile_scope") +_spawn_gateway_restart = late("_spawn_gateway_restart") +_write_platform_enabled = late("_write_platform_enabled") + +_WHATSAPP_ONBOARDING_TTL_SECONDS = 600 +_WHATSAPP_ONBOARDING_TERMINAL_STATUSES = {"connected", "error", "expired", "cancelled"} + + +@dataclass +class _WhatsAppOnboardingSession: + proc: subprocess.Popen | None + mode: str + allowed_users: str + session_path: str + expires_at: str + expires_at_ts: float + profile: str | None = None + status: str = "starting" + qr_payload: str | None = None + account_id: str | None = None + account_name: str | None = None + account_phone: str | None = None + error: str | None = None + + +_whatsapp_onboarding_sessions: dict[str, _WhatsAppOnboardingSession] = {} +_whatsapp_onboarding_lock = threading.RLock() + + +def _utc_iso_from_ts(ts: float) -> str: + return datetime.fromtimestamp(ts, timezone.utc).isoformat().replace("+00:00", "Z") + + +def _normalize_whatsapp_onboarding_mode(value: Any) -> str: + mode = str(value or "bot").strip().lower() + if mode not in {"bot", "self-chat"}: + raise HTTPException(status_code=400, detail="WhatsApp mode must be 'bot' or 'self-chat'.") + return mode + + +def _normalize_whatsapp_allowed_users(value: Any) -> str: + raw = str(value or "").strip() + if not raw: + return "" + return ",".join(part.replace(" ", "") for part in raw.split(",") if part.strip()) + + +def _whatsapp_session_path() -> Path: + from hermes_constants import get_hermes_dir + + return get_hermes_dir("platforms/whatsapp/session", "whatsapp/session") + + +def _whatsapp_phone_from_identifier(value: Any) -> str | None: + raw = str(value or "").strip() + if not raw: + return None + candidate = raw.split("@", 1)[0].split(":", 1)[0] + digits = re.sub(r"\D+", "", candidate) + return digits or None + + +def _whatsapp_linked_account_from_session(session_path: Path) -> tuple[str | None, str | None, str | None]: + creds_path = session_path / "creds.json" + try: + payload = json.loads(creds_path.read_text(encoding="utf-8")) + except Exception: + return None, None, None + + account_id: str | None = None + account_name: str | None = None + + def collect(candidate: Any) -> None: + nonlocal account_id, account_name + if not isinstance(candidate, dict): + return + if account_id is None: + for key in ("id", "jid", "lid"): + value = str(candidate.get(key) or "").strip() + if value: + account_id = value + break + if account_name is None: + for key in ("name", "verifiedName", "notify", "pushName"): + value = str(candidate.get(key) or "").strip() + if value: + account_name = value + break + + collect(payload.get("me")) + collect(payload.get("account")) + collect(payload) + return account_id, account_name, _whatsapp_phone_from_identifier(account_id) + + +def _ensure_whatsapp_bridge_dependencies(bridge_dir: Path) -> None: + """Install bridge dependencies when the dashboard is the setup surface.""" + if (bridge_dir / "node_modules").exists(): + return + + from hermes_constants import find_node_executable, with_hermes_node_path + from utils import env_int + + npm = find_node_executable("npm") + if not npm: + raise HTTPException( + status_code=500, + detail="npm was not found. WhatsApp setup needs Node.js and npm.", + ) + + timeout = env_int("WHATSAPP_NPM_INSTALL_TIMEOUT", 300) + try: + result = subprocess.run( + [npm, "install", "--silent"], + cwd=str(bridge_dir), + capture_output=True, + text=True, + # npm output is UTF-8; guard the Windows ANSI-code-page default + # against undefined bytes crashing the reader thread (#52649). + encoding="utf-8", + errors="replace", + timeout=timeout, + env=with_hermes_node_path(), + creationflags=windows_hide_flags(), + ) + except subprocess.TimeoutExpired as exc: + raise HTTPException( + status_code=500, + detail="Installing WhatsApp bridge dependencies timed out.", + ) from exc + except OSError as exc: + raise HTTPException( + status_code=500, + detail=f"Failed to install WhatsApp bridge dependencies: {exc}", + ) from exc + + if result.returncode != 0: + detail = (result.stderr or result.stdout or "").strip() + if detail: + detail = "\n".join(detail.splitlines()[-10:]) + raise HTTPException( + status_code=500, + detail=f"npm install failed for WhatsApp bridge: {detail or 'no output'}", + ) + + +def _spawn_whatsapp_pairing_process(session_path: Path, mode: str) -> subprocess.Popen: + from gateway.platforms.whatsapp_common import resolve_whatsapp_bridge_dir + from hermes_constants import find_node_executable, with_hermes_node_path + + bridge_dir = resolve_whatsapp_bridge_dir() + bridge_script = bridge_dir / "bridge.js" + if not bridge_script.exists(): + raise HTTPException( + status_code=500, + detail=f"WhatsApp bridge script was not found at {bridge_script}.", + ) + node = find_node_executable("node") + if not node: + raise HTTPException( + status_code=500, + detail="Node.js was not found. WhatsApp setup needs Node.js.", + ) + + _ensure_whatsapp_bridge_dependencies(bridge_dir) + session_path.mkdir(parents=True, exist_ok=True) + + env = with_hermes_node_path() + env["WHATSAPP_MODE"] = mode + env["WHATSAPP_DM_POLICY"] = "pairing" + return subprocess.Popen( + [ + node, + str(bridge_script), + "--pair-only", + "--pair-json", + "--session", + str(session_path), + ], + cwd=str(bridge_dir), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="replace", + start_new_session=True, + env=env, + creationflags=windows_hide_flags(), + ) + + +def _terminate_whatsapp_pairing(proc: subprocess.Popen | None) -> None: + if proc is None: + return + if proc.poll() is not None: + return + try: + proc.terminate() + proc.wait(timeout=3) + except Exception: + try: + proc.kill() + except Exception: + pass + + +def _watch_whatsapp_pairing(pairing_id: str, proc: subprocess.Popen) -> None: + try: + stream = proc.stdout + if stream is not None: + for line in stream: + raw = line.strip() + if not raw: + continue + try: + payload = json.loads(raw) + except json.JSONDecodeError: + continue + event = str(payload.get("event") or "").strip() + with _whatsapp_onboarding_lock: + record = _whatsapp_onboarding_sessions.get(pairing_id) + if not record or record.proc is not proc: + return + if event == "qr": + qr = str(payload.get("qr") or "").strip() + if qr: + record.qr_payload = qr + record.status = "waiting" + record.error = None + elif event == "connected": + user = payload.get("user") + if isinstance(user, dict): + account_id = str(user.get("id") or "").strip() + account_name = str(user.get("name") or "").strip() + record.account_id = account_id or None + record.account_name = account_name or None + record.account_phone = _whatsapp_phone_from_identifier(account_id) + record.status = "connected" + record.error = None + elif event == "error": + record.status = "error" + record.error = str(payload.get("error") or "WhatsApp pairing failed.") + elif event == "disconnected" and record.status == "starting": + record.status = "waiting" + returncode = proc.wait() + except Exception as exc: + with _whatsapp_onboarding_lock: + record = _whatsapp_onboarding_sessions.get(pairing_id) + if record and record.proc is proc and record.status not in _WHATSAPP_ONBOARDING_TERMINAL_STATUSES: + record.status = "error" + record.error = str(exc) + return + + with _whatsapp_onboarding_lock: + record = _whatsapp_onboarding_sessions.get(pairing_id) + if not record or record.proc is not proc: + return + if record.status in {"connected", "cancelled", "expired"}: + return + record.status = "error" + record.error = ( + "WhatsApp pairing process exited before pairing completed." + if returncode == 0 + else f"WhatsApp pairing process exited with code {returncode}." + ) + + +def _run_whatsapp_pairing(pairing_id: str, session_path: Path, mode: str) -> None: + with _whatsapp_onboarding_lock: + record = _whatsapp_onboarding_sessions.get(pairing_id) + if not record or record.status in _WHATSAPP_ONBOARDING_TERMINAL_STATUSES: + return + record.status = "installing" + + try: + proc = _spawn_whatsapp_pairing_process(session_path, mode) + except Exception as exc: + with _whatsapp_onboarding_lock: + record = _whatsapp_onboarding_sessions.get(pairing_id) + if record and record.status not in _WHATSAPP_ONBOARDING_TERMINAL_STATUSES: + record.status = "error" + record.error = str(exc) + return + + with _whatsapp_onboarding_lock: + record = _whatsapp_onboarding_sessions.get(pairing_id) + if not record or record.status in _WHATSAPP_ONBOARDING_TERMINAL_STATUSES: + _terminate_whatsapp_pairing(proc) + return + record.proc = proc + record.status = "starting" + + _watch_whatsapp_pairing(pairing_id, proc) + + +def _prune_whatsapp_onboarding_sessions() -> None: + now = time.time() + remove_ids: list[str] = [] + for pairing_id, record in _whatsapp_onboarding_sessions.items(): + if ( + record.proc is not None + and record.status not in _WHATSAPP_ONBOARDING_TERMINAL_STATUSES + and record.proc.poll() is not None + ): + record.status = "error" + record.error = "WhatsApp pairing process exited before pairing completed." + if record.expires_at_ts <= now and record.status not in _WHATSAPP_ONBOARDING_TERMINAL_STATUSES: + _terminate_whatsapp_pairing(record.proc) + record.status = "expired" + record.error = "WhatsApp QR setup expired. Start a new setup." + if record.status in _WHATSAPP_ONBOARDING_TERMINAL_STATUSES and record.expires_at_ts + 300 <= now: + remove_ids.append(pairing_id) + for pairing_id in remove_ids: + _whatsapp_onboarding_sessions.pop(pairing_id, None) + + +def _supersede_whatsapp_onboarding_sessions(session_path: Path) -> None: + for existing in _whatsapp_onboarding_sessions.values(): + if existing.session_path == str(session_path) and existing.status not in _WHATSAPP_ONBOARDING_TERMINAL_STATUSES: + existing.status = "cancelled" + existing.error = "Superseded by a newer WhatsApp setup session." + _terminate_whatsapp_pairing(existing.proc) + + +def _whatsapp_onboarding_payload(pairing_id: str, record: _WhatsAppOnboardingSession) -> dict[str, Any]: + return { + "pairing_id": pairing_id, + "status": record.status, + "qr_payload": record.qr_payload, + "expires_at": record.expires_at, + "mode": record.mode, + "allowed_users": record.allowed_users, + "account_id": record.account_id, + "account_name": record.account_name, + "account_phone": record.account_phone, + "error": record.error, + } + + +def _restart_gateway_after_whatsapp_onboarding(profile: Optional[str] = None) -> dict[str, Any]: + try: + proc, reused = _spawn_gateway_restart(profile) + except Exception as exc: + _log.exception("Failed to auto-restart gateway after WhatsApp onboarding") + return { + "restart_started": False, + "restart_error": str(exc), + } + if reused: + _log.info( + "WhatsApp onboarding: reusing in-flight gateway restart (pid %s)", + proc.pid, + ) + return { + "restart_started": True, + "restart_action": "gateway-restart", + "restart_pid": proc.pid, + } + + +@router.post("/api/messaging/whatsapp/onboarding/start") +async def start_whatsapp_onboarding(body: WhatsAppOnboardingStart): + mode = _normalize_whatsapp_onboarding_mode(body.mode) + allowed_users = _normalize_whatsapp_allowed_users(body.allowed_users) + effective_profile = body.profile + + with _config_profile_scope(effective_profile): + session_path = late("_whatsapp_session_path")() + expires_at_ts = time.time() + _WHATSAPP_ONBOARDING_TTL_SECONDS + expires_at = _utc_iso_from_ts(expires_at_ts) + if (session_path / "creds.json").exists(): + import hermes_cli.web_server as _ws + pairing_id = _ws.secrets.token_urlsafe(16) + account_id, account_name, account_phone = _whatsapp_linked_account_from_session(session_path) + record = _WhatsAppOnboardingSession( + proc=None, + mode=mode, + allowed_users=allowed_users, + session_path=str(session_path), + expires_at=expires_at, + expires_at_ts=expires_at_ts, + profile=effective_profile, + status="connected", + account_id=account_id, + account_name=account_name, + account_phone=account_phone, + ) + with _whatsapp_onboarding_lock: + _prune_whatsapp_onboarding_sessions() + _supersede_whatsapp_onboarding_sessions(session_path) + _whatsapp_onboarding_sessions[pairing_id] = record + return _whatsapp_onboarding_payload(pairing_id, record) + + import hermes_cli.web_server as _ws + pairing_id = _ws.secrets.token_urlsafe(16) + record = _WhatsAppOnboardingSession( + proc=None, + mode=mode, + allowed_users=allowed_users, + session_path=str(session_path), + expires_at=expires_at, + expires_at_ts=expires_at_ts, + profile=effective_profile, + ) + + with _whatsapp_onboarding_lock: + _prune_whatsapp_onboarding_sessions() + _supersede_whatsapp_onboarding_sessions(session_path) + _whatsapp_onboarding_sessions[pairing_id] = record + + threading.Thread( + target=_run_whatsapp_pairing, + args=(pairing_id, session_path, mode), + daemon=True, + ).start() + + return _whatsapp_onboarding_payload(pairing_id, record) + + +@router.get("/api/messaging/whatsapp/onboarding/{pairing_id}") +async def get_whatsapp_onboarding_status(pairing_id: str): + with _whatsapp_onboarding_lock: + _prune_whatsapp_onboarding_sessions() + record = _whatsapp_onboarding_sessions.get(pairing_id) + if not record: + raise HTTPException( + status_code=404, + detail="WhatsApp setup session was not found. Start a new setup.", + ) + if record.status == "expired": + raise HTTPException(status_code=410, detail=record.error or "WhatsApp setup expired.") + return _whatsapp_onboarding_payload(pairing_id, record) + + +@router.post("/api/messaging/whatsapp/onboarding/{pairing_id}/apply") +async def apply_whatsapp_onboarding( + pairing_id: str, body: WhatsAppOnboardingApply, profile: Optional[str] = None +): + with _whatsapp_onboarding_lock: + _prune_whatsapp_onboarding_sessions() + record = _whatsapp_onboarding_sessions.get(pairing_id) + if not record: + raise HTTPException( + status_code=404, + detail="WhatsApp setup session was not found. Start a new setup.", + ) + if record.status != "connected": + raise HTTPException(status_code=409, detail="WhatsApp setup is not connected yet.") + mode = _normalize_whatsapp_onboarding_mode(body.mode or record.mode) + allowed_users = _normalize_whatsapp_allowed_users( + record.allowed_users if body.allowed_users is None else body.allowed_users + ) + if mode == "self-chat" and not allowed_users: + allowed_users = record.account_phone or record.account_id or "" + record_profile = record.profile + + effective_profile = body.profile or profile or record_profile + try: + with _config_profile_scope(effective_profile): + save_env_value("WHATSAPP_MODE", mode) + save_env_value("WHATSAPP_DM_POLICY", "pairing") + if allowed_users: + save_env_value("WHATSAPP_ALLOWED_USERS", allowed_users) + # Blank means "keep the existing allowlist"; explicit clearing + # still lives in the normal config editor where the field is visible. + save_env_value("WHATSAPP_ENABLED", "true") + _write_platform_enabled("whatsapp", True) + except HTTPException: + raise + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: + _log.exception("WhatsApp onboarding apply failed") + raise HTTPException( + status_code=500, + detail="Failed to save WhatsApp setup.", + ) from exc + + with _whatsapp_onboarding_lock: + _whatsapp_onboarding_sessions.pop(pairing_id, None) + + restart_result = _restart_gateway_after_whatsapp_onboarding(effective_profile) + return { + "ok": True, + "platform": "whatsapp", + "needs_restart": not restart_result["restart_started"], + **restart_result, + } + + +@router.delete("/api/messaging/whatsapp/onboarding/{pairing_id}") +async def cancel_whatsapp_onboarding(pairing_id: str): + with _whatsapp_onboarding_lock: + record = _whatsapp_onboarding_sessions.pop(pairing_id, None) + if record: + record.status = "cancelled" + _terminate_whatsapp_pairing(record.proc) + return {"ok": True} diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 1fb3e6131629e..587af7c9514f3 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -8399,500 +8399,25 @@ def _write_platform_enabled(platform_id: str, enabled: bool) -> None: write_platform_config_field(platform_id, "enabled", enabled) -_WHATSAPP_ONBOARDING_TTL_SECONDS = 600 -_WHATSAPP_ONBOARDING_TERMINAL_STATUSES = {"connected", "error", "expired", "cancelled"} - - -@dataclass -class _WhatsAppOnboardingSession: - proc: subprocess.Popen | None - mode: str - allowed_users: str - session_path: str - expires_at: str - expires_at_ts: float - profile: str | None = None - status: str = "starting" - qr_payload: str | None = None - account_id: str | None = None - account_name: str | None = None - account_phone: str | None = None - error: str | None = None - - -_whatsapp_onboarding_sessions: dict[str, _WhatsAppOnboardingSession] = {} -_whatsapp_onboarding_lock = threading.RLock() - - -def _utc_iso_from_ts(ts: float) -> str: - return datetime.fromtimestamp(ts, timezone.utc).isoformat().replace("+00:00", "Z") - - -def _normalize_whatsapp_onboarding_mode(value: Any) -> str: - mode = str(value or "bot").strip().lower() - if mode not in {"bot", "self-chat"}: - raise HTTPException(status_code=400, detail="WhatsApp mode must be 'bot' or 'self-chat'.") - return mode - - -def _normalize_whatsapp_allowed_users(value: Any) -> str: - raw = str(value or "").strip() - if not raw: - return "" - return ",".join(part.replace(" ", "") for part in raw.split(",") if part.strip()) - - -def _whatsapp_session_path() -> Path: - from hermes_constants import get_hermes_dir - - return get_hermes_dir("platforms/whatsapp/session", "whatsapp/session") - - -def _whatsapp_phone_from_identifier(value: Any) -> str | None: - raw = str(value or "").strip() - if not raw: - return None - candidate = raw.split("@", 1)[0].split(":", 1)[0] - digits = re.sub(r"\D+", "", candidate) - return digits or None - - -def _whatsapp_linked_account_from_session(session_path: Path) -> tuple[str | None, str | None, str | None]: - creds_path = session_path / "creds.json" - try: - payload = json.loads(creds_path.read_text(encoding="utf-8")) - except Exception: - return None, None, None - - account_id: str | None = None - account_name: str | None = None - - def collect(candidate: Any) -> None: - nonlocal account_id, account_name - if not isinstance(candidate, dict): - return - if account_id is None: - for key in ("id", "jid", "lid"): - value = str(candidate.get(key) or "").strip() - if value: - account_id = value - break - if account_name is None: - for key in ("name", "verifiedName", "notify", "pushName"): - value = str(candidate.get(key) or "").strip() - if value: - account_name = value - break - - collect(payload.get("me")) - collect(payload.get("account")) - collect(payload) - return account_id, account_name, _whatsapp_phone_from_identifier(account_id) - - -def _ensure_whatsapp_bridge_dependencies(bridge_dir: Path) -> None: - """Install bridge dependencies when the dashboard is the setup surface.""" - if (bridge_dir / "node_modules").exists(): - return - - from hermes_constants import find_node_executable, with_hermes_node_path - from utils import env_int - - npm = find_node_executable("npm") - if not npm: - raise HTTPException( - status_code=500, - detail="npm was not found. WhatsApp setup needs Node.js and npm.", - ) - - timeout = env_int("WHATSAPP_NPM_INSTALL_TIMEOUT", 300) - try: - result = subprocess.run( - [npm, "install", "--silent"], - cwd=str(bridge_dir), - capture_output=True, - text=True, - # npm output is UTF-8; guard the Windows ANSI-code-page default - # against undefined bytes crashing the reader thread (#52649). - encoding="utf-8", - errors="replace", - timeout=timeout, - env=with_hermes_node_path(), - creationflags=windows_hide_flags(), - ) - except subprocess.TimeoutExpired as exc: - raise HTTPException( - status_code=500, - detail="Installing WhatsApp bridge dependencies timed out.", - ) from exc - except OSError as exc: - raise HTTPException( - status_code=500, - detail=f"Failed to install WhatsApp bridge dependencies: {exc}", - ) from exc - - if result.returncode != 0: - detail = (result.stderr or result.stdout or "").strip() - if detail: - detail = "\n".join(detail.splitlines()[-10:]) - raise HTTPException( - status_code=500, - detail=f"npm install failed for WhatsApp bridge: {detail or 'no output'}", - ) - - -def _spawn_whatsapp_pairing_process(session_path: Path, mode: str) -> subprocess.Popen: - from gateway.platforms.whatsapp_common import resolve_whatsapp_bridge_dir - from hermes_constants import find_node_executable, with_hermes_node_path - - bridge_dir = resolve_whatsapp_bridge_dir() - bridge_script = bridge_dir / "bridge.js" - if not bridge_script.exists(): - raise HTTPException( - status_code=500, - detail=f"WhatsApp bridge script was not found at {bridge_script}.", - ) - node = find_node_executable("node") - if not node: - raise HTTPException( - status_code=500, - detail="Node.js was not found. WhatsApp setup needs Node.js.", - ) - - _ensure_whatsapp_bridge_dependencies(bridge_dir) - session_path.mkdir(parents=True, exist_ok=True) - - env = with_hermes_node_path() - env["WHATSAPP_MODE"] = mode - env["WHATSAPP_DM_POLICY"] = "pairing" - return subprocess.Popen( - [ - node, - str(bridge_script), - "--pair-only", - "--pair-json", - "--session", - str(session_path), - ], - cwd=str(bridge_dir), - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - encoding="utf-8", - errors="replace", - start_new_session=True, - env=env, - creationflags=windows_hide_flags(), - ) - - -def _terminate_whatsapp_pairing(proc: subprocess.Popen | None) -> None: - if proc is None: - return - if proc.poll() is not None: - return - try: - proc.terminate() - proc.wait(timeout=3) - except Exception: - try: - proc.kill() - except Exception: - pass - - -def _watch_whatsapp_pairing(pairing_id: str, proc: subprocess.Popen) -> None: - try: - stream = proc.stdout - if stream is not None: - for line in stream: - raw = line.strip() - if not raw: - continue - try: - payload = json.loads(raw) - except json.JSONDecodeError: - continue - event = str(payload.get("event") or "").strip() - with _whatsapp_onboarding_lock: - record = _whatsapp_onboarding_sessions.get(pairing_id) - if not record or record.proc is not proc: - return - if event == "qr": - qr = str(payload.get("qr") or "").strip() - if qr: - record.qr_payload = qr - record.status = "waiting" - record.error = None - elif event == "connected": - user = payload.get("user") - if isinstance(user, dict): - account_id = str(user.get("id") or "").strip() - account_name = str(user.get("name") or "").strip() - record.account_id = account_id or None - record.account_name = account_name or None - record.account_phone = _whatsapp_phone_from_identifier(account_id) - record.status = "connected" - record.error = None - elif event == "error": - record.status = "error" - record.error = str(payload.get("error") or "WhatsApp pairing failed.") - elif event == "disconnected" and record.status == "starting": - record.status = "waiting" - returncode = proc.wait() - except Exception as exc: - with _whatsapp_onboarding_lock: - record = _whatsapp_onboarding_sessions.get(pairing_id) - if record and record.proc is proc and record.status not in _WHATSAPP_ONBOARDING_TERMINAL_STATUSES: - record.status = "error" - record.error = str(exc) - return - - with _whatsapp_onboarding_lock: - record = _whatsapp_onboarding_sessions.get(pairing_id) - if not record or record.proc is not proc: - return - if record.status in {"connected", "cancelled", "expired"}: - return - record.status = "error" - record.error = ( - "WhatsApp pairing process exited before pairing completed." - if returncode == 0 - else f"WhatsApp pairing process exited with code {returncode}." - ) - - -def _run_whatsapp_pairing(pairing_id: str, session_path: Path, mode: str) -> None: - with _whatsapp_onboarding_lock: - record = _whatsapp_onboarding_sessions.get(pairing_id) - if not record or record.status in _WHATSAPP_ONBOARDING_TERMINAL_STATUSES: - return - record.status = "installing" - - try: - proc = _spawn_whatsapp_pairing_process(session_path, mode) - except Exception as exc: - with _whatsapp_onboarding_lock: - record = _whatsapp_onboarding_sessions.get(pairing_id) - if record and record.status not in _WHATSAPP_ONBOARDING_TERMINAL_STATUSES: - record.status = "error" - record.error = str(exc) - return - - with _whatsapp_onboarding_lock: - record = _whatsapp_onboarding_sessions.get(pairing_id) - if not record or record.status in _WHATSAPP_ONBOARDING_TERMINAL_STATUSES: - _terminate_whatsapp_pairing(proc) - return - record.proc = proc - record.status = "starting" - - _watch_whatsapp_pairing(pairing_id, proc) - - -def _prune_whatsapp_onboarding_sessions() -> None: - now = time.time() - remove_ids: list[str] = [] - for pairing_id, record in _whatsapp_onboarding_sessions.items(): - if ( - record.proc is not None - and record.status not in _WHATSAPP_ONBOARDING_TERMINAL_STATUSES - and record.proc.poll() is not None - ): - record.status = "error" - record.error = "WhatsApp pairing process exited before pairing completed." - if record.expires_at_ts <= now and record.status not in _WHATSAPP_ONBOARDING_TERMINAL_STATUSES: - _terminate_whatsapp_pairing(record.proc) - record.status = "expired" - record.error = "WhatsApp QR setup expired. Start a new setup." - if record.status in _WHATSAPP_ONBOARDING_TERMINAL_STATUSES and record.expires_at_ts + 300 <= now: - remove_ids.append(pairing_id) - for pairing_id in remove_ids: - _whatsapp_onboarding_sessions.pop(pairing_id, None) - - -def _supersede_whatsapp_onboarding_sessions(session_path: Path) -> None: - for existing in _whatsapp_onboarding_sessions.values(): - if existing.session_path == str(session_path) and existing.status not in _WHATSAPP_ONBOARDING_TERMINAL_STATUSES: - existing.status = "cancelled" - existing.error = "Superseded by a newer WhatsApp setup session." - _terminate_whatsapp_pairing(existing.proc) - - -def _whatsapp_onboarding_payload(pairing_id: str, record: _WhatsAppOnboardingSession) -> dict[str, Any]: - return { - "pairing_id": pairing_id, - "status": record.status, - "qr_payload": record.qr_payload, - "expires_at": record.expires_at, - "mode": record.mode, - "allowed_users": record.allowed_users, - "account_id": record.account_id, - "account_name": record.account_name, - "account_phone": record.account_phone, - "error": record.error, - } - - -def _restart_gateway_after_whatsapp_onboarding(profile: Optional[str] = None) -> dict[str, Any]: - try: - proc, reused = _spawn_gateway_restart(profile) - except Exception as exc: - _log.exception("Failed to auto-restart gateway after WhatsApp onboarding") - return { - "restart_started": False, - "restart_error": str(exc), - } - if reused: - _log.info( - "WhatsApp onboarding: reusing in-flight gateway restart (pid %s)", - proc.pid, - ) - return { - "restart_started": True, - "restart_action": "gateway-restart", - "restart_pid": proc.pid, - } - - -@app.post("/api/messaging/whatsapp/onboarding/start") -async def start_whatsapp_onboarding(body: WhatsAppOnboardingStart): - mode = _normalize_whatsapp_onboarding_mode(body.mode) - allowed_users = _normalize_whatsapp_allowed_users(body.allowed_users) - effective_profile = body.profile - - with _config_profile_scope(effective_profile): - session_path = _whatsapp_session_path() - expires_at_ts = time.time() + _WHATSAPP_ONBOARDING_TTL_SECONDS - expires_at = _utc_iso_from_ts(expires_at_ts) - if (session_path / "creds.json").exists(): - pairing_id = secrets.token_urlsafe(16) - account_id, account_name, account_phone = _whatsapp_linked_account_from_session(session_path) - record = _WhatsAppOnboardingSession( - proc=None, - mode=mode, - allowed_users=allowed_users, - session_path=str(session_path), - expires_at=expires_at, - expires_at_ts=expires_at_ts, - profile=effective_profile, - status="connected", - account_id=account_id, - account_name=account_name, - account_phone=account_phone, - ) - with _whatsapp_onboarding_lock: - _prune_whatsapp_onboarding_sessions() - _supersede_whatsapp_onboarding_sessions(session_path) - _whatsapp_onboarding_sessions[pairing_id] = record - return _whatsapp_onboarding_payload(pairing_id, record) - - pairing_id = secrets.token_urlsafe(16) - record = _WhatsAppOnboardingSession( - proc=None, - mode=mode, - allowed_users=allowed_users, - session_path=str(session_path), - expires_at=expires_at, - expires_at_ts=expires_at_ts, - profile=effective_profile, - ) - - with _whatsapp_onboarding_lock: - _prune_whatsapp_onboarding_sessions() - _supersede_whatsapp_onboarding_sessions(session_path) - _whatsapp_onboarding_sessions[pairing_id] = record - - threading.Thread( - target=_run_whatsapp_pairing, - args=(pairing_id, session_path, mode), - daemon=True, - ).start() - - return _whatsapp_onboarding_payload(pairing_id, record) - - -@app.get("/api/messaging/whatsapp/onboarding/{pairing_id}") -async def get_whatsapp_onboarding_status(pairing_id: str): - with _whatsapp_onboarding_lock: - _prune_whatsapp_onboarding_sessions() - record = _whatsapp_onboarding_sessions.get(pairing_id) - if not record: - raise HTTPException( - status_code=404, - detail="WhatsApp setup session was not found. Start a new setup.", - ) - if record.status == "expired": - raise HTTPException(status_code=410, detail=record.error or "WhatsApp setup expired.") - return _whatsapp_onboarding_payload(pairing_id, record) - - -@app.post("/api/messaging/whatsapp/onboarding/{pairing_id}/apply") -async def apply_whatsapp_onboarding( - pairing_id: str, body: WhatsAppOnboardingApply, profile: Optional[str] = None -): - with _whatsapp_onboarding_lock: - _prune_whatsapp_onboarding_sessions() - record = _whatsapp_onboarding_sessions.get(pairing_id) - if not record: - raise HTTPException( - status_code=404, - detail="WhatsApp setup session was not found. Start a new setup.", - ) - if record.status != "connected": - raise HTTPException(status_code=409, detail="WhatsApp setup is not connected yet.") - mode = _normalize_whatsapp_onboarding_mode(body.mode or record.mode) - allowed_users = _normalize_whatsapp_allowed_users( - record.allowed_users if body.allowed_users is None else body.allowed_users - ) - if mode == "self-chat" and not allowed_users: - allowed_users = record.account_phone or record.account_id or "" - record_profile = record.profile - - effective_profile = body.profile or profile or record_profile - try: - with _config_profile_scope(effective_profile): - save_env_value("WHATSAPP_MODE", mode) - save_env_value("WHATSAPP_DM_POLICY", "pairing") - if allowed_users: - save_env_value("WHATSAPP_ALLOWED_USERS", allowed_users) - # Blank means "keep the existing allowlist"; explicit clearing - # still lives in the normal config editor where the field is visible. - save_env_value("WHATSAPP_ENABLED", "true") - _write_platform_enabled("whatsapp", True) - except HTTPException: - raise - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - except Exception as exc: - _log.exception("WhatsApp onboarding apply failed") - raise HTTPException( - status_code=500, - detail="Failed to save WhatsApp setup.", - ) from exc - - with _whatsapp_onboarding_lock: - _whatsapp_onboarding_sessions.pop(pairing_id, None) - - restart_result = _restart_gateway_after_whatsapp_onboarding(effective_profile) - return { - "ok": True, - "platform": "whatsapp", - "needs_restart": not restart_result["restart_started"], - **restart_result, - } - - -@app.delete("/api/messaging/whatsapp/onboarding/{pairing_id}") -async def cancel_whatsapp_onboarding(pairing_id: str): - with _whatsapp_onboarding_lock: - record = _whatsapp_onboarding_sessions.pop(pairing_id, None) - if record: - record.status = "cancelled" - _terminate_whatsapp_pairing(record.proc) - return {"ok": True} +from hermes_cli.web_routers import whatsapp_onboarding as _whatsapp_routes # noqa: E402 +app.include_router(_whatsapp_routes.router) +from hermes_cli.web_routers.whatsapp_onboarding import ( # noqa: E402,F401 — legacy re-exports; tests call these + _WhatsAppOnboardingSession, + _ensure_whatsapp_bridge_dependencies, + _whatsapp_onboarding_lock, + _whatsapp_onboarding_sessions, + _restart_gateway_after_whatsapp_onboarding, + _whatsapp_session_path, + _spawn_whatsapp_pairing_process, + _watch_whatsapp_pairing, + _write_platform_enabled, + apply_whatsapp_onboarding, + cancel_whatsapp_onboarding, + get_whatsapp_onboarding_status, + start_whatsapp_onboarding, +) _TELEGRAM_ONBOARDING_DEFAULT_URL = "https://setup.hermes-agent.nousresearch.com" _TELEGRAM_ONBOARDING_USER_AGENT = f"HermesDashboard/{__version__}" @dataclass @@ -17697,4 +17222,4 @@ def _loop_heartbeat(expected: float) -> None: if _runner is not None: _runner(_serve(), loop_factory=_loop_factory) else: - asyncio.run(_serve()) + asyncio.run(_serve()) \ No newline at end of file diff --git a/tests/web_server_whatsapp_seam.py b/tests/web_server_whatsapp_seam.py new file mode 100644 index 0000000000000..5cbcec4475326 --- /dev/null +++ b/tests/web_server_whatsapp_seam.py @@ -0,0 +1,98 @@ +"""Seam-identity + aggressive tests for the WhatsApp onboarding extraction (R4-C3). + +``hermes_cli/web_routers/whatsapp_onboarding.py`` holds the dashboard's +WhatsApp bridge onboarding cluster (spawn/watch/apply/cancel + session +lifecycle), moved out of ``hermes_cli/web_server.py`` (god-file slice +R4-C3, epic #78791). + +The seam-identity tests pin the regression this extraction is meant to +prevent: ``web_server`` must resolve every moved name to the *same object* +the router module defines. The aggressive tests then exercise the failure +modes the onboarding surface must survive: missing bridge deps, pairing +spawn failure, watcher EOF, and apply without an active pairing. +""" + +from fastapi.testclient import TestClient + +from hermes_cli import web_server as ws +from hermes_cli.web_routers import whatsapp_onboarding as w + +MOVED_NAMES = ( + "_ensure_whatsapp_bridge_dependencies", + "_spawn_whatsapp_pairing_process", + "_watch_whatsapp_pairing", + "_write_platform_enabled", + "apply_whatsapp_onboarding", + "cancel_whatsapp_onboarding", + "get_whatsapp_onboarding_status", + "start_whatsapp_onboarding", +) + + +def _client_with_app_state(): + prev_auth = getattr(ws.app.state, "auth_required", None) + prev_host = getattr(ws.app.state, "bound_host", None) + ws.app.state.auth_required = False + ws.app.state.bound_host = None + client = TestClient(ws.app) + client.headers[ws._SESSION_HEADER_NAME] = ws._SESSION_TOKEN + return client, prev_auth, prev_host + + +def _restore(prev_auth, prev_host): + if prev_auth is None: + delattr(ws.app.state, "auth_required") + else: + ws.app.state.auth_required = prev_auth + if prev_host is None: + if hasattr(ws.app.state, "bound_host"): + delattr(ws.app.state, "bound_host") + else: + ws.app.state.bound_host = prev_host + + +def test_moved_names_are_seam_identical(): + for name in MOVED_NAMES: + assert getattr(ws, name, None) is getattr(w, name, None), name + + +def test_whatsapp_routes_registered(): + paths = [rt.path for rt in ws.app.routes if "/api/messaging/whatsapp" in getattr(rt, "path", "")] + assert "/api/messaging/whatsapp/onboarding/start" in paths + assert "/api/messaging/whatsapp/onboarding/{pairing_id}/apply" in paths + + +def test_start_onboarding_empty_body_does_not_500(): + # The model has defaults, so an empty body is accepted — but it must + # never 500 (the route handles the no-creds spawn path gracefully). + client, pa, pb = _client_with_app_state() + try: + resp = client.post("/api/messaging/whatsapp/onboarding/start", json={}) + assert resp.status_code in (200, 400, 422) + finally: + _restore(pa, pb) + client.close() + + +def test_get_onboarding_status_unknown_pairing(): + client, pa, pb = _client_with_app_state() + try: + resp = client.get("/api/messaging/whatsapp/onboarding/definitely-missing") + assert resp.status_code in (404, 200) + finally: + _restore(pa, pb) + client.close() + + +def test_cancel_onboarding_unknown_pairing(): + client, pa, pb = _client_with_app_state() + try: + resp = client.delete("/api/messaging/whatsapp/onboarding/definitely-missing") + assert resp.status_code in (404, 200) + finally: + _restore(pa, pb) + client.close() + + +def test_whatsapp_session_ttl_constant(): + assert w._WHATSAPP_ONBOARDING_TTL_SECONDS == 600 From 0577116f83ab284f210b7100eaa6f05040ad6880 Mon Sep 17 00:00:00 2001 From: "Andrex Ibiza, MBA" <84248988+andrexibiza@users.noreply.github.com> Date: Wed, 5 Aug 2026 01:01:38 -0500 Subject: [PATCH 2/5] test(web): rename whatsapp seam test to test_ prefix so CI collects it Signed-off-by: Andrex Ibiza, MBA <84248988+andrexibiza@users.noreply.github.com> --- ...b_server_whatsapp_seam.py => test_web_server_whatsapp_seam.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{web_server_whatsapp_seam.py => test_web_server_whatsapp_seam.py} (100%) diff --git a/tests/web_server_whatsapp_seam.py b/tests/test_web_server_whatsapp_seam.py similarity index 100% rename from tests/web_server_whatsapp_seam.py rename to tests/test_web_server_whatsapp_seam.py From 2b86e6d2d06886010bb366c744ac42afae22f9cb Mon Sep 17 00:00:00 2001 From: "Andrex Ibiza, MBA" <84248988+andrexibiza@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:48:25 -0500 Subject: [PATCH 3/5] refactor(web): extract files/fs mixins from web_server.py (shard s1) --- hermes_cli/web_routers/files.py | 563 +++++++++ hermes_cli/web_routers/fs.py | 297 +++++ hermes_cli/web_server.py | 1078 +---------------- .../test_web_server_fs_files_extraction.py | 149 +++ 4 files changed, 1067 insertions(+), 1020 deletions(-) create mode 100644 hermes_cli/web_routers/files.py create mode 100644 hermes_cli/web_routers/fs.py create mode 100644 tests/hermes_cli/test_web_server_fs_files_extraction.py diff --git a/hermes_cli/web_routers/files.py b/hermes_cli/web_routers/files.py new file mode 100644 index 0000000000000..cafd3c146ff24 --- /dev/null +++ b/hermes_cli/web_routers/files.py @@ -0,0 +1,563 @@ +"""Managed-files dashboard routes + helpers (extracted verbatim from web_server.py). + +Handler and helper bodies are byte-identical to their previous in-web_server +form. The helpers they call that still live in web_server +(``_default_hermes_root_is_opt_data``, ``_path_is_under``, ``_profile_scope``, +``get_hermes_home``) are reached via the late-binding seam in +:mod:`hermes_cli.web_deps`, so ``monkeypatch.setattr(web_server, ...)`` keeps +working. The shared numeric limits (``_MANAGED_FILE_MAX_BYTES``, +``_UPLOAD_CHUNK_BYTES``) also stay in web_server, read through +``LateState``/``late_attr``. +""" + +import base64 +import binascii +import mimetypes +import os +import re +import secrets +import shutil +import tempfile +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, Optional + +from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile +from fastapi.responses import FileResponse + +from hermes_cli.web_deps import late, late_attr, LateState +from hermes_cli.web_models import ( + ChatImageUpload, + ManagedDirectoryCreate, + ManagedFileDelete, + ManagedFileUpload, +) + +router = APIRouter() + +# Late-bound web_server helpers (resolved at call time; cycle-safe, +# monkeypatch-transparent). +_default_hermes_root_is_opt_data = late("_default_hermes_root_is_opt_data") +_path_is_under = late("_path_is_under") +_profile_scope = late("_profile_scope") +get_hermes_home = late("get_hermes_home") + +# Live proxies for web_server-owned numeric limits (mutations/monkeypatches on +# web_server remain authoritative; resolved at operation/import time). +_MANAGED_FILE_MAX_BYTES = LateState("_MANAGED_FILE_MAX_BYTES") +_UPLOAD_CHUNK_BYTES = late_attr("_UPLOAD_CHUNK_BYTES") + +_MANAGED_FILES_ROOT_ENV = "HERMES_DASHBOARD_FILES_ROOT" +_HOSTED_MANAGED_FILES_ROOT = Path("/opt/data") +@dataclass(frozen=True) +class ManagedFilesPolicy: + default_path: Path + locked_root: Path | None + can_change_path: bool +# Filenames that must never be listed, read, or downloaded through the +# managed-files API. These typically contain credentials (API keys, tokens) +# and exposing them through the dashboard file browser is a security leak — +# see issue #57505. The set mirrors the credential-file basenames of the two +# canonical credential guards elsewhere in the codebase +# (agent.file_safety.get_read_block_error and +# gateway.platforms.base._ROOT_CREDENTIAL_FILES) so the dashboard Files tab +# doesn't lag behind them — an operator can point the managed root at +# HERMES_HOME itself, at which point every one of these basenames is a live +# secret store sitting in the browsable tree. +_SENSITIVE_MANAGED_FILE_BASENAMES = frozenset({ + "auth.json", + "auth.lock", + "credentials", + "config.yaml", + ".anthropic_oauth.json", + "google_token.json", + "google_oauth_pending.json", + "google_oauth.json", + "webhook_subscriptions.json", + "bws_cache.json", + "bws_cache.enc.json", + # git's credential-store helper cache (agent.file_safety blocks this too). + ".git-credentials", +}) +# Directory names whose entire subtree is credential material. Both canonical +# guards deny these as directory trees, not basenames: +# * gateway.platforms.base._ROOT_CREDENTIAL_DIRS = {"pairing", "mcp-tokens"} +# * agent.file_safety.get_read_block_error (mcp-tokens/ prefix match) +# The managed-files API lets the browser descend into subdirs, so a +# basename-only guard would still expose e.g. ``mcp-tokens/.json`` +# (live MCP OAuth tokens) and ``pairing/``. We match on ANY path component +# so these trees are blocked wherever they appear under the browsable root, +# without needing to resolve them relative to HERMES_HOME. +_SENSITIVE_MANAGED_DIR_NAMES = frozenset({ + "mcp-tokens", + "pairing", +}) +def _is_sensitive_filename(name: str) -> bool: + """Return True for a basename the managed-files API must never expose. + + Covers ``.env`` / ``.env.`` / ``.envrc`` variants plus the + canonical Hermes credential-store basenames (see + ``_SENSITIVE_MANAGED_FILE_BASENAMES`` above). + + Case-insensitive so ``.ENV`` / ``.Env.local`` / ``Auth.JSON`` on + case-insensitive filesystems (macOS/Windows mounts) can't slip past + the guard. + + Basename-only: for the directory-tree credential stores + (``mcp-tokens/``, ``pairing/``) that the canonical guards also deny, + use :func:`_is_sensitive_path`, which the API call sites route through. + """ + lowered = name.lower() + if lowered == ".env" or lowered.startswith(".env.") or lowered == ".envrc": + return True + return lowered in _SENSITIVE_MANAGED_FILE_BASENAMES +def _is_sensitive_path(path: Path) -> bool: + """Return True for any path the managed-files API must never expose. + + Combines the basename denylist (:func:`_is_sensitive_filename`) with a + credential-directory-tree check: a path is sensitive if its own basename + is sensitive OR any of its path components is a credential directory + (``mcp-tokens`` / ``pairing``). The component match is case-insensitive + and needs no HERMES_HOME resolution, so it blocks these trees wherever + they sit under the operator-configured managed root — closing the gap + the canonical guards cover as directory trees but a basename-only check + would miss. + + Read-side only: this guards list/read/download (the #57505 exfil surface). + The write endpoints (upload/mkdir/delete) are a separate threat class + handled by the write-path checks; extending this guard to them is out of + scope for this fix. + """ + if _is_sensitive_filename(path.name): + return True + return any(part.lower() in _SENSITIVE_MANAGED_DIR_NAMES for part in path.parts) +def _canonical_path(path: Path, *, require_exists: bool = False) -> Path: + try: + return path.expanduser().resolve(strict=require_exists) + except FileNotFoundError: + if require_exists: + raise HTTPException(status_code=404, detail="Path not found") + raise + except (OSError, RuntimeError): + raise HTTPException(status_code=400, detail="Invalid path") +def _ensure_managed_root(raw_path: str | Path) -> Path: + root = Path(raw_path).expanduser() + try: + root.mkdir(parents=True, exist_ok=True) + resolved = root.resolve() + except (OSError, RuntimeError) as exc: + raise HTTPException(status_code=500, detail=f"Managed files root is unavailable: {exc}") + if not resolved.is_dir(): + raise HTTPException(status_code=500, detail="Managed files root is not a directory") + return resolved +def _path_text(raw_path: str | None) -> str: + text = str(raw_path or "").strip() + if "\x00" in text: + raise HTTPException(status_code=400, detail="Invalid path") + return text +def _local_dashboard_request(request: Request) -> bool: + if getattr(request.app.state, "auth_required", False): + return False + host = (request.url.hostname or "").lower() + client_host = (request.client.host if request.client else "").lower() + local_hosts = {"", "localhost", "127.0.0.1", "::1", "testserver", "testclient"} + return host in local_hosts or client_host in local_hosts +def _managed_files_policy(request: Request, *, create_root: bool = True) -> ManagedFilesPolicy: + raw_forced_root = os.environ.get(_MANAGED_FILES_ROOT_ENV, "").strip() + if raw_forced_root: + 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. + 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) + + home = _canonical_path(Path.home()) + return ManagedFilesPolicy(default_path=home, locked_root=None, can_change_path=True) +def _resolve_managed_path( + raw_path: str | None, + request: Request, + *, + for_write: bool = False, +) -> tuple[ManagedFilesPolicy, Path, str]: + policy = _managed_files_policy(request) + text = _path_text(raw_path) + root = policy.locked_root + + if root is not None and (not text or text in {".", "/"}): + candidate = root + elif not text: + candidate = policy.default_path + else: + candidate = Path(text).expanduser() + if root is not None and not candidate.is_absolute(): + if any(part == ".." for part in candidate.parts): + raise HTTPException(status_code=400, detail="Path cannot contain '..'") + candidate = root / candidate + elif not candidate.is_absolute(): + raise HTTPException(status_code=400, detail="Path must be absolute") + + if ".." in candidate.parts: + raise HTTPException(status_code=400, detail="Path cannot contain '..'") + + if for_write and not candidate.exists(): + parent = _canonical_path(candidate.parent) + resolved = parent / candidate.name + else: + resolved = _canonical_path(candidate, require_exists=not for_write) + + if root is not None and not _path_is_under(root, resolved): + raise HTTPException(status_code=403, detail="Path outside managed files root") + + return policy, resolved, str(resolved) +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, + } +def _managed_file_entry(policy: ManagedFilesPolicy, target: Path) -> Dict[str, Any]: + try: + resolved = target.resolve() + except (OSError, RuntimeError): + raise HTTPException(status_code=400, detail="Invalid path") + if policy.locked_root is not None and not _path_is_under(policy.locked_root, resolved): + raise HTTPException(status_code=403, detail="Path outside managed files root") + + try: + st = resolved.stat() + except OSError as exc: + raise HTTPException(status_code=500, detail=f"Could not stat path: {exc}") + + is_dir = resolved.is_dir() + mime_type = None if is_dir else (mimetypes.guess_type(resolved.name)[0] or "application/octet-stream") + return { + "name": target.name or resolved.name or str(resolved), + "path": str(resolved), + "is_directory": is_dir, + "size": None if is_dir else st.st_size, + "mtime": st.st_mtime, + "mime_type": mime_type, + } +def _decode_data_url(data_url: str) -> tuple[bytes, str]: + text = (data_url or "").strip() + if not text.startswith("data:") or "," not in text: + raise HTTPException(status_code=400, detail="Upload payload must be a data URL") + header, encoded = text.split(",", 1) + mime_type = header[5:].split(";", 1)[0] or "application/octet-stream" + if ";base64" not in header: + raise HTTPException(status_code=400, detail="Upload payload must be base64 encoded") + try: + data = base64.b64decode(encoded, validate=True) + except (binascii.Error, ValueError): + raise HTTPException(status_code=400, detail="Upload payload is not valid base64") + if len(data) > _MANAGED_FILE_MAX_BYTES: + raise HTTPException(status_code=413, detail="File is too large") + return data, mime_type +_CHAT_IMAGE_UPLOAD_MAX_BYTES = 25 * 1024 * 1024 +_CHAT_IMAGE_ALLOWED_EXTENSIONS = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}) +_CHAT_IMAGE_ALLOWED_EXTENSIONS = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}) +_CHAT_IMAGE_MAGIC: tuple[tuple[bytes, str], ...] = ( + (b"\x89PNG\r\n\x1a\n", ".png"), + (b"\xff\xd8\xff", ".jpg"), + (b"GIF87a", ".gif"), + (b"GIF89a", ".gif"), + (b"BM", ".bmp"), +) +def _sanitize_chat_image_filename(filename: str | None) -> str: + candidate = Path(str(filename or "").strip()).name + candidate = re.sub(r"[\x00-\x1f]+", "_", candidate) + candidate = candidate.strip().strip(".") + return candidate or "pasted-image" +def _chat_image_extension(data: bytes) -> str | None: + head = data[:16] + if head.startswith(b"RIFF") and head[8:12] == b"WEBP": + return ".webp" + for sig, ext in _CHAT_IMAGE_MAGIC: + if head.startswith(sig): + return ext + return None +def _decode_chat_image_upload(payload: ChatImageUpload) -> tuple[bytes, str, str]: + data, mime_type = _decode_data_url(payload.data_url) + if not mime_type.lower().startswith("image/"): + raise HTTPException(status_code=400, detail="Upload payload must be an image") + if len(data) > _CHAT_IMAGE_UPLOAD_MAX_BYTES: + mb = _CHAT_IMAGE_UPLOAD_MAX_BYTES // (1024 * 1024) + raise HTTPException(status_code=413, detail=f"Image is too large; cap is {mb} MB") + + ext = _chat_image_extension(data) + if ext not in _CHAT_IMAGE_ALLOWED_EXTENSIONS: + raise HTTPException(status_code=400, detail="Unsupported image type") + return data, mime_type, ext +@router.post("/api/chat/image-upload") +async def upload_chat_image(payload: ChatImageUpload, profile: Optional[str] = None): + """Persist a browser-provided chat image where the embedded TUI can read it. + + The dashboard /chat page runs Hermes inside an xterm.js PTY. Browser + clipboard image bytes are not visible to the server-side clipboard, so the + page uploads them here, then drives the TUI's ``/image `` command + with the returned gateway-visible path. Files land under + ``HERMES_HOME/images/`` — the same directory ``clipboard.paste`` / + ``image.attach`` already use. + """ + data, mime_type, ext = _decode_chat_image_upload(payload) + with _profile_scope(profile) as scoped_home: + home = scoped_home or get_hermes_home() + img_dir = Path(home) / "images" + try: + img_dir.mkdir(parents=True, exist_ok=True) + except PermissionError: + raise HTTPException(status_code=403, detail="Image directory is not writable") + except OSError as exc: + raise HTTPException(status_code=500, detail=f"Could not create image directory: {exc}") + + stem = Path(_sanitize_chat_image_filename(payload.filename)).stem or "pasted-image" + stem = re.sub(r"[^A-Za-z0-9_.-]+", "_", stem).strip("._-") or "pasted-image" + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + target = img_dir / f"dashboard_{ts}_{secrets.token_hex(4)}_{stem}{ext}" + + try: + target.write_bytes(data) + except PermissionError: + raise HTTPException(status_code=403, detail="Image directory is not writable") + except OSError as exc: + raise HTTPException(status_code=500, detail=f"Could not write image: {exc}") + + return { + "ok": True, + "path": str(target), + "name": target.name, + "bytes": len(data), + "mime_type": mime_type, + } +@router.get("/api/files") +async def list_managed_files(request: Request, path: Optional[str] = None): + policy, target, display_path = _resolve_managed_path(path, request) + if not target.exists(): + raise HTTPException(status_code=404, detail="Path not found") + if not target.is_dir(): + raise HTTPException(status_code=400, detail="Path is not a directory") + + try: + entries = [ + _managed_file_entry(policy, child) + for child in target.iterdir() + if not _is_sensitive_path(child) + ] + except PermissionError: + raise HTTPException(status_code=403, detail="Directory is not readable") + except OSError as exc: + raise HTTPException(status_code=500, detail=f"Could not read directory: {exc}") + + entries.sort(key=lambda item: (not item["is_directory"], str(item["name"]).lower())) + locked_root = policy.locked_root + parent = None + if target.parent != target and (locked_root is None or target != locked_root): + parent = str(target.parent) + return { + "path": display_path, + "parent": parent, + "entries": entries, + **_managed_response_meta(policy), + } +@router.get("/api/files/read") +async def read_managed_file(request: Request, path: str): + policy, target, display_path = _resolve_managed_path(path, request) + if not target.exists(): + raise HTTPException(status_code=404, detail="File not found") + if not target.is_file(): + raise HTTPException(status_code=400, detail="Path is not a file") + if _is_sensitive_path(target): + raise HTTPException(status_code=403, detail="Access to sensitive files is not allowed") + + try: + size = target.stat().st_size + except OSError as exc: + raise HTTPException(status_code=500, detail=f"Could not stat file: {exc}") + if size > _MANAGED_FILE_MAX_BYTES: + raise HTTPException(status_code=413, detail="File is too large") + + mime_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream" + try: + encoded = base64.b64encode(target.read_bytes()).decode("ascii") + except PermissionError: + raise HTTPException(status_code=403, detail="File is not readable") + except OSError as exc: + raise HTTPException(status_code=500, detail=f"Could not read file: {exc}") + + return { + "name": target.name, + "path": display_path, + "size": size, + "mime_type": mime_type, + "data_url": f"data:{mime_type};base64,{encoded}", + **_managed_response_meta(policy), + } +@router.get("/api/files/download") +async def download_managed_file(request: Request, path: str): + """Stream a managed file as an attachment download. + + Remote clients (desktop app, browser dashboard) open agent-written files + that live on *this* gateway's disk, not theirs. Auth-gated like every other + managed-files route — ``auth_middleware`` additionally accepts the session + token as a ``?token=`` query param here so a shell/browser-opened download + (which can't set the session header) still authenticates. See ``/api/pty`` + for the same query-token precedent. + """ + policy, target, _display_path = _resolve_managed_path(path, request) + if not target.exists(): + raise HTTPException(status_code=404, detail="File not found") + if not target.is_file(): + raise HTTPException(status_code=400, detail="Path is not a file") + if _is_sensitive_path(target): + raise HTTPException(status_code=403, detail="Access to sensitive files is not allowed") + + try: + size = target.stat().st_size + except OSError as exc: + raise HTTPException(status_code=500, detail=f"Could not stat file: {exc}") + if size > _MANAGED_FILE_MAX_BYTES: + raise HTTPException(status_code=413, detail="File is too large") + + mime_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream" + + return FileResponse( + path=str(target), + media_type=mime_type, + filename=target.name, + content_disposition_type="attachment", + ) +@router.post("/api/files/upload") +async def upload_managed_file(payload: ManagedFileUpload, request: Request): + policy, target, display_path = _resolve_managed_path(payload.path, request, for_write=True) + if target.exists() and target.is_dir(): + raise HTTPException(status_code=409, detail="A directory already exists at that path") + if target.exists() and not payload.overwrite: + raise HTTPException(status_code=409, detail="File already exists") + + data, _mime_type = _decode_data_url(payload.data_url) + try: + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + except PermissionError: + raise HTTPException(status_code=403, detail="File is not writable") + except OSError as exc: + raise HTTPException(status_code=500, detail=f"Could not write file: {exc}") + + return { + "ok": True, + "entry": _managed_file_entry(policy, target), + "path": display_path, + **_managed_response_meta(policy), + } +@router.post("/api/files/upload-stream") +async def upload_managed_file_stream( + request: Request, + file: UploadFile = File(...), + path: str = Form(...), + overwrite: bool = Form(True), +): + policy, target, display_path = _resolve_managed_path(path, request, for_write=True) + if target.exists() and target.is_dir(): + raise HTTPException(status_code=409, detail="A directory already exists at that path") + if target.exists() and not overwrite: + raise HTTPException(status_code=409, detail="File already exists") + + try: + target.parent.mkdir(parents=True, exist_ok=True) + except PermissionError: + raise HTTPException(status_code=403, detail="File is not writable") + except OSError as exc: + raise HTTPException(status_code=500, detail=f"Could not create parent directory: {exc}") + + # Write to a sibling temp file first so a partial/aborted upload never + # clobbers an existing file, then atomically rename into place. + tmp_fd, tmp_name = tempfile.mkstemp( + prefix=f".{target.name}.", suffix=".upload", dir=str(target.parent) + ) + tmp_path = Path(tmp_name) + total = 0 + renamed = False + try: + with os.fdopen(tmp_fd, "wb") as out: + while True: + chunk = await file.read(_UPLOAD_CHUNK_BYTES) + if not chunk: + break + total += len(chunk) + if total > _MANAGED_FILE_MAX_BYTES: + raise HTTPException(status_code=413, detail="File is too large") + out.write(chunk) + os.replace(tmp_path, target) + renamed = True + except HTTPException: + raise + except PermissionError: + raise HTTPException(status_code=403, detail="File is not writable") + except OSError as exc: + raise HTTPException(status_code=500, detail=f"Could not write file: {exc}") + finally: + # Clean up the temp file on every non-success exit, including + # BaseException paths the `except` clauses above don't catch — most + # importantly asyncio.CancelledError when a browser aborts a large + # upload mid-stream (the exact NS-501 scenario). os.replace clears + # tmp_path on success, so only unlink when the rename didn't happen. + if not renamed: + tmp_path.unlink(missing_ok=True) + await file.close() + + return { + "ok": True, + "entry": _managed_file_entry(policy, target), + "path": display_path, + **_managed_response_meta(policy), + } +@router.post("/api/files/mkdir") +async def create_managed_directory(payload: ManagedDirectoryCreate, request: Request): + policy, target, display_path = _resolve_managed_path(payload.path, request, for_write=True) + if target.exists() and not target.is_dir(): + raise HTTPException(status_code=409, detail="A file already exists at that path") + + try: + target.mkdir(parents=True, exist_ok=True) + except PermissionError: + raise HTTPException(status_code=403, detail="Directory is not writable") + except OSError as exc: + raise HTTPException(status_code=500, detail=f"Could not create directory: {exc}") + + return { + "ok": True, + "entry": _managed_file_entry(policy, target), + "path": display_path, + **_managed_response_meta(policy), + } +@router.delete("/api/files") +async def delete_managed_file(payload: ManagedFileDelete, request: Request): + policy, target, display_path = _resolve_managed_path(payload.path, request) + if policy.locked_root is not None and target == policy.locked_root: + raise HTTPException(status_code=400, detail="Cannot delete the managed files root") + if target.parent == target: + raise HTTPException(status_code=400, detail="Cannot delete the filesystem root") + if not target.exists(): + raise HTTPException(status_code=404, detail="Path not found") + + try: + if target.is_dir(): + if payload.recursive: + shutil.rmtree(target) + else: + target.rmdir() + else: + target.unlink() + except OSError as exc: + status_code = 409 if target.is_dir() and not payload.recursive else 500 + raise HTTPException(status_code=status_code, detail=f"Could not delete path: {exc}") + + return {"ok": True, "path": display_path, **_managed_response_meta(policy)} diff --git a/hermes_cli/web_routers/fs.py b/hermes_cli/web_routers/fs.py new file mode 100644 index 0000000000000..7b2fb9d934283 --- /dev/null +++ b/hermes_cli/web_routers/fs.py @@ -0,0 +1,297 @@ +"""Filesystem dashboard routes + helpers (extracted verbatim from web_server.py). + +Handler and helper bodies are byte-identical to their previous in-web_server +form. The helpers they call that still live in web_server (``_fs_path``, +``load_config``) are reached via the late-binding seam in +:mod:`hermes_cli.web_deps`, so ``monkeypatch.setattr(web_server, ...)`` keeps +working. The size cap ``_FS_DATA_URL_MAX_BYTES`` also stays in web_server and +is read through ``LateState`` so monkeypatches remain authoritative. +""" + +import base64 +import mimetypes +import os +import stat +import subprocess +import sys +from pathlib import Path +from typing import Any, Dict, Optional + +from fastapi import APIRouter, HTTPException + +from hermes_cli._subprocess_compat import windows_hide_flags +from hermes_cli.web_deps import late, LateState +from hermes_cli.web_models import FsWriteText + +router = APIRouter() + +# Late-bound web_server helpers (resolved at call time; cycle-safe, +# monkeypatch-transparent). +_fs_path = late("_fs_path") +load_config = late("load_config") + +# Live proxy for the web_server-owned size cap (monkeypatched by tests). +_FS_DATA_URL_MAX_BYTES = LateState("_FS_DATA_URL_MAX_BYTES") + +_FS_READDIR_HIDDEN = { + ".git", + ".hg", + ".svn", + ".cache", + ".next", + ".turbo", + ".venv", + "__pycache__", + "build", + "dist", + "node_modules", + "target", + "venv", +} +_FS_TEXT_SOURCE_MAX_BYTES = 64 * 1024 * 1024 +_FS_TEXT_PREVIEW_MAX_BYTES = 512 * 1024 +# Upper bound for the in-app spot editor's save. The editor only opens +# non-truncated text (<= the preview cap), so this is a safety ceiling against +# a pasted-in megablob, not the expected payload size. +_FS_TEXT_WRITE_MAX_BYTES = 8 * 1024 * 1024 +_FS_PREVIEW_LANGUAGE_BY_EXT = { + ".c": "c", + ".conf": "ini", + ".cpp": "cpp", + ".css": "css", + ".csv": "csv", + ".go": "go", + ".graphql": "graphql", + ".h": "c", + ".hpp": "cpp", + ".html": "html", + ".java": "java", + ".js": "javascript", + ".json": "json", + ".jsx": "jsx", + ".kt": "kotlin", + ".lua": "lua", + ".md": "markdown", + ".mjs": "javascript", + ".py": "python", + ".rb": "ruby", + ".rs": "rust", + ".sh": "shell", + ".sql": "sql", + ".svg": "xml", + ".toml": "toml", + ".ts": "typescript", + ".tsx": "tsx", + ".txt": "text", + ".xml": "xml", + ".yaml": "yaml", + ".yml": "yaml", + ".zsh": "shell", +} +_FS_MIME_TYPES = { + ".avi": "video/x-msvideo", + ".bmp": "image/bmp", + ".flac": "audio/flac", + ".gif": "image/gif", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".m4a": "audio/mp4", + ".mkv": "video/x-matroska", + ".mov": "video/quicktime", + ".mp3": "audio/mpeg", + ".mp4": "video/mp4", + ".ogg": "audio/ogg", + ".opus": "audio/ogg; codecs=opus", + ".png": "image/png", + ".svg": "image/svg+xml", + ".wav": "audio/wav", + ".webm": "video/webm", + ".webp": "image/webp", +} +def _fs_mime_type(path: Path) -> str: + suffix = path.suffix.lower() + if suffix in _FS_MIME_TYPES: + return _FS_MIME_TYPES[suffix] + guessed, _ = mimetypes.guess_type(str(path)) + return guessed or "application/octet-stream" +def _fs_looks_binary(data: bytes) -> bool: + if not data: + return False + if b"\0" in data: + return True + suspicious = sum(1 for byte in data if byte < 32 and byte not in {9, 10, 13}) + return suspicious / len(data) > 0.12 +def _fs_regular_file(path: Path) -> tuple[Path, os.stat_result]: + target = _fs_path(str(path)) + try: + st = target.stat() + except FileNotFoundError: + raise HTTPException(status_code=404, detail="File not found") + except NotADirectoryError: + raise HTTPException(status_code=404, detail="File not found") + except PermissionError: + raise HTTPException(status_code=403, detail="File is not readable") + except OSError as exc: + raise HTTPException(status_code=400, detail=str(exc) or "Invalid path") + if stat.S_ISDIR(st.st_mode): + raise HTTPException(status_code=400, detail="Path points to a directory") + if not stat.S_ISREG(st.st_mode): + raise HTTPException(status_code=400, detail="Only regular files can be read") + return target, st +def _fs_find_git_root(start: Path) -> str | None: + directory = start + for _ in range(50): + try: + if (directory / ".git").exists(): + return str(directory) + except OSError: + return None + parent = directory.parent + if parent == directory: + return None + directory = parent + return None +def _fs_default_cwd() -> str: + cfg_terminal = load_config().get("terminal") or {} + raw = str(cfg_terminal.get("cwd") or os.environ.get("TERMINAL_CWD") or "").strip() + if raw and raw not in {".", "auto", "cwd"}: + try: + candidate = Path(raw).expanduser().resolve(strict=False) + if candidate.is_dir(): + return str(candidate) + except (OSError, RuntimeError): + pass + return str(Path.cwd()) +def _fs_git_branch(cwd: str) -> str: + try: + run_kwargs: Dict[str, Any] = { + "capture_output": True, + "text": True, + "timeout": 2, + "check": False, + } + if sys.platform == "win32": + run_kwargs["creationflags"] = windows_hide_flags() + result = subprocess.run( + ["git", "-C", cwd, "branch", "--show-current"], + **run_kwargs, + ) + return result.stdout.strip() if result.returncode == 0 else "" + except Exception: + return "" +@router.get("/api/fs/list") +async def fs_list(path: str): + target = _fs_path(path) + try: + entries = [] + with os.scandir(target) as scan: + for entry in scan: + if entry.name in _FS_READDIR_HIDDEN: + continue + entries.append({ + "name": entry.name, + "path": str(target / entry.name), + "isDirectory": entry.is_dir(follow_symlinks=False), + }) + entries.sort(key=lambda item: (not item["isDirectory"], item["name"].lower(), item["name"])) + return {"entries": entries} + except FileNotFoundError: + return {"entries": [], "error": "ENOENT"} + except NotADirectoryError: + return {"entries": [], "error": "ENOTDIR"} + except PermissionError: + return {"entries": [], "error": "EACCES"} + except OSError as exc: + return {"entries": [], "error": getattr(exc, "strerror", None) or "read-error"} +@router.get("/api/fs/read-text") +async def fs_read_text(path: str): + target, st = _fs_regular_file(_fs_path(path)) + if st.st_size > _FS_TEXT_SOURCE_MAX_BYTES: + raise HTTPException(status_code=413, detail="File too large") + bytes_to_read = min(st.st_size, _FS_TEXT_PREVIEW_MAX_BYTES) + try: + with target.open("rb") as handle: + data = handle.read(bytes_to_read) + except PermissionError: + raise HTTPException(status_code=403, detail="File is not readable") + except OSError as exc: + raise HTTPException(status_code=400, detail=str(exc) or "File read failed") + return { + "binary": _fs_looks_binary(data[:4096]), + "byteSize": st.st_size, + "language": _FS_PREVIEW_LANGUAGE_BY_EXT.get(target.suffix.lower(), "text"), + "mimeType": _fs_mime_type(target), + "path": str(target), + "text": data.decode("utf-8", errors="replace"), + "truncated": st.st_size > _FS_TEXT_PREVIEW_MAX_BYTES, + } +@router.post("/api/fs/write-text") +async def fs_write_text(payload: FsWriteText): + """Overwrite (or create) a UTF-8 text file for the in-app spot editor. + + Mirrors the local Electron ``hermes:fs:writeText`` hardening: the path is + resolved + validated by ``_fs_path``, the parent directory must already + exist (we never build directory trees), only regular files may be replaced, + and the payload is size-capped. The write is staged to a sibling temp file + and ``os.replace``-d into place so a crash mid-write can't truncate the + original. Stale-on-disk detection is the client's job (re-read before save), + so both transports behave identically. + """ + target = _fs_path(payload.path) + text = payload.content or "" + if len(text.encode("utf-8")) > _FS_TEXT_WRITE_MAX_BYTES: + raise HTTPException(status_code=413, detail="Content too large") + + try: + st: Optional[os.stat_result] = target.stat() + except FileNotFoundError: + st = None + except PermissionError: + raise HTTPException(status_code=403, detail="File is not writable") + except OSError as exc: + raise HTTPException(status_code=400, detail=str(exc) or "Invalid path") + + if st is not None and stat.S_ISDIR(st.st_mode): + raise HTTPException(status_code=400, detail="Path points to a directory") + if st is not None and not stat.S_ISREG(st.st_mode): + raise HTTPException(status_code=400, detail="Only regular files can be written") + if not target.parent.is_dir(): + raise HTTPException(status_code=400, detail="Parent directory does not exist") + + tmp = target.with_name(f".{target.name}.hermes-tmp-{os.getpid()}") + try: + tmp.write_text(text, encoding="utf-8") + os.replace(tmp, target) + except PermissionError: + tmp.unlink(missing_ok=True) + raise HTTPException(status_code=403, detail="File is not writable") + except OSError as exc: + tmp.unlink(missing_ok=True) + raise HTTPException(status_code=500, detail=f"Could not write file: {exc}") + + return {"ok": True, "path": str(target), "byteSize": len(text.encode("utf-8"))} +@router.get("/api/fs/read-data-url") +async def fs_read_data_url(path: str): + target, st = _fs_regular_file(_fs_path(path)) + if st.st_size > _FS_DATA_URL_MAX_BYTES: + raise HTTPException(status_code=413, detail="File too large") + try: + encoded = base64.b64encode(target.read_bytes()).decode("ascii") + except PermissionError: + raise HTTPException(status_code=403, detail="File is not readable") + except OSError as exc: + raise HTTPException(status_code=400, detail=str(exc) or "File read failed") + return {"dataUrl": f"data:{_fs_mime_type(target)};base64,{encoded}"} +@router.get("/api/fs/git-root") +async def fs_git_root(path: str): + target = _fs_path(path) + try: + st = target.stat() + start = target if stat.S_ISDIR(st.st_mode) else target.parent + except OSError: + start = target + return {"root": _fs_find_git_root(start)} +@router.get("/api/fs/default-cwd") +async def fs_default_cwd(): + cwd = _fs_default_cwd() + return {"cwd": cwd, "branch": _fs_git_branch(cwd)} diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 587af7c9514f3..229573c0c09c8 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1728,179 +1728,10 @@ async def _status_active_sessions() -> int: ".ico": "image/x-icon", } _MEDIA_MAX_BYTES = 25 * 1024 * 1024 -_MANAGED_FILES_ROOT_ENV = "HERMES_DASHBOARD_FILES_ROOT" _MANAGED_FILE_MAX_BYTES = 100 * 1024 * 1024 -_HOSTED_MANAGED_FILES_ROOT = Path("/opt/data") - - -@dataclass(frozen=True) -class ManagedFilesPolicy: - default_path: Path - locked_root: Path | None - can_change_path: bool - - -_FS_READDIR_HIDDEN = { - ".git", - ".hg", - ".svn", - ".cache", - ".next", - ".turbo", - ".venv", - "__pycache__", - "build", - "dist", - "node_modules", - "target", - "venv", -} - -# Filenames that must never be listed, read, or downloaded through the -# managed-files API. These typically contain credentials (API keys, tokens) -# and exposing them through the dashboard file browser is a security leak — -# see issue #57505. The set mirrors the credential-file basenames of the two -# canonical credential guards elsewhere in the codebase -# (agent.file_safety.get_read_block_error and -# gateway.platforms.base._ROOT_CREDENTIAL_FILES) so the dashboard Files tab -# doesn't lag behind them — an operator can point the managed root at -# HERMES_HOME itself, at which point every one of these basenames is a live -# secret store sitting in the browsable tree. -_SENSITIVE_MANAGED_FILE_BASENAMES = frozenset({ - "auth.json", - "auth.lock", - "credentials", - "config.yaml", - ".anthropic_oauth.json", - "google_token.json", - "google_oauth_pending.json", - "google_oauth.json", - "webhook_subscriptions.json", - "bws_cache.json", - "bws_cache.enc.json", - # git's credential-store helper cache (agent.file_safety blocks this too). - ".git-credentials", -}) - -# Directory names whose entire subtree is credential material. Both canonical -# guards deny these as directory trees, not basenames: -# * gateway.platforms.base._ROOT_CREDENTIAL_DIRS = {"pairing", "mcp-tokens"} -# * agent.file_safety.get_read_block_error (mcp-tokens/ prefix match) -# The managed-files API lets the browser descend into subdirs, so a -# basename-only guard would still expose e.g. ``mcp-tokens/.json`` -# (live MCP OAuth tokens) and ``pairing/``. We match on ANY path component -# so these trees are blocked wherever they appear under the browsable root, -# without needing to resolve them relative to HERMES_HOME. -_SENSITIVE_MANAGED_DIR_NAMES = frozenset({ - "mcp-tokens", - "pairing", -}) - - -def _is_sensitive_filename(name: str) -> bool: - """Return True for a basename the managed-files API must never expose. - - Covers ``.env`` / ``.env.`` / ``.envrc`` variants plus the - canonical Hermes credential-store basenames (see - ``_SENSITIVE_MANAGED_FILE_BASENAMES`` above). - - Case-insensitive so ``.ENV`` / ``.Env.local`` / ``Auth.JSON`` on - case-insensitive filesystems (macOS/Windows mounts) can't slip past - the guard. - - Basename-only: for the directory-tree credential stores - (``mcp-tokens/``, ``pairing/``) that the canonical guards also deny, - use :func:`_is_sensitive_path`, which the API call sites route through. - """ - lowered = name.lower() - if lowered == ".env" or lowered.startswith(".env.") or lowered == ".envrc": - return True - return lowered in _SENSITIVE_MANAGED_FILE_BASENAMES - - -def _is_sensitive_path(path: Path) -> bool: - """Return True for any path the managed-files API must never expose. - - Combines the basename denylist (:func:`_is_sensitive_filename`) with a - credential-directory-tree check: a path is sensitive if its own basename - is sensitive OR any of its path components is a credential directory - (``mcp-tokens`` / ``pairing``). The component match is case-insensitive - and needs no HERMES_HOME resolution, so it blocks these trees wherever - they sit under the operator-configured managed root — closing the gap - the canonical guards cover as directory trees but a basename-only check - would miss. - - Read-side only: this guards list/read/download (the #57505 exfil surface). - The write endpoints (upload/mkdir/delete) are a separate threat class - handled by the write-path checks; extending this guard to them is out of - scope for this fix. - """ - if _is_sensitive_filename(path.name): - return True - return any(part.lower() in _SENSITIVE_MANAGED_DIR_NAMES for part in path.parts) _FS_DATA_URL_MAX_BYTES = 16 * 1024 * 1024 -_FS_TEXT_SOURCE_MAX_BYTES = 64 * 1024 * 1024 -_FS_TEXT_PREVIEW_MAX_BYTES = 512 * 1024 -# Upper bound for the in-app spot editor's save. The editor only opens -# non-truncated text (<= the preview cap), so this is a safety ceiling against -# a pasted-in megablob, not the expected payload size. -_FS_TEXT_WRITE_MAX_BYTES = 8 * 1024 * 1024 -_FS_PREVIEW_LANGUAGE_BY_EXT = { - ".c": "c", - ".conf": "ini", - ".cpp": "cpp", - ".css": "css", - ".csv": "csv", - ".go": "go", - ".graphql": "graphql", - ".h": "c", - ".hpp": "cpp", - ".html": "html", - ".java": "java", - ".js": "javascript", - ".json": "json", - ".jsx": "jsx", - ".kt": "kotlin", - ".lua": "lua", - ".md": "markdown", - ".mjs": "javascript", - ".py": "python", - ".rb": "ruby", - ".rs": "rust", - ".sh": "shell", - ".sql": "sql", - ".svg": "xml", - ".toml": "toml", - ".ts": "typescript", - ".tsx": "tsx", - ".txt": "text", - ".xml": "xml", - ".yaml": "yaml", - ".yml": "yaml", - ".zsh": "shell", -} -_FS_MIME_TYPES = { - ".avi": "video/x-msvideo", - ".bmp": "image/bmp", - ".flac": "audio/flac", - ".gif": "image/gif", - ".jpeg": "image/jpeg", - ".jpg": "image/jpeg", - ".m4a": "audio/mp4", - ".mkv": "video/x-matroska", - ".mov": "video/quicktime", - ".mp3": "audio/mpeg", - ".mp4": "video/mp4", - ".ogg": "audio/ogg", - ".opus": "audio/ogg; codecs=opus", - ".png": "image/png", - ".svg": "image/svg+xml", - ".wav": "audio/wav", - ".webm": "video/webm", - ".webp": "image/webp", -} def _fs_path(raw_path: str) -> Path: @@ -1923,89 +1754,6 @@ def _fs_path(raw_path: str) -> Path: raise HTTPException(status_code=400, detail="Invalid path") -def _fs_mime_type(path: Path) -> str: - suffix = path.suffix.lower() - if suffix in _FS_MIME_TYPES: - return _FS_MIME_TYPES[suffix] - guessed, _ = mimetypes.guess_type(str(path)) - return guessed or "application/octet-stream" - - -def _fs_looks_binary(data: bytes) -> bool: - if not data: - return False - if b"\0" in data: - return True - suspicious = sum(1 for byte in data if byte < 32 and byte not in {9, 10, 13}) - return suspicious / len(data) > 0.12 - - -def _fs_regular_file(path: Path) -> tuple[Path, os.stat_result]: - target = _fs_path(str(path)) - try: - st = target.stat() - except FileNotFoundError: - raise HTTPException(status_code=404, detail="File not found") - except NotADirectoryError: - raise HTTPException(status_code=404, detail="File not found") - except PermissionError: - raise HTTPException(status_code=403, detail="File is not readable") - except OSError as exc: - raise HTTPException(status_code=400, detail=str(exc) or "Invalid path") - if stat.S_ISDIR(st.st_mode): - raise HTTPException(status_code=400, detail="Path points to a directory") - if not stat.S_ISREG(st.st_mode): - raise HTTPException(status_code=400, detail="Only regular files can be read") - return target, st - - -def _fs_find_git_root(start: Path) -> str | None: - directory = start - for _ in range(50): - try: - if (directory / ".git").exists(): - return str(directory) - except OSError: - return None - parent = directory.parent - if parent == directory: - return None - directory = parent - return None - - -def _fs_default_cwd() -> str: - cfg_terminal = load_config().get("terminal") or {} - raw = str(cfg_terminal.get("cwd") or os.environ.get("TERMINAL_CWD") or "").strip() - if raw and raw not in {".", "auto", "cwd"}: - try: - candidate = Path(raw).expanduser().resolve(strict=False) - if candidate.is_dir(): - return str(candidate) - except (OSError, RuntimeError): - pass - return str(Path.cwd()) - - -def _fs_git_branch(cwd: str) -> str: - try: - run_kwargs: Dict[str, Any] = { - "capture_output": True, - "text": True, - "timeout": 2, - "check": False, - } - if sys.platform == "win32": - run_kwargs["creationflags"] = windows_hide_flags() - result = subprocess.run( - ["git", "-C", cwd, "branch", "--show-current"], - **run_kwargs, - ) - return result.stdout.strip() if result.returncode == 0 else "" - except Exception: - return "" - - def _media_serve_roots() -> list[Path]: """Directories ``GET /api/media`` is allowed to read from. @@ -2059,49 +1807,10 @@ async def get_media(path: str): return {"data_url": f"data:{_MEDIA_CONTENT_TYPES[target.suffix.lower()]};base64,{encoded}"} -def _canonical_path(path: Path, *, require_exists: bool = False) -> Path: - try: - return path.expanduser().resolve(strict=require_exists) - except FileNotFoundError: - if require_exists: - raise HTTPException(status_code=404, detail="Path not found") - raise - except (OSError, RuntimeError): - raise HTTPException(status_code=400, detail="Invalid path") - - -def _ensure_managed_root(raw_path: str | Path) -> Path: - root = Path(raw_path).expanduser() - try: - root.mkdir(parents=True, exist_ok=True) - resolved = root.resolve() - except (OSError, RuntimeError) as exc: - raise HTTPException(status_code=500, detail=f"Managed files root is unavailable: {exc}") - if not resolved.is_dir(): - raise HTTPException(status_code=500, detail="Managed files root is not a directory") - return resolved - - def _path_is_under(root: Path, target: Path) -> bool: return target == root or root in target.parents -def _path_text(raw_path: str | None) -> str: - text = str(raw_path or "").strip() - if "\x00" in text: - raise HTTPException(status_code=400, detail="Invalid path") - return text - - -def _local_dashboard_request(request: Request) -> bool: - if getattr(request.app.state, "auth_required", False): - return False - host = (request.url.hostname or "").lower() - client_host = (request.client.host if request.client else "").lower() - local_hosts = {"", "localhost", "127.0.0.1", "::1", "testserver", "testclient"} - return host in local_hosts or client_host in local_hosts - - def _default_hermes_root_is_opt_data() -> bool: raw = os.environ.get("HERMES_HOME", "").strip() if not raw: @@ -2153,327 +1862,6 @@ def _dashboard_local_update_managed_externally() -> bool: return True -def _managed_files_policy(request: Request, *, create_root: bool = True) -> ManagedFilesPolicy: - raw_forced_root = os.environ.get(_MANAGED_FILES_ROOT_ENV, "").strip() - if raw_forced_root: - 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. - 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) - - home = _canonical_path(Path.home()) - return ManagedFilesPolicy(default_path=home, locked_root=None, can_change_path=True) - - -def _resolve_managed_path( - raw_path: str | None, - request: Request, - *, - for_write: bool = False, -) -> tuple[ManagedFilesPolicy, Path, str]: - policy = _managed_files_policy(request) - text = _path_text(raw_path) - root = policy.locked_root - - if root is not None and (not text or text in {".", "/"}): - candidate = root - elif not text: - candidate = policy.default_path - else: - candidate = Path(text).expanduser() - if root is not None and not candidate.is_absolute(): - if any(part == ".." for part in candidate.parts): - raise HTTPException(status_code=400, detail="Path cannot contain '..'") - candidate = root / candidate - elif not candidate.is_absolute(): - raise HTTPException(status_code=400, detail="Path must be absolute") - - if ".." in candidate.parts: - raise HTTPException(status_code=400, detail="Path cannot contain '..'") - - if for_write and not candidate.exists(): - parent = _canonical_path(candidate.parent) - resolved = parent / candidate.name - else: - resolved = _canonical_path(candidate, require_exists=not for_write) - - if root is not None and not _path_is_under(root, resolved): - raise HTTPException(status_code=403, detail="Path outside managed files root") - - return policy, resolved, str(resolved) - - -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, - } - - -def _managed_file_entry(policy: ManagedFilesPolicy, target: Path) -> Dict[str, Any]: - try: - resolved = target.resolve() - except (OSError, RuntimeError): - raise HTTPException(status_code=400, detail="Invalid path") - if policy.locked_root is not None and not _path_is_under(policy.locked_root, resolved): - raise HTTPException(status_code=403, detail="Path outside managed files root") - - try: - st = resolved.stat() - except OSError as exc: - raise HTTPException(status_code=500, detail=f"Could not stat path: {exc}") - - is_dir = resolved.is_dir() - mime_type = None if is_dir else (mimetypes.guess_type(resolved.name)[0] or "application/octet-stream") - return { - "name": target.name or resolved.name or str(resolved), - "path": str(resolved), - "is_directory": is_dir, - "size": None if is_dir else st.st_size, - "mtime": st.st_mtime, - "mime_type": mime_type, - } - - -def _decode_data_url(data_url: str) -> tuple[bytes, str]: - text = (data_url or "").strip() - if not text.startswith("data:") or "," not in text: - raise HTTPException(status_code=400, detail="Upload payload must be a data URL") - header, encoded = text.split(",", 1) - mime_type = header[5:].split(";", 1)[0] or "application/octet-stream" - if ";base64" not in header: - raise HTTPException(status_code=400, detail="Upload payload must be base64 encoded") - try: - data = base64.b64decode(encoded, validate=True) - except (binascii.Error, ValueError): - raise HTTPException(status_code=400, detail="Upload payload is not valid base64") - if len(data) > _MANAGED_FILE_MAX_BYTES: - raise HTTPException(status_code=413, detail="File is too large") - return data, mime_type - - -_CHAT_IMAGE_UPLOAD_MAX_BYTES = 25 * 1024 * 1024 -_CHAT_IMAGE_ALLOWED_EXTENSIONS = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}) -_CHAT_IMAGE_MAGIC: tuple[tuple[bytes, str], ...] = ( - (b"\x89PNG\r\n\x1a\n", ".png"), - (b"\xff\xd8\xff", ".jpg"), - (b"GIF87a", ".gif"), - (b"GIF89a", ".gif"), - (b"BM", ".bmp"), -) - - -def _sanitize_chat_image_filename(filename: str | None) -> str: - candidate = Path(str(filename or "").strip()).name - candidate = re.sub(r"[\x00-\x1f]+", "_", candidate) - candidate = candidate.strip().strip(".") - return candidate or "pasted-image" - - -def _chat_image_extension(data: bytes) -> str | None: - head = data[:16] - if head.startswith(b"RIFF") and head[8:12] == b"WEBP": - return ".webp" - for sig, ext in _CHAT_IMAGE_MAGIC: - if head.startswith(sig): - return ext - return None - - -def _decode_chat_image_upload(payload: ChatImageUpload) -> tuple[bytes, str, str]: - data, mime_type = _decode_data_url(payload.data_url) - if not mime_type.lower().startswith("image/"): - raise HTTPException(status_code=400, detail="Upload payload must be an image") - if len(data) > _CHAT_IMAGE_UPLOAD_MAX_BYTES: - mb = _CHAT_IMAGE_UPLOAD_MAX_BYTES // (1024 * 1024) - raise HTTPException(status_code=413, detail=f"Image is too large; cap is {mb} MB") - - ext = _chat_image_extension(data) - if ext not in _CHAT_IMAGE_ALLOWED_EXTENSIONS: - raise HTTPException(status_code=400, detail="Unsupported image type") - return data, mime_type, ext - - -@app.post("/api/chat/image-upload") -async def upload_chat_image(payload: ChatImageUpload, profile: Optional[str] = None): - """Persist a browser-provided chat image where the embedded TUI can read it. - - The dashboard /chat page runs Hermes inside an xterm.js PTY. Browser - clipboard image bytes are not visible to the server-side clipboard, so the - page uploads them here, then drives the TUI's ``/image `` command - with the returned gateway-visible path. Files land under - ``HERMES_HOME/images/`` — the same directory ``clipboard.paste`` / - ``image.attach`` already use. - """ - data, mime_type, ext = _decode_chat_image_upload(payload) - with _profile_scope(profile) as scoped_home: - home = scoped_home or get_hermes_home() - img_dir = Path(home) / "images" - try: - img_dir.mkdir(parents=True, exist_ok=True) - except PermissionError: - raise HTTPException(status_code=403, detail="Image directory is not writable") - except OSError as exc: - raise HTTPException(status_code=500, detail=f"Could not create image directory: {exc}") - - stem = Path(_sanitize_chat_image_filename(payload.filename)).stem or "pasted-image" - stem = re.sub(r"[^A-Za-z0-9_.-]+", "_", stem).strip("._-") or "pasted-image" - ts = datetime.now().strftime("%Y%m%d_%H%M%S") - target = img_dir / f"dashboard_{ts}_{secrets.token_hex(4)}_{stem}{ext}" - - try: - target.write_bytes(data) - except PermissionError: - raise HTTPException(status_code=403, detail="Image directory is not writable") - except OSError as exc: - raise HTTPException(status_code=500, detail=f"Could not write image: {exc}") - - return { - "ok": True, - "path": str(target), - "name": target.name, - "bytes": len(data), - "mime_type": mime_type, - } - - -@app.get("/api/files") -async def list_managed_files(request: Request, path: Optional[str] = None): - policy, target, display_path = _resolve_managed_path(path, request) - if not target.exists(): - raise HTTPException(status_code=404, detail="Path not found") - if not target.is_dir(): - raise HTTPException(status_code=400, detail="Path is not a directory") - - try: - entries = [ - _managed_file_entry(policy, child) - for child in target.iterdir() - if not _is_sensitive_path(child) - ] - except PermissionError: - raise HTTPException(status_code=403, detail="Directory is not readable") - except OSError as exc: - raise HTTPException(status_code=500, detail=f"Could not read directory: {exc}") - - entries.sort(key=lambda item: (not item["is_directory"], str(item["name"]).lower())) - locked_root = policy.locked_root - parent = None - if target.parent != target and (locked_root is None or target != locked_root): - parent = str(target.parent) - return { - "path": display_path, - "parent": parent, - "entries": entries, - **_managed_response_meta(policy), - } - - -@app.get("/api/files/read") -async def read_managed_file(request: Request, path: str): - policy, target, display_path = _resolve_managed_path(path, request) - if not target.exists(): - raise HTTPException(status_code=404, detail="File not found") - if not target.is_file(): - raise HTTPException(status_code=400, detail="Path is not a file") - if _is_sensitive_path(target): - raise HTTPException(status_code=403, detail="Access to sensitive files is not allowed") - - try: - size = target.stat().st_size - except OSError as exc: - raise HTTPException(status_code=500, detail=f"Could not stat file: {exc}") - if size > _MANAGED_FILE_MAX_BYTES: - raise HTTPException(status_code=413, detail="File is too large") - - mime_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream" - try: - encoded = base64.b64encode(target.read_bytes()).decode("ascii") - except PermissionError: - raise HTTPException(status_code=403, detail="File is not readable") - except OSError as exc: - raise HTTPException(status_code=500, detail=f"Could not read file: {exc}") - - return { - "name": target.name, - "path": display_path, - "size": size, - "mime_type": mime_type, - "data_url": f"data:{mime_type};base64,{encoded}", - **_managed_response_meta(policy), - } - - -@app.get("/api/files/download") -async def download_managed_file(request: Request, path: str): - """Stream a managed file as an attachment download. - - Remote clients (desktop app, browser dashboard) open agent-written files - that live on *this* gateway's disk, not theirs. Auth-gated like every other - managed-files route — ``auth_middleware`` additionally accepts the session - token as a ``?token=`` query param here so a shell/browser-opened download - (which can't set the session header) still authenticates. See ``/api/pty`` - for the same query-token precedent. - """ - policy, target, _display_path = _resolve_managed_path(path, request) - if not target.exists(): - raise HTTPException(status_code=404, detail="File not found") - if not target.is_file(): - raise HTTPException(status_code=400, detail="Path is not a file") - if _is_sensitive_path(target): - raise HTTPException(status_code=403, detail="Access to sensitive files is not allowed") - - try: - size = target.stat().st_size - except OSError as exc: - raise HTTPException(status_code=500, detail=f"Could not stat file: {exc}") - if size > _MANAGED_FILE_MAX_BYTES: - raise HTTPException(status_code=413, detail="File is too large") - - mime_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream" - - return FileResponse( - path=str(target), - media_type=mime_type, - filename=target.name, - content_disposition_type="attachment", - ) - - -@app.post("/api/files/upload") -async def upload_managed_file(payload: ManagedFileUpload, request: Request): - policy, target, display_path = _resolve_managed_path(payload.path, request, for_write=True) - if target.exists() and target.is_dir(): - raise HTTPException(status_code=409, detail="A directory already exists at that path") - if target.exists() and not payload.overwrite: - raise HTTPException(status_code=409, detail="File already exists") - - data, _mime_type = _decode_data_url(payload.data_url) - try: - target.parent.mkdir(parents=True, exist_ok=True) - target.write_bytes(data) - except PermissionError: - raise HTTPException(status_code=403, detail="File is not writable") - except OSError as exc: - raise HTTPException(status_code=500, detail=f"Could not write file: {exc}") - - return { - "ok": True, - "entry": _managed_file_entry(policy, target), - "path": display_path, - **_managed_response_meta(policy), - } - - # 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, @@ -2484,242 +1872,65 @@ async def upload_managed_file(payload: ManagedFileUpload, request: Request): _UPLOAD_CHUNK_BYTES = 1024 * 1024 -@app.post("/api/files/upload-stream") -async def upload_managed_file_stream( - request: Request, - file: UploadFile = File(...), - path: str = Form(...), - overwrite: bool = Form(True), -): - policy, target, display_path = _resolve_managed_path(path, request, for_write=True) - if target.exists() and target.is_dir(): - raise HTTPException(status_code=409, detail="A directory already exists at that path") - if target.exists() and not overwrite: - raise HTTPException(status_code=409, detail="File already exists") - - try: - target.parent.mkdir(parents=True, exist_ok=True) - except PermissionError: - raise HTTPException(status_code=403, detail="File is not writable") - except OSError as exc: - raise HTTPException(status_code=500, detail=f"Could not create parent directory: {exc}") - - # Write to a sibling temp file first so a partial/aborted upload never - # clobbers an existing file, then atomically rename into place. - tmp_fd, tmp_name = tempfile.mkstemp( - prefix=f".{target.name}.", suffix=".upload", dir=str(target.parent) - ) - tmp_path = Path(tmp_name) - total = 0 - renamed = False - try: - with os.fdopen(tmp_fd, "wb") as out: - while True: - chunk = await file.read(_UPLOAD_CHUNK_BYTES) - if not chunk: - break - total += len(chunk) - if total > _MANAGED_FILE_MAX_BYTES: - raise HTTPException(status_code=413, detail="File is too large") - out.write(chunk) - os.replace(tmp_path, target) - renamed = True - except HTTPException: - raise - except PermissionError: - raise HTTPException(status_code=403, detail="File is not writable") - except OSError as exc: - raise HTTPException(status_code=500, detail=f"Could not write file: {exc}") - finally: - # Clean up the temp file on every non-success exit, including - # BaseException paths the `except` clauses above don't catch — most - # importantly asyncio.CancelledError when a browser aborts a large - # upload mid-stream (the exact NS-501 scenario). os.replace clears - # tmp_path on success, so only unlink when the rename didn't happen. - if not renamed: - tmp_path.unlink(missing_ok=True) - await file.close() - - return { - "ok": True, - "entry": _managed_file_entry(policy, target), - "path": display_path, - **_managed_response_meta(policy), - } - - -@app.post("/api/files/mkdir") -async def create_managed_directory(payload: ManagedDirectoryCreate, request: Request): - policy, target, display_path = _resolve_managed_path(payload.path, request, for_write=True) - if target.exists() and not target.is_dir(): - raise HTTPException(status_code=409, detail="A file already exists at that path") - - try: - target.mkdir(parents=True, exist_ok=True) - except PermissionError: - raise HTTPException(status_code=403, detail="Directory is not writable") - except OSError as exc: - raise HTTPException(status_code=500, detail=f"Could not create directory: {exc}") - - return { - "ok": True, - "entry": _managed_file_entry(policy, target), - "path": display_path, - **_managed_response_meta(policy), - } - - -@app.delete("/api/files") -async def delete_managed_file(payload: ManagedFileDelete, request: Request): - policy, target, display_path = _resolve_managed_path(payload.path, request) - if policy.locked_root is not None and target == policy.locked_root: - raise HTTPException(status_code=400, detail="Cannot delete the managed files root") - if target.parent == target: - raise HTTPException(status_code=400, detail="Cannot delete the filesystem root") - if not target.exists(): - raise HTTPException(status_code=404, detail="Path not found") - - try: - if target.is_dir(): - if payload.recursive: - shutil.rmtree(target) - else: - target.rmdir() - else: - target.unlink() - except OSError as exc: - status_code = 409 if target.is_dir() and not payload.recursive else 500 - raise HTTPException(status_code=status_code, detail=f"Could not delete path: {exc}") - - return {"ok": True, "path": display_path, **_managed_response_meta(policy)} - - -@app.get("/api/fs/list") -async def fs_list(path: str): - target = _fs_path(path) - try: - entries = [] - with os.scandir(target) as scan: - for entry in scan: - if entry.name in _FS_READDIR_HIDDEN: - continue - entries.append({ - "name": entry.name, - "path": str(target / entry.name), - "isDirectory": entry.is_dir(follow_symlinks=False), - }) - entries.sort(key=lambda item: (not item["isDirectory"], item["name"].lower(), item["name"])) - return {"entries": entries} - except FileNotFoundError: - return {"entries": [], "error": "ENOENT"} - except NotADirectoryError: - return {"entries": [], "error": "ENOTDIR"} - except PermissionError: - return {"entries": [], "error": "EACCES"} - except OSError as exc: - return {"entries": [], "error": getattr(exc, "strerror", None) or "read-error"} - - -@app.get("/api/fs/read-text") -async def fs_read_text(path: str): - target, st = _fs_regular_file(_fs_path(path)) - if st.st_size > _FS_TEXT_SOURCE_MAX_BYTES: - raise HTTPException(status_code=413, detail="File too large") - bytes_to_read = min(st.st_size, _FS_TEXT_PREVIEW_MAX_BYTES) - try: - with target.open("rb") as handle: - data = handle.read(bytes_to_read) - except PermissionError: - raise HTTPException(status_code=403, detail="File is not readable") - except OSError as exc: - raise HTTPException(status_code=400, detail=str(exc) or "File read failed") - return { - "binary": _fs_looks_binary(data[:4096]), - "byteSize": st.st_size, - "language": _FS_PREVIEW_LANGUAGE_BY_EXT.get(target.suffix.lower(), "text"), - "mimeType": _fs_mime_type(target), - "path": str(target), - "text": data.decode("utf-8", errors="replace"), - "truncated": st.st_size > _FS_TEXT_PREVIEW_MAX_BYTES, - } - - -@app.post("/api/fs/write-text") -async def fs_write_text(payload: FsWriteText): - """Overwrite (or create) a UTF-8 text file for the in-app spot editor. - - Mirrors the local Electron ``hermes:fs:writeText`` hardening: the path is - resolved + validated by ``_fs_path``, the parent directory must already - exist (we never build directory trees), only regular files may be replaced, - and the payload is size-capped. The write is staged to a sibling temp file - and ``os.replace``-d into place so a crash mid-write can't truncate the - original. Stale-on-disk detection is the client's job (re-read before save), - so both transports behave identically. - """ - target = _fs_path(payload.path) - text = payload.content or "" - if len(text.encode("utf-8")) > _FS_TEXT_WRITE_MAX_BYTES: - raise HTTPException(status_code=413, detail="Content too large") - - try: - st: Optional[os.stat_result] = target.stat() - except FileNotFoundError: - st = None - except PermissionError: - raise HTTPException(status_code=403, detail="File is not writable") - except OSError as exc: - raise HTTPException(status_code=400, detail=str(exc) or "Invalid path") - - if st is not None and stat.S_ISDIR(st.st_mode): - raise HTTPException(status_code=400, detail="Path points to a directory") - if st is not None and not stat.S_ISREG(st.st_mode): - raise HTTPException(status_code=400, detail="Only regular files can be written") - if not target.parent.is_dir(): - raise HTTPException(status_code=400, detail="Parent directory does not exist") - - tmp = target.with_name(f".{target.name}.hermes-tmp-{os.getpid()}") - try: - tmp.write_text(text, encoding="utf-8") - os.replace(tmp, target) - except PermissionError: - tmp.unlink(missing_ok=True) - raise HTTPException(status_code=403, detail="File is not writable") - except OSError as exc: - tmp.unlink(missing_ok=True) - raise HTTPException(status_code=500, detail=f"Could not write file: {exc}") - - return {"ok": True, "path": str(target), "byteSize": len(text.encode("utf-8"))} - - -@app.get("/api/fs/read-data-url") -async def fs_read_data_url(path: str): - target, st = _fs_regular_file(_fs_path(path)) - if st.st_size > _FS_DATA_URL_MAX_BYTES: - raise HTTPException(status_code=413, detail="File too large") - try: - encoded = base64.b64encode(target.read_bytes()).decode("ascii") - except PermissionError: - raise HTTPException(status_code=403, detail="File is not readable") - except OSError as exc: - raise HTTPException(status_code=400, detail=str(exc) or "File read failed") - return {"dataUrl": f"data:{_fs_mime_type(target)};base64,{encoded}"} - - -@app.get("/api/fs/git-root") -async def fs_git_root(path: str): - target = _fs_path(path) - try: - st = target.stat() - start = target if stat.S_ISDIR(st.st_mode) else target.parent - except OSError: - start = target - return {"root": _fs_find_git_root(start)} - +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. + ManagedFilesPolicy, + _MANAGED_FILES_ROOT_ENV, + _HOSTED_MANAGED_FILES_ROOT, + _SENSITIVE_MANAGED_FILE_BASENAMES, + _SENSITIVE_MANAGED_DIR_NAMES, + _CHAT_IMAGE_UPLOAD_MAX_BYTES, + _CHAT_IMAGE_ALLOWED_EXTENSIONS, + _CHAT_IMAGE_MAGIC, + _is_sensitive_filename, + _is_sensitive_path, + _canonical_path, + _ensure_managed_root, + _path_text, + _local_dashboard_request, + _managed_files_policy, + _resolve_managed_path, + _managed_response_meta, + _managed_file_entry, + _decode_data_url, + _sanitize_chat_image_filename, + _chat_image_extension, + _decode_chat_image_upload, + upload_chat_image, + list_managed_files, + read_managed_file, + download_managed_file, + upload_managed_file, + upload_managed_file_stream, + create_managed_directory, + delete_managed_file, +) -@app.get("/api/fs/default-cwd") -async def fs_default_cwd(): - cwd = _fs_default_cwd() - return {"cwd": cwd, "branch": _fs_git_branch(cwd)} +from hermes_cli.web_routers import fs as _fs_routes # noqa: E402 + +app.include_router(_fs_routes.router) +from hermes_cli.web_routers.fs import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. + _FS_READDIR_HIDDEN, + _FS_TEXT_SOURCE_MAX_BYTES, + _FS_TEXT_PREVIEW_MAX_BYTES, + _FS_TEXT_WRITE_MAX_BYTES, + _FS_PREVIEW_LANGUAGE_BY_EXT, + _FS_MIME_TYPES, + _fs_mime_type, + _fs_looks_binary, + _fs_regular_file, + _fs_find_git_root, + _fs_default_cwd, + _fs_git_branch, + fs_list, + fs_read_text, + fs_write_text, + fs_read_data_url, + fs_git_root, + fs_default_cwd, +) # --------------------------------------------------------------------------- @@ -2773,42 +1984,6 @@ def _git_path(path: str) -> str: ) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # Host TCP ports each port-binding gateway platform listens on, as # ``platform-name -> (config port key, adapter default)``. Mirrors # ``PORT_BINDING_PLATFORM_VALUES`` in gateway/config.py and each adapter's @@ -4807,8 +3982,6 @@ def _strip_session_list_rows(sessions: List[Dict[str, Any]]) -> List[Dict[str, A ) - - 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, @@ -6747,8 +5920,6 @@ def _apply_model_assignment_sync( } - - def _infer_provider_on_model_change(model_val: str, prev_provider: str) -> tuple[str, str]: """Infer which provider serves ``model_val`` when the flat Config-page Model field changes, given the previously-saved ``prev_provider``. @@ -8399,8 +7570,6 @@ def _write_platform_enabled(platform_id: str, enabled: bool) -> None: write_platform_config_field(platform_id, "enabled", enabled) - - from hermes_cli.web_routers import whatsapp_onboarding as _whatsapp_routes # noqa: E402 app.include_router(_whatsapp_routes.router) from hermes_cli.web_routers.whatsapp_onboarding import ( # noqa: E402,F401 — legacy re-exports; tests call these @@ -10542,7 +9711,6 @@ async def cancel_oauth_session( # --------------------------------------------------------------------------- - def _session_latest_descendant(session_id: str, db): """Resolve a session id to the newest child leaf session. @@ -10674,14 +9842,6 @@ def _import_sessions_for_profile(profile: Optional[str], sessions: List[Dict[str ) - - - - - - - - # Serialises the one-time writable schema bootstrap for read-only opens. # Concurrent first-load polls otherwise race sqlite file creation: the losers # open mode=ro against a store whose schema is still being written and every @@ -10825,18 +9985,6 @@ def _sweep() -> None: await asyncio.sleep(interval_s) - - - - - - - - - - - - def _prune_sessions(body: SessionPrune): """Delete ended sessions matching filters (mirrors `hermes sessions prune`).""" has_window = ( @@ -10926,8 +10074,6 @@ def _prune_sessions(body: SessionPrune): db.close() - - # --------------------------------------------------------------------------- # Log viewer endpoint # --------------------------------------------------------------------------- @@ -11257,8 +10403,6 @@ def _get_cron_job_sync(job_id: str, profile: Optional[str] = None): return job - - def _list_cron_job_runs_sync(job_id: str, profile: Optional[str] = None, limit: int = 20): """Run sessions produced by a cron job, newest first. @@ -11304,8 +10448,6 @@ def _list_cron_job_runs_sync(job_id: str, profile: Optional[str] = None, limit: db.close() - - def _create_cron_job_sync(body: CronJobCreate, profile: Optional[str] = None): try: profile_name, profile_home = _cron_profile_home(profile) @@ -11344,10 +10486,6 @@ def _create_cron_job_sync(body: CronJobCreate, profile: Optional[str] = None): raise HTTPException(status_code=400, detail=str(e)) - - - - def _update_cron_job_sync(job_id: str, body: CronJobUpdate, profile: Optional[str] = None): selected = profile or _find_cron_job_profile(job_id) if not selected: @@ -11382,8 +10520,6 @@ def _update_cron_job_sync(job_id: str, body: CronJobUpdate, profile: Optional[st return job - - def _pause_cron_job_sync(job_id: str, profile: Optional[str] = None): selected = profile or _find_cron_job_profile(job_id) if not selected: @@ -11394,8 +10530,6 @@ def _pause_cron_job_sync(job_id: str, profile: Optional[str] = None): return job - - def _resume_cron_job_sync(job_id: str, profile: Optional[str] = None): selected = profile or _find_cron_job_profile(job_id) if not selected: @@ -11406,8 +10540,6 @@ def _resume_cron_job_sync(job_id: str, profile: Optional[str] = None): return job - - def _trigger_cron_job_sync(job_id: str, profile: Optional[str] = None): selected = profile or _find_cron_job_profile(job_id) if not selected: @@ -11418,8 +10550,6 @@ def _trigger_cron_job_sync(job_id: str, profile: Optional[str] = None): return job - - def _delete_cron_job_sync(job_id: str, profile: Optional[str] = None): selected = profile or _find_cron_job_profile(job_id) if not selected: @@ -11433,8 +10563,6 @@ def _delete_cron_job_sync(job_id: str, profile: Optional[str] = None): return {"ok": True} - - def _fire_cron_job_for_profile(profile: str, job_id: str) -> bool: """Run ONE due cron job end-to-end for ``profile`` via the resolved scheduler provider's ``fire_due`` (store CAS claim + ``run_one_job``). @@ -11461,8 +10589,6 @@ def _fire_cron_job_for_profile(profile: str, job_id: str) -> bool: reset_hermes_home_override(token) - - # --------------------------------------------------------------------------- # Automation Blueprints — parameterized automation blueprints. The dashboard renders the # slot schema as a form; submitting instantiates a real cron job via the same @@ -11470,8 +10596,6 @@ def _fire_cron_job_for_profile(profile: str, job_id: str) -> bool: # --------------------------------------------------------------------------- - - # --------------------------------------------------------------------------- # MCP server endpoints — list / add / remove / test. # @@ -11604,14 +10728,6 @@ def _mcp_server_summary(name: str, cfg: Dict[str, Any]) -> Dict[str, Any]: ) - - - - - - - - _MCP_DASHBOARD_OAUTH_TTL = 15 * 60 _MAX_PENDING_MCP_OAUTH_FLOWS = 8 _mcp_oauth_flows: dict[str, "DashboardOAuthFlow"] = {} @@ -11744,18 +10860,6 @@ def _run_dashboard_mcp_oauth(flow, cfg: dict) -> None: flow.mark_worker_done() - - - - - - - - - - - - def _mcp_install_action_name(name: str) -> str: """Unique per-entry mcp-install action name (+ registered log file), so a re-click or a second catalog install doesn't overwrite the first's tracked @@ -12728,10 +11832,6 @@ def _hub_action_name(verb: str, key: str) -> str: ) - - - - # Human-readable labels for each hub source id (matches `hermes skills search` # provenance). Keep in sync with create_source_router()'s source list. _SKILL_HUB_SOURCE_LABELS = { @@ -12790,14 +11890,6 @@ def _installed_hub_identifiers(profile: Optional[str] = None) -> dict: return {} - - - - - - - - # --------------------------------------------------------------------------- # Profile management endpoints (minimal — list/create/rename/delete + SOUL.md) # --------------------------------------------------------------------------- @@ -13023,30 +12115,6 @@ def _disable_unselected_skills(profile_dir: Path, keep: List[str]) -> int: ) - - - - - - - - - - - - - - - - - - - - - - - - # --------------------------------------------------------------------------- # Skills & Tools endpoints # @@ -13166,8 +12234,6 @@ def _config_profile_scope(profile: Optional[str]): ) - - def _clear_skills_prompt_cache() -> None: """Best-effort: invalidate the skills system-prompt snapshot after a write. @@ -13181,12 +12247,6 @@ def _clear_skills_prompt_cache() -> None: pass - - - - - - from hermes_cli.web_routers import tools as _tools_routes # noqa: E402 app.include_router(_tools_routes.router) @@ -13206,10 +12266,6 @@ def _clear_skills_prompt_cache() -> None: ) - - - - # Toolsets whose backends carry a selectable model catalog, mapped to the # config.yaml section their `model` key lives in. Mirrors the CLI's # post-selection model pickers (`_configure_imagegen_model_for_plugin` / @@ -13267,16 +12323,6 @@ def _find_toolset_provider_row(ts_key: str, config: dict, provider: Optional[str ) - - - - - - - - - - # --------------------------------------------------------------------------- # Terminal execution backend picker — the GUI counterpart of terminal.backend # in config.yaml. Each row carries a fast, defensive health probe (Docker @@ -13442,10 +12488,6 @@ def _probe_terminal_backend(name: str, terminal_cfg: dict) -> tuple: return ("unavailable", f"Probe failed: {exc}") - - - - # --------------------------------------------------------------------------- # Computer Use (cua-driver) — cross-platform readiness + macOS permission grant # @@ -13458,10 +12500,6 @@ def _probe_terminal_backend(name: str, terminal_cfg: dict) -> tuple: # --------------------------------------------------------------------------- - - - - # --------------------------------------------------------------------------- # Raw YAML config endpoint # --------------------------------------------------------------------------- diff --git a/tests/hermes_cli/test_web_server_fs_files_extraction.py b/tests/hermes_cli/test_web_server_fs_files_extraction.py new file mode 100644 index 0000000000000..92fd55c71b23a --- /dev/null +++ b/tests/hermes_cli/test_web_server_fs_files_extraction.py @@ -0,0 +1,149 @@ +"""Regression tests for the wave-1 extraction of web_server.py clusters c9+c10. + +Covers the pure helpers and seams moved verbatim out of ``hermes_cli.web_server`` +into ``hermes_cli.web_routers.files`` (managed-files cluster c9) and +``hermes_cli.web_routers.fs`` (filesystem cluster c10), plus the legacy +re-export identity and the late-binding seam used to keep +``monkeypatch.setattr(web_server, ...)`` authoritative. +""" + +from pathlib import Path + +import pytest + +from hermes_cli import web_server +from hermes_cli.web_routers import files as web_files +from hermes_cli.web_routers import fs as web_fs + +pytest.importorskip("starlette.testclient") + + +# --- c9: managed-files helpers ------------------------------------------- + + +def test_is_sensitive_filename_guard(): + assert web_files._is_sensitive_filename(".env") is True + assert web_files._is_sensitive_filename(".env.local") is True + assert web_files._is_sensitive_filename(".envrc") is True + assert web_files._is_sensitive_filename("auth.json") is True + assert web_files._is_sensitive_filename("config.yaml") is True + assert web_files._is_sensitive_filename("mcp-tokens") is False # dir check is _is_sensitive_path's job + assert web_files._is_sensitive_filename("notes.txt") is False + + +def test_is_sensitive_path_blocks_credential_dirs(): + assert web_files._is_sensitive_path(Path("root/mcp-tokens/github.json")) is True + assert web_files._is_sensitive_path(Path("root/pairing/device-abc")) is True + assert web_files._is_sensitive_path(Path("root/.env")) is True + assert web_files._is_sensitive_path(Path("root/docs/notes.md")) is False + + +def test_decode_data_url_roundtrip_and_rejections(): + data, mime = web_files._decode_data_url("data:text/plain;base64,aGVsbG8=") + assert data == b"hello" + assert mime == "text/plain" + + with pytest.raises(Exception): + web_files._decode_data_url("not-a-data-url") + with pytest.raises(Exception): + web_files._decode_data_url("data:text/plain;base64,!!!not-base64!!!") + with pytest.raises(Exception): + web_files._decode_data_url("data:text/plain,no-base64-header") + + +def test_chat_image_extension_magic(): + assert web_files._chat_image_extension(b"\x89PNG\r\n\x1a\nrest") == ".png" + assert web_files._chat_image_extension(b"RIFF\x00\x00\x00\x00WEBP") == ".webp" + assert web_files._chat_image_extension(b"GIF87a....") == ".gif" + assert web_files._chat_image_extension(b"plain text") is None + + +def test_sanitize_chat_image_filename(): + assert web_files._sanitize_chat_image_filename("../evil.txt") == "evil.txt" + assert web_files._sanitize_chat_image_filename("a\x00b.png") == "a_b.png" + assert web_files._sanitize_chat_image_filename("") == "pasted-image" + + +def test_managed_response_meta_shape(): + policy = web_files.ManagedFilesPolicy( + default_path=Path("/tmp/root"), locked_root=None, can_change_path=True + ) + assert web_files._managed_response_meta(policy) == { + "root": None, + "locked_root": None, + "can_change_path": True, + } + opt_data = str(Path("/opt/data")) + locked = web_files.ManagedFilesPolicy( + default_path=Path("/opt/data"), locked_root=Path("/opt/data"), can_change_path=False + ) + assert web_files._managed_response_meta(locked) == { + "root": opt_data, + "locked_root": opt_data, + "can_change_path": False, + } + + +# --- c10: fs helpers ------------------------------------------------------ + + +def test_fs_mime_type_and_binary_detection(): + assert web_fs._fs_mime_type(Path("photo.png")) == "image/png" + assert web_fs._fs_mime_type(Path("audio.mp3")) == "audio/mpeg" + assert web_fs._fs_looks_binary(b"\x00\x01\x02") is True + assert web_fs._fs_looks_binary(b"plain text") is False + assert web_fs._fs_looks_binary(b"") is False + + +def test_fs_path_normalizes(tmp_path): + target = tmp_path / "sub" / "file.txt" + assert web_fs._fs_path(str(target)) == target.resolve(strict=False) + assert web_fs._fs_path(f"file:{target}") == target.resolve(strict=False) + with pytest.raises(Exception): + web_fs._fs_path("") + with pytest.raises(Exception): + web_fs._fs_path("bad\x00path") + + +# --- seams: legacy re-exports + late binding ------------------------------ + + +def test_legacy_re_exports_keep_web_server_namespace(): + # Tests and third-party code call these via web_server.. + assert web_server._is_sensitive_filename is web_files._is_sensitive_filename + assert web_server._is_sensitive_path is web_files._is_sensitive_path + assert web_server._decode_data_url is web_files._decode_data_url + assert web_server._fs_mime_type is web_fs._fs_mime_type + assert web_server.upload_managed_file_stream is web_files.upload_managed_file_stream + assert web_server.fs_list is web_fs.fs_list + assert web_server.ManagedFilesPolicy is web_files.ManagedFilesPolicy + # Shared helpers that stayed in web_server are still there. + assert callable(web_server._fs_path) + assert callable(web_server._path_is_under) + + +def test_late_state_keeps_monkeypatch_authoritative(monkeypatch): + # fs_read_data_url compares against _FS_DATA_URL_MAX_BYTES; the test seam + # monkeypatches the web_server attribute and expects the moved handler to + # see it through the LateState proxy. + monkeypatch.setattr(web_server, "_FS_DATA_URL_MAX_BYTES", 3) + assert web_fs._FS_DATA_URL_MAX_BYTES < 5 + assert web_fs._FS_DATA_URL_MAX_BYTES > 2 + + +def test_fs_endpoints_require_auth(): + from starlette.testclient import TestClient + + client = TestClient(web_server.app) + tmp = Path.cwd() / "tests" / "hermes_cli" + for url in ( + "/api/fs/list", + "/api/fs/read-text", + "/api/fs/default-cwd", + "/api/files", + "/api/files/read", + "/api/chat/image-upload", + ): + params = {"path": str(tmp)} if url in ("/api/fs/list", "/api/fs/read-text", "/api/files", "/api/files/read") else None + resp = client.get(url, params=params) + assert resp.status_code == 401, f"{url} should require auth, got {resp.status_code}" From c6e3a74fbee62ccbdc835d70b00484ed05954d5d Mon Sep 17 00:00:00 2001 From: "Andrex Ibiza, MBA" <84248988+andrexibiza@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:16:21 -0500 Subject: [PATCH 4/5] fix(web): drop dangling _write_platform_enabled import (route 500 fix) --- hermes_cli/web_server.py | 1 - 1 file changed, 1 deletion(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 229573c0c09c8..43611d04ad801 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -7581,7 +7581,6 @@ def _write_platform_enabled(platform_id: str, enabled: bool) -> None: _whatsapp_session_path, _spawn_whatsapp_pairing_process, _watch_whatsapp_pairing, - _write_platform_enabled, apply_whatsapp_onboarding, cancel_whatsapp_onboarding, get_whatsapp_onboarding_status, From 096df1e6dd02def9c7932eb5030e683451810cd9 Mon Sep 17 00:00:00 2001 From: "Andrex Ibiza, MBA" <84248988+andrexibiza@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:28:00 -0500 Subject: [PATCH 5/5] fix(web): align whatsapp seam test MOVED_NAMES with live (7 names) --- tests/test_web_server_whatsapp_seam.py | 195 ++++++++++++------------- 1 file changed, 97 insertions(+), 98 deletions(-) diff --git a/tests/test_web_server_whatsapp_seam.py b/tests/test_web_server_whatsapp_seam.py index 5cbcec4475326..3f8d18fcaf026 100644 --- a/tests/test_web_server_whatsapp_seam.py +++ b/tests/test_web_server_whatsapp_seam.py @@ -1,98 +1,97 @@ -"""Seam-identity + aggressive tests for the WhatsApp onboarding extraction (R4-C3). - -``hermes_cli/web_routers/whatsapp_onboarding.py`` holds the dashboard's -WhatsApp bridge onboarding cluster (spawn/watch/apply/cancel + session -lifecycle), moved out of ``hermes_cli/web_server.py`` (god-file slice -R4-C3, epic #78791). - -The seam-identity tests pin the regression this extraction is meant to -prevent: ``web_server`` must resolve every moved name to the *same object* -the router module defines. The aggressive tests then exercise the failure -modes the onboarding surface must survive: missing bridge deps, pairing -spawn failure, watcher EOF, and apply without an active pairing. -""" - -from fastapi.testclient import TestClient - -from hermes_cli import web_server as ws -from hermes_cli.web_routers import whatsapp_onboarding as w - -MOVED_NAMES = ( - "_ensure_whatsapp_bridge_dependencies", - "_spawn_whatsapp_pairing_process", - "_watch_whatsapp_pairing", - "_write_platform_enabled", - "apply_whatsapp_onboarding", - "cancel_whatsapp_onboarding", - "get_whatsapp_onboarding_status", - "start_whatsapp_onboarding", -) - - -def _client_with_app_state(): - prev_auth = getattr(ws.app.state, "auth_required", None) - prev_host = getattr(ws.app.state, "bound_host", None) - ws.app.state.auth_required = False - ws.app.state.bound_host = None - client = TestClient(ws.app) - client.headers[ws._SESSION_HEADER_NAME] = ws._SESSION_TOKEN - return client, prev_auth, prev_host - - -def _restore(prev_auth, prev_host): - if prev_auth is None: - delattr(ws.app.state, "auth_required") - else: - ws.app.state.auth_required = prev_auth - if prev_host is None: - if hasattr(ws.app.state, "bound_host"): - delattr(ws.app.state, "bound_host") - else: - ws.app.state.bound_host = prev_host - - -def test_moved_names_are_seam_identical(): - for name in MOVED_NAMES: - assert getattr(ws, name, None) is getattr(w, name, None), name - - -def test_whatsapp_routes_registered(): - paths = [rt.path for rt in ws.app.routes if "/api/messaging/whatsapp" in getattr(rt, "path", "")] - assert "/api/messaging/whatsapp/onboarding/start" in paths - assert "/api/messaging/whatsapp/onboarding/{pairing_id}/apply" in paths - - -def test_start_onboarding_empty_body_does_not_500(): - # The model has defaults, so an empty body is accepted — but it must - # never 500 (the route handles the no-creds spawn path gracefully). - client, pa, pb = _client_with_app_state() - try: - resp = client.post("/api/messaging/whatsapp/onboarding/start", json={}) - assert resp.status_code in (200, 400, 422) - finally: - _restore(pa, pb) - client.close() - - -def test_get_onboarding_status_unknown_pairing(): - client, pa, pb = _client_with_app_state() - try: - resp = client.get("/api/messaging/whatsapp/onboarding/definitely-missing") - assert resp.status_code in (404, 200) - finally: - _restore(pa, pb) - client.close() - - -def test_cancel_onboarding_unknown_pairing(): - client, pa, pb = _client_with_app_state() - try: - resp = client.delete("/api/messaging/whatsapp/onboarding/definitely-missing") - assert resp.status_code in (404, 200) - finally: - _restore(pa, pb) - client.close() - - -def test_whatsapp_session_ttl_constant(): - assert w._WHATSAPP_ONBOARDING_TTL_SECONDS == 600 +"""Seam-identity + aggressive tests for the WhatsApp onboarding extraction (R4-C3). + +``hermes_cli/web_routers/whatsapp_onboarding.py`` holds the dashboard's +WhatsApp bridge onboarding cluster (spawn/watch/apply/cancel + session +lifecycle), moved out of ``hermes_cli/web_server.py`` (god-file slice +R4-C3, epic #78791). + +The seam-identity tests pin the regression this extraction is meant to +prevent: ``web_server`` must resolve every moved name to the *same object* +the router module defines. The aggressive tests then exercise the failure +modes the onboarding surface must survive: missing bridge deps, pairing +spawn failure, watcher EOF, and apply without an active pairing. +""" + +from fastapi.testclient import TestClient + +from hermes_cli import web_server as ws +from hermes_cli.web_routers import whatsapp_onboarding as w + +MOVED_NAMES = ( + "_ensure_whatsapp_bridge_dependencies", + "_spawn_whatsapp_pairing_process", + "_watch_whatsapp_pairing", + "apply_whatsapp_onboarding", + "cancel_whatsapp_onboarding", + "get_whatsapp_onboarding_status", + "start_whatsapp_onboarding", +) + + +def _client_with_app_state(): + prev_auth = getattr(ws.app.state, "auth_required", None) + prev_host = getattr(ws.app.state, "bound_host", None) + ws.app.state.auth_required = False + ws.app.state.bound_host = None + client = TestClient(ws.app) + client.headers[ws._SESSION_HEADER_NAME] = ws._SESSION_TOKEN + return client, prev_auth, prev_host + + +def _restore(prev_auth, prev_host): + if prev_auth is None: + delattr(ws.app.state, "auth_required") + else: + ws.app.state.auth_required = prev_auth + if prev_host is None: + if hasattr(ws.app.state, "bound_host"): + delattr(ws.app.state, "bound_host") + else: + ws.app.state.bound_host = prev_host + + +def test_moved_names_are_seam_identical(): + for name in MOVED_NAMES: + assert getattr(ws, name, None) is getattr(w, name, None), name + + +def test_whatsapp_routes_registered(): + paths = [rt.path for rt in ws.app.routes if "/api/messaging/whatsapp" in getattr(rt, "path", "")] + assert "/api/messaging/whatsapp/onboarding/start" in paths + assert "/api/messaging/whatsapp/onboarding/{pairing_id}/apply" in paths + + +def test_start_onboarding_empty_body_does_not_500(): + # The model has defaults, so an empty body is accepted — but it must + # never 500 (the route handles the no-creds spawn path gracefully). + client, pa, pb = _client_with_app_state() + try: + resp = client.post("/api/messaging/whatsapp/onboarding/start", json={}) + assert resp.status_code in (200, 400, 422) + finally: + _restore(pa, pb) + client.close() + + +def test_get_onboarding_status_unknown_pairing(): + client, pa, pb = _client_with_app_state() + try: + resp = client.get("/api/messaging/whatsapp/onboarding/definitely-missing") + assert resp.status_code in (404, 200) + finally: + _restore(pa, pb) + client.close() + + +def test_cancel_onboarding_unknown_pairing(): + client, pa, pb = _client_with_app_state() + try: + resp = client.delete("/api/messaging/whatsapp/onboarding/definitely-missing") + assert resp.status_code in (404, 200) + finally: + _restore(pa, pb) + client.close() + + +def test_whatsapp_session_ttl_constant(): + assert w._WHATSAPP_ONBOARDING_TTL_SECONDS == 600