677e8ed8a4
A Desktop-owned `hermes serve --isolated --ssh-session-token-file ...` child is spawned with an explicit `--profile <name>` when the connection names a remote profile, and with no flag for the remote root home. Without the flag, `_apply_profile_override` read the remote host's sticky `active_profile` file and re-homed the backend into whatever profile the user last selected on that machine's CLI. Settings then read one config.yaml while the remote gateway wrote another, so model picks and toggles "didn't stick". Treat the SSH token flag as a fixed-identity marker, the same way supervisor-launched gateway children are (#74872): a Desktop backend's profile is chosen by the client, never by the host. Live repro (before/after, temp HERMES_HOME with active_profile=foo): serve --isolated --ssh-session-token-file ... hermes_home=<root>/profiles/foo -> <root> same + --profile foo hermes_home=<root>/profiles/foo (unchanged) serve (no token file, user CLI) hermes_home=<root>/profiles/foo (unchanged)
304 lines
12 KiB
Python
304 lines
12 KiB
Python
"""Regression tests for _apply_profile_override HERMES_HOME guard (issue #22502).
|
|
|
|
When HERMES_HOME is set to the hermes root (e.g. systemd hardcodes
|
|
HERMES_HOME=/root/.hermes), _apply_profile_override must still read
|
|
active_profile and update HERMES_HOME to the profile directory.
|
|
|
|
When HERMES_HOME is already a profile directory (.../profiles/<name>),
|
|
_apply_profile_override must trust it and return without re-reading
|
|
active_profile (child-process inheritance contract).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
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, extra_env: dict[str, str] | None = None,
|
|
):
|
|
"""Run _apply_profile_override in isolation.
|
|
|
|
Returns the value of os.environ["HERMES_HOME"] after the call,
|
|
or None if unset.
|
|
"""
|
|
hermes_root = tmp_path / ".hermes"
|
|
hermes_root.mkdir(parents=True, exist_ok=True)
|
|
|
|
if active_profile is not None:
|
|
(hermes_root / "active_profile").write_text(active_profile)
|
|
|
|
if active_profile and active_profile != "default":
|
|
(hermes_root / "profiles" / active_profile).mkdir(parents=True, exist_ok=True)
|
|
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
if hermes_home is not None:
|
|
monkeypatch.setenv("HERMES_HOME", hermes_home)
|
|
else:
|
|
monkeypatch.delenv("HERMES_HOME", raising=False)
|
|
|
|
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()
|
|
|
|
return os.environ.get("HERMES_HOME")
|
|
|
|
|
|
class TestApplyProfileOverrideHermesHomeGuard:
|
|
"""Regression guard for issue #22502.
|
|
|
|
Verifies that HERMES_HOME pointing to the hermes root does NOT suppress
|
|
the active_profile check, while HERMES_HOME already pointing to a
|
|
profile directory IS trusted as-is.
|
|
"""
|
|
|
|
def test_hermes_home_at_root_with_active_profile_is_redirected(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
"""HERMES_HOME=/root/.hermes + active_profile=coder must redirect
|
|
HERMES_HOME to .../profiles/coder.
|
|
|
|
Bug scenario from #22502: systemd sets HERMES_HOME to the hermes root
|
|
and the user switches to a profile via `hermes profile use`.
|
|
Before the fix, the guard returned early and active_profile was ignored.
|
|
"""
|
|
hermes_root = tmp_path / ".hermes"
|
|
hermes_root.mkdir(parents=True, exist_ok=True)
|
|
|
|
result = _run_apply_profile_override(
|
|
tmp_path,
|
|
monkeypatch,
|
|
hermes_home=str(hermes_root),
|
|
active_profile="coder",
|
|
)
|
|
|
|
assert result is not None, "HERMES_HOME must be set after profile redirect"
|
|
assert "profiles" in result, (
|
|
f"Expected HERMES_HOME to point into profiles/ dir, got: {result!r}"
|
|
)
|
|
assert result.endswith("coder"), (
|
|
f"Expected HERMES_HOME to end with 'coder', got: {result!r}"
|
|
)
|
|
|
|
|
|
def test_sudo_explicit_profile_resolves_invoking_users_profile(self, tmp_path, monkeypatch):
|
|
"""sudo elias ... should resolve `-p elias` under SUDO_USER, not root."""
|
|
root_home = tmp_path / "root"
|
|
user_home = tmp_path / "home" / "hermes"
|
|
profile_dir = user_home / ".hermes" / "profiles" / "elias"
|
|
profile_dir.mkdir(parents=True, exist_ok=True)
|
|
(root_home / ".hermes").mkdir(parents=True, exist_ok=True)
|
|
|
|
monkeypatch.setattr(Path, "home", lambda: root_home)
|
|
monkeypatch.setenv("SUDO_USER", "hermes")
|
|
monkeypatch.delenv("HERMES_HOME", raising=False)
|
|
monkeypatch.setattr(os, "geteuid", lambda: 0, raising=False)
|
|
monkeypatch.setattr(sys, "argv", ["hermes", "-p", "elias", "gateway", "install", "--system"])
|
|
|
|
import pwd
|
|
|
|
monkeypatch.setattr(pwd, "getpwnam", lambda name: SimpleNamespace(pw_dir=str(user_home)))
|
|
|
|
from hermes_cli.main import _apply_profile_override
|
|
_apply_profile_override()
|
|
|
|
assert os.environ.get("HERMES_HOME") == str(profile_dir)
|
|
assert sys.argv == ["hermes", "gateway", "install", "--system"]
|
|
|
|
|
|
|
|
|
|
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_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")
|
|
|
|
|
|
|
|
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_desktop_ssh_serve_child_skips_active_profile(self, tmp_path, monkeypatch):
|
|
"""A Desktop-owned `serve --ssh-session-token-file` child names its profile explicitly
|
|
(or none for the root home); the remote host's sticky active_profile must not re-home
|
|
it, or Settings read one profile's config.yaml while the user edits another."""
|
|
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", "serve", "--isolated", "--host", "127.0.0.1", "--port", "0",
|
|
"--ssh-session-token-file", "/tmp/x/y.token"],
|
|
)
|
|
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
|