From 606903badc49056f66f8006ce34e972e02a5b04b Mon Sep 17 00:00:00 2001 From: infinitycrew39 Date: Thu, 10 Sep 2026 22:09:26 +0700 Subject: [PATCH] fix(tui): bind launch-profile terminal scope once multiplexing is active After any secondary profile home is served, launch-profile turns used to stay unscoped and fall back to ambient os.environ. Bind the launch home's own terminal policy in that case so a poisoned ambient bridge can never become the launch turn's authority (#107422 residual of #68559). (cherry picked from commit f81147c1e5d283837e5e27f4da79710da7025235) --- tests/tools/test_terminal_scope_multiplex.py | 36 ++++++++++++++++++++ tui_gateway/prompt_turn.py | 7 ++++ tui_gateway/server.py | 17 +++------ 3 files changed, 47 insertions(+), 13 deletions(-) diff --git a/tests/tools/test_terminal_scope_multiplex.py b/tests/tools/test_terminal_scope_multiplex.py index 80e112e020..1ad67eda64 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/tui_gateway/prompt_turn.py b/tui_gateway/prompt_turn.py index e78a276b9d..0121c947a9 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 2d4ceee64b..8175d2d0c3 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -506,19 +506,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)