From 2e24e06e5513fa425ccf935d2e41991cb11ff383 Mon Sep 17 00:00:00 2001 From: Bergmann89 Date: Fri, 28 Aug 2026 09:03:34 +0000 Subject: [PATCH] fix(env): scope terminal config re-bridge to the true process profile In a multiplex dashboard (one process serving every profile on the host), a secondary profile's terminal.backend could leak into the shared os.environ and silently override the launch profile's terminal backend. A profile configured for terminal.backend: ssh would find itself running every command locally because a sibling profile with backend: local had polluted the shared environment. The tell is an asymmetric env state on the launch session: TERMINAL_ENV=local # leaked from a sibling profile TERMINAL_SSH_HOST=10.10.0.103 # still the launch profile's, untouched A restart does not help: as soon as the sibling profile is touched again (a turn, a cron tick), the shared env is re-poisoned. Root cause: env_loader._reapply_terminal_config_bridge() guards "only re-bridge config into the shared os.environ when this load is for the process's own profile" via _process_hermes_home(). That helper delegated to get_hermes_home(), which follows the context-local set_hermes_home_override a per-request task installs. When a per-turn handler is scoped to a secondary profile and triggers a dotenv reload, both sides of the comparison resolve to that secondary profile, the guard passes, and the bridge writes the wrong profile's terminal.backend into the shared environment. Because the secondary profile's config carries no TERMINAL_SSH_* keys, only TERMINAL_ENV is overwritten, producing the asymmetric state above. The helper was introduced for the config-cache key (where following the active home is correct) and later reused for the terminal bridge guard, where the override-following semantics are wrong. Fix: delegate _process_hermes_home() to get_process_hermes_home(), the override-immune resolver built for exactly this. It reflects the scope the process was launched under, ignoring per-task overrides. Both call sites (the bridge guard and the secrets-cache reuse check) want the true process home. Single-profile / CLI behaviour is unchanged: with no active override the two resolvers are identical. Complements the terminal_tool._active_environments session-key fix: that scopes the per-session environment cache; this keeps the shared os.environ the cache reads TERMINAL_ENV from uncontaminated in the first place. Adds tests/hermes_cli/test_terminal_bridge_profile_scope.py covering both the override-immune resolver and the no-leak reload behaviour. --- hermes_cli/env_loader.py | 29 +++++- .../test_terminal_bridge_profile_scope.py | 93 +++++++++++++++++++ 2 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 tests/hermes_cli/test_terminal_bridge_profile_scope.py 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"