Files
webui/bootstrap.py
nesquena-hermes dd17a0e9b7 security: bandit fixes B310/B324/B110 + QuietHTTPServer (#354)
* security: fix bandit security issues (B310, B324)

- Add usedforsecurity=False to MD5 hash in gateway_watcher.py
- Add URL scheme validation to prevent file:// access in config.py
- Add URL validation to bootstrap.py health check
- Add nosec comments where runtime validation exists

* fix: handle ConnectionResetError gracefully and add debug logging

- Add QuietHTTPServer class to suppress noisy connection reset errors
  caused by clients disconnecting abruptly (fixes log spam from
  'ConnectionResetError: [Errno 54] Connection reset by peer')

- Replace silent 'pass' statements with logger.debug() calls across
  api/auth.py, api/config.py, api/gateway_watcher.py, api/models.py,
  and api/onboarding.py for better observability during troubleshooting

- All tests pass (25 passed in test_regressions.py)

* chore: add debug logging to profiles and routes modules

- Replace silent 'pass' statements with logger.debug() calls in
  api/profiles.py for better error visibility during profile switching
  and module patching

- Add logger initialization to api/routes.py

* security: fix B110 bare except/pass issues (bandit security scan)

- Replace bare except/pass patterns with logger.debug() calls
- Fixes CWE-703 (improper check/handling of exceptional conditions)
- Files affected: routes.py, state_sync.py, streaming.py, workspace.py, server.py
- All tests pass successfully

* security: bandit fixes B310/B324/B110 + QuietHTTPServer (#354)

- api/gateway_watcher.py: MD5 usedforsecurity=False (B324)
- api/config.py, bootstrap.py: URL scheme validation before urlopen (B310)
- 12 files: replace bare except/pass with logger.debug() (B110)
- server.py: QuietHTTPServer suppresses client disconnect log noise
- server.py: fix sys.exc_info() (was traceback.sys.exc_info(), impl detail)
- tests/test_sprint43.py: 19 new tests covering all security fixes
- CHANGELOG.md: v0.50.14 entry; 841 tests total (up from 822)

---------

Co-authored-by: lawrencel1ng <lawrence.ling@global.ntt>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 11:11:56 -07:00

233 lines
7.1 KiB
Python

#!/usr/bin/env python3
"""One-shot bootstrap launcher for Hermes Web UI."""
from __future__ import annotations
import argparse
import os
import platform
import shutil
import subprocess
import sys
import time
import urllib.error
import urllib.request
import venv
import webbrowser
from pathlib import Path
INSTALLER_URL = "https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh"
REPO_ROOT = Path(__file__).resolve().parent
DEFAULT_HOST = os.getenv("HERMES_WEBUI_HOST", "127.0.0.1")
DEFAULT_PORT = int(os.getenv("HERMES_WEBUI_PORT", "8787"))
# Set HERMES_WEBUI_SKIP_ONBOARDING=1 to bypass the first-run wizard when
# the environment is already fully configured (e.g. managed hosting).
def info(msg: str) -> None:
print(f"[bootstrap] {msg}", flush=True)
def is_wsl() -> bool:
if platform.system() != "Linux":
return False
release = platform.release().lower()
return (
"microsoft" in release or "wsl" in release or bool(os.getenv("WSL_DISTRO_NAME"))
)
def ensure_supported_platform() -> None:
if platform.system() == "Windows" and not is_wsl():
raise RuntimeError(
"Native Windows is not supported for this bootstrap yet. "
"Please run it from Linux, macOS, or inside WSL2."
)
def discover_agent_dir() -> Path | None:
home = Path(os.getenv("HERMES_HOME", str(Path.home() / ".hermes"))).expanduser()
candidates = [
os.getenv("HERMES_WEBUI_AGENT_DIR", ""),
str(home / "hermes-agent"),
str(REPO_ROOT.parent / "hermes-agent"),
str(Path.home() / ".hermes" / "hermes-agent"),
str(Path.home() / "hermes-agent"),
]
for raw in candidates:
if not raw:
continue
candidate = Path(raw).expanduser().resolve()
if candidate.exists() and (candidate / "run_agent.py").exists():
return candidate
return None
def discover_launcher_python(agent_dir: Path | None) -> str:
env_python = os.getenv("HERMES_WEBUI_PYTHON")
if env_python:
return env_python
if agent_dir:
for rel in ("venv/bin/python", "venv/Scripts/python.exe"):
candidate = agent_dir / rel
if candidate.exists():
return str(candidate)
for rel in (".venv/bin/python", ".venv/Scripts/python.exe"):
candidate = REPO_ROOT / rel
if candidate.exists():
return str(candidate)
return shutil.which("python3") or shutil.which("python") or sys.executable
def ensure_python_has_webui_deps(python_exe: str) -> str:
check = subprocess.run(
[python_exe, "-c", "import yaml"],
capture_output=True,
text=True,
)
if check.returncode == 0:
return python_exe
venv_dir = REPO_ROOT / ".venv"
venv_python = venv_dir / (
"Scripts/python.exe" if platform.system() == "Windows" else "bin/python"
)
if not venv_python.exists():
info(f"Creating local virtualenv at {venv_dir}")
venv.EnvBuilder(with_pip=True).create(venv_dir)
info("Installing WebUI dependencies into local virtualenv")
subprocess.run(
[str(venv_python), "-m", "pip", "install", "--quiet", "--upgrade", "pip"],
check=True,
)
subprocess.run(
[
str(venv_python),
"-m",
"pip",
"install",
"--quiet",
"-r",
str(REPO_ROOT / "requirements.txt"),
],
check=True,
)
return str(venv_python)
def hermes_command_exists() -> bool:
return shutil.which("hermes") is not None
def install_hermes_agent() -> None:
info(f"Hermes Agent not found. Attempting install via {INSTALLER_URL}")
subprocess.run(
["/bin/bash", "-lc", f"curl -fsSL {INSTALLER_URL} | bash"], check=True
)
def wait_for_health(url: str, timeout: float = 25.0) -> bool:
deadline = time.time() + timeout
# Validate URL scheme to prevent file:// and other dangerous schemes
if not url.startswith(("http://", "https://")):
raise ValueError(f"Invalid health check URL: {url}")
while time.time() < deadline:
try:
with urllib.request.urlopen(url, timeout=2) as response: # nosec B310
if b'"status": "ok"' in response.read():
return True
except Exception:
time.sleep(0.4)
return False
def open_browser(url: str) -> None:
try:
webbrowser.open(url)
except Exception as exc:
info(f"Could not open browser automatically: {exc}")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Bootstrap Hermes Web UI onboarding.")
parser.add_argument("port", nargs="?", type=int, default=DEFAULT_PORT)
parser.add_argument("--host", default=DEFAULT_HOST)
parser.add_argument(
"--no-browser",
action="store_true",
help="Do not open a browser tab automatically.",
)
parser.add_argument(
"--skip-agent-install",
action="store_true",
help="Fail instead of attempting the official Hermes installer.",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
ensure_supported_platform()
agent_dir = discover_agent_dir()
if not agent_dir and not hermes_command_exists():
if args.skip_agent_install:
raise RuntimeError(
"Hermes Agent was not found and auto-install was disabled."
)
install_hermes_agent()
agent_dir = discover_agent_dir()
python_exe = ensure_python_has_webui_deps(discover_launcher_python(agent_dir))
state_dir = Path(
os.getenv("HERMES_WEBUI_STATE_DIR", str(Path.home() / ".hermes" / "webui"))
).expanduser()
state_dir.mkdir(parents=True, exist_ok=True)
log_path = state_dir / f"bootstrap-{args.port}.log"
env = os.environ.copy()
env["HERMES_WEBUI_HOST"] = args.host
env["HERMES_WEBUI_PORT"] = str(args.port)
env.setdefault("HERMES_WEBUI_STATE_DIR", str(state_dir))
if agent_dir:
env["HERMES_WEBUI_AGENT_DIR"] = str(agent_dir)
info(f"Starting Hermes Web UI on http://{args.host}:{args.port}")
with log_path.open("ab") as log_file:
proc = subprocess.Popen(
[python_exe, str(REPO_ROOT / "server.py")],
cwd=str(agent_dir or REPO_ROOT),
env=env,
stdout=log_file,
stderr=subprocess.STDOUT,
start_new_session=True,
)
health_url = f"http://{args.host}:{args.port}/health"
if not wait_for_health(health_url):
raise RuntimeError(
f"Web UI did not become healthy at {health_url}. "
f"Check the log at {log_path}. Server PID: {proc.pid}"
)
app_url = (
f"http://localhost:{args.port}"
if args.host in ("127.0.0.1", "localhost")
else f"http://{args.host}:{args.port}"
)
info(f"Web UI is ready: {app_url}")
info(f"Log file: {log_path}")
if not args.no_browser:
open_browser(app_url)
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as exc:
print(f"[bootstrap] ERROR: {exc}", file=sys.stderr)
raise SystemExit(1)