hermes-agent/tests/hermes_cli/test_apply_profile_override.py
Wesley Simplicio a33c63b9f8 fix(profiles): honour active_profile when HERMES_HOME points to hermes root
Problem:
After `hermes profile use NAME`, the gateway (started via systemd with
HERMES_HOME=/root/.hermes hardcoded) ignores the active profile and
always runs as the Default profile.  WebUI, Telegram, and all non-CLI
platforms are affected.

Root cause:
_apply_profile_override() contained an early-return guard:

    if profile_name is None and os.environ.get("HERMES_HOME"):
        return   # trust the inherited value

The intent was to let child processes inherit their parent's profile via
HERMES_HOME without redundantly re-reading active_profile.  But
systemd also sets HERMES_HOME — to the hermes root (/root/.hermes),
not a profile directory — so the guard fired and silently skipped the
active_profile check.  The user's `hermes profile use NAME` write to
~/.hermes/active_profile was never seen by the gateway process.

Fix:
Only skip the active_profile check when HERMES_HOME is already a
profile directory, identified by its immediate parent directory being
named "profiles" (e.g. ~/.hermes/profiles/coder or
/opt/data/profiles/coder).  When HERMES_HOME points to a root
directory (parent name != "profiles"), continue to read active_profile.

Tests:
- test_hermes_home_at_root_with_active_profile_is_redirected: the
  bug scenario — HERMES_HOME=/root/.hermes + active_profile=coder →
  HERMES_HOME must be redirected to .../profiles/coder.
  Stash-verified: FAILS without fix, PASSES with fix.
- test_hermes_home_already_profile_dir_is_trusted: child-process
  inheritance contract unchanged — .../profiles/coder is trusted as-is.
- test_hermes_home_unset_reads_active_profile: classic path unchanged.
- test_hermes_home_unset_default_profile_no_redirect: "default" still
  produces no redirect.
4/4 tests green.

Closes #22502.
2026-05-09 11:10:53 -07:00

141 lines
5.1 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
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