289 lines
11 KiB
Python
289 lines
11 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_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
|