fix(gateway): preserve launchd supervisor marker across stderr_timestamp wrapper

launchd only stamps XPC_SERVICE_NAME on its direct child. The timestamp
wrapper is that child, so the grandchild gateway sees XPC_SERVICE_NAME=0
and the supervised-conflict guard refuses the service's own spawn.

Forward HERMES_GATEWAY_EXTERNAL_SUPERVISOR=1 when the wrapper itself is
launchd-supervised. Interactive XPC_SERVICE_NAME=0 starts stay unmarked.

Fixes #86893
This commit is contained in:
Christopher
2026-08-15 15:09:29 +02:00
committed by Teknium
parent 730c5fc5d0
commit c69a0872ea
2 changed files with 114 additions and 1 deletions
+31 -1
View File
@@ -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=<job label>`` 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:
+83
View File
@@ -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"