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.
This commit is contained in:
41
bootstrap.py
41
bootstrap.py
@@ -232,6 +232,14 @@ def parse_args() -> argparse.Namespace:
|
||||
# - 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)
|
||||
#
|
||||
# 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.
|
||||
_SUPERVISOR_ENV_VARS = (
|
||||
"INVOCATION_ID",
|
||||
"JOURNAL_STREAM",
|
||||
@@ -241,6 +249,26 @@ _SUPERVISOR_ENV_VARS = (
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _detect_supervisor() -> str | None:
|
||||
"""Return the name of the detected supervisor env var, or None.
|
||||
|
||||
@@ -251,7 +279,8 @@ def _detect_supervisor() -> str | None:
|
||||
if explicit in ("1", "true", "yes", "on"):
|
||||
return "HERMES_WEBUI_FOREGROUND"
|
||||
for name in _SUPERVISOR_ENV_VARS:
|
||||
if os.environ.get(name):
|
||||
value = os.environ.get(name, "")
|
||||
if _is_real_supervisor_value(name, value):
|
||||
return name
|
||||
return None
|
||||
|
||||
@@ -301,6 +330,16 @@ def main() -> int:
|
||||
raise RuntimeError(
|
||||
f"Could not chdir to {server_cwd!r} before exec: {exc}"
|
||||
) from exc
|
||||
# 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}."
|
||||
)
|
||||
# os.execv replaces the current process image. Anything after this line
|
||||
# only runs if execv itself fails (it raises OSError on failure).
|
||||
os.execv(python_exe, [python_exe, server_path])
|
||||
|
||||
@@ -172,13 +172,43 @@ These trigger ``--foreground`` behavior even when the flag is not passed:
|
||||
| ``INVOCATION_ID`` | systemd | Set on every service activation |
|
||||
| ``JOURNAL_STREAM`` | systemd | Set when stdio is wired to journald |
|
||||
| ``NOTIFY_SOCKET`` | systemd ``Type=notify`` / s6 | sd_notify-style notification socket |
|
||||
| ``XPC_SERVICE_NAME`` | launchd | Set to the plist Label |
|
||||
| ``XPC_SERVICE_NAME`` | launchd | Set to the plist Label — narrowed to ``com.<rdns>.<svc>`` form (see below) |
|
||||
| ``SUPERVISOR_ENABLED`` | supervisord | Always set under supervisord |
|
||||
| ``HERMES_WEBUI_FOREGROUND`` | you | Explicit opt-in; accepts ``1`` / ``true`` / ``yes`` / ``on`` |
|
||||
|
||||
If you're running under a supervisor that is not in the list and your tracked
|
||||
PID keeps exiting, set ``HERMES_WEBUI_FOREGROUND=1`` in the service
|
||||
environment.
|
||||
### XPC_SERVICE_NAME noise filter
|
||||
|
||||
macOS launchd sets ``XPC_SERVICE_NAME`` in **every Terminal-spawned shell**,
|
||||
not just real services. Typical noise values:
|
||||
|
||||
- ``0`` — set on launchd descendants generally
|
||||
- ``application.com.apple.Terminal.<UUID>`` — Terminal.app shells
|
||||
- ``application.com.googlecode.iterm2`` — iTerm2
|
||||
- ``application.com.microsoft.VSCode`` — VSCode integrated terminal
|
||||
|
||||
A bare existence check on this var would auto-promote interactive
|
||||
``./start.sh`` runs to foreground mode on every Mac dev machine, breaking
|
||||
the most common installation path. We narrow detection to launchd
|
||||
**Label-style** names (typically reverse-DNS like ``com.example.foo``).
|
||||
Real launchd plists always use this form. If you ever see
|
||||
``XPC_SERVICE_NAME=0`` in your service environment, the auto-detect will
|
||||
ignore it — set ``HERMES_WEBUI_FOREGROUND=1`` or pass ``--foreground``
|
||||
explicitly to be safe.
|
||||
|
||||
### Supervisors that are NOT auto-detected
|
||||
|
||||
The following set no env var that we can reliably detect. Pass
|
||||
``--foreground`` (or ``HERMES_WEBUI_FOREGROUND=1``) explicitly:
|
||||
|
||||
- **runit** (without sd_notify) — pure runit chains
|
||||
- **daemontools** / ``svc``
|
||||
- **PM2** (Node.js process manager occasionally repurposed for Python)
|
||||
- **Foreman** / **Honcho** (Procfile-style)
|
||||
- **Docker** with a custom CMD entrypoint that doesn't already use ``exec``
|
||||
- **Custom shell-script supervisors** that fork-and-wait
|
||||
|
||||
If your supervisor isn't in the auto-detect list and you see the orphan-PID
|
||||
respawn loop, set ``HERMES_WEBUI_FOREGROUND=1`` in the service environment.
|
||||
|
||||
## Diagnostic recipe
|
||||
|
||||
|
||||
@@ -30,18 +30,23 @@ Coverage
|
||||
explicit opt-in, accepting ``1``/``true``/``yes``/``on`` (case-insensitive)
|
||||
5. ``_detect_supervisor()`` ignores ``HERMES_WEBUI_FOREGROUND=0`` /
|
||||
``=false`` / ``=`` and falls through to env-var probing
|
||||
6. ``main()`` calls ``os.execv`` (NOT ``subprocess.Popen``) when
|
||||
6. ``XPC_SERVICE_NAME`` noise filter: bare ``"0"`` and ``application.<id>``
|
||||
values do NOT trigger foreground (the macOS Terminal default state),
|
||||
while real launchd Labels (``com.<rdns>.<svc>``) do
|
||||
7. ``main()`` calls ``os.execv`` (NOT ``subprocess.Popen``) when
|
||||
``--foreground`` is passed
|
||||
7. ``main()`` calls ``os.execv`` (NOT ``subprocess.Popen``) when a supervisor
|
||||
8. ``main()`` calls ``os.execv`` (NOT ``subprocess.Popen``) when a supervisor
|
||||
env var is set even without the explicit flag
|
||||
8. Default ``main()`` path (no flag, clean env) still uses ``Popen``
|
||||
9. Foreground path chdir's to ``agent_dir or REPO_ROOT`` before execv (matches
|
||||
9. Default ``main()`` path (no flag, clean env) still uses ``Popen``
|
||||
10. Foreground path chdir's to ``agent_dir or REPO_ROOT`` before execv (matches
|
||||
the cwd the legacy Popen uses)
|
||||
10. Foreground path exports ``HERMES_WEBUI_HOST`` / ``HERMES_WEBUI_PORT`` /
|
||||
11. Foreground path exports ``HERMES_WEBUI_HOST`` / ``HERMES_WEBUI_PORT`` /
|
||||
``HERMES_WEBUI_AGENT_DIR`` / ``HERMES_WEBUI_STATE_DIR`` to ``os.environ``
|
||||
so the post-exec server picks them up
|
||||
11. Foreground path skips ``wait_for_health`` (no client to retry from)
|
||||
12. ``--foreground`` help text mentions launchd / systemd / supervisord
|
||||
12. Foreground path skips ``wait_for_health`` (no client to retry from)
|
||||
13. ``--foreground`` help text mentions launchd / systemd / supervisord
|
||||
14. Non-executable ``python_exe`` raises ``RuntimeError`` instead of
|
||||
looping the supervisor on ``execv`` failure
|
||||
|
||||
These tests do NOT actually exec — ``os.execv`` is monkeypatched. We're
|
||||
pinning the structural choice (which path runs, which cwd, which env) not the
|
||||
@@ -66,14 +71,27 @@ sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
@pytest.fixture
|
||||
def clean_env(monkeypatch):
|
||||
"""Strip all known supervisor env vars so detection starts from a clean state."""
|
||||
"""Strip all known supervisor env vars + resolved bootstrap vars so each
|
||||
test starts from a known-clean state.
|
||||
|
||||
The resolved-vars stripping (HERMES_WEBUI_HOST etc.) prevents leakage
|
||||
where a previous test's ``main()`` mutated ``os.environ`` and a later
|
||||
test re-imports ``bootstrap``, picking up the polluted defaults. With
|
||||
these stripped, ``DEFAULT_HOST`` / ``DEFAULT_PORT`` fall back to their
|
||||
hardcoded defaults at module load time.
|
||||
"""
|
||||
for name in (
|
||||
# Supervisor-detection env vars
|
||||
"INVOCATION_ID",
|
||||
"JOURNAL_STREAM",
|
||||
"NOTIFY_SOCKET",
|
||||
"XPC_SERVICE_NAME",
|
||||
"SUPERVISOR_ENABLED",
|
||||
"HERMES_WEBUI_FOREGROUND",
|
||||
# Bootstrap-resolved env vars (mutated by main(), can leak across tests)
|
||||
"HERMES_WEBUI_HOST",
|
||||
"HERMES_WEBUI_PORT",
|
||||
"HERMES_WEBUI_AGENT_DIR",
|
||||
):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
|
||||
@@ -155,6 +173,49 @@ class TestDetectSupervisor:
|
||||
assert import_bootstrap._detect_supervisor() == "HERMES_WEBUI_FOREGROUND"
|
||||
|
||||
|
||||
class TestXPCServiceNameNoiseFilter:
|
||||
"""macOS launchd sets XPC_SERVICE_NAME in EVERY Terminal-spawned shell.
|
||||
|
||||
Without filtering, every Mac dev running ``./start.sh`` would silently
|
||||
auto-promote to foreground mode and lose the /health probe + browser open
|
||||
+ bootstrap log. We narrow to launchd Label-style names (com.<rdns>.<svc>)
|
||||
while rejecting the well-known noise values.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize("noise_value", [
|
||||
"0", # launchd descendants
|
||||
"application.com.apple.Terminal.0BCDDEAD-1234-5678", # Terminal.app shells
|
||||
"application.com.googlecode.iterm2", # iTerm2
|
||||
"application.com.microsoft.VSCode", # VSCode terminal
|
||||
])
|
||||
def test_xpc_noise_values_do_not_trigger(self, import_bootstrap, clean_env, monkeypatch, noise_value):
|
||||
monkeypatch.setenv("XPC_SERVICE_NAME", noise_value)
|
||||
assert import_bootstrap._detect_supervisor() is None, (
|
||||
f"XPC_SERVICE_NAME={noise_value!r} should not trigger foreground "
|
||||
f"mode — that would break interactive ./start.sh on every Mac."
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("real_value", [
|
||||
"com.example.hermes-webui",
|
||||
"com.acme.production-server",
|
||||
"io.github.user.my-service",
|
||||
])
|
||||
def test_xpc_real_label_triggers(self, import_bootstrap, clean_env, monkeypatch, real_value):
|
||||
monkeypatch.setenv("XPC_SERVICE_NAME", real_value)
|
||||
assert import_bootstrap._detect_supervisor() == "XPC_SERVICE_NAME", (
|
||||
f"XPC_SERVICE_NAME={real_value!r} is a launchd Label and should "
|
||||
f"trigger foreground mode."
|
||||
)
|
||||
|
||||
def test_xpc_noise_does_not_block_other_supervisor_var(self, import_bootstrap, clean_env, monkeypatch):
|
||||
# If XPC has a noise value but INVOCATION_ID is set (mixed env, e.g.
|
||||
# systemd unit run on a Mac CI runner), we should still detect via
|
||||
# INVOCATION_ID rather than swallow it.
|
||||
monkeypatch.setenv("XPC_SERVICE_NAME", "0")
|
||||
monkeypatch.setenv("INVOCATION_ID", "deadbeef")
|
||||
assert import_bootstrap._detect_supervisor() == "INVOCATION_ID"
|
||||
|
||||
|
||||
# ---------- main() routing ------------------------------------------------
|
||||
|
||||
|
||||
@@ -356,3 +417,39 @@ class TestForegroundEnvAndCwd:
|
||||
# In foreground mode there's no parent left to retry from — the
|
||||
# supervisor's KeepAlive handles it. wait_for_health must not run.
|
||||
assert len(wait_calls) == 0
|
||||
|
||||
|
||||
class TestForegroundExecutabilityGuard:
|
||||
"""If python_exe is missing or non-executable, raise a clear error
|
||||
instead of letting os.execv raise OSError → SystemExit(1) → supervisor
|
||||
restart loop. This guard prevents the exact failure mode #1458 reports."""
|
||||
|
||||
@pytest.fixture
|
||||
def setup_with_bad_python(self, monkeypatch, tmp_path):
|
||||
import bootstrap as bs
|
||||
agent_dir = tmp_path / "agent"
|
||||
agent_dir.mkdir()
|
||||
# Create a non-executable file at the python path
|
||||
bad_python = tmp_path / "bad-python"
|
||||
bad_python.write_text("#!/bin/bash\necho hi", encoding="utf-8")
|
||||
bad_python.chmod(0o644) # NOT executable
|
||||
monkeypatch.setattr(bs, "ensure_supported_platform", lambda: None)
|
||||
monkeypatch.setattr(bs, "discover_agent_dir", lambda: agent_dir)
|
||||
monkeypatch.setattr(bs, "hermes_command_exists", lambda: True)
|
||||
monkeypatch.setattr(bs, "discover_launcher_python", lambda *a: str(bad_python))
|
||||
monkeypatch.setattr(bs, "ensure_python_has_webui_deps", lambda p: p)
|
||||
monkeypatch.setenv("HERMES_WEBUI_STATE_DIR", str(tmp_path / "state"))
|
||||
return bs
|
||||
|
||||
def test_non_executable_python_raises_runtime_error(self, setup_with_bad_python, monkeypatch, clean_env):
|
||||
bs = setup_with_bad_python
|
||||
monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--foreground"])
|
||||
|
||||
execv_calls = []
|
||||
monkeypatch.setattr(os, "execv", lambda *a: execv_calls.append(a))
|
||||
monkeypatch.setattr(os, "chdir", lambda p: None)
|
||||
|
||||
with pytest.raises(RuntimeError, match="not executable"):
|
||||
bs.main()
|
||||
# execv must NOT have been called when the guard fires
|
||||
assert len(execv_calls) == 0
|
||||
|
||||
Reference in New Issue
Block a user