2026-04-12 07:11:41 +00:00
|
|
|
#!/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()
|
|
|
|
|
|
2026-04-12 07:11:41 +00:00
|
|
|
DEFAULT_HOST = os.getenv("HERMES_WEBUI_HOST", "127.0.0.1")
|
|
|
|
|
DEFAULT_PORT = int(os.getenv("HERMES_WEBUI_PORT", "8787"))
|
2026-04-12 21:26:00 +00:00
|
|
|
# Set HERMES_WEBUI_SKIP_ONBOARDING=1 to bypass the first-run wizard when
|
|
|
|
|
# the environment is already fully configured (e.g. managed hosting).
|
2026-04-12 07:11:41 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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:
|
2026-04-24 06:25:45 +00:00
|
|
|
for rel in ("venv/bin/python", "venv/Scripts/python.exe", ".venv/bin/python", ".venv/Scripts/python.exe"):
|
2026-04-12 07:11:41 +00:00
|
|
|
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}")
|
2026-04-12 07:11:41 +00:00
|
|
|
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
|
2026-04-12 07:11:41 +00:00
|
|
|
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.",
|
|
|
|
|
)
|
fix(bootstrap): add --foreground mode for process supervisors (#1458 Bug #1)
Issue #1458 reports persistent-host crashes (≥1/day) when running the WebUI
under launchd KeepAlive on macOS. Root cause: `bootstrap.py` calls
`subprocess.Popen([python, "server.py"], start_new_session=True)`, probes
/health, then exits 0. Under any process supervisor (launchd, systemd,
supervisord, runit, s6), the supervisor sees its tracked PID exit, marks
the program as "completed," and respawns it. The new bootstrap fails to
bind port 8787 (orphaned server still has it), exits non-zero, supervisor
respawns again — loop until the orphan crashes for some other reason and
the next respawn finds the port free.
This PR addresses Bug #1 of the three failure modes tracked in #1458:
the `bootstrap.py` double-fork breaking process supervisors. Bug #2
(state.db FD leak) and Bug #3 (HTTP-unhealthy wedge) remain open under
the same issue — they need diagnosis data before a fix can land.
Changes
-------
1. `bootstrap.py`:
- New `--foreground` argparse flag with help text mentioning launchd /
systemd / supervisord.
- New `_detect_supervisor()` that returns the env var name for any
supervisor it detects: `INVOCATION_ID` / `JOURNAL_STREAM` /
`NOTIFY_SOCKET` (systemd, s6), `XPC_SERVICE_NAME` (launchd),
`SUPERVISOR_ENABLED` (supervisord), or `HERMES_WEBUI_FOREGROUND` for
the explicit user opt-in. Truthy values for the explicit opt-in:
`1` / `true` / `yes` / `on` (case-insensitive).
- `main()` branches on `args.foreground or _detect_supervisor()`:
- **Foreground path:** chdir to `agent_dir or REPO_ROOT`, then
`os.execv(python, [python, server_path])` to replace the bootstrap
process image with the server. The supervisor sees the long-lived
server as the original child. No `wait_for_health` probe — the
supervisor's KeepAlive / Restart=on-failure handles liveness.
- **Default path:** unchanged. Spawn server as detached child via
`Popen + start_new_session=True`, probe /health, return 0. This
still works for interactive `bash start.sh` invocations.
- Resolved env vars (HOST/PORT/STATE_DIR/AGENT_DIR) are now mutated on
`os.environ` directly instead of into a local `env` copy so they
are inherited across `os.execv`.
2. `docs/supervisor.md` (new): runnable launchd plist, systemd .service,
and supervisord conf examples + a diagnostic recipe (`lsof` + ppid
chain) for catching the orphan-loop in production.
3. `.gitignore`: allowlist `docs/supervisor.md` (the directory uses an
opt-in pattern; matches the existing `!docs/docker.md` precedent).
4. `tests/test_bootstrap_foreground.py` (new): 35 regression tests
covering the argparse flag, `_detect_supervisor()` behavior across all
five supervisor env vars, the explicit opt-in's truthy/falsy values,
and `main()`'s execv-vs-Popen routing decision under each input
combination. `os.execv` is monkeypatched in the routing tests — we
pin the structural choice (which call is made, with which args, in
which cwd, with which env) not the post-exec behavior.
Why this scope and no more
--------------------------
Bug #2 (state.db FD leak) lists 5 candidate paths and asks the reporter
for `lsof -p <pid> | sort | uniq -c | sort -rn | head -20` output to
disambiguate. Until that data lands, any "fix" would be speculative —
explicitly out of scope per the contributor-pickup comment on the issue.
Bug #3 (launchd-running, port-listening, HTTP-unhealthy) was added in
@stefanpieter's reply comment. Diagnosis is in flight; no concrete fix
shape yet. Also out of scope.
Running locally end-to-end verifies the behavior:
```
[bootstrap] Starting Hermes Web UI on http://127.0.0.1:8789 (foreground mode: --foreground)
$ pgrep -af 'server.py'
2997632 /home/.../python /tmp/wt-fix-1458/server.py
$ ps -o ppid -p 2997632
2997581 ← bash that ran bootstrap.py — same PID as the original bootstrap
$ ps -p 2997581 -o cmd
... bootstrap.py ... ← but exec'd into server.py
```
The same PID that bash forked for `bootstrap.py` is now `server.py`.
A supervisor watching that PID would correctly observe the long-lived
server. No double-fork.
Verification
------------
- 3811 tests pass (`pytest tests/` — full suite, +51 from this PR plus
master-merge-in)
- All 35 new bootstrap-foreground tests pass
- `bash scripts/run-browser-tests.sh` PASS (HTTP API checks against worktree)
- `bash scripts/webui_qa_agent.sh 8789` PASS (23/23 visual QA)
- Live verified: server starts cleanly under both `--foreground` and
`HERMES_WEBUI_FOREGROUND=1`; PID lineage confirms no double-fork
Closes #1458 (Bug #1 only). Bugs #2 and #3 remain tracked under the
issue.
2026-05-02 17:37:54 +00:00
|
|
|
parser.add_argument(
|
|
|
|
|
"--foreground",
|
|
|
|
|
action="store_true",
|
|
|
|
|
help=(
|
|
|
|
|
"Run server.py in this process (via os.execv) instead of spawning a "
|
|
|
|
|
"child. Use this under launchd / systemd / supervisord so the "
|
|
|
|
|
"supervisor sees the long-lived server as the original child. "
|
|
|
|
|
"Implies --no-browser. Skips the post-launch health probe — the "
|
|
|
|
|
"supervisor's own KeepAlive / Restart=on-failure handles liveness."
|
|
|
|
|
),
|
|
|
|
|
)
|
2026-04-12 07:11:41 +00:00
|
|
|
return parser.parse_args()
|
|
|
|
|
|
|
|
|
|
|
fix(bootstrap): add --foreground mode for process supervisors (#1458 Bug #1)
Issue #1458 reports persistent-host crashes (≥1/day) when running the WebUI
under launchd KeepAlive on macOS. Root cause: `bootstrap.py` calls
`subprocess.Popen([python, "server.py"], start_new_session=True)`, probes
/health, then exits 0. Under any process supervisor (launchd, systemd,
supervisord, runit, s6), the supervisor sees its tracked PID exit, marks
the program as "completed," and respawns it. The new bootstrap fails to
bind port 8787 (orphaned server still has it), exits non-zero, supervisor
respawns again — loop until the orphan crashes for some other reason and
the next respawn finds the port free.
This PR addresses Bug #1 of the three failure modes tracked in #1458:
the `bootstrap.py` double-fork breaking process supervisors. Bug #2
(state.db FD leak) and Bug #3 (HTTP-unhealthy wedge) remain open under
the same issue — they need diagnosis data before a fix can land.
Changes
-------
1. `bootstrap.py`:
- New `--foreground` argparse flag with help text mentioning launchd /
systemd / supervisord.
- New `_detect_supervisor()` that returns the env var name for any
supervisor it detects: `INVOCATION_ID` / `JOURNAL_STREAM` /
`NOTIFY_SOCKET` (systemd, s6), `XPC_SERVICE_NAME` (launchd),
`SUPERVISOR_ENABLED` (supervisord), or `HERMES_WEBUI_FOREGROUND` for
the explicit user opt-in. Truthy values for the explicit opt-in:
`1` / `true` / `yes` / `on` (case-insensitive).
- `main()` branches on `args.foreground or _detect_supervisor()`:
- **Foreground path:** chdir to `agent_dir or REPO_ROOT`, then
`os.execv(python, [python, server_path])` to replace the bootstrap
process image with the server. The supervisor sees the long-lived
server as the original child. No `wait_for_health` probe — the
supervisor's KeepAlive / Restart=on-failure handles liveness.
- **Default path:** unchanged. Spawn server as detached child via
`Popen + start_new_session=True`, probe /health, return 0. This
still works for interactive `bash start.sh` invocations.
- Resolved env vars (HOST/PORT/STATE_DIR/AGENT_DIR) are now mutated on
`os.environ` directly instead of into a local `env` copy so they
are inherited across `os.execv`.
2. `docs/supervisor.md` (new): runnable launchd plist, systemd .service,
and supervisord conf examples + a diagnostic recipe (`lsof` + ppid
chain) for catching the orphan-loop in production.
3. `.gitignore`: allowlist `docs/supervisor.md` (the directory uses an
opt-in pattern; matches the existing `!docs/docker.md` precedent).
4. `tests/test_bootstrap_foreground.py` (new): 35 regression tests
covering the argparse flag, `_detect_supervisor()` behavior across all
five supervisor env vars, the explicit opt-in's truthy/falsy values,
and `main()`'s execv-vs-Popen routing decision under each input
combination. `os.execv` is monkeypatched in the routing tests — we
pin the structural choice (which call is made, with which args, in
which cwd, with which env) not the post-exec behavior.
Why this scope and no more
--------------------------
Bug #2 (state.db FD leak) lists 5 candidate paths and asks the reporter
for `lsof -p <pid> | sort | uniq -c | sort -rn | head -20` output to
disambiguate. Until that data lands, any "fix" would be speculative —
explicitly out of scope per the contributor-pickup comment on the issue.
Bug #3 (launchd-running, port-listening, HTTP-unhealthy) was added in
@stefanpieter's reply comment. Diagnosis is in flight; no concrete fix
shape yet. Also out of scope.
Running locally end-to-end verifies the behavior:
```
[bootstrap] Starting Hermes Web UI on http://127.0.0.1:8789 (foreground mode: --foreground)
$ pgrep -af 'server.py'
2997632 /home/.../python /tmp/wt-fix-1458/server.py
$ ps -o ppid -p 2997632
2997581 ← bash that ran bootstrap.py — same PID as the original bootstrap
$ ps -p 2997581 -o cmd
... bootstrap.py ... ← but exec'd into server.py
```
The same PID that bash forked for `bootstrap.py` is now `server.py`.
A supervisor watching that PID would correctly observe the long-lived
server. No double-fork.
Verification
------------
- 3811 tests pass (`pytest tests/` — full suite, +51 from this PR plus
master-merge-in)
- All 35 new bootstrap-foreground tests pass
- `bash scripts/run-browser-tests.sh` PASS (HTTP API checks against worktree)
- `bash scripts/webui_qa_agent.sh 8789` PASS (23/23 visual QA)
- Live verified: server starts cleanly under both `--foreground` and
`HERMES_WEBUI_FOREGROUND=1`; PID lineage confirms no double-fork
Closes #1458 (Bug #1 only). Bugs #2 and #3 remain tracked under the
issue.
2026-05-02 17:37:54 +00:00
|
|
|
# Env vars whose presence indicates this process was launched by a supervisor
|
|
|
|
|
# that wants to manage the server's lifecycle (KeepAlive, Restart=always, etc.).
|
|
|
|
|
# When any is set, we auto-promote to --foreground so we don't double-fork.
|
|
|
|
|
#
|
|
|
|
|
# - INVOCATION_ID systemd (set on every service activation)
|
|
|
|
|
# - JOURNAL_STREAM systemd (set when stdio is wired to the journal)
|
|
|
|
|
# - NOTIFY_SOCKET systemd Type=notify, s6 sd_notify-style
|
|
|
|
|
# - XPC_SERVICE_NAME launchd (set to the Label of the running plist)
|
|
|
|
|
# - SUPERVISOR_ENABLED supervisord
|
|
|
|
|
# - HERMES_WEBUI_FOREGROUND explicit user opt-in (=1 / true / yes / on)
|
|
|
|
|
_SUPERVISOR_ENV_VARS = (
|
|
|
|
|
"INVOCATION_ID",
|
|
|
|
|
"JOURNAL_STREAM",
|
|
|
|
|
"NOTIFY_SOCKET",
|
|
|
|
|
"XPC_SERVICE_NAME",
|
|
|
|
|
"SUPERVISOR_ENABLED",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _detect_supervisor() -> str | None:
|
|
|
|
|
"""Return the name of the detected supervisor env var, or None.
|
|
|
|
|
|
|
|
|
|
Pure inspection of os.environ — no side effects. Returned name is the env
|
|
|
|
|
var that triggered detection, useful for log messages and for tests.
|
|
|
|
|
"""
|
|
|
|
|
explicit = os.environ.get("HERMES_WEBUI_FOREGROUND", "").strip().lower()
|
|
|
|
|
if explicit in ("1", "true", "yes", "on"):
|
|
|
|
|
return "HERMES_WEBUI_FOREGROUND"
|
|
|
|
|
for name in _SUPERVISOR_ENV_VARS:
|
|
|
|
|
if os.environ.get(name):
|
|
|
|
|
return name
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
2026-04-12 07:11:41 +00:00
|
|
|
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)
|
|
|
|
|
|
fix(bootstrap): add --foreground mode for process supervisors (#1458 Bug #1)
Issue #1458 reports persistent-host crashes (≥1/day) when running the WebUI
under launchd KeepAlive on macOS. Root cause: `bootstrap.py` calls
`subprocess.Popen([python, "server.py"], start_new_session=True)`, probes
/health, then exits 0. Under any process supervisor (launchd, systemd,
supervisord, runit, s6), the supervisor sees its tracked PID exit, marks
the program as "completed," and respawns it. The new bootstrap fails to
bind port 8787 (orphaned server still has it), exits non-zero, supervisor
respawns again — loop until the orphan crashes for some other reason and
the next respawn finds the port free.
This PR addresses Bug #1 of the three failure modes tracked in #1458:
the `bootstrap.py` double-fork breaking process supervisors. Bug #2
(state.db FD leak) and Bug #3 (HTTP-unhealthy wedge) remain open under
the same issue — they need diagnosis data before a fix can land.
Changes
-------
1. `bootstrap.py`:
- New `--foreground` argparse flag with help text mentioning launchd /
systemd / supervisord.
- New `_detect_supervisor()` that returns the env var name for any
supervisor it detects: `INVOCATION_ID` / `JOURNAL_STREAM` /
`NOTIFY_SOCKET` (systemd, s6), `XPC_SERVICE_NAME` (launchd),
`SUPERVISOR_ENABLED` (supervisord), or `HERMES_WEBUI_FOREGROUND` for
the explicit user opt-in. Truthy values for the explicit opt-in:
`1` / `true` / `yes` / `on` (case-insensitive).
- `main()` branches on `args.foreground or _detect_supervisor()`:
- **Foreground path:** chdir to `agent_dir or REPO_ROOT`, then
`os.execv(python, [python, server_path])` to replace the bootstrap
process image with the server. The supervisor sees the long-lived
server as the original child. No `wait_for_health` probe — the
supervisor's KeepAlive / Restart=on-failure handles liveness.
- **Default path:** unchanged. Spawn server as detached child via
`Popen + start_new_session=True`, probe /health, return 0. This
still works for interactive `bash start.sh` invocations.
- Resolved env vars (HOST/PORT/STATE_DIR/AGENT_DIR) are now mutated on
`os.environ` directly instead of into a local `env` copy so they
are inherited across `os.execv`.
2. `docs/supervisor.md` (new): runnable launchd plist, systemd .service,
and supervisord conf examples + a diagnostic recipe (`lsof` + ppid
chain) for catching the orphan-loop in production.
3. `.gitignore`: allowlist `docs/supervisor.md` (the directory uses an
opt-in pattern; matches the existing `!docs/docker.md` precedent).
4. `tests/test_bootstrap_foreground.py` (new): 35 regression tests
covering the argparse flag, `_detect_supervisor()` behavior across all
five supervisor env vars, the explicit opt-in's truthy/falsy values,
and `main()`'s execv-vs-Popen routing decision under each input
combination. `os.execv` is monkeypatched in the routing tests — we
pin the structural choice (which call is made, with which args, in
which cwd, with which env) not the post-exec behavior.
Why this scope and no more
--------------------------
Bug #2 (state.db FD leak) lists 5 candidate paths and asks the reporter
for `lsof -p <pid> | sort | uniq -c | sort -rn | head -20` output to
disambiguate. Until that data lands, any "fix" would be speculative —
explicitly out of scope per the contributor-pickup comment on the issue.
Bug #3 (launchd-running, port-listening, HTTP-unhealthy) was added in
@stefanpieter's reply comment. Diagnosis is in flight; no concrete fix
shape yet. Also out of scope.
Running locally end-to-end verifies the behavior:
```
[bootstrap] Starting Hermes Web UI on http://127.0.0.1:8789 (foreground mode: --foreground)
$ pgrep -af 'server.py'
2997632 /home/.../python /tmp/wt-fix-1458/server.py
$ ps -o ppid -p 2997632
2997581 ← bash that ran bootstrap.py — same PID as the original bootstrap
$ ps -p 2997581 -o cmd
... bootstrap.py ... ← but exec'd into server.py
```
The same PID that bash forked for `bootstrap.py` is now `server.py`.
A supervisor watching that PID would correctly observe the long-lived
server. No double-fork.
Verification
------------
- 3811 tests pass (`pytest tests/` — full suite, +51 from this PR plus
master-merge-in)
- All 35 new bootstrap-foreground tests pass
- `bash scripts/run-browser-tests.sh` PASS (HTTP API checks against worktree)
- `bash scripts/webui_qa_agent.sh 8789` PASS (23/23 visual QA)
- Live verified: server starts cleanly under both `--foreground` and
`HERMES_WEBUI_FOREGROUND=1`; PID lineage confirms no double-fork
Closes #1458 (Bug #1 only). Bugs #2 and #3 remain tracked under the
issue.
2026-05-02 17:37:54 +00:00
|
|
|
# Mutate os.environ so child (or post-execv) inherits the resolved values.
|
|
|
|
|
os.environ["HERMES_WEBUI_HOST"] = args.host
|
|
|
|
|
os.environ["HERMES_WEBUI_PORT"] = str(args.port)
|
|
|
|
|
os.environ.setdefault("HERMES_WEBUI_STATE_DIR", str(state_dir))
|
2026-04-12 07:11:41 +00:00
|
|
|
if agent_dir:
|
fix(bootstrap): add --foreground mode for process supervisors (#1458 Bug #1)
Issue #1458 reports persistent-host crashes (≥1/day) when running the WebUI
under launchd KeepAlive on macOS. Root cause: `bootstrap.py` calls
`subprocess.Popen([python, "server.py"], start_new_session=True)`, probes
/health, then exits 0. Under any process supervisor (launchd, systemd,
supervisord, runit, s6), the supervisor sees its tracked PID exit, marks
the program as "completed," and respawns it. The new bootstrap fails to
bind port 8787 (orphaned server still has it), exits non-zero, supervisor
respawns again — loop until the orphan crashes for some other reason and
the next respawn finds the port free.
This PR addresses Bug #1 of the three failure modes tracked in #1458:
the `bootstrap.py` double-fork breaking process supervisors. Bug #2
(state.db FD leak) and Bug #3 (HTTP-unhealthy wedge) remain open under
the same issue — they need diagnosis data before a fix can land.
Changes
-------
1. `bootstrap.py`:
- New `--foreground` argparse flag with help text mentioning launchd /
systemd / supervisord.
- New `_detect_supervisor()` that returns the env var name for any
supervisor it detects: `INVOCATION_ID` / `JOURNAL_STREAM` /
`NOTIFY_SOCKET` (systemd, s6), `XPC_SERVICE_NAME` (launchd),
`SUPERVISOR_ENABLED` (supervisord), or `HERMES_WEBUI_FOREGROUND` for
the explicit user opt-in. Truthy values for the explicit opt-in:
`1` / `true` / `yes` / `on` (case-insensitive).
- `main()` branches on `args.foreground or _detect_supervisor()`:
- **Foreground path:** chdir to `agent_dir or REPO_ROOT`, then
`os.execv(python, [python, server_path])` to replace the bootstrap
process image with the server. The supervisor sees the long-lived
server as the original child. No `wait_for_health` probe — the
supervisor's KeepAlive / Restart=on-failure handles liveness.
- **Default path:** unchanged. Spawn server as detached child via
`Popen + start_new_session=True`, probe /health, return 0. This
still works for interactive `bash start.sh` invocations.
- Resolved env vars (HOST/PORT/STATE_DIR/AGENT_DIR) are now mutated on
`os.environ` directly instead of into a local `env` copy so they
are inherited across `os.execv`.
2. `docs/supervisor.md` (new): runnable launchd plist, systemd .service,
and supervisord conf examples + a diagnostic recipe (`lsof` + ppid
chain) for catching the orphan-loop in production.
3. `.gitignore`: allowlist `docs/supervisor.md` (the directory uses an
opt-in pattern; matches the existing `!docs/docker.md` precedent).
4. `tests/test_bootstrap_foreground.py` (new): 35 regression tests
covering the argparse flag, `_detect_supervisor()` behavior across all
five supervisor env vars, the explicit opt-in's truthy/falsy values,
and `main()`'s execv-vs-Popen routing decision under each input
combination. `os.execv` is monkeypatched in the routing tests — we
pin the structural choice (which call is made, with which args, in
which cwd, with which env) not the post-exec behavior.
Why this scope and no more
--------------------------
Bug #2 (state.db FD leak) lists 5 candidate paths and asks the reporter
for `lsof -p <pid> | sort | uniq -c | sort -rn | head -20` output to
disambiguate. Until that data lands, any "fix" would be speculative —
explicitly out of scope per the contributor-pickup comment on the issue.
Bug #3 (launchd-running, port-listening, HTTP-unhealthy) was added in
@stefanpieter's reply comment. Diagnosis is in flight; no concrete fix
shape yet. Also out of scope.
Running locally end-to-end verifies the behavior:
```
[bootstrap] Starting Hermes Web UI on http://127.0.0.1:8789 (foreground mode: --foreground)
$ pgrep -af 'server.py'
2997632 /home/.../python /tmp/wt-fix-1458/server.py
$ ps -o ppid -p 2997632
2997581 ← bash that ran bootstrap.py — same PID as the original bootstrap
$ ps -p 2997581 -o cmd
... bootstrap.py ... ← but exec'd into server.py
```
The same PID that bash forked for `bootstrap.py` is now `server.py`.
A supervisor watching that PID would correctly observe the long-lived
server. No double-fork.
Verification
------------
- 3811 tests pass (`pytest tests/` — full suite, +51 from this PR plus
master-merge-in)
- All 35 new bootstrap-foreground tests pass
- `bash scripts/run-browser-tests.sh` PASS (HTTP API checks against worktree)
- `bash scripts/webui_qa_agent.sh 8789` PASS (23/23 visual QA)
- Live verified: server starts cleanly under both `--foreground` and
`HERMES_WEBUI_FOREGROUND=1`; PID lineage confirms no double-fork
Closes #1458 (Bug #1 only). Bugs #2 and #3 remain tracked under the
issue.
2026-05-02 17:37:54 +00:00
|
|
|
os.environ["HERMES_WEBUI_AGENT_DIR"] = str(agent_dir)
|
|
|
|
|
|
|
|
|
|
server_cwd = str(agent_dir or REPO_ROOT)
|
|
|
|
|
server_path = str(REPO_ROOT / "server.py")
|
|
|
|
|
|
|
|
|
|
# --foreground (or auto-detected supervisor): replace this process with the
|
|
|
|
|
# server. The supervisor sees the long-lived server as the original child,
|
|
|
|
|
# so KeepAlive / Restart=always / autorestart=true work correctly. No
|
|
|
|
|
# health probe — the supervisor's own restart-on-exit handles liveness.
|
|
|
|
|
foreground_reason = "--foreground" if args.foreground else _detect_supervisor()
|
|
|
|
|
if foreground_reason:
|
|
|
|
|
info(
|
|
|
|
|
f"Starting Hermes Web UI on http://{args.host}:{args.port} "
|
|
|
|
|
f"(foreground mode: {foreground_reason})"
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
os.chdir(server_cwd)
|
|
|
|
|
except OSError as exc:
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
f"Could not chdir to {server_cwd!r} before exec: {exc}"
|
|
|
|
|
) from exc
|
|
|
|
|
# os.execv replaces the current process image. Anything after this line
|
|
|
|
|
# only runs if execv itself fails (it raises OSError on failure).
|
|
|
|
|
os.execv(python_exe, [python_exe, server_path])
|
|
|
|
|
# Unreachable — execv either replaces the process or raises.
|
|
|
|
|
raise RuntimeError("os.execv returned unexpectedly")
|
|
|
|
|
|
|
|
|
|
# Default (legacy) path: spawn the server as a detached child, probe
|
|
|
|
|
# /health, then return. Suitable for an interactive `bash start.sh` run.
|
|
|
|
|
log_path = state_dir / f"bootstrap-{args.port}.log"
|
2026-04-12 07:11:41 +00:00
|
|
|
|
|
|
|
|
info(f"Starting Hermes Web UI on http://{args.host}:{args.port}")
|
|
|
|
|
with log_path.open("ab") as log_file:
|
|
|
|
|
proc = subprocess.Popen(
|
fix(bootstrap): add --foreground mode for process supervisors (#1458 Bug #1)
Issue #1458 reports persistent-host crashes (≥1/day) when running the WebUI
under launchd KeepAlive on macOS. Root cause: `bootstrap.py` calls
`subprocess.Popen([python, "server.py"], start_new_session=True)`, probes
/health, then exits 0. Under any process supervisor (launchd, systemd,
supervisord, runit, s6), the supervisor sees its tracked PID exit, marks
the program as "completed," and respawns it. The new bootstrap fails to
bind port 8787 (orphaned server still has it), exits non-zero, supervisor
respawns again — loop until the orphan crashes for some other reason and
the next respawn finds the port free.
This PR addresses Bug #1 of the three failure modes tracked in #1458:
the `bootstrap.py` double-fork breaking process supervisors. Bug #2
(state.db FD leak) and Bug #3 (HTTP-unhealthy wedge) remain open under
the same issue — they need diagnosis data before a fix can land.
Changes
-------
1. `bootstrap.py`:
- New `--foreground` argparse flag with help text mentioning launchd /
systemd / supervisord.
- New `_detect_supervisor()` that returns the env var name for any
supervisor it detects: `INVOCATION_ID` / `JOURNAL_STREAM` /
`NOTIFY_SOCKET` (systemd, s6), `XPC_SERVICE_NAME` (launchd),
`SUPERVISOR_ENABLED` (supervisord), or `HERMES_WEBUI_FOREGROUND` for
the explicit user opt-in. Truthy values for the explicit opt-in:
`1` / `true` / `yes` / `on` (case-insensitive).
- `main()` branches on `args.foreground or _detect_supervisor()`:
- **Foreground path:** chdir to `agent_dir or REPO_ROOT`, then
`os.execv(python, [python, server_path])` to replace the bootstrap
process image with the server. The supervisor sees the long-lived
server as the original child. No `wait_for_health` probe — the
supervisor's KeepAlive / Restart=on-failure handles liveness.
- **Default path:** unchanged. Spawn server as detached child via
`Popen + start_new_session=True`, probe /health, return 0. This
still works for interactive `bash start.sh` invocations.
- Resolved env vars (HOST/PORT/STATE_DIR/AGENT_DIR) are now mutated on
`os.environ` directly instead of into a local `env` copy so they
are inherited across `os.execv`.
2. `docs/supervisor.md` (new): runnable launchd plist, systemd .service,
and supervisord conf examples + a diagnostic recipe (`lsof` + ppid
chain) for catching the orphan-loop in production.
3. `.gitignore`: allowlist `docs/supervisor.md` (the directory uses an
opt-in pattern; matches the existing `!docs/docker.md` precedent).
4. `tests/test_bootstrap_foreground.py` (new): 35 regression tests
covering the argparse flag, `_detect_supervisor()` behavior across all
five supervisor env vars, the explicit opt-in's truthy/falsy values,
and `main()`'s execv-vs-Popen routing decision under each input
combination. `os.execv` is monkeypatched in the routing tests — we
pin the structural choice (which call is made, with which args, in
which cwd, with which env) not the post-exec behavior.
Why this scope and no more
--------------------------
Bug #2 (state.db FD leak) lists 5 candidate paths and asks the reporter
for `lsof -p <pid> | sort | uniq -c | sort -rn | head -20` output to
disambiguate. Until that data lands, any "fix" would be speculative —
explicitly out of scope per the contributor-pickup comment on the issue.
Bug #3 (launchd-running, port-listening, HTTP-unhealthy) was added in
@stefanpieter's reply comment. Diagnosis is in flight; no concrete fix
shape yet. Also out of scope.
Running locally end-to-end verifies the behavior:
```
[bootstrap] Starting Hermes Web UI on http://127.0.0.1:8789 (foreground mode: --foreground)
$ pgrep -af 'server.py'
2997632 /home/.../python /tmp/wt-fix-1458/server.py
$ ps -o ppid -p 2997632
2997581 ← bash that ran bootstrap.py — same PID as the original bootstrap
$ ps -p 2997581 -o cmd
... bootstrap.py ... ← but exec'd into server.py
```
The same PID that bash forked for `bootstrap.py` is now `server.py`.
A supervisor watching that PID would correctly observe the long-lived
server. No double-fork.
Verification
------------
- 3811 tests pass (`pytest tests/` — full suite, +51 from this PR plus
master-merge-in)
- All 35 new bootstrap-foreground tests pass
- `bash scripts/run-browser-tests.sh` PASS (HTTP API checks against worktree)
- `bash scripts/webui_qa_agent.sh 8789` PASS (23/23 visual QA)
- Live verified: server starts cleanly under both `--foreground` and
`HERMES_WEBUI_FOREGROUND=1`; PID lineage confirms no double-fork
Closes #1458 (Bug #1 only). Bugs #2 and #3 remain tracked under the
issue.
2026-05-02 17:37:54 +00:00
|
|
|
[python_exe, server_path],
|
|
|
|
|
cwd=server_cwd,
|
|
|
|
|
env=os.environ.copy(),
|
2026-04-12 07:11:41 +00:00
|
|
|
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)
|