diff --git a/hermes_cli/main.py b/hermes_cli/main.py index f728159da32..2e3ae37bb2f 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -144,11 +144,19 @@ def _apply_profile_override() -> None: profile_name = None consume = 0 - # 1.5 If HERMES_HOME is already set and no explicit flag was given, trust it. - # This lets child processes (relaunch, subprocess) inherit the parent's - # profile choice without having to pass --profile again. - if profile_name is None and os.environ.get("HERMES_HOME"): - return + # 1.5 If HERMES_HOME is already set and no explicit flag was given, trust it + # only when it already points to a specific profile directory. The + # distinguishing heuristic: a profile path has "profiles" as its immediate + # parent directory name (e.g. ~/.hermes/profiles/coder or + # /opt/data/profiles/coder). If HERMES_HOME points to the hermes root + # instead (e.g. systemd hardcodes HERMES_HOME=/root/.hermes), we must + # still read active_profile — the user may have switched profiles via + # `hermes profile use` and the gateway should honour that choice. + # See issue #22502. + hermes_home_env = os.environ.get("HERMES_HOME", "") + if profile_name is None and hermes_home_env: + 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: diff --git a/tests/hermes_cli/test_apply_profile_override.py b/tests/hermes_cli/test_apply_profile_override.py new file mode 100644 index 00000000000..c17c10c439f --- /dev/null +++ b/tests/hermes_cli/test_apply_profile_override.py @@ -0,0 +1,141 @@ +"""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/), +_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 + +import pytest + + +def _run_apply_profile_override( + tmp_path, monkeypatch, *, hermes_home: str | None, active_profile: str | None, + argv: list[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"]) + + 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_hermes_home_already_profile_dir_is_trusted(self, tmp_path, monkeypatch): + """HERMES_HOME=.../profiles/coder must not be overridden even when + active_profile says something different. + + Preserves the child-process inheritance contract: a subprocess spawned + with HERMES_HOME already set to a specific profile must stay in that + profile. + """ + hermes_root = tmp_path / ".hermes" + profile_dir = hermes_root / "profiles" / "coder" + profile_dir.mkdir(parents=True, exist_ok=True) + + (hermes_root / "active_profile").write_text("other") + + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(profile_dir)) + monkeypatch.setattr(sys, "argv", ["hermes", "gateway", "start"]) + + from hermes_cli.main import _apply_profile_override + _apply_profile_override() + + assert os.environ.get("HERMES_HOME") == str(profile_dir), ( + "HERMES_HOME must remain unchanged when already pointing to a profile dir" + ) + + def test_hermes_home_unset_reads_active_profile(self, tmp_path, monkeypatch): + """Classic case: HERMES_HOME unset + active_profile=coder must set + HERMES_HOME to the profile directory (existing behaviour must not regress). + """ + result = _run_apply_profile_override( + tmp_path, + monkeypatch, + hermes_home=None, + active_profile="coder", + ) + + assert result is not None + assert "coder" in result + + def test_hermes_home_unset_default_profile_no_redirect(self, tmp_path, monkeypatch): + """active_profile=default must not redirect HERMES_HOME.""" + hermes_root = tmp_path / ".hermes" + hermes_root.mkdir(parents=True, exist_ok=True) + + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.delenv("HERMES_HOME", raising=False) + monkeypatch.setattr(sys, "argv", ["hermes", "gateway", "start"]) + (hermes_root / "active_profile").write_text("default") + + from hermes_cli.main import _apply_profile_override + _apply_profile_override() + + assert os.environ.get("HERMES_HOME") is None