diff --git a/tests/tools/test_terminal_env_bridge.py b/tests/tools/test_terminal_env_bridge.py index aa90c5759757d..92c5a88f5661e 100644 --- a/tests/tools/test_terminal_env_bridge.py +++ b/tests/tools/test_terminal_env_bridge.py @@ -152,3 +152,56 @@ def test_bridge_config_failure_does_not_crash(monkeypatch): assert config["env_type"] == "ssh" assert config["ssh_host"] == "example.test" + + +def test_secondary_home_override_does_not_latch_ambient_env(tmp_path, monkeypatch): + """#107422: first bridge under a secondary profile must not poison os.environ. + + Multiplexed dashboard sets ``set_hermes_home_override`` for profile B. If + ``_ensure_terminal_env_bridged`` ran there (no terminal scope yet), the + one-shot latch used to write B's docker policy into process-global env and + every later unscoped launch-profile tool call inherited it. + """ + from hermes_constants import reset_hermes_home_override, set_hermes_home_override + + launch_home = tmp_path / "launch" + secondary_home = tmp_path / "profiles" / "docker-bee" + launch_home.mkdir(parents=True) + secondary_home.mkdir(parents=True) + (launch_home / "config.yaml").write_text( + "terminal:\n backend: local\n", encoding="utf-8" + ) + (secondary_home / "config.yaml").write_text( + "terminal:\n" + " backend: docker\n" + " docker_image: bee/local:1\n" + ' docker_volumes:\n' + ' - /bee/vol:/data\n', + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(launch_home)) + # Clean ambient — the dashboard process starts without TERMINAL_ENV. + for name in ( + "TERMINAL_ENV", + "TERMINAL_DOCKER_IMAGE", + "TERMINAL_DOCKER_VOLUMES", + ): + monkeypatch.delenv(name, raising=False) + + token = set_hermes_home_override(str(secondary_home)) + try: + # Unscoped call under secondary home (the residual path). + terminal_tool._ensure_terminal_env_bridged() + finally: + reset_hermes_home_override(token) + + assert "TERMINAL_ENV" not in os.environ + assert "TERMINAL_DOCKER_IMAGE" not in os.environ + assert "TERMINAL_DOCKER_VOLUMES" not in os.environ + # Bridge must still be available for the real launch profile afterwards. + assert terminal_tool._terminal_config_bridge_attempted is False + + config = terminal_tool._get_env_config() + assert config["env_type"] == "local" + assert os.environ["TERMINAL_ENV"] == "local" + assert "bee/local:1" not in os.environ.get("TERMINAL_DOCKER_IMAGE", "") diff --git a/tests/tools/test_terminal_scope_multiplex.py b/tests/tools/test_terminal_scope_multiplex.py index 80e112e0202cc..1ad67eda64c34 100644 --- a/tests/tools/test_terminal_scope_multiplex.py +++ b/tests/tools/test_terminal_scope_multiplex.py @@ -240,3 +240,39 @@ def test_dotenv_json_strings_stay_json_strings(tmp_path): scope = build_profile_terminal_scope(home) assert json.loads(scope["TERMINAL_DOCKER_FORWARD_ENV"]) == ["EMAIL_HOME_ADDRESS"] assert json.loads(scope["TERMINAL_DOCKER_VOLUMES"]) == ["/tmp/a:/data"] + + +def test_launch_turn_binds_terminal_scope_once_multiplexing_is_active( + tmp_path, monkeypatch +): + """#107422: after multiplexing starts, launch turns bind the launch home's + own terminal policy (mirrors ``prompt_turn._prepare_turn_input``'s + ``elif _served_profile_homes`` branch) so poisoned ambient os.environ is + never the authority.""" + from tools.terminal_scope import ( + get_terminal_scope, + install_profile_terminal_scope, + reset_terminal_scope, + ) + + launch_home = tmp_path / ".hermes" + launch_home.mkdir() + (launch_home / "config.yaml").write_text( + "terminal:\n backend: local\n", encoding="utf-8" + ) + monkeypatch.setenv("HERMES_HOME", str(launch_home)) + # Poison ambient the way the pre-fix latch did — launch scope must win. + monkeypatch.setenv("TERMINAL_ENV", "docker") + monkeypatch.setenv("TERMINAL_DOCKER_IMAGE", "bee/img:1") + + token = install_profile_terminal_scope(launch_home) + try: + assert get_terminal_scope() is not None + assert terminal_env("TERMINAL_ENV") == "local" + # DEFAULT_CONFIG may backfill docker_image; the poisoned bee image must not win. + assert terminal_env("TERMINAL_DOCKER_IMAGE", "") != "bee/img:1" + assert os.environ["TERMINAL_ENV"] == "docker" + assert os.environ["TERMINAL_DOCKER_IMAGE"] == "bee/img:1" + finally: + reset_terminal_scope(token) + assert get_terminal_scope() is None diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 736e8e2ba0efe..46ceeb586b96d 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -540,12 +540,25 @@ def _ensure_terminal_env_bridged() -> None: suppresses the bridge entirely: writing scope values into the process-global env would re-create the first-writer-wins cross-profile leak the scope fixes. + Ambient ``os.environ`` is the *launch* profile's authority only. Under a + context-local ``HERMES_HOME`` override (multiplexed dashboard / gateway + secondary profile), this bridge is a no-op — otherwise the first unscoped + call under that override would latch the secondary profile's ``terminal.*`` + into process-global env and poison later unscoped launch-profile turns + (#107422 residual of #68559). Routed profiles must bind a terminal scope + instead (same rule as ``env_loader._reapply_terminal_config_bridge``). + terminal_tool reads ALL terminal settings from os.environ (TERMINAL_*). See #61115, #65696. """ from tools.terminal_scope import get_terminal_scope if get_terminal_scope() is not None: return + # Never write a secondary profile's terminal.* into process-global env. + from hermes_constants import get_hermes_home_override + + if get_hermes_home_override() is not None: + return global _terminal_config_bridge_attempted if _terminal_config_bridge_attempted: return diff --git a/tui_gateway/prompt_turn.py b/tui_gateway/prompt_turn.py index 95c981d3fe51a..4e29970db7b36 100644 --- a/tui_gateway/prompt_turn.py +++ b/tui_gateway/prompt_turn.py @@ -450,6 +450,13 @@ def _prepare_turn_input(sid: str, session: dict, st: _TurnRun, text: Any, images scopes.secret = set_secret_scope(build_profile_secret_scope(Path(profile_home))) from tools.terminal_scope import install_profile_terminal_scope scopes.terminal = install_profile_terminal_scope(Path(profile_home)) + elif _served_profile_homes: + # Multiplex residual of #68559 / #107422: the launch profile used to run + # unscoped and fall back to ambient os.environ. Once any secondary home + # has been served, bind the launch home's own terminal policy so a + # poisoned ambient bridge can never become the launch turn's authority. + from tools.terminal_scope import install_profile_terminal_scope + scopes.terminal = install_profile_terminal_scope(Path(_hermes_home)) # The sudo password callback is thread-local: without re-wiring here, sudo prompts # fall through to /dev/tty and hang the headless gateway (re-run is a no-op). _wire_callbacks(sid) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index e3c486b48e591..ec8cb7cf17ee7 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -497,19 +497,10 @@ def _profile_scoped(handler): Secondary-profile adapters are constructed inside ``_profile_runtime_scope`` (secret scope installed + multiplex active) — the same discriminator the Buzz/SimpleX adapters use for this bug class (#98738). - The DEFAULT profile under multiplexing runs unscoped: ``os.environ`` holds its own bridge output there - and keeps its legacy precedence. - Same discriminator as the Buzz/SimpleX/Raft adapters (#98738): secret scope installed + multiplex - active. The DEFAULT profile under multiplexing (and every single-profile process) runs unscoped and - keeps its legacy ``os.environ`` precedence. - Secondary-profile adapters are constructed, connected, and reloaded inside ``_profile_runtime_scope`` - (secret scope installed + multiplex active) — the same discriminator as the Discord adapter's - ``_profile_scoped_config_load`` (#72348). The DEFAULT profile under multiplexing runs unscoped: - ``os.environ`` holds its own bridge output there and keeps its legacy precedence. - Secondary-profile adapters are constructed, connected, and reloaded inside ``_profile_runtime_scope`` - (secret scope installed + multiplex active) — the same discriminator the Buzz/SimpleX adapters use for - this bug class (#98738). The DEFAULT profile under multiplexing runs unscoped: ``os.environ`` holds its - own bridge output there and keeps its legacy precedence. + Once multiplexing is active, launch-profile *turns* bind their own terminal scope + (``prompt_turn._prepare_turn_input``) so they never depend on ambient ``os.environ`` + that a secondary context might have poisoned (#107422). Single-profile processes stay + unscoped and keep legacy ``os.environ`` precedence. """ def wrapper(rid, params): home = _profile_home(params.get("profile") if isinstance(params, dict) else None)