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