fix(s6): reserved default gateway must not follow sticky active_profile (#46483)

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.
This commit is contained in:
Ben Barclay 2026-06-15 15:36:20 +10:00 committed by GitHub
parent 80f8ffc74c
commit 95715dcb03
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 96 additions and 2 deletions

View File

@ -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 <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"):
try:
from hermes_constants import get_default_hermes_root

View File

@ -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 <name>`` 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")