diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py
index fc6819ab44..351d03b86e 100644
--- a/hermes_cli/gateway.py
+++ b/hermes_cli/gateway.py
@@ -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:
{venv_dir}
HERMES_HOME
{hermes_home}
+ HERMES_SUPERVISED_CHILD
+ 1
LimitLoadToSessionType
diff --git a/hermes_cli/gateway_windows.py b/hermes_cli/gateway_windows.py
index de394d39ee..e9ca230f7b 100644
--- a/hermes_cli/gateway_windows.py
+++ b/hermes_cli/gateway_windows.py
@@ -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=;%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(
diff --git a/hermes_cli/main.py b/hermes_cli/main.py
index c84c38a7c9..bab6cf6f7e 100644
--- a/hermes_cli/main.py
+++ b/hermes_cli/main.py
@@ -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 `` 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 `` 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
diff --git a/hermes_cli/service_manager.py b/hermes_cli/service_manager.py
index 03ed06dfbb..a7194d99c6 100644
--- a/hermes_cli/service_manager.py
+++ b/hermes_cli/service_manager.py
@@ -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
diff --git a/tests/hermes_cli/test_apply_profile_override.py b/tests/hermes_cli/test_apply_profile_override.py
index 0eb6fc7a39..90837cf940 100644
--- a/tests/hermes_cli/test_apply_profile_override.py
+++ b/tests/hermes_cli/test_apply_profile_override.py
@@ -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 "HERMES_SUPERVISED_CHILD" in plist