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:
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user