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:
Hermes Bot
2026-05-02 17:52:13 +00:00
parent f84b6a4e2f
commit 6a26e82c22
3 changed files with 179 additions and 13 deletions

View File

@@ -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])

View File

@@ -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

View File

@@ -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