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
|
2026-06-21 16:55:40 +00:00
|
|
|
import re
|
2026-04-12 07:11:41 +00:00
|
|
|
import shutil
|
|
|
|
|
import subprocess
|
|
|
|
|
import sys
|
|
|
|
|
import time
|
|
|
|
|
import urllib.error
|
2026-06-18 18:13:46 +00:00
|
|
|
import urllib.parse
|
2026-04-12 07:11:41 +00:00
|
|
|
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.
|
2026-05-24 14:52:59 +00:00
|
|
|
``ctl.sh`` sets HERMES_WEBUI_PRESERVE_ENV=1 when it has already resolved
|
|
|
|
|
launcher-specific values such as HERMES_HOME or HERMES_WEBUI_STATE_DIR.
|
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
|
|
|
|
|
|
|
|
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:
|
2026-05-24 14:52:59 +00:00
|
|
|
preserve_existing = os.getenv("HERMES_WEBUI_PRESERVE_ENV", "").strip().lower() in {
|
|
|
|
|
"1",
|
|
|
|
|
"true",
|
|
|
|
|
"yes",
|
|
|
|
|
"on",
|
|
|
|
|
}
|
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
|
|
|
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:
|
2026-05-24 14:52:59 +00:00
|
|
|
if preserve_existing and k in os.environ:
|
|
|
|
|
continue
|
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
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-06-18 18:13:46 +00:00
|
|
|
def warn(msg: str) -> None:
|
|
|
|
|
print(f"[bootstrap] [warn] {msg}", file=sys.stderr, flush=True)
|
|
|
|
|
|
|
|
|
|
|
2026-04-12 07:11:41 +00:00
|
|
|
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():
|
Release v0.51.236 — Release HD (stage-q7) (#3491)
## Release v0.51.236 — Release HD (stage-q7)
First Phase 3 (deep-review) release — picked by the 3-factor framework (contributor × impact × mitigated-risk): high-impact (#1952 native Windows support), backend-only (no screenshots), well-mitigated risk (POSIX path provably unchanged), from a contributor active this session (@rodboev, #3446/#3486 shipped earlier today).
### Added
| PR | Author | Fix |
|----|--------|-----|
| #1952 | @rodboev | Native Windows support for `bootstrap.py` + the embedded terminal: POSIX-only `fcntl`/`termios`/`select` guarded behind `_TERMINAL_SUPPORTED`; terminal entry points raise `NotImplementedError`/no-op on Windows; bootstrap Windows block → warning; auto-install errors clearly on native Windows (WSL unaffected); foreground uses `Popen`+exit on Windows instead of `os.execv`. **POSIX behavior unchanged on every path.** |
### Absorbed on the way in (fix-it-ourselves, reviewed fresh)
- `subprocess.CREATE_NEW_PROCESS_GROUP` → `getattr(subprocess, ..., 0)` — the constant is Windows-only, so a win32-simulating test `AttributeError`'d on Linux. Mirrors the `SO_EXCLUSIVEADDRUSE` getattr guard.
- Fixed 2 over-reaching tests in `test_windows_native_support.py` — one was launching a **real installer subprocess** via an unstubbed `subprocess.run` (now stubbed; harness 2.8s vs 80s); removed unused imports.
- Updated `test_onboarding_static.py` — it asserted the OLD "Native Windows is not supported" hard-block string this PR intentionally replaces; now asserts the new experimental-warning + auto-install guard.
- Help-text accuracy: `--foreground` help now describes the Windows Popen path (Opus nit).
### Gate results
- **Full pytest suite**: 7478 passed, 9 skipped, 3 xpassed, **0 failed**
- **ruff forward gate**: CLEAN
- **browser-smoke gate**: CLEAN (gate hardened mid-release to auto-detect the cached chromium revision)
- **Codex (regression)**: SAFE TO SHIP (simulated `sys.platform=win32`, verified POSIX modules not imported + all terminal guards complete + POSIX foreground still uses execv)
- **Opus (correctness)**: SAFE TO SHIP (POSIX path provably unchanged, all fcntl/termios/select guarded, Popen+exit correct; noted inherent-Windows trade-offs that aren't PR bugs)
Note: the Windows *runtime* path can't be executed on the Linux CI box; it was reviewed statically by both reviewers + the contributor's 209-line test (win32 simulated via monkeypatch). Linux/POSIX no-regression is fully verified.
Closes #1952.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-03 17:46:06 +00:00
|
|
|
info(
|
|
|
|
|
"Warning: Native Windows bootstrap is experimental. "
|
|
|
|
|
"Embedded terminal and auto-install are not supported."
|
2026-04-12 07:11:41 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-06-21 16:55:40 +00:00
|
|
|
def _walk_up_for_run_agent(start: Path) -> Path | None:
|
|
|
|
|
"""Walk up the parents of ``start`` and return the first dir with run_agent.py."""
|
|
|
|
|
for parent in start.parents:
|
|
|
|
|
if (parent / "run_agent.py").exists():
|
|
|
|
|
return parent.resolve()
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
2026-05-07 13:22:11 +00:00
|
|
|
def _agent_dir_from_hermes_cli() -> Path | None:
|
2026-06-21 16:55:40 +00:00
|
|
|
"""Resolve the agent install root by inspecting the `hermes` CLI launcher.
|
|
|
|
|
|
|
|
|
|
The Hermes Agent installer drops a `hermes` launcher in the user's PATH.
|
|
|
|
|
It comes in two shapes depending on installer version:
|
|
|
|
|
|
|
|
|
|
1. A Python console-script whose shebang points at the agent's venv::
|
2026-05-07 13:22:11 +00:00
|
|
|
|
2026-06-21 16:55:40 +00:00
|
|
|
#!/path/to/hermes-agent/venv/bin/python3
|
2026-05-07 13:22:11 +00:00
|
|
|
|
2026-06-21 16:55:40 +00:00
|
|
|
2. A small POSIX shell wrapper that ``exec``s the real venv entrypoint
|
|
|
|
|
(the current installer shape — clears PYTHONPATH/PYTHONHOME first)::
|
2026-05-07 13:22:11 +00:00
|
|
|
|
2026-06-21 16:55:40 +00:00
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
exec "/path/to/hermes-agent/venv/bin/hermes" "$@"
|
|
|
|
|
|
|
|
|
|
In both cases an absolute path inside the launcher points into the agent's
|
|
|
|
|
venv. Walking up its parents until we find a directory containing
|
|
|
|
|
`run_agent.py` recovers the install root regardless of where the agent
|
|
|
|
|
lives — e.g. the root-on-Linux FHS layout (`/usr/local/lib/hermes-agent`)
|
|
|
|
|
or a custom clone (`~/Projects/GitHub/hermes-agent`) — neither of which the
|
|
|
|
|
hard-coded candidate list in :func:`discover_agent_dir` can know about.
|
2026-05-07 16:35:48 +00:00
|
|
|
|
|
|
|
|
Last-resort only: this is invoked after every explicit candidate
|
|
|
|
|
(`HERMES_WEBUI_AGENT_DIR`, `$HERMES_HOME/hermes-agent`, etc.) has missed.
|
|
|
|
|
A stale clone in a known location still wins over the live `hermes` CLI
|
|
|
|
|
— that's intentional, since the candidate list is treated as
|
|
|
|
|
authoritative when present, and matches existing behavior.
|
2026-05-07 13:22:11 +00:00
|
|
|
"""
|
|
|
|
|
hermes_path = shutil.which("hermes")
|
|
|
|
|
if not hermes_path:
|
|
|
|
|
return None
|
|
|
|
|
try:
|
2026-06-21 16:55:40 +00:00
|
|
|
# The launcher is tiny; read a bounded prefix so we never slurp a huge
|
|
|
|
|
# file if `hermes` resolves to something unexpected.
|
2026-05-07 13:22:11 +00:00
|
|
|
with open(hermes_path, "r", encoding="utf-8", errors="replace") as f:
|
2026-06-21 16:55:40 +00:00
|
|
|
lines = [f.readline() for _ in range(20)]
|
2026-05-07 13:22:11 +00:00
|
|
|
except OSError:
|
|
|
|
|
return None
|
2026-06-21 16:55:40 +00:00
|
|
|
if not lines or not lines[0].startswith("#!"):
|
2026-05-07 13:22:11 +00:00
|
|
|
return None
|
2026-06-21 16:55:40 +00:00
|
|
|
|
|
|
|
|
# Collect every absolute path the launcher references — the shebang
|
|
|
|
|
# interpreter (Python-console-script shape) plus any quoted path in an
|
|
|
|
|
# `exec`/wrapper line (shell-wrapper shape). A `#!/usr/bin/env bash`
|
|
|
|
|
# shebang yields a useless `/usr/bin/env`, so the wrapper's exec target is
|
|
|
|
|
# what actually points at the agent venv.
|
|
|
|
|
candidate_paths: list[Path] = []
|
|
|
|
|
|
|
|
|
|
shebang_field = lines[0][2:].strip().split(None, 1)
|
|
|
|
|
if shebang_field:
|
|
|
|
|
interp = Path(shebang_field[0])
|
|
|
|
|
# Skip env-style indirection (`/usr/bin/env bash`) — env itself is not
|
|
|
|
|
# in the agent tree; the real target is the wrapped exec line below.
|
|
|
|
|
if interp.is_absolute() and interp.name != "env":
|
|
|
|
|
candidate_paths.append(interp)
|
|
|
|
|
|
|
|
|
|
for line in lines[1:]:
|
|
|
|
|
for match in re.findall(r"""['"](/[^'"]+)['"]""", line):
|
|
|
|
|
candidate_paths.append(Path(match))
|
|
|
|
|
|
|
|
|
|
for candidate in candidate_paths:
|
|
|
|
|
if not candidate.is_absolute():
|
|
|
|
|
continue
|
|
|
|
|
found = _walk_up_for_run_agent(candidate)
|
|
|
|
|
if found:
|
|
|
|
|
return found
|
2026-05-07 13:22:11 +00:00
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
2026-07-09 23:49:56 +00:00
|
|
|
def _agent_dir_from_python(python_exe: str) -> Path | None:
|
|
|
|
|
script = (
|
|
|
|
|
'import importlib.util\n'
|
|
|
|
|
'spec = importlib.util.find_spec("run_agent")\n'
|
|
|
|
|
'print(spec.origin if spec else "")\n'
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
check = subprocess.run(
|
|
|
|
|
[python_exe, "-c", script],
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
)
|
|
|
|
|
except OSError:
|
|
|
|
|
return None
|
|
|
|
|
if check.returncode != 0:
|
|
|
|
|
return None
|
|
|
|
|
lines = check.stdout.splitlines()
|
|
|
|
|
if not lines:
|
|
|
|
|
return None
|
|
|
|
|
origin = Path(lines[0].strip())
|
|
|
|
|
if not origin.is_absolute() or origin.name != "run_agent.py" or not origin.is_file():
|
|
|
|
|
return None
|
|
|
|
|
return origin.parent.resolve()
|
|
|
|
|
|
|
|
|
|
|
2026-04-12 07:11:41 +00:00
|
|
|
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"),
|
2026-06-21 16:55:40 +00:00
|
|
|
# Root-on-Linux FHS layout: the installer puts agent code under
|
|
|
|
|
# /usr/local/lib and links the CLI into /usr/local/bin (matches
|
|
|
|
|
# Claude Code / Codex). HERMES_HOME stays at /root/.hermes, so the
|
|
|
|
|
# `home / "hermes-agent"` candidate above does NOT cover this case.
|
|
|
|
|
"/usr/local/lib/hermes-agent",
|
2026-04-12 07:11:41 +00:00
|
|
|
]
|
|
|
|
|
for raw in candidates:
|
|
|
|
|
if not raw:
|
|
|
|
|
continue
|
|
|
|
|
candidate = Path(raw).expanduser().resolve()
|
|
|
|
|
if candidate.exists() and (candidate / "run_agent.py").exists():
|
|
|
|
|
return candidate
|
2026-07-09 23:49:56 +00:00
|
|
|
agent_dir = _agent_dir_from_hermes_cli()
|
|
|
|
|
if agent_dir:
|
|
|
|
|
return agent_dir
|
|
|
|
|
return _agent_dir_from_python(discover_launcher_python(None))
|
2026-04-12 07:11:41 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-04-30 08:06:16 +00:00
|
|
|
def _python_can_run_webui_and_agent(python_exe: str, agent_dir: Path | None = None) -> bool:
|
|
|
|
|
script = "import yaml\nfrom run_agent import AIAgent\n"
|
|
|
|
|
env = os.environ.copy()
|
|
|
|
|
if agent_dir:
|
2026-05-02 19:54:21 +00:00
|
|
|
# PREPEND agent_dir to PYTHONPATH so an `agent_dir/run_agent.py` wins
|
|
|
|
|
# over any stale `run_agent` package in system site-packages (sys.path
|
|
|
|
|
# order: script-dir → PYTHONPATH entries → site-packages). The
|
|
|
|
|
# "if PYTHONPATH unset" branch avoids a leading os.pathsep, which
|
|
|
|
|
# CPython would interpret as "current directory" — a footgun.
|
2026-04-30 08:06:16 +00:00
|
|
|
env["PYTHONPATH"] = (
|
|
|
|
|
str(agent_dir)
|
|
|
|
|
if not env.get("PYTHONPATH")
|
|
|
|
|
else f"{agent_dir}{os.pathsep}{env['PYTHONPATH']}"
|
|
|
|
|
)
|
2026-04-12 07:11:41 +00:00
|
|
|
check = subprocess.run(
|
2026-04-30 08:06:16 +00:00
|
|
|
[python_exe, "-c", script],
|
2026-04-12 07:11:41 +00:00
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
2026-04-30 08:06:16 +00:00
|
|
|
env=env,
|
2026-04-12 07:11:41 +00:00
|
|
|
)
|
2026-04-30 08:06:16 +00:00
|
|
|
return check.returncode == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def ensure_python_has_webui_deps(python_exe: str, agent_dir: Path | None = None) -> str:
|
|
|
|
|
"""Return a Python executable that can run both WebUI and Hermes Agent.
|
|
|
|
|
|
|
|
|
|
The WebUI can be launched directly with its local .venv. That venv has the
|
|
|
|
|
WebUI dependencies (for example PyYAML), but may not have Hermes Agent on its
|
|
|
|
|
import path. In that case the server starts healthy, then chat fails later
|
|
|
|
|
with "AIAgent not available". Prefer the agent venv when it is usable, and
|
|
|
|
|
validate the final interpreter before starting the server.
|
|
|
|
|
"""
|
|
|
|
|
if _python_can_run_webui_and_agent(python_exe, agent_dir):
|
2026-04-12 07:11:41 +00:00
|
|
|
return python_exe
|
|
|
|
|
|
2026-04-30 08:06:16 +00:00
|
|
|
agent_candidates: list[Path] = []
|
|
|
|
|
if agent_dir:
|
|
|
|
|
for rel in (
|
|
|
|
|
"venv/bin/python",
|
|
|
|
|
"venv/Scripts/python.exe",
|
|
|
|
|
".venv/bin/python",
|
|
|
|
|
".venv/Scripts/python.exe",
|
|
|
|
|
):
|
|
|
|
|
agent_candidates.append(agent_dir / rel)
|
|
|
|
|
for candidate in agent_candidates:
|
|
|
|
|
if str(candidate) != python_exe and candidate.exists():
|
|
|
|
|
if _python_can_run_webui_and_agent(str(candidate), agent_dir):
|
|
|
|
|
return str(candidate)
|
|
|
|
|
|
2026-07-04 04:31:34 +00:00
|
|
|
if (os.getenv("HERMES_WEBUI_DISABLE_LOCAL_VENV") or "").strip().lower() in {"1", "true", "yes", "on"}:
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
"Python environment cannot import both WebUI dependencies and Hermes Agent, "
|
|
|
|
|
"and local .venv creation is disabled for this packaged launch. Set "
|
|
|
|
|
"HERMES_WEBUI_PYTHON to an interpreter with Hermes Agent dependencies."
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-12 07:11:41 +00:00
|
|
|
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}")
|
2026-05-07 13:01:57 +00:00
|
|
|
# symlinks=True: some Python builds (notably mise/asdf shared-library
|
|
|
|
|
# installs on macOS) default venv to copy mode. The copied binary still
|
|
|
|
|
# uses @executable_path/../lib/libpython3.X.dylib for its load command,
|
|
|
|
|
# so the venv binary aborts with SIGABRT on first import because the
|
|
|
|
|
# dylib never gets copied into .venv/lib. Symlinking the interpreter
|
|
|
|
|
# keeps @executable_path resolving back to the original install.
|
2026-05-07 16:35:00 +00:00
|
|
|
# CPython's venv falls back to copy mode automatically when symlink
|
|
|
|
|
# creation fails (e.g. older Windows without SeCreateSymbolicLinkPrivilege),
|
|
|
|
|
# so this is safe to set unconditionally.
|
2026-05-07 13:01:57 +00:00
|
|
|
venv.EnvBuilder(with_pip=True, symlinks=True).create(venv_dir)
|
2026-04-12 07:11:41 +00:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
)
|
2026-04-30 08:06:16 +00:00
|
|
|
if _python_can_run_webui_and_agent(str(venv_python), agent_dir):
|
|
|
|
|
return str(venv_python)
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
"Python environment cannot import both WebUI dependencies and Hermes Agent. "
|
|
|
|
|
"Set HERMES_WEBUI_PYTHON to the Hermes Agent venv Python or install the "
|
|
|
|
|
"WebUI requirements into that environment."
|
|
|
|
|
)
|
2026-04-12 07:11:41 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def hermes_command_exists() -> bool:
|
|
|
|
|
return shutil.which("hermes") is not None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def install_hermes_agent() -> None:
|
Release v0.51.236 — Release HD (stage-q7) (#3491)
## Release v0.51.236 — Release HD (stage-q7)
First Phase 3 (deep-review) release — picked by the 3-factor framework (contributor × impact × mitigated-risk): high-impact (#1952 native Windows support), backend-only (no screenshots), well-mitigated risk (POSIX path provably unchanged), from a contributor active this session (@rodboev, #3446/#3486 shipped earlier today).
### Added
| PR | Author | Fix |
|----|--------|-----|
| #1952 | @rodboev | Native Windows support for `bootstrap.py` + the embedded terminal: POSIX-only `fcntl`/`termios`/`select` guarded behind `_TERMINAL_SUPPORTED`; terminal entry points raise `NotImplementedError`/no-op on Windows; bootstrap Windows block → warning; auto-install errors clearly on native Windows (WSL unaffected); foreground uses `Popen`+exit on Windows instead of `os.execv`. **POSIX behavior unchanged on every path.** |
### Absorbed on the way in (fix-it-ourselves, reviewed fresh)
- `subprocess.CREATE_NEW_PROCESS_GROUP` → `getattr(subprocess, ..., 0)` — the constant is Windows-only, so a win32-simulating test `AttributeError`'d on Linux. Mirrors the `SO_EXCLUSIVEADDRUSE` getattr guard.
- Fixed 2 over-reaching tests in `test_windows_native_support.py` — one was launching a **real installer subprocess** via an unstubbed `subprocess.run` (now stubbed; harness 2.8s vs 80s); removed unused imports.
- Updated `test_onboarding_static.py` — it asserted the OLD "Native Windows is not supported" hard-block string this PR intentionally replaces; now asserts the new experimental-warning + auto-install guard.
- Help-text accuracy: `--foreground` help now describes the Windows Popen path (Opus nit).
### Gate results
- **Full pytest suite**: 7478 passed, 9 skipped, 3 xpassed, **0 failed**
- **ruff forward gate**: CLEAN
- **browser-smoke gate**: CLEAN (gate hardened mid-release to auto-detect the cached chromium revision)
- **Codex (regression)**: SAFE TO SHIP (simulated `sys.platform=win32`, verified POSIX modules not imported + all terminal guards complete + POSIX foreground still uses execv)
- **Opus (correctness)**: SAFE TO SHIP (POSIX path provably unchanged, all fcntl/termios/select guarded, Popen+exit correct; noted inherent-Windows trade-offs that aren't PR bugs)
Note: the Windows *runtime* path can't be executed on the Linux CI box; it was reviewed statically by both reviewers + the contributor's 209-line test (win32 simulated via monkeypatch). Linux/POSIX no-regression is fully verified.
Closes #1952.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-03 17:46:06 +00:00
|
|
|
if platform.system() == "Windows" and not is_wsl():
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
"Auto-install is not supported on native Windows. "
|
|
|
|
|
"Install hermes-agent manually first."
|
|
|
|
|
)
|
2026-04-12 07:11:41 +00:00
|
|
|
info(f"Hermes Agent not found. Attempting install via {INSTALLER_URL}")
|
|
|
|
|
subprocess.run(
|
|
|
|
|
["/bin/bash", "-lc", f"curl -fsSL {INSTALLER_URL} | bash"], check=True
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-06-18 18:13:46 +00:00
|
|
|
def _truthy(value: str | None) -> bool:
|
|
|
|
|
return (value or "").strip().lower() in {"1", "true", "yes", "on"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _tls_probe_enabled() -> bool:
|
|
|
|
|
"""Mirror api.config.TLS_ENABLED: HTTPS when both cert and key are set."""
|
|
|
|
|
return bool(os.getenv("HERMES_WEBUI_TLS_CERT", "").strip()) and bool(
|
|
|
|
|
os.getenv("HERMES_WEBUI_TLS_KEY", "").strip()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _health_ok(url: str, verify: bool = True) -> bool:
|
|
|
|
|
"""Single /health request. Returns True iff the server answered with ok.
|
|
|
|
|
|
|
|
|
|
``verify=False`` disables TLS certificate verification (self-signed certs).
|
|
|
|
|
"""
|
|
|
|
|
# Validate URL scheme to prevent file:// and other dangerous schemes
|
|
|
|
|
if not url.startswith(("http://", "https://")):
|
|
|
|
|
raise ValueError(f"Invalid health check URL: {url}")
|
|
|
|
|
context = None
|
|
|
|
|
if url.startswith("https://") and not verify:
|
|
|
|
|
import ssl
|
|
|
|
|
|
|
|
|
|
context = ssl.create_default_context()
|
|
|
|
|
context.check_hostname = False
|
|
|
|
|
context.verify_mode = ssl.CERT_NONE
|
|
|
|
|
try:
|
|
|
|
|
with urllib.request.urlopen(url, timeout=2, context=context) as response: # nosec B310
|
|
|
|
|
return b'"status": "ok"' in response.read()
|
|
|
|
|
except Exception:
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def wait_for_health(url: str, timeout: float = 25.0) -> str:
|
|
|
|
|
"""Poll /health until the server answers ok or the timeout elapses.
|
|
|
|
|
|
|
|
|
|
Returns the scheme that actually answered ("https" or "http") on success,
|
|
|
|
|
or "" on timeout. The scheme string is truthy on success, so existing
|
|
|
|
|
``if not wait_for_health(...)`` / ``assert wait_for_health(...)`` callers
|
|
|
|
|
keep working unchanged.
|
|
|
|
|
|
|
|
|
|
TLS-aware: when TLS is configured (HERMES_WEBUI_TLS_CERT/KEY set) the server
|
|
|
|
|
serves HTTPS, so probe HTTPS first. Self-signed certs are handled by a
|
|
|
|
|
second, unverified attempt (with a one-line warning). server.py falls back
|
|
|
|
|
to plain HTTP when the cert/key are unloadable
|
|
|
|
|
(tests/test_tls_support.py::test_tls_startup_failure_fallback_to_http), so
|
|
|
|
|
HTTP is probed last to honor that contract instead of polling HTTPS forever.
|
|
|
|
|
The returned scheme reflects that fallback, so callers print the URL the
|
|
|
|
|
server is actually reachable on.
|
|
|
|
|
|
|
|
|
|
HERMES_WEBUI_TLS_INSECURE_PROBE=1 is an explicit opt-in that skips the
|
|
|
|
|
verified attempt and stays silent by contract.
|
|
|
|
|
"""
|
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-06-18 18:13:46 +00:00
|
|
|
deadline = time.time() + timeout
|
|
|
|
|
https = _tls_probe_enabled()
|
|
|
|
|
insecure_optin = _truthy(os.getenv("HERMES_WEBUI_TLS_INSECURE_PROBE"))
|
|
|
|
|
# Derive host:port/path from the passed URL, then build scheme-correct URLs.
|
|
|
|
|
parsed = urllib.parse.urlsplit(url)
|
|
|
|
|
authority = parsed.netloc
|
|
|
|
|
path = parsed.path or "/health"
|
|
|
|
|
https_url = f"https://{authority}{path}"
|
|
|
|
|
http_url = f"http://{authority}{path}"
|
|
|
|
|
warned = False
|
2026-04-12 07:11:41 +00:00
|
|
|
while time.time() < deadline:
|
2026-06-18 18:13:46 +00:00
|
|
|
if not https:
|
|
|
|
|
if _health_ok(http_url, verify=True):
|
|
|
|
|
return "http"
|
|
|
|
|
else:
|
|
|
|
|
if insecure_optin:
|
|
|
|
|
if _health_ok(https_url, verify=False):
|
|
|
|
|
return "https"
|
|
|
|
|
else:
|
|
|
|
|
if _health_ok(https_url, verify=True):
|
|
|
|
|
return "https"
|
|
|
|
|
if _health_ok(https_url, verify=False):
|
|
|
|
|
if not warned:
|
|
|
|
|
warned = True
|
|
|
|
|
warn(
|
|
|
|
|
f"Health probe: TLS certificate at {https_url} is "
|
|
|
|
|
"self-signed or not trusted; proceeding without "
|
|
|
|
|
"verification."
|
|
|
|
|
)
|
|
|
|
|
return "https"
|
|
|
|
|
# server.py may have fallen back to plain HTTP (cert/key unloadable).
|
|
|
|
|
if _health_ok(http_url, verify=True):
|
|
|
|
|
return "http"
|
|
|
|
|
time.sleep(0.4)
|
|
|
|
|
return ""
|
2026-04-12 07:11:41 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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=(
|
Release v0.51.236 — Release HD (stage-q7) (#3491)
## Release v0.51.236 — Release HD (stage-q7)
First Phase 3 (deep-review) release — picked by the 3-factor framework (contributor × impact × mitigated-risk): high-impact (#1952 native Windows support), backend-only (no screenshots), well-mitigated risk (POSIX path provably unchanged), from a contributor active this session (@rodboev, #3446/#3486 shipped earlier today).
### Added
| PR | Author | Fix |
|----|--------|-----|
| #1952 | @rodboev | Native Windows support for `bootstrap.py` + the embedded terminal: POSIX-only `fcntl`/`termios`/`select` guarded behind `_TERMINAL_SUPPORTED`; terminal entry points raise `NotImplementedError`/no-op on Windows; bootstrap Windows block → warning; auto-install errors clearly on native Windows (WSL unaffected); foreground uses `Popen`+exit on Windows instead of `os.execv`. **POSIX behavior unchanged on every path.** |
### Absorbed on the way in (fix-it-ourselves, reviewed fresh)
- `subprocess.CREATE_NEW_PROCESS_GROUP` → `getattr(subprocess, ..., 0)` — the constant is Windows-only, so a win32-simulating test `AttributeError`'d on Linux. Mirrors the `SO_EXCLUSIVEADDRUSE` getattr guard.
- Fixed 2 over-reaching tests in `test_windows_native_support.py` — one was launching a **real installer subprocess** via an unstubbed `subprocess.run` (now stubbed; harness 2.8s vs 80s); removed unused imports.
- Updated `test_onboarding_static.py` — it asserted the OLD "Native Windows is not supported" hard-block string this PR intentionally replaces; now asserts the new experimental-warning + auto-install guard.
- Help-text accuracy: `--foreground` help now describes the Windows Popen path (Opus nit).
### Gate results
- **Full pytest suite**: 7478 passed, 9 skipped, 3 xpassed, **0 failed**
- **ruff forward gate**: CLEAN
- **browser-smoke gate**: CLEAN (gate hardened mid-release to auto-detect the cached chromium revision)
- **Codex (regression)**: SAFE TO SHIP (simulated `sys.platform=win32`, verified POSIX modules not imported + all terminal guards complete + POSIX foreground still uses execv)
- **Opus (correctness)**: SAFE TO SHIP (POSIX path provably unchanged, all fcntl/termios/select guarded, Popen+exit correct; noted inherent-Windows trade-offs that aren't PR bugs)
Note: the Windows *runtime* path can't be executed on the Linux CI box; it was reviewed statically by both reviewers + the contributor's 209-line test (win32 simulated via monkeypatch). Linux/POSIX no-regression is fully verified.
Closes #1952.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-03 17:46:06 +00:00
|
|
|
"Run server.py in this process (via os.execv on POSIX; via a "
|
|
|
|
|
"Popen child + exit on Windows, where execv can't replace the "
|
|
|
|
|
"process image) instead of spawning a detached child. Use this "
|
|
|
|
|
"under launchd / systemd / supervisord so the "
|
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
|
|
|
"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)
|
fix(bootstrap): address Opus pre-merge review feedback (#1478)
Three changes from the pre-merge Opus review:
**MUST-FIX** — XPC_SERVICE_NAME false-positive on macOS Terminal
macOS launchd sets `XPC_SERVICE_NAME` in EVERY Terminal-spawned shell, not
just real services. Typical noise values: `"0"` (truthy in Python!) and
`"application.com.apple.Terminal.<UUID>"`. A bare `os.environ.get(name)`
existence check would auto-promote interactive `./start.sh` runs to
foreground mode on every Mac dev machine — silently breaking the most
common installation path (no /health probe, no browser open, no log file,
hanging shell).
Fix: new `_is_real_supervisor_value()` helper that filters noise. For
`XPC_SERVICE_NAME` specifically, reject `"0"` and any `"application.*"`
prefix. Real launchd plists use reverse-DNS Label form (`com.<rdns>.<svc>`)
which still triggers correctly.
7 new tests in `TestXPCServiceNameNoiseFilter`:
- 4 noise values (`0`, Terminal.app, iTerm2, VSCode) → no detection
- 3 real Label forms → correct detection
- Mixed env with XPC noise + real INVOCATION_ID → falls through to systemd
**SHOULD-FIX 1** — Test env leakage
The original `clean_env` fixture stripped supervisor-detection env vars
but not the resolved bootstrap vars (HERMES_WEBUI_HOST/PORT/AGENT_DIR)
that `main()` mutates onto `os.environ`. After
`test_foreground_exports_resolved_env_vars` ran, later tests would import
bootstrap with polluted defaults (DEFAULT_HOST="0.0.0.0" instead of
"127.0.0.1"). Existing assertions still passed (tautological vs DEFAULT_*),
but it was a footgun for future tests.
Fix: extend `clean_env` to also `delenv` the three resolved vars before
each test.
**SHOULD-FIX 2** — Pre-execv executability guard
If `discover_launcher_python` returns a path that doesn't exist or isn't
executable, `os.execv` raises OSError → wrapper catches → SystemExit(1)
→ supervisor restarts → loop forever. That's exactly the failure mode
this PR is supposed to eliminate.
Fix: `os.access(python_exe, os.X_OK)` check before execv. Converts
infinite supervisor loop into a single visible RuntimeError.
1 new test in `TestForegroundExecutabilityGuard` pinning that the guard
fires before execv when the python path is non-executable.
**Docs** — supervisor.md updates
- New section explaining the XPC_SERVICE_NAME noise filter and what
values trigger / don't trigger detection
- New section listing supervisors that are NOT auto-detected (runit,
daemontools, PM2, Foreman/Honcho, custom shell-script supervisors)
with explicit recommendation to set HERMES_WEBUI_FOREGROUND=1
Verification
- 3820 tests pass (+9 from this commit's new tests vs the original PR
push of 3811)
- Filter manually verified end-to-end with the live os.environ:
XPC=0 → None, XPC=application.* → None, XPC=com.example.foo → triggers
- run-browser-tests.sh ALL CHECKS PASSED on the worktree
Items deferred from the Opus review
- #4 chdir target may not exist: REPO_ROOT comes from __file__.resolve()
so it's stable; not a real concern in practice
- #6 two startup messages in foreground mode: cosmetic, useful for
diagnostics
- #7 stricter explicit-only mode: leaves user the override of just not
passing --foreground (current behavior)
- #8 test stub return value: trivial, can fix later if regression surface
- #9 argparse positional-after-option ordering: test reads fine
These can be follow-up issues if anyone hits them.
2026-05-02 17:52:13 +00:00
|
|
|
#
|
|
|
|
|
# Note on XPC_SERVICE_NAME: macOS launchd sets this in EVERY Terminal-launched
|
|
|
|
|
# shell too — typical values include "0" (truthy in Python!) and
|
|
|
|
|
# "application.com.apple.Terminal.<UUID>". A bare existence check would
|
|
|
|
|
# false-positive on every Mac dev machine running ./start.sh interactively.
|
|
|
|
|
# We narrow to launchd Label-style names (com.<reverse-dns>.<svc>) — those
|
|
|
|
|
# are real services. Verified with `launchctl getenv XPC_SERVICE_NAME` and
|
|
|
|
|
# Apple's documented launchd behavior.
|
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
|
|
|
_SUPERVISOR_ENV_VARS = (
|
|
|
|
|
"INVOCATION_ID",
|
|
|
|
|
"JOURNAL_STREAM",
|
|
|
|
|
"NOTIFY_SOCKET",
|
|
|
|
|
"XPC_SERVICE_NAME",
|
|
|
|
|
"SUPERVISOR_ENABLED",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
fix(bootstrap): address Opus pre-merge review feedback (#1478)
Three changes from the pre-merge Opus review:
**MUST-FIX** — XPC_SERVICE_NAME false-positive on macOS Terminal
macOS launchd sets `XPC_SERVICE_NAME` in EVERY Terminal-spawned shell, not
just real services. Typical noise values: `"0"` (truthy in Python!) and
`"application.com.apple.Terminal.<UUID>"`. A bare `os.environ.get(name)`
existence check would auto-promote interactive `./start.sh` runs to
foreground mode on every Mac dev machine — silently breaking the most
common installation path (no /health probe, no browser open, no log file,
hanging shell).
Fix: new `_is_real_supervisor_value()` helper that filters noise. For
`XPC_SERVICE_NAME` specifically, reject `"0"` and any `"application.*"`
prefix. Real launchd plists use reverse-DNS Label form (`com.<rdns>.<svc>`)
which still triggers correctly.
7 new tests in `TestXPCServiceNameNoiseFilter`:
- 4 noise values (`0`, Terminal.app, iTerm2, VSCode) → no detection
- 3 real Label forms → correct detection
- Mixed env with XPC noise + real INVOCATION_ID → falls through to systemd
**SHOULD-FIX 1** — Test env leakage
The original `clean_env` fixture stripped supervisor-detection env vars
but not the resolved bootstrap vars (HERMES_WEBUI_HOST/PORT/AGENT_DIR)
that `main()` mutates onto `os.environ`. After
`test_foreground_exports_resolved_env_vars` ran, later tests would import
bootstrap with polluted defaults (DEFAULT_HOST="0.0.0.0" instead of
"127.0.0.1"). Existing assertions still passed (tautological vs DEFAULT_*),
but it was a footgun for future tests.
Fix: extend `clean_env` to also `delenv` the three resolved vars before
each test.
**SHOULD-FIX 2** — Pre-execv executability guard
If `discover_launcher_python` returns a path that doesn't exist or isn't
executable, `os.execv` raises OSError → wrapper catches → SystemExit(1)
→ supervisor restarts → loop forever. That's exactly the failure mode
this PR is supposed to eliminate.
Fix: `os.access(python_exe, os.X_OK)` check before execv. Converts
infinite supervisor loop into a single visible RuntimeError.
1 new test in `TestForegroundExecutabilityGuard` pinning that the guard
fires before execv when the python path is non-executable.
**Docs** — supervisor.md updates
- New section explaining the XPC_SERVICE_NAME noise filter and what
values trigger / don't trigger detection
- New section listing supervisors that are NOT auto-detected (runit,
daemontools, PM2, Foreman/Honcho, custom shell-script supervisors)
with explicit recommendation to set HERMES_WEBUI_FOREGROUND=1
Verification
- 3820 tests pass (+9 from this commit's new tests vs the original PR
push of 3811)
- Filter manually verified end-to-end with the live os.environ:
XPC=0 → None, XPC=application.* → None, XPC=com.example.foo → triggers
- run-browser-tests.sh ALL CHECKS PASSED on the worktree
Items deferred from the Opus review
- #4 chdir target may not exist: REPO_ROOT comes from __file__.resolve()
so it's stable; not a real concern in practice
- #6 two startup messages in foreground mode: cosmetic, useful for
diagnostics
- #7 stricter explicit-only mode: leaves user the override of just not
passing --foreground (current behavior)
- #8 test stub return value: trivial, can fix later if regression surface
- #9 argparse positional-after-option ordering: test reads fine
These can be follow-up issues if anyone hits them.
2026-05-02 17:52:13 +00:00
|
|
|
def _is_real_supervisor_value(name: str, value: str) -> bool:
|
|
|
|
|
"""Filter out known-noise env-var values that aren't actual supervisors.
|
|
|
|
|
|
|
|
|
|
Most env vars in _SUPERVISOR_ENV_VARS are only set by the supervisor we
|
|
|
|
|
care about, so any non-empty value is meaningful. XPC_SERVICE_NAME is the
|
|
|
|
|
exception: macOS launchd sets it in every Terminal-spawned shell with
|
|
|
|
|
values like "0" or "application.com.apple.Terminal.<UUID>". A real
|
|
|
|
|
launchd-managed service has a reverse-DNS Label like "com.example.foo".
|
|
|
|
|
"""
|
|
|
|
|
if not value:
|
|
|
|
|
return False
|
|
|
|
|
if name == "XPC_SERVICE_NAME":
|
|
|
|
|
# Reject Apple's noise values; accept Label-style names.
|
|
|
|
|
if value == "0":
|
|
|
|
|
return False
|
|
|
|
|
if value.startswith("application."):
|
|
|
|
|
return False
|
|
|
|
|
return 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
|
|
|
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:
|
fix(bootstrap): address Opus pre-merge review feedback (#1478)
Three changes from the pre-merge Opus review:
**MUST-FIX** — XPC_SERVICE_NAME false-positive on macOS Terminal
macOS launchd sets `XPC_SERVICE_NAME` in EVERY Terminal-spawned shell, not
just real services. Typical noise values: `"0"` (truthy in Python!) and
`"application.com.apple.Terminal.<UUID>"`. A bare `os.environ.get(name)`
existence check would auto-promote interactive `./start.sh` runs to
foreground mode on every Mac dev machine — silently breaking the most
common installation path (no /health probe, no browser open, no log file,
hanging shell).
Fix: new `_is_real_supervisor_value()` helper that filters noise. For
`XPC_SERVICE_NAME` specifically, reject `"0"` and any `"application.*"`
prefix. Real launchd plists use reverse-DNS Label form (`com.<rdns>.<svc>`)
which still triggers correctly.
7 new tests in `TestXPCServiceNameNoiseFilter`:
- 4 noise values (`0`, Terminal.app, iTerm2, VSCode) → no detection
- 3 real Label forms → correct detection
- Mixed env with XPC noise + real INVOCATION_ID → falls through to systemd
**SHOULD-FIX 1** — Test env leakage
The original `clean_env` fixture stripped supervisor-detection env vars
but not the resolved bootstrap vars (HERMES_WEBUI_HOST/PORT/AGENT_DIR)
that `main()` mutates onto `os.environ`. After
`test_foreground_exports_resolved_env_vars` ran, later tests would import
bootstrap with polluted defaults (DEFAULT_HOST="0.0.0.0" instead of
"127.0.0.1"). Existing assertions still passed (tautological vs DEFAULT_*),
but it was a footgun for future tests.
Fix: extend `clean_env` to also `delenv` the three resolved vars before
each test.
**SHOULD-FIX 2** — Pre-execv executability guard
If `discover_launcher_python` returns a path that doesn't exist or isn't
executable, `os.execv` raises OSError → wrapper catches → SystemExit(1)
→ supervisor restarts → loop forever. That's exactly the failure mode
this PR is supposed to eliminate.
Fix: `os.access(python_exe, os.X_OK)` check before execv. Converts
infinite supervisor loop into a single visible RuntimeError.
1 new test in `TestForegroundExecutabilityGuard` pinning that the guard
fires before execv when the python path is non-executable.
**Docs** — supervisor.md updates
- New section explaining the XPC_SERVICE_NAME noise filter and what
values trigger / don't trigger detection
- New section listing supervisors that are NOT auto-detected (runit,
daemontools, PM2, Foreman/Honcho, custom shell-script supervisors)
with explicit recommendation to set HERMES_WEBUI_FOREGROUND=1
Verification
- 3820 tests pass (+9 from this commit's new tests vs the original PR
push of 3811)
- Filter manually verified end-to-end with the live os.environ:
XPC=0 → None, XPC=application.* → None, XPC=com.example.foo → triggers
- run-browser-tests.sh ALL CHECKS PASSED on the worktree
Items deferred from the Opus review
- #4 chdir target may not exist: REPO_ROOT comes from __file__.resolve()
so it's stable; not a real concern in practice
- #6 two startup messages in foreground mode: cosmetic, useful for
diagnostics
- #7 stricter explicit-only mode: leaves user the override of just not
passing --foreground (current behavior)
- #8 test stub return value: trivial, can fix later if regression surface
- #9 argparse positional-after-option ordering: test reads fine
These can be follow-up issues if anyone hits them.
2026-05-02 17:52:13 +00:00
|
|
|
value = os.environ.get(name, "")
|
|
|
|
|
if _is_real_supervisor_value(name, value):
|
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
|
|
|
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()
|
|
|
|
|
|
2026-04-30 08:06:16 +00:00
|
|
|
python_exe = ensure_python_has_webui_deps(discover_launcher_python(agent_dir), agent_dir)
|
2026-04-12 07:11:41 +00:00
|
|
|
state_dir = Path(
|
2026-06-19 23:26:05 +00:00
|
|
|
os.getenv("HERMES_WEBUI_STATE_DIR")
|
|
|
|
|
or Path(os.getenv("HERMES_HOME") or (Path.home() / ".hermes")) / "webui"
|
2026-04-12 07:11:41 +00:00
|
|
|
).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)
|
|
|
|
|
|
2026-06-19 23:26:05 +00:00
|
|
|
# Let operators move fallback relative writes out of a read-only agent dir.
|
|
|
|
|
server_cwd = os.environ.get("HERMES_WEBUI_SERVER_CWD", "").strip() or str(agent_dir or REPO_ROOT)
|
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
|
|
|
server_path = str(REPO_ROOT / "server.py")
|
2026-06-18 18:13:46 +00:00
|
|
|
# Scheme the server will advertise (HTTPS when TLS cert+key are configured).
|
|
|
|
|
scheme = "https" if _tls_probe_enabled() else "http"
|
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
|
|
|
|
|
|
|
|
# --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(
|
2026-06-18 18:13:46 +00:00
|
|
|
f"Starting Hermes Web UI on {scheme}://{args.host}:{args.port} "
|
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
|
|
|
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
|
fix(bootstrap): address Opus pre-merge review feedback (#1478)
Three changes from the pre-merge Opus review:
**MUST-FIX** — XPC_SERVICE_NAME false-positive on macOS Terminal
macOS launchd sets `XPC_SERVICE_NAME` in EVERY Terminal-spawned shell, not
just real services. Typical noise values: `"0"` (truthy in Python!) and
`"application.com.apple.Terminal.<UUID>"`. A bare `os.environ.get(name)`
existence check would auto-promote interactive `./start.sh` runs to
foreground mode on every Mac dev machine — silently breaking the most
common installation path (no /health probe, no browser open, no log file,
hanging shell).
Fix: new `_is_real_supervisor_value()` helper that filters noise. For
`XPC_SERVICE_NAME` specifically, reject `"0"` and any `"application.*"`
prefix. Real launchd plists use reverse-DNS Label form (`com.<rdns>.<svc>`)
which still triggers correctly.
7 new tests in `TestXPCServiceNameNoiseFilter`:
- 4 noise values (`0`, Terminal.app, iTerm2, VSCode) → no detection
- 3 real Label forms → correct detection
- Mixed env with XPC noise + real INVOCATION_ID → falls through to systemd
**SHOULD-FIX 1** — Test env leakage
The original `clean_env` fixture stripped supervisor-detection env vars
but not the resolved bootstrap vars (HERMES_WEBUI_HOST/PORT/AGENT_DIR)
that `main()` mutates onto `os.environ`. After
`test_foreground_exports_resolved_env_vars` ran, later tests would import
bootstrap with polluted defaults (DEFAULT_HOST="0.0.0.0" instead of
"127.0.0.1"). Existing assertions still passed (tautological vs DEFAULT_*),
but it was a footgun for future tests.
Fix: extend `clean_env` to also `delenv` the three resolved vars before
each test.
**SHOULD-FIX 2** — Pre-execv executability guard
If `discover_launcher_python` returns a path that doesn't exist or isn't
executable, `os.execv` raises OSError → wrapper catches → SystemExit(1)
→ supervisor restarts → loop forever. That's exactly the failure mode
this PR is supposed to eliminate.
Fix: `os.access(python_exe, os.X_OK)` check before execv. Converts
infinite supervisor loop into a single visible RuntimeError.
1 new test in `TestForegroundExecutabilityGuard` pinning that the guard
fires before execv when the python path is non-executable.
**Docs** — supervisor.md updates
- New section explaining the XPC_SERVICE_NAME noise filter and what
values trigger / don't trigger detection
- New section listing supervisors that are NOT auto-detected (runit,
daemontools, PM2, Foreman/Honcho, custom shell-script supervisors)
with explicit recommendation to set HERMES_WEBUI_FOREGROUND=1
Verification
- 3820 tests pass (+9 from this commit's new tests vs the original PR
push of 3811)
- Filter manually verified end-to-end with the live os.environ:
XPC=0 → None, XPC=application.* → None, XPC=com.example.foo → triggers
- run-browser-tests.sh ALL CHECKS PASSED on the worktree
Items deferred from the Opus review
- #4 chdir target may not exist: REPO_ROOT comes from __file__.resolve()
so it's stable; not a real concern in practice
- #6 two startup messages in foreground mode: cosmetic, useful for
diagnostics
- #7 stricter explicit-only mode: leaves user the override of just not
passing --foreground (current behavior)
- #8 test stub return value: trivial, can fix later if regression surface
- #9 argparse positional-after-option ordering: test reads fine
These can be follow-up issues if anyone hits them.
2026-05-02 17:52:13 +00:00
|
|
|
# Defensive check: if python_exe is missing or non-executable, execv
|
|
|
|
|
# raises OSError, the wrapper catches and SystemExit(1)s, and the
|
|
|
|
|
# supervisor restarts — looping forever, exactly the failure mode this
|
|
|
|
|
# PR is meant to eliminate. Convert to a single visible error.
|
|
|
|
|
if not os.access(python_exe, os.X_OK):
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
f"Python interpreter at {python_exe!r} is not executable. "
|
|
|
|
|
f"Set HERMES_WEBUI_PYTHON to a working interpreter or fix "
|
|
|
|
|
f"the agent venv at {agent_dir}."
|
|
|
|
|
)
|
Release v0.51.236 — Release HD (stage-q7) (#3491)
## Release v0.51.236 — Release HD (stage-q7)
First Phase 3 (deep-review) release — picked by the 3-factor framework (contributor × impact × mitigated-risk): high-impact (#1952 native Windows support), backend-only (no screenshots), well-mitigated risk (POSIX path provably unchanged), from a contributor active this session (@rodboev, #3446/#3486 shipped earlier today).
### Added
| PR | Author | Fix |
|----|--------|-----|
| #1952 | @rodboev | Native Windows support for `bootstrap.py` + the embedded terminal: POSIX-only `fcntl`/`termios`/`select` guarded behind `_TERMINAL_SUPPORTED`; terminal entry points raise `NotImplementedError`/no-op on Windows; bootstrap Windows block → warning; auto-install errors clearly on native Windows (WSL unaffected); foreground uses `Popen`+exit on Windows instead of `os.execv`. **POSIX behavior unchanged on every path.** |
### Absorbed on the way in (fix-it-ourselves, reviewed fresh)
- `subprocess.CREATE_NEW_PROCESS_GROUP` → `getattr(subprocess, ..., 0)` — the constant is Windows-only, so a win32-simulating test `AttributeError`'d on Linux. Mirrors the `SO_EXCLUSIVEADDRUSE` getattr guard.
- Fixed 2 over-reaching tests in `test_windows_native_support.py` — one was launching a **real installer subprocess** via an unstubbed `subprocess.run` (now stubbed; harness 2.8s vs 80s); removed unused imports.
- Updated `test_onboarding_static.py` — it asserted the OLD "Native Windows is not supported" hard-block string this PR intentionally replaces; now asserts the new experimental-warning + auto-install guard.
- Help-text accuracy: `--foreground` help now describes the Windows Popen path (Opus nit).
### Gate results
- **Full pytest suite**: 7478 passed, 9 skipped, 3 xpassed, **0 failed**
- **ruff forward gate**: CLEAN
- **browser-smoke gate**: CLEAN (gate hardened mid-release to auto-detect the cached chromium revision)
- **Codex (regression)**: SAFE TO SHIP (simulated `sys.platform=win32`, verified POSIX modules not imported + all terminal guards complete + POSIX foreground still uses execv)
- **Opus (correctness)**: SAFE TO SHIP (POSIX path provably unchanged, all fcntl/termios/select guarded, Popen+exit correct; noted inherent-Windows trade-offs that aren't PR bugs)
Note: the Windows *runtime* path can't be executed on the Linux CI box; it was reviewed statically by both reviewers + the contributor's 209-line test (win32 simulated via monkeypatch). Linux/POSIX no-regression is fully verified.
Closes #1952.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-03 17:46:06 +00:00
|
|
|
# os.execv replaces the current process image. On Windows, execv
|
|
|
|
|
# spawns a new process instead of replacing (Python calls CreateProcess),
|
|
|
|
|
# orphaning it from any supervisor. Use Popen + exit there instead.
|
|
|
|
|
if sys.platform == "win32":
|
2026-06-22 03:05:00 +00:00
|
|
|
# Mirror the robust pattern from api/updates._schedule_restart:
|
|
|
|
|
# 1. Prefer pythonw.exe (windowless subsystem) over python.exe
|
|
|
|
|
# so the restarted server never creates a visible console window.
|
|
|
|
|
# 2. DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW
|
|
|
|
|
# suppresses the brief console flash even when python.exe is used.
|
|
|
|
|
_exe = str(python_exe)
|
|
|
|
|
if _exe.lower().endswith("python.exe"):
|
|
|
|
|
_w = _exe[:-4] + "w.exe" # python.exe -> pythonw.exe
|
|
|
|
|
if os.path.isfile(_w):
|
|
|
|
|
_exe = _w
|
|
|
|
|
_flags = 0
|
|
|
|
|
for _attr in ("DETACHED_PROCESS", "CREATE_NEW_PROCESS_GROUP",
|
|
|
|
|
"CREATE_NO_WINDOW"):
|
|
|
|
|
_flags |= getattr(subprocess, _attr, 0)
|
|
|
|
|
# Redirect the windowless child's stdout/stderr to a real log file
|
|
|
|
|
# (not DEVNULL): server.py writes startup/request/error diagnostics
|
|
|
|
|
# to stdout/stderr, and with no console (pythonw + CREATE_NO_WINDOW)
|
|
|
|
|
# there is nowhere else for them to go — DEVNULL would silently drop
|
|
|
|
|
# all Windows server logs after a supervisor restart. Mirror the
|
|
|
|
|
# default-path log sink (state_dir/bootstrap-<port>.log).
|
|
|
|
|
_win_log_path = state_dir / f"bootstrap-{args.port}.log"
|
|
|
|
|
_win_log = _win_log_path.open("ab")
|
|
|
|
|
try:
|
|
|
|
|
subprocess.Popen(
|
|
|
|
|
[_exe, str(server_path)],
|
|
|
|
|
cwd=str(server_cwd),
|
|
|
|
|
env=os.environ.copy(),
|
|
|
|
|
creationflags=_flags,
|
|
|
|
|
close_fds=True,
|
|
|
|
|
stdin=subprocess.DEVNULL,
|
|
|
|
|
stdout=_win_log,
|
|
|
|
|
stderr=subprocess.STDOUT,
|
|
|
|
|
)
|
|
|
|
|
finally:
|
|
|
|
|
_win_log.close()
|
Release v0.51.236 — Release HD (stage-q7) (#3491)
## Release v0.51.236 — Release HD (stage-q7)
First Phase 3 (deep-review) release — picked by the 3-factor framework (contributor × impact × mitigated-risk): high-impact (#1952 native Windows support), backend-only (no screenshots), well-mitigated risk (POSIX path provably unchanged), from a contributor active this session (@rodboev, #3446/#3486 shipped earlier today).
### Added
| PR | Author | Fix |
|----|--------|-----|
| #1952 | @rodboev | Native Windows support for `bootstrap.py` + the embedded terminal: POSIX-only `fcntl`/`termios`/`select` guarded behind `_TERMINAL_SUPPORTED`; terminal entry points raise `NotImplementedError`/no-op on Windows; bootstrap Windows block → warning; auto-install errors clearly on native Windows (WSL unaffected); foreground uses `Popen`+exit on Windows instead of `os.execv`. **POSIX behavior unchanged on every path.** |
### Absorbed on the way in (fix-it-ourselves, reviewed fresh)
- `subprocess.CREATE_NEW_PROCESS_GROUP` → `getattr(subprocess, ..., 0)` — the constant is Windows-only, so a win32-simulating test `AttributeError`'d on Linux. Mirrors the `SO_EXCLUSIVEADDRUSE` getattr guard.
- Fixed 2 over-reaching tests in `test_windows_native_support.py` — one was launching a **real installer subprocess** via an unstubbed `subprocess.run` (now stubbed; harness 2.8s vs 80s); removed unused imports.
- Updated `test_onboarding_static.py` — it asserted the OLD "Native Windows is not supported" hard-block string this PR intentionally replaces; now asserts the new experimental-warning + auto-install guard.
- Help-text accuracy: `--foreground` help now describes the Windows Popen path (Opus nit).
### Gate results
- **Full pytest suite**: 7478 passed, 9 skipped, 3 xpassed, **0 failed**
- **ruff forward gate**: CLEAN
- **browser-smoke gate**: CLEAN (gate hardened mid-release to auto-detect the cached chromium revision)
- **Codex (regression)**: SAFE TO SHIP (simulated `sys.platform=win32`, verified POSIX modules not imported + all terminal guards complete + POSIX foreground still uses execv)
- **Opus (correctness)**: SAFE TO SHIP (POSIX path provably unchanged, all fcntl/termios/select guarded, Popen+exit correct; noted inherent-Windows trade-offs that aren't PR bugs)
Note: the Windows *runtime* path can't be executed on the Linux CI box; it was reviewed statically by both reviewers + the contributor's 209-line test (win32 simulated via monkeypatch). Linux/POSIX no-regression is fully verified.
Closes #1952.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-03 17:46:06 +00:00
|
|
|
sys.exit(0)
|
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.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
|
|
|
|
2026-06-18 18:13:46 +00:00
|
|
|
info(f"Starting Hermes Web UI on {scheme}://{args.host}:{args.port}")
|
2026-04-12 07:11:41 +00:00
|
|
|
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,
|
|
|
|
|
)
|
|
|
|
|
|
2026-06-18 18:13:46 +00:00
|
|
|
health_url = f"{scheme}://{args.host}:{args.port}/health"
|
|
|
|
|
healthy_scheme = wait_for_health(health_url)
|
|
|
|
|
if not healthy_scheme:
|
2026-04-12 07:11:41 +00:00
|
|
|
raise RuntimeError(
|
|
|
|
|
f"Web UI did not become healthy at {health_url}. "
|
|
|
|
|
f"Check the log at {log_path}. Server PID: {proc.pid}"
|
|
|
|
|
)
|
|
|
|
|
|
2026-06-18 18:13:46 +00:00
|
|
|
# server.py falls back to plain HTTP when the cert/key are unloadable, so the
|
|
|
|
|
# scheme that actually answered the probe is the one the server is reachable
|
|
|
|
|
# on — use it for the ready URL and browser-open, not the configured scheme.
|
|
|
|
|
ready_scheme = healthy_scheme or scheme
|
2026-04-12 07:11:41 +00:00
|
|
|
app_url = (
|
2026-06-18 18:13:46 +00:00
|
|
|
f"{ready_scheme}://localhost:{args.port}"
|
2026-04-12 07:11:41 +00:00
|
|
|
if args.host in ("127.0.0.1", "localhost")
|
2026-06-18 18:13:46 +00:00
|
|
|
else f"{ready_scheme}://{args.host}:{args.port}"
|
2026-04-12 07:11:41 +00:00
|
|
|
)
|
|
|
|
|
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)
|