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