diff --git a/hermes_cli/env_loader.py b/hermes_cli/env_loader.py index 66441058b9..7508f53c25 100644 --- a/hermes_cli/env_loader.py +++ b/hermes_cli/env_loader.py @@ -539,10 +539,31 @@ def _load_secrets_config(home_path: Path) -> dict: def _process_hermes_home() -> Path: - """The HERMES_HOME the shared config cache is keyed to.""" - try: - from hermes_constants import get_hermes_home + """The HERMES_HOME the running process was launched under. - return get_hermes_home() + Must be the *true* process home, ignoring any context-local + ``set_hermes_home_override`` a per-request task has installed. Both + callers depend on that: + + * ``_reapply_terminal_config_bridge`` guards "only re-bridge config into + the shared ``os.environ`` when THIS load is for the process's own + profile". If this followed the task override, a per-turn handler scoped + to a *secondary* profile (the multiplex dashboard serving every profile + from one process) would satisfy the guard and bridge that profile's + ``terminal.backend`` into the shared env — e.g. a ``local`` sibling + profile clobbering the launch profile's ``TERMINAL_ENV=ssh`` while + leaving its ``TERMINAL_SSH_*`` untouched, so the session silently runs + commands locally (cross-profile terminal-backend leak). + * ``_load_secrets_config`` uses it to decide whether the shared + (mtime,size)-keyed config cache is safe to reuse; under an override it + must fall through to an isolated parse of the scoped profile. + + ``hermes_constants.get_process_hermes_home()`` is the override-immune + resolver built for exactly this; delegate to it. + """ + try: + from hermes_constants import get_process_hermes_home + + return get_process_hermes_home() except Exception: return Path.home() / ".hermes" diff --git a/tests/hermes_cli/test_terminal_bridge_profile_scope.py b/tests/hermes_cli/test_terminal_bridge_profile_scope.py new file mode 100644 index 0000000000..4dade567c7 --- /dev/null +++ b/tests/hermes_cli/test_terminal_bridge_profile_scope.py @@ -0,0 +1,93 @@ +"""Regression: the terminal config→env re-bridge is scoped to the *true* +process profile, never a per-task ``set_hermes_home_override``. + +The multiplex dashboard serves every profile on the host from one process, +so ``os.environ`` is shared across all of them. A per-turn handler scoped to +a secondary profile (via ``set_hermes_home_override``) must NOT cause that +profile's ``terminal.backend`` to be bridged into the shared environment — +otherwise a ``local`` sibling profile silently clobbers the launch profile's +``TERMINAL_ENV=ssh`` (leaving its ``TERMINAL_SSH_*`` intact), and the launch +session runs every command locally instead of over SSH. + +The guard in ``env_loader._reapply_terminal_config_bridge`` compares the +loaded home against the process home. It must use the override-immune +``get_process_hermes_home()`` so the comparison reflects the launch scope, +not whichever profile the current task is scoped to. +""" + +import os + +import pytest + +import hermes_cli.env_loader as env_loader +from hermes_constants import ( + set_hermes_home_override, + reset_hermes_home_override, +) + + +def _write_terminal_config(home, text: str) -> None: + home.mkdir(parents=True, exist_ok=True) + (home / "config.yaml").write_text(text) + + +@pytest.fixture(autouse=True) +def _clean_terminal_env(monkeypatch): + for name in ("TERMINAL_ENV", "TERMINAL_SSH_HOST", "TERMINAL_SSH_USER"): + monkeypatch.delenv(name, raising=False) + yield + + +def test_process_hermes_home_ignores_task_override(tmp_path, monkeypatch): + """The guard's home resolver must not follow a per-task override.""" + launch_home = tmp_path / "laptop" + other_home = tmp_path / "tommy" + launch_home.mkdir() + other_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(launch_home)) + + token = set_hermes_home_override(str(other_home)) + try: + assert ( + env_loader._process_hermes_home().resolve() == launch_home.resolve() + ), "process home leaked to the per-task override profile" + finally: + reset_hermes_home_override(token) + + +def test_secondary_profile_reload_does_not_bridge_into_shared_env( + tmp_path, monkeypatch +): + """A secondary profile's terminal.backend must not touch os.environ. + + Simulates the dashboard multiplex: process launched under ``laptop`` + (ssh), a per-turn handler scoped to ``tommy`` (local) triggers a dotenv + reload for tommy's home. The launch session's TERMINAL_ENV must survive. + """ + launch_home = tmp_path / "laptop" + other_home = tmp_path / "tommy" + _write_terminal_config( + launch_home, + "terminal:\n" + " backend: ssh\n" + " ssh_host: 10.10.0.103\n" + " ssh_user: bergmann\n", + ) + _write_terminal_config(other_home, "terminal:\n backend: local\n") + monkeypatch.setenv("HERMES_HOME", str(launch_home)) + + # Launch profile's backend is what the shared env carries. + monkeypatch.setenv("TERMINAL_ENV", "ssh") + monkeypatch.setenv("TERMINAL_SSH_HOST", "10.10.0.103") + + # A per-turn handler for the secondary profile is scoped via the contextvar + # and drives a reload for tommy's home. + token = set_hermes_home_override(str(other_home)) + try: + env_loader._reapply_terminal_config_bridge(other_home) + finally: + reset_hermes_home_override(token) + + # tommy's `local` must NOT have leaked into the shared process env. + assert os.environ["TERMINAL_ENV"] == "ssh" + assert os.environ["TERMINAL_SSH_HOST"] == "10.10.0.103"