HERMES-GIELIE/bootstrap.py

277 lines
8.9 KiB
Python
Raw Normal View History

#!/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
fix: bootstrap.py loads REPO_ROOT/.env so direct invocation matches start.sh (#730) (#791) * fix: bootstrap.py loads REPO_ROOT/.env so direct invocation matches start.sh When users run 'python3 bootstrap.py' directly (the primary documented entry point in README), HERMES_WEBUI_HOST, HERMES_WEBUI_PORT and other .env settings were silently ignored because the shell-level 'source .env' in start.sh was never executed. Add _load_repo_dotenv() in bootstrap.py that reads REPO_ROOT/.env into os.environ before DEFAULT_HOST / DEFAULT_PORT are evaluated at module level. Uses unconditional assignment matching 'set -a; source .env' shell semantics. Only loads the repo .env (bootstrap config) — not ~/.hermes/.env, which the server still loads independently at startup for provider credentials. Reported in #730 by @leap233 who had HERMES_WEBUI_HOST=0.0.0.0 and HERMES_WEBUI_PORT=18787 in the webui .env; running bootstrap.py directly caused the server to ignore both settings. Tests: 15 new tests in tests/test_bootstrap_dotenv.py covering the full loader (key=value, comments, blank lines, quoted values, no-file, unreadable-file, overwrite semantics, values with = signs) and structural assertions that _load_repo_dotenv() is called before DEFAULT_HOST/PORT. 1613 tests total. * fix: address review feedback on PR #791 - bootstrap.py: document overwrite semantics and 'export' note in docstring - bootstrap.py: handle 'export FOO=bar' prefix (strip before splitting on =) - bootstrap.py: print warning to stderr on .env parse failure (not silent swallow) - bootstrap.py: add side-effect comment at _load_repo_dotenv() call site - CHANGELOG.md: restore v0.50.124 and v0.50.123 headers (were merged into v0.50.125 section, making three consecutive ### Fixed blocks with no ## header between them) - tests: fix test_noop_when_dotenv_unreadable to assert warning is emitted - tests: tighten test_does_not_set_empty_values with concrete assertion - tests: add test_export_prefix_stripped - tests: remove dead _import_bootstrap_with_env() helper (never called) 1614 tests total --------- Co-authored-by: nesquena-hermes <hermes@nesquena.com>
2026-04-21 03:55:53 +00:00
def _load_repo_dotenv() -> None:
"""Load REPO_ROOT/.env into os.environ.
Mirrors what start.sh does via ``set -a; source .env`` so that running
``python3 bootstrap.py`` directly behaves identically to ``./start.sh``.
Variables are set unconditionally (matching shell source semantics), so a
value in .env overrides one already present in the shell environment.
To keep a CLI-supplied value, unset it from .env or launch via start.sh
and override there.
Only loads the webui repo .env — not ~/.hermes/.env, which the server
loads independently at startup for provider credentials.
Note: does not handle the ``export FOO=bar`` prefix — strip ``export``
from .env values if copy-pasting from a shell rc file.
"""
env_path = REPO_ROOT / ".env"
if not env_path.exists():
return
try:
for raw_line in env_path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
k = k.strip()
# Strip optional 'export' prefix (common in copy-pasted shell snippets)
if k.startswith("export "):
k = k[7:].strip()
v = v.strip().strip('"').strip("'")
if k:
os.environ[k] = v
except Exception as exc:
import sys as _sys
print(f"[bootstrap] Warning: could not load .env — {exc}", file=_sys.stderr)
# Side effect: loads REPO_ROOT/.env into os.environ on import.
# Must run before DEFAULT_HOST / DEFAULT_PORT so os.getenv() picks up
# values from .env even when bootstrap.py is invoked directly (not via start.sh).
_load_repo_dotenv()
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", ".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
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 18:11:56 +00:00
# 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:
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 18:11:56 +00:00
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)