diff --git a/hermes_cli/stderr_timestamp.py b/hermes_cli/stderr_timestamp.py index 83e8a20ac5..c9250e6214 100644 --- a/hermes_cli/stderr_timestamp.py +++ b/hermes_cli/stderr_timestamp.py @@ -3,14 +3,18 @@ from __future__ import annotations import argparse +import os import re import signal import subprocess import sys +from collections.abc import Mapping from datetime import datetime from pathlib import Path from typing import BinaryIO, Sequence, TextIO +from gateway.restart import EXTERNAL_GATEWAY_SUPERVISOR_ENV + _TIMESTAMP_PREFIX = re.compile( r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3}(?:\s|$)" @@ -66,6 +70,28 @@ def _restore_signal_handlers(previous: dict[int, object]) -> None: signal.signal(signum, handler) +def _child_env_for_command( + environ: Mapping[str, str] | None = None, +) -> dict[str, str] | None: + """Preserve launchd supervision across this one-hop wrapper. + + launchd stamps ``XPC_SERVICE_NAME=`` only on its *direct* + child. This module is that child when the generated plist wraps + ``gateway run`` for timestamped stderr. The grandchild then sees + ``XPC_SERVICE_NAME=0`` and ``_guard_supervised_gateway_conflict`` + treats the service's own spawn as a foreign supervised gateway + (#86893). Forward the existing opt-in marker so the grandchild + still recognizes itself as the supervised process. + """ + env = os.environ if environ is None else environ + xpc_service = str(env.get("XPC_SERVICE_NAME", "")).strip() + if not xpc_service or xpc_service == "0": + return None + child_env = dict(env) + child_env[EXTERNAL_GATEWAY_SUPERVISOR_ENV] = "1" + return child_env + + def _parse_args(argv: Sequence[str] | None) -> argparse.Namespace: parser = argparse.ArgumentParser( description="Run a command and timestamp each stderr line into a log file." @@ -85,7 +111,11 @@ def main(argv: Sequence[str] | None = None) -> int: log_path: Path = args.error_log try: - proc = subprocess.Popen(args.command, stderr=subprocess.PIPE) + proc = subprocess.Popen( + args.command, + stderr=subprocess.PIPE, + env=_child_env_for_command(), + ) except OSError as exc: log_path.parent.mkdir(parents=True, exist_ok=True) with log_path.open("a", encoding="utf-8", buffering=1) as log_file: diff --git a/tests/hermes_cli/test_stderr_timestamp.py b/tests/hermes_cli/test_stderr_timestamp.py index 6bda125b8f..c73a871df9 100644 --- a/tests/hermes_cli/test_stderr_timestamp.py +++ b/tests/hermes_cli/test_stderr_timestamp.py @@ -3,6 +3,7 @@ import re import sys +from gateway.restart import EXTERNAL_GATEWAY_SUPERVISOR_ENV, is_gateway_supervisor_process from hermes_cli import stderr_timestamp @@ -34,3 +35,85 @@ def test_main_timestamps_each_stderr_line(tmp_path): assert re.fullmatch(f"{timestamp} first failure", lines[0]) assert re.fullmatch(f"{timestamp} second failure without newline", lines[1]) assert lines[2] == "2026-07-15 12:34:56,789 already timestamped" + + +def test_child_env_forwards_supervisor_marker_under_launchd(): + """launchd's XPC label on the wrapper must survive into the grandchild.""" + child_env = stderr_timestamp._child_env_for_command( + { + "PATH": "/usr/bin", + "XPC_SERVICE_NAME": "ai.hermes.gateway-butler", + } + ) + assert child_env is not None + assert child_env["PATH"] == "/usr/bin" + assert child_env[EXTERNAL_GATEWAY_SUPERVISOR_ENV] == "1" + assert is_gateway_supervisor_process(child_env) is True + + +def test_child_env_skips_interactive_xpc_zero(): + """Interactive macOS shells inherit XPC_SERVICE_NAME=0 — do not mark them.""" + assert ( + stderr_timestamp._child_env_for_command( + {"PATH": "/usr/bin", "XPC_SERVICE_NAME": "0"} + ) + is None + ) + assert stderr_timestamp._child_env_for_command({"PATH": "/usr/bin"}) is None + assert is_gateway_supervisor_process({"XPC_SERVICE_NAME": "0"}) is False + + +def test_main_forwards_supervisor_marker_to_child(tmp_path, monkeypatch): + """The wrapper hop must set HERMES_GATEWAY_EXTERNAL_SUPERVISOR in the child.""" + monkeypatch.setenv("XPC_SERVICE_NAME", "ai.hermes.gateway-butler") + monkeypatch.delenv(EXTERNAL_GATEWAY_SUPERVISOR_ENV, raising=False) + log_path = tmp_path / "gateway.error.log" + marker_path = tmp_path / "marker.txt" + code = ( + "import os\n" + f"from pathlib import Path\n" + f"Path({str(marker_path)!r}).write_text(" + f"os.environ.get({EXTERNAL_GATEWAY_SUPERVISOR_ENV!r}, ''), encoding='utf-8')\n" + ) + + rc = stderr_timestamp.main( + [ + "--error-log", + str(log_path), + "--", + sys.executable, + "-c", + code, + ] + ) + + assert rc == 0 + assert marker_path.read_text(encoding="utf-8") == "1" + + +def test_main_does_not_mark_unsupervised_child(tmp_path, monkeypatch): + """Foreground/unsupervised starts must not inherit a fabricated marker.""" + monkeypatch.setenv("XPC_SERVICE_NAME", "0") + monkeypatch.delenv(EXTERNAL_GATEWAY_SUPERVISOR_ENV, raising=False) + log_path = tmp_path / "gateway.error.log" + marker_path = tmp_path / "marker.txt" + code = ( + "import os\n" + f"from pathlib import Path\n" + f"Path({str(marker_path)!r}).write_text(" + f"os.environ.get({EXTERNAL_GATEWAY_SUPERVISOR_ENV!r}, 'unset'), encoding='utf-8')\n" + ) + + rc = stderr_timestamp.main( + [ + "--error-log", + str(log_path), + "--", + sys.executable, + "-c", + code, + ] + ) + + assert rc == 0 + assert marker_path.read_text(encoding="utf-8") == "unset"