From 95715dcb03003eab086eb0494083e6dc1c65f3b3 Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Mon, 15 Jun 2026 15:36:20 +1000 Subject: [PATCH] fix(s6): reserved default gateway must not follow sticky active_profile (#46483) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The supervised `gateway-default` s6 slot runs bare `hermes gateway run` (no -p) to mean "the root HERMES_HOME profile". But `_apply_profile_override` falls through its #22502 HERMES_HOME guard for the container root (/opt/data, whose parent is not `profiles`) and reads the sticky `active_profile` file. If the user set another profile active (e.g. via the dashboard), the reserved default gateway gets redirected into that profile — producing a duplicate gateway for the active profile and no real default gateway. The profile page and `gateway status` then correctly report default as "not running" because there genuinely isn't one. Guard step 2 (the sticky active_profile fallback) with the existing HERMES_S6_SUPERVISED_CHILD sentinel that the container run-script already exports. Supervised named-profile slots pass -p explicitly (step 1, never reaches step 2); only the bare default slot was affected. Inert outside the s6 container — the sentinel is never set elsewhere. Reported in the 'Docker & Profiles & Dashboard' support thread. --- hermes_cli/main.py | 15 +++- .../hermes_cli/test_apply_profile_override.py | 83 +++++++++++++++++++ 2 files changed, 96 insertions(+), 2 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index acd82fd081..b08b6acb3f 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -452,8 +452,19 @@ def _apply_profile_override() -> None: if Path(hermes_home_env).parent.name == "profiles": return - # 2. If no flag, check active_profile in the hermes root - if profile_name is 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"): try: from hermes_constants import get_default_hermes_root diff --git a/tests/hermes_cli/test_apply_profile_override.py b/tests/hermes_cli/test_apply_profile_override.py index 0d3c2956a4..377df8079a 100644 --- a/tests/hermes_cli/test_apply_profile_override.py +++ b/tests/hermes_cli/test_apply_profile_override.py @@ -240,3 +240,86 @@ class TestApplyProfileOverrideHermesHomeGuard: assert result is not None assert result.endswith("coder") assert sys.argv == ["hermes", "--continue"] + + +class TestSupervisedChildIgnoresStickyProfile: + """The reserved default gateway s6 slot must not follow active_profile. + + Inside the Docker s6 image the ``gateway-default`` service slot runs a + bare ``hermes gateway run`` (no ``-p``) to mean "the root HERMES_HOME + profile". The run-script exports ``HERMES_S6_SUPERVISED_CHILD=1``. + Without a guard, ``_apply_profile_override`` would read the sticky + ``active_profile`` file (set by e.g. the dashboard profile switcher) and + redirect the reserved default gateway into that profile — producing a + duplicate gateway for the active profile and no real default gateway. + """ + + def test_supervised_child_does_not_follow_active_profile( + self, tmp_path, monkeypatch + ): + """HERMES_S6_SUPERVISED_CHILD + active_profile=briefer must NOT redirect. + + Reproduces the Docker/profile scoping bug: the supervised default + gateway is launched as bare ``hermes gateway run`` with + HERMES_HOME=/opt/data (the container root, whose parent is NOT + ``profiles``), and a sticky ``active_profile`` of another profile. + The reserved default slot must stay on the root profile. + """ + hermes_root = tmp_path / ".hermes" + hermes_root.mkdir(parents=True, exist_ok=True) + (hermes_root / "active_profile").write_text("briefer") + (hermes_root / "profiles" / "briefer").mkdir(parents=True, exist_ok=True) + + monkeypatch.setattr(Path, "home", lambda: tmp_path) + # Container root HERMES_HOME: parent dir is NOT "profiles", so the + # #22502 guard does not short-circuit — step 2 (active_profile) runs. + monkeypatch.setenv("HERMES_HOME", str(hermes_root)) + monkeypatch.setenv("HERMES_S6_SUPERVISED_CHILD", "1") + monkeypatch.setattr(sys, "argv", ["hermes", "gateway", "run"]) + + from hermes_cli.main import _apply_profile_override + _apply_profile_override() + + assert os.environ.get("HERMES_HOME") == str(hermes_root), ( + "Supervised default gateway must stay on the root profile, not be " + f"hijacked by active_profile; got {os.environ.get('HERMES_HOME')!r}" + ) + + def test_non_supervised_run_still_follows_active_profile( + self, tmp_path, monkeypatch + ): + """Without the sentinel, a normal `hermes gateway run` still honors + active_profile — the guard is scoped strictly to supervised children.""" + result = _run_apply_profile_override( + tmp_path, + monkeypatch, + hermes_home=None, + active_profile="briefer", + argv=["hermes", "gateway", "run"], + ) + + assert result is not None + assert result.endswith("briefer") + + def test_supervised_named_profile_flag_still_wins(self, tmp_path, monkeypatch): + """A supervised named-profile slot passes ``-p `` explicitly; + that must still resolve (the sentinel guard only skips the sticky + active_profile fallback, never an explicit flag).""" + hermes_root = tmp_path / ".hermes" + hermes_root.mkdir(parents=True, exist_ok=True) + (hermes_root / "active_profile").write_text("briefer") + (hermes_root / "profiles" / "briefer").mkdir(parents=True, exist_ok=True) + (hermes_root / "profiles" / "coder").mkdir(parents=True, exist_ok=True) + + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.delenv("HERMES_HOME", raising=False) + monkeypatch.setenv("HERMES_S6_SUPERVISED_CHILD", "1") + monkeypatch.setattr(sys, "argv", ["hermes", "-p", "coder", "gateway", "run"]) + + from hermes_cli.main import _apply_profile_override + _apply_profile_override() + + result = os.environ.get("HERMES_HOME") + assert result is not None + assert result.endswith("coder") +