fix(gateway): put --external-supervisor on launchd gateway argv

hermes update decides restart ownership from the live grandchild argv,
not from an env marker. Newly generated plists now include the flag.
The stderr_timestamp wrapper upgrades only historical Hermes gateway
run shapes for stale plists and leaves arbitrary launchd children unmarked.
This commit is contained in:
Christopher
2026-08-15 15:41:02 +02:00
committed by Teknium
parent c69a0872ea
commit 7008fb81b3
5 changed files with 213 additions and 49 deletions
+47 -21
View File
@@ -13,7 +13,7 @@ from datetime import datetime
from pathlib import Path
from typing import BinaryIO, Sequence, TextIO
from gateway.restart import EXTERNAL_GATEWAY_SUPERVISOR_ENV
EXTERNAL_SUPERVISOR_FLAG = "--external-supervisor"
_TIMESTAMP_PREFIX = re.compile(
@@ -70,26 +70,53 @@ 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.
"""
def _is_launchd_supervised(environ: Mapping[str, str] | None = None) -> bool:
"""True when this process is launchd's direct child (not an interactive shell)."""
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
return bool(xpc_service and xpc_service != "0")
def _is_hermes_gateway_run_argv(command: Sequence[str]) -> bool:
"""True for Hermes ``gateway run`` argv this wrapper is allowed to upgrade.
The wrapper is generic. Only historical/current Hermes gateway shapes
get ``--external-supervisor``; an arbitrary launchd child must not be
marked as gateway-supervised (#87005).
"""
try:
from gateway.status import looks_like_gateway_command_line
except Exception:
return False
return bool(looks_like_gateway_command_line(" ".join(str(part) for part in command)))
def _with_external_supervisor_flag(command: Sequence[str]) -> list[str]:
argv = [str(part) for part in command]
if EXTERNAL_SUPERVISOR_FLAG not in argv:
argv.append(EXTERNAL_SUPERVISOR_FLAG)
return argv
def _prepare_child_command(
command: Sequence[str],
environ: Mapping[str, str] | None = None,
) -> list[str]:
"""Return the argv to exec, upgrading stale launchd-wrapped gateway commands.
launchd stamps ``XPC_SERVICE_NAME=<job label>`` only on this wrapper.
The grandchild sees ``XPC_SERVICE_NAME=0``. Newly generated plists put
``--external-supervisor`` on the inner ``gateway run`` so ``hermes update``
can see the flag on the live process argv. Stale plists still wrap the
historical ``gateway run --replace`` shape without that flag; append it
here, and only for that shape.
"""
argv = [str(part) for part in command]
if not _is_launchd_supervised(environ):
return argv
if not _is_hermes_gateway_run_argv(argv):
return argv
return _with_external_supervisor_flag(argv)
def _parse_args(argv: Sequence[str] | None) -> argparse.Namespace:
@@ -112,9 +139,8 @@ def main(argv: Sequence[str] | None = None) -> int:
try:
proc = subprocess.Popen(
args.command,
_prepare_child_command(args.command),
stderr=subprocess.PIPE,
env=_child_env_for_command(),
)
except OSError as exc:
log_path.parent.mkdir(parents=True, exist_ok=True)