fix(cli): supervised gateway launches skip the sticky active_profile redirect

Generalize the HERMES_S6_SUPERVISED_CHILD supervisor-marker mechanism so
ANY supervised gateway launch (systemd, launchd, Windows Scheduled Task,
external supervisor) skips the active_profile redirect in
_apply_profile_override(). Previously only the s6 container marker was
honored, so a systemd-launched default-profile gateway with
HERMES_HOME=<root> followed the sticky active_profile file and silently
assumed another profile's identity — logging under that profile's tree
and connecting with its Telegram bot token (double-polling a token owned
by that profile's own live gateway).

- hermes_cli/main.py: honor HERMES_SUPERVISED_CHILD (new generalized
  marker), HERMES_S6_SUPERVISED_CHILD (back-compat), INVOCATION_ID
  (systemd; gateway commands only, since it leaks into every descendant
  of systemd-launched processes), and HERMES_GATEWAY_EXTERNAL_SUPERVISOR.
- hermes_cli/gateway.py: export HERMES_SUPERVISED_CHILD=1 in generated
  systemd units (user + system) and the launchd plist.
- hermes_cli/gateway_windows.py: export it from the Scheduled-Task cmd/vbs
  launchers and the windowless respawn env overlay.
- hermes_cli/service_manager.py: export it alongside the s6 sentinel.
- tests: regression coverage for all markers + non-gateway INVOCATION_ID
  neutrality + generated-unit marker presence.

Fixes #74872
This commit is contained in:
Teknium
2026-08-31 11:50:36 -07:00
parent e0abbc4d18
commit b0acc558b1
5 changed files with 183 additions and 12 deletions
+4
View File
@@ -4073,6 +4073,7 @@ Environment="LOGNAME={username}"
Environment="PATH={sane_path}"
Environment="VIRTUAL_ENV={venv_dir}"
Environment="HERMES_HOME={hermes_home}"
Environment="HERMES_SUPERVISED_CHILD=1"
Restart=always
RestartSec=5
RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}
@@ -4111,6 +4112,7 @@ WorkingDirectory={working_dir}
Environment="PATH={sane_path}"
Environment="VIRTUAL_ENV={venv_dir}"
Environment="HERMES_HOME={hermes_home}"
Environment="HERMES_SUPERVISED_CHILD=1"
Restart=always
RestartSec=5
RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}
@@ -5397,6 +5399,8 @@ def generate_launchd_plist() -> str:
<string>{venv_dir}</string>
<key>HERMES_HOME</key>
<string>{hermes_home}</string>
<key>HERMES_SUPERVISED_CHILD</key>
<string>1</string>
</dict>
<key>LimitLoadToSessionType</key>
+3
View File
@@ -415,6 +415,7 @@ def _build_gateway_cmd_script(
lines.append(f'set "HERMES_HOME={hermes_home}"')
lines.append('set "PYTHONIOENCODING=utf-8"')
lines.append('set "HERMES_GATEWAY_DETACHED=1"')
lines.append('set "HERMES_SUPERVISED_CHILD=1"')
python_exe_path, venv_dir, extra_pythonpath = _resolve_detached_python(python_path)
# VIRTUAL_ENV lets the gateway's own python detection find the venv
# if someone imports hermes_constants-based logic during startup.
@@ -500,6 +501,7 @@ def _build_gateway_vbs_script(
f"env.Item({_quote_vbs_string('HERMES_HOME')}) = {_quote_vbs_string(hermes_home)}",
f"env.Item({_quote_vbs_string('PYTHONIOENCODING')}) = {_quote_vbs_string('utf-8')}",
f"env.Item({_quote_vbs_string('HERMES_GATEWAY_DETACHED')}) = {_quote_vbs_string('1')}",
f"env.Item({_quote_vbs_string('HERMES_SUPERVISED_CHILD')}) = {_quote_vbs_string('1')}",
f"env.Item({_quote_vbs_string('VIRTUAL_ENV')}) = {_quote_vbs_string(_preserve_hermes_home_path(venv_dir))}",
# Mirror the cmd wrapper's ``PYTHONPATH=<static>;%PYTHONPATH%``: chain onto
# whatever PYTHONPATH the task environment already carries, at runtime.
@@ -814,6 +816,7 @@ def _build_gateway_argv() -> tuple[list[str], str, dict[str, str]]:
"HERMES_HOME": hermes_home,
"PYTHONIOENCODING": "utf-8",
"HERMES_GATEWAY_DETACHED": "1",
"HERMES_SUPERVISED_CHILD": "1",
"VIRTUAL_ENV": _preserve_hermes_home_path(venv_dir),
}
_prepend_pythonpath(
+49 -11
View File
@@ -633,17 +633,55 @@ def _apply_profile_override() -> None:
# 2. If no flag, check active_profile in the hermes root.
#
# EXCEPTION: a supervised s6 gateway child (exported by the container
# run-script as HERMES_S6_SUPERVISED_CHILD=1) must NOT follow the sticky
# active_profile. Each supervised slot has a fixed profile identity: named
# slots pass ``-p <name>`` explicitly (handled in step 1 above), and the
# reserved ``gateway-default`` slot runs bare ``hermes gateway run`` to mean
# "the root HERMES_HOME profile". If the reserved default child read
# active_profile here, switching the active profile (e.g. via the dashboard)
# would silently redirect the default gateway into that profile — yielding a
# duplicate gateway for the active profile and no real default gateway. See
# the "Docker & Profiles & Dashboard" report.
if profile_name is None and not os.environ.get("HERMES_S6_SUPERVISED_CHILD"):
# EXCEPTION: a supervisor-launched gateway child must NOT follow the
# sticky active_profile. Each supervised slot has a fixed profile
# identity: named slots pass ``-p <name>`` explicitly (handled in step 1
# above) or pin ``HERMES_HOME`` to the profile directory (step 1.5), and
# a bare invocation means "the root HERMES_HOME profile". If a supervised
# default-profile child read active_profile here, switching the active
# profile (e.g. via the dashboard or ``hermes profile use``) would
# silently redirect the default gateway into that profile — the default
# gateway then assumes the other profile's identity/credentials (logs
# under the other profile's tree, connects with its Telegram bot token)
# and double-polls a token already owned by that profile's own gateway.
# See issue #74872 and the "Docker & Profiles & Dashboard" report.
#
# Supervisor markers honored (see gateway/restart.py
# ``is_gateway_supervisor_process`` for the sibling detection used by
# restart routing):
# - HERMES_SUPERVISED_CHILD: generalized marker exported by the
# generated systemd unit, launchd plist, and Windows Scheduled-Task
# launchers (#74872).
# - HERMES_S6_SUPERVISED_CHILD: legacy s6 container marker (back-compat;
# exported by S6ServiceManager's run-script).
# - INVOCATION_ID: set by systemd for service children only (never in
# interactive shells) — covers already-installed gateway units that
# predate the HERMES_SUPERVISED_CHILD marker. Consulted ONLY for
# gateway commands: INVOCATION_ID is inherited by every descendant of
# a systemd-launched process (self-hosted CI runners, user services
# running unrelated hermes commands), so honoring it globally would
# silently disable the sticky active_profile for those.
# - HERMES_GATEWAY_EXTERNAL_SUPERVISOR: explicit external-supervisor
# opt-in (``hermes gateway run --external-supervisor``).
#
# XPC_SERVICE_NAME is deliberately NOT consulted here: interactive macOS
# terminals set it too, and a false positive would silently break the
# sticky active_profile for every interactive command.
def _under_gateway_supervisor() -> bool:
if os.environ.get("HERMES_SUPERVISED_CHILD"):
return True
if os.environ.get("HERMES_S6_SUPERVISED_CHILD"):
return True
is_gateway_cmd = next(
(a for a in argv if not a.startswith("-")), None
) == "gateway"
if is_gateway_cmd and os.environ.get("INVOCATION_ID"):
return True
return os.environ.get(
"HERMES_GATEWAY_EXTERNAL_SUPERVISOR", ""
).strip().lower() in {"1", "true", "yes", "on"}
if profile_name is None and not _under_gateway_supervisor():
try:
from hermes_constants import get_default_hermes_root
+4
View File
@@ -684,6 +684,10 @@ class S6ServiceManager:
# start`, etc. See `_gateway_command_inner` for the matching
# guard.
lines.append("export HERMES_S6_SUPERVISED_CHILD=1")
# Generalized supervisor marker (#74872) — same meaning for the
# profile-redirect guard in hermes_cli.main._apply_profile_override,
# kept alongside the s6-specific sentinel for back-compat.
lines.append("export HERMES_SUPERVISED_CHILD=1")
# ``--replace`` makes the supervised gateway authoritative for its
# profile's HERMES_HOME. Without it, a gateway started OUTSIDE s6
# (a stray ``hermes gateway run`` from a shell, an agent action, or
+123 -1
View File
@@ -19,7 +19,7 @@ from types import SimpleNamespace
def _run_apply_profile_override(
tmp_path, monkeypatch, *, hermes_home: str | None, active_profile: str | None,
argv: list[str] | None = None,
argv: list[str] | None = None, extra_env: dict[str, str] | None = None,
):
"""Run _apply_profile_override in isolation.
@@ -43,6 +43,19 @@ def _run_apply_profile_override(
monkeypatch.setattr(sys, "argv", argv or ["hermes", "gateway", "start"])
# Scrub supervisor markers the host environment may carry (systemd-run
# CI runners export INVOCATION_ID) so each test controls them explicitly.
for var in (
"HERMES_SUPERVISED_CHILD",
"HERMES_S6_SUPERVISED_CHILD",
"INVOCATION_ID",
"HERMES_GATEWAY_EXTERNAL_SUPERVISOR",
):
monkeypatch.delenv(var, raising=False)
for key, value in (extra_env or {}).items():
monkeypatch.setenv(key, value)
from hermes_cli.main import _apply_profile_override
_apply_profile_override()
@@ -164,3 +177,112 @@ class TestSupervisedChildIgnoresStickyProfile:
assert result is not None
assert result.endswith("coder")
class TestGeneralizedSupervisorMarkers:
"""Regression tests for issue #74872.
A systemd/launchd/Scheduled-Task supervised gateway launch pins its
profile identity via the unit's HERMES_HOME (root home for the default
profile). It must NEVER follow the sticky ``active_profile`` file —
otherwise the default-profile gateway silently assumes another profile's
identity (logs + Telegram bot token) and double-polls that profile's
token. Markers: HERMES_SUPERVISED_CHILD (generalized, exported by
generated units), INVOCATION_ID (systemd, gateway commands only), and
HERMES_GATEWAY_EXTERNAL_SUPERVISOR (explicit opt-in).
"""
def _root_home(self, tmp_path):
hermes_root = tmp_path / ".hermes"
hermes_root.mkdir(parents=True, exist_ok=True)
return hermes_root
def test_supervised_child_marker_skips_active_profile(
self, tmp_path, monkeypatch
):
"""HERMES_SUPERVISED_CHILD=1 + root HERMES_HOME must keep the
default profile's home even when active_profile names another
profile (the #74872 identity-assumption vector)."""
hermes_root = self._root_home(tmp_path)
result = _run_apply_profile_override(
tmp_path,
monkeypatch,
hermes_home=str(hermes_root),
active_profile="telegram_nick",
argv=["hermes", "gateway", "run"],
extra_env={"HERMES_SUPERVISED_CHILD": "1"},
)
assert result == str(hermes_root), (
f"supervised default gateway was redirected to {result!r}"
)
def test_systemd_invocation_id_skips_active_profile_for_gateway(
self, tmp_path, monkeypatch
):
"""INVOCATION_ID (systemd service child) must suppress the sticky
redirect for gateway commands — covers units installed before the
HERMES_SUPERVISED_CHILD marker existed."""
hermes_root = self._root_home(tmp_path)
result = _run_apply_profile_override(
tmp_path,
monkeypatch,
hermes_home=str(hermes_root),
active_profile="telegram_nick",
argv=["hermes", "gateway", "run"],
extra_env={"INVOCATION_ID": "deadbeef" * 4},
)
assert result == str(hermes_root)
def test_invocation_id_does_not_affect_non_gateway_commands(
self, tmp_path, monkeypatch
):
"""INVOCATION_ID leaks into every descendant of a systemd-launched
process (CI runners, user services). Non-gateway commands must keep
honoring the sticky active_profile."""
hermes_root = self._root_home(tmp_path)
result = _run_apply_profile_override(
tmp_path,
monkeypatch,
hermes_home=str(hermes_root),
active_profile="coder",
argv=["hermes", "chat"],
extra_env={"INVOCATION_ID": "deadbeef" * 4},
)
assert result is not None
assert result.endswith("coder")
def test_external_supervisor_marker_skips_active_profile(
self, tmp_path, monkeypatch
):
hermes_root = self._root_home(tmp_path)
result = _run_apply_profile_override(
tmp_path,
monkeypatch,
hermes_home=str(hermes_root),
active_profile="telegram_nick",
argv=["hermes", "gateway", "run"],
extra_env={"HERMES_GATEWAY_EXTERNAL_SUPERVISOR": "1"},
)
assert result == str(hermes_root)
def test_generated_systemd_unit_exports_supervised_marker(
self, tmp_path, monkeypatch
):
"""The generated systemd unit must carry the marker so fresh installs
are protected without relying on the INVOCATION_ID heuristic."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
(tmp_path / "home").mkdir()
from hermes_cli.gateway import generate_systemd_unit
unit = generate_systemd_unit()
assert 'Environment="HERMES_SUPERVISED_CHILD=1"' in unit
def test_generated_launchd_plist_exports_supervised_marker(
self, tmp_path, monkeypatch
):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
(tmp_path / "home").mkdir()
from hermes_cli.gateway import generate_launchd_plist
plist = generate_launchd_plist()
assert "<key>HERMES_SUPERVISED_CHILD</key>" in plist