From b87cfa55b8d932a4cdedc20cf61384ba91948135 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:47:54 -0700 Subject: [PATCH 1/3] fix(tui-gateway): secondary side workers and agent builds bind their own terminal scope Under multiplexing tools/terminal_tool.py no longer bridges a profile's terminal.* into os.environ while a home override is bound (#108440), so any secondary-profile entrypoint that binds only the home (+ secrets) leaves terminal_tool on the launch process's ambient TERMINAL_*: a secondary with `terminal.backend: docker` ran its prompt.background / prompt.btw / preview.restart side agents, its eager resume and its session.branch build on the launch `local` backend. _spawn_side_agent and _profile_build_scope now enter _session_profile_runtime_scope, the same home -> secrets -> terminal composition a prompt turn binds (and gateway/run.py::_profile_runtime_scope mirrors). The terminal scope is installed for the whole worker/build lifetime and reset on success and on exception; a malformed secondary config still yields the fail-closed refusal scope. No ambient os.environ write is restored. Refs #108440 review (andrexibiza), #107442 (ehz0ah). --- tui_gateway/methods_prompt.py | 25 +++++++++---------------- tui_gateway/methods_session.py | 17 +++++------------ 2 files changed, 14 insertions(+), 28 deletions(-) diff --git a/tui_gateway/methods_prompt.py b/tui_gateway/methods_prompt.py index bdb0a40b929f..8b59ff98aec5 100644 --- a/tui_gateway/methods_prompt.py +++ b/tui_gateway/methods_prompt.py @@ -938,24 +938,17 @@ def _spawn_side_agent( def run(): session_tokens = _set_session_context(task_id, cwd=(cwd or _session_cwd(session))) - # Bug #50233: ephemeral agent threads don't inherit the session's HERMES_HOME override (the - # ContextVar set on the session-create thread doesn't propagate here), so a background turn under a - # non-default profile would run against the wrong home. Re-bind the override for the duration of - # this turn, exactly as the normal prompt turn does, and restore it afterward. - # Bug #50233: ephemeral preview-restart agent threads don't inherit the session's HERMES_HOME - # override (the ContextVar set on the session-create thread doesn't propagate here). Re-bind it for - # the duration of the turn, mirroring the normal prompt turn, then restore it. NOTE: we deliberately - # do NOT close this agent through task-wide process cleanup — the whole point of preview.restart is - # to leave a background server running under this task_id, and AIAgent.close() would kill every - # process for the task_id and tear down the very server the restart just started. - profile_home = session.get("profile_home") - home_token = set_hermes_home_override(profile_home) if profile_home else None + # Bug #50233: ephemeral agent threads don't inherit the session's ContextVar scopes (set on the + # session-create thread), so a side turn under a non-default profile ran against the wrong home. + # Bind the profile's home + secrets + terminal policy for the whole body, exactly as a prompt turn + # does: home alone left terminal_tool on the launch process's ambient TERMINAL_* (a docker + # secondary's background/btw/preview agent ran local). NOTE: we deliberately do NOT close this + # agent through task-wide process cleanup — the whole point of preview.restart is to leave a + # background server running under this task_id, and AIAgent.close() would kill every process for + # the task_id and tear down the very server the restart just started. try: - try: + with _session_profile_runtime_scope(session): text = body() - finally: - if home_token is not None: - reset_hermes_home_override(home_token) _emit(event, parent, {"task_id": task_id, **extra, "text": text}) except Exception as e: _emit(event, parent, {"task_id": task_id, **extra, "text": f"error: {e}"}) diff --git a/tui_gateway/methods_session.py b/tui_gateway/methods_session.py index 47187312ca7e..1910bb6d9abb 100644 --- a/tui_gateway/methods_session.py +++ b/tui_gateway/methods_session.py @@ -67,19 +67,12 @@ def _new_runtime_ids(params: dict) -> tuple[str, str]: return uuid.uuid4().hex[:8], _resolve_session_source(_str_param(params, "source") or None) -@contextlib.contextmanager def _profile_build_scope(profile_home): - """Bind HERMES_HOME + secret scope for an agent build (home alone leaves get_secret() on the LAUNCH .env).""" - if not profile_home: - yield - return - home_token = set_hermes_home_override(str(profile_home)) - secret_token = set_secret_scope(build_profile_secret_scope(Path(str(profile_home)))) - try: - yield - finally: - reset_hermes_home_override(home_token) - reset_secret_scope(secret_token) + """Bind HERMES_HOME + secret + terminal scope for an agent build: the same composition a turn + binds (``_session_profile_runtime_scope``). Home alone leaves ``get_secret()`` on the LAUNCH + ``.env``; home + secrets alone leaves ``_make_agent``'s terminal probing on the launch process's + ambient ``TERMINAL_*`` (a ``terminal.backend: docker`` secondary built a ``local`` agent).""" + return _session_profile_runtime_scope({"profile_home": str(profile_home) if profile_home else None}) def _make_agent_in_context(sid: str, key: str, **kwargs): From 8c15c7f062a051b5b5995bd3cbd04cec76f568f1 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:47:54 -0700 Subject: [PATCH 2/3] fix(tui-gateway): launch-profile turns keep their env-only terminal policy once multiplexing is active MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 606903badc49 made launch-profile turns bind a file-backed terminal scope as soon as any secondary home is served, so a poisoned ambient bridge can never be the launch turn's authority. That scope is rebuilt from defaults + /.env + config.yaml only, which drops the launch process's legitimate env-only policy: TERMINAL_ENV=ssh TERMINAL_SSH_HOST=example.test with a `{}` config.yaml became backend=local, ssh_host='' the moment a second profile was served. tui_gateway/launch_terminal_policy.py freezes the process TERMINAL_* once, in _profile_home right before the first secondary home is registered as served — the last moment ambient env is provably the launch profile's own. build_profile_terminal_scope takes that snapshot as a trusted env_overlay sitting where the process env sits in the standalone bridge (explicit YAML keys still win). Launch turns overlay the snapshot; ambient os.environ is never re-read after activation, so a later secondary write is still rejected, and the scope is reset after the turn as before. Refs #108440 review (andrexibiza), #107442 (ehz0ah). --- tools/terminal_scope.py | 24 +++++++++++---- tui_gateway/launch_terminal_policy.py | 42 +++++++++++++++++++++++++++ tui_gateway/prompt_turn.py | 7 ++++- tui_gateway/server.py | 5 ++++ 4 files changed, 71 insertions(+), 7 deletions(-) create mode 100644 tui_gateway/launch_terminal_policy.py diff --git a/tools/terminal_scope.py b/tools/terminal_scope.py index 5c90b4b6f3f5..56988ad2a65d 100644 --- a/tools/terminal_scope.py +++ b/tools/terminal_scope.py @@ -86,12 +86,21 @@ def terminal_env(name: str, default: str = "") -> str: return default if value is None else str(value) -def build_profile_terminal_scope(hermes_home: "Any") -> Dict[str, str]: +def build_profile_terminal_scope( + hermes_home: "Any", *, env_overlay: Optional[Dict[str, str]] = None) -> Dict[str, str]: """Build the COMPLETE effective ``TERMINAL_*`` policy for a profile home. - Projection: ``DEFAULT_CONFIG['terminal']`` <- profile ``.env`` TERMINAL_* <- profile - ``config.yaml`` ``terminal:``. Total by construction, so a bound scope never widens back to - ambient authority. Raises :class:`TerminalPolicyUnavailable` if a present file is unreadable. + Projection: ``DEFAULT_CONFIG['terminal']`` <- profile ``.env`` TERMINAL_* <- *env_overlay* + <- profile ``config.yaml`` ``terminal:``. Total by construction, so a bound scope never + widens back to ambient authority. Raises :class:`TerminalPolicyUnavailable` if a present + file is unreadable. + + *env_overlay* is a TRUSTED ``TERMINAL_*`` mapping captured from the launch process before + multiplexing began (``tui_gateway/launch_terminal_policy.py``): the launch profile's + env-only policy (``TERMINAL_ENV=ssh`` from systemd, ``op run``, a launcher bridge) has no + file to rebuild it from, and reading live ``os.environ`` here is the leak this module + closes. It sits where the process env sits in the standalone bridge — explicit YAML keys + still win (``apply_terminal_config_to_env``). """ from hermes_cli.config import TERMINAL_CONFIG_ENV_MAP, _terminal_env_value from hermes_cli.config_defaults import DEFAULT_CONFIG @@ -124,6 +133,8 @@ def _apply(mapping: Dict[str, Any]) -> None: scope.update((k, str(v)) for k, v in load_env_file(env_path).items() if k.startswith("TERMINAL_")) + if env_overlay: + scope.update((k, str(v)) for k, v in env_overlay.items() if k.startswith("TERMINAL_")) # Read config.yaml directly, not via read_raw_config() (which collapses "missing" and # "unparseable" into {}): present-but-unparseable must fail closed. config_path = home / "config.yaml" @@ -168,10 +179,11 @@ def _resolve_scope_cwd_placeholder(scope: Dict[str, str]) -> None: scope["TERMINAL_CWD"] = resolved -def install_profile_terminal_scope(hermes_home: "Any") -> Token: +def install_profile_terminal_scope( + hermes_home: "Any", *, env_overlay: Optional[Dict[str, str]] = None) -> Token: """Build AND install a profile's policy; on failure install the refusal scope. Never raises.""" try: - return set_terminal_scope(build_profile_terminal_scope(hermes_home)) + return set_terminal_scope(build_profile_terminal_scope(hermes_home, env_overlay=env_overlay)) except TerminalPolicyUnavailable as exc: logger.warning("terminal policy unavailable: %s", exc) return _terminal_scope_var.set(TerminalPolicyRefusal(str(exc))) diff --git a/tui_gateway/launch_terminal_policy.py b/tui_gateway/launch_terminal_policy.py new file mode 100644 index 000000000000..8025388e5707 --- /dev/null +++ b/tui_gateway/launch_terminal_policy.py @@ -0,0 +1,42 @@ +"""Launch-profile ``TERMINAL_*`` snapshot for multiplexed TUI-gateway turns. + +Once this backend serves a secondary profile, launch-profile turns bind a terminal scope instead +of reading ambient ``os.environ`` (a secondary context must never become the launch turn's +authority; #107422). A scope rebuilt from ``/.env`` + ``config.yaml`` alone drops +the launch process's legitimate env-only policy — ``TERMINAL_ENV=ssh TERMINAL_SSH_HOST=...`` +injected by systemd / ``op run`` / a launcher bridge has no file to rebuild it from and silently +became ``backend=local``. The env is trusted exactly once: frozen at multiplex activation, before +any secondary code has run in this process, and never re-read from ambient state afterwards. +""" + +from __future__ import annotations + +import os +import threading +from typing import Dict, Optional + +_lock = threading.Lock() +_snapshot: Optional[Dict[str, str]] = None + + +def capture_launch_terminal_env() -> Dict[str, str]: + """Freeze the process's ``TERMINAL_*`` env; the first capture wins, later calls are no-ops. + + Called by ``server._profile_home`` immediately before the first secondary home is registered + as served — the last moment ambient env is provably the launch profile's own. + """ + global _snapshot + with _lock: + if _snapshot is None: + _snapshot = {k: v for k, v in os.environ.items() if k.startswith("TERMINAL_")} + return dict(_snapshot) + + +def launch_terminal_env() -> Dict[str, str]: + """The frozen launch ``TERMINAL_*`` overlay for a launch-profile turn's terminal scope. + + Production always captured at activation (``_profile_home`` is the only writer of + ``_served_profile_homes``); a first capture here only happens when a harness populated the + served set directly. + """ + return capture_launch_terminal_env() diff --git a/tui_gateway/prompt_turn.py b/tui_gateway/prompt_turn.py index 0121c947a96e..5270c4df8314 100644 --- a/tui_gateway/prompt_turn.py +++ b/tui_gateway/prompt_turn.py @@ -455,8 +455,13 @@ def _prepare_turn_input(sid: str, session: dict, st: _TurnRun, text: Any, images # 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. + # The launch process's env-only policy (TERMINAL_ENV=ssh from systemd / + # a launcher) has no file to rebuild it from: overlay the TERMINAL_* + # snapshot frozen at multiplex activation, never live os.environ. from tools.terminal_scope import install_profile_terminal_scope - scopes.terminal = install_profile_terminal_scope(Path(_hermes_home)) + from tui_gateway.launch_terminal_policy import launch_terminal_env + scopes.terminal = install_profile_terminal_scope( + Path(_hermes_home), env_overlay=launch_terminal_env()) # 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 089c88a29f90..ec7a7d13e1da 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -492,6 +492,11 @@ def _profile_home(profile: str | None) -> Path | None: raise FileNotFoundError(f"Profile '{name}' does not exist.") if home.resolve() == Path(_hermes_home).resolve(): return None # already the launch profile (no override needed) + if home not in _served_profile_homes: + # Last moment ambient TERMINAL_* is provably the launch profile's own: freeze it for + # launch-profile turns before any secondary code runs (tui_gateway/launch_terminal_policy.py). + from tui_gateway.launch_terminal_policy import capture_launch_terminal_env + capture_launch_terminal_env() _served_profile_homes.add(home) # the change watcher must stat every served sibling store too return home From e9ca8c92568e13a7e62a917a9aae937e3e1a6dc1 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:47:55 -0700 Subject: [PATCH 3/3] test(tui-gateway): invariants for secondary side-worker/build terminal scope and launch env-only policy Two per finding, proven red on origin/main b9271bcb34e1: secondary side worker and branch build run their own docker backend (scope reset on success and exception, os.environ untouched); launch turns keep env-only SSH policy after activation and ignore ambient TERMINAL_* written afterwards. --- ...test_profile_terminal_scope_entrypoints.py | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 tests/tui_gateway/test_profile_terminal_scope_entrypoints.py diff --git a/tests/tui_gateway/test_profile_terminal_scope_entrypoints.py b/tests/tui_gateway/test_profile_terminal_scope_entrypoints.py new file mode 100644 index 000000000000..34eaae3dad9a --- /dev/null +++ b/tests/tui_gateway/test_profile_terminal_scope_entrypoints.py @@ -0,0 +1,170 @@ +"""Terminal-scope invariants for the TUI gateway's off-turn profile entrypoints under multiplexing. + +``tools/terminal_tool.py`` no longer bridges a profile's ``terminal.*`` into ``os.environ`` under a +home override, so every secondary-profile entrypoint must bind the owning terminal scope itself: +side workers (``prompt.background`` / ``prompt.btw`` / ``preview.restart``) and agent builds +(eager resume, ``session.branch``) used to bind only the home (+ secrets) and ran a +``terminal.backend: docker`` secondary on the launch process's ``local`` backend. + +Launch-profile turns bind a scope once any secondary is served (#107422); that scope must carry +the launch process's env-only policy (``TERMINAL_ENV=ssh`` from systemd / a launcher, no file to +rebuild it from) via the snapshot frozen at activation, while a later ambient write from a +secondary context still never becomes the launch turn's authority. + +Review findings on #108440 (andrexibiza); #107442 (ehz0ah). +""" + +import os +import threading +import types +from unittest.mock import patch + +import pytest + +from tools import terminal_tool as tt +from tools.terminal_scope import get_terminal_scope +from tui_gateway import launch_terminal_policy as ltp +from tui_gateway import server + + +@pytest.fixture(autouse=True) +def _launch_local_env(monkeypatch): + """The launch process runs the local backend; secondaries must never see it.""" + monkeypatch.setenv("TERMINAL_ENV", "local") + monkeypatch.setattr(tt, "_terminal_config_bridge_attempted", False) + monkeypatch.setattr(ltp, "_snapshot", None) + monkeypatch.setattr("agent.secret_scope.build_profile_secret_scope", lambda _h: {}) + + +def _secondary(tmp_path): + home = tmp_path / "profiles" / "secondary" + home.mkdir(parents=True) + (home / "config.yaml").write_text( + "terminal:\n backend: docker\n docker_image: secondary:test\n", encoding="utf-8") + return home + + +def _observe(got): + got.update(scope_bound=get_terminal_scope() is not None, backend=tt._get_env_config()["env_type"]) + + +def _run_side_worker(session, body): + done = threading.Event() + with patch.object(server, "_emit", lambda *a, **k: done.set()): + server._spawn_side_agent(1, session, "side-task", "parent", "background.complete", body, + cwd=session["cwd"]) + assert done.wait(15), "side worker did not finish" + + +def test_secondary_side_worker_runs_its_own_terminal_backend(tmp_path): + session = {"profile_home": str(_secondary(tmp_path)), "cwd": str(tmp_path)} + got = {} + + def body(): + _observe(got) + return "done" + + _run_side_worker(session, body) + assert got == {"scope_bound": True, "backend": "docker"} + assert os.environ["TERMINAL_ENV"] == "local" # never an ambient write + assert get_terminal_scope() is None + + # A body that blows up still releases the scope (and the worker still reports). + seen = {} + + def exploding(): + _observe(seen) + raise RuntimeError("side turn blew up") + + _run_side_worker(session, exploding) + assert seen["backend"] == "docker" + assert get_terminal_scope() is None + assert os.environ["TERMINAL_ENV"] == "local" + + +def test_secondary_branch_build_runs_its_own_terminal_backend(tmp_path): + session = {"profile_home": str(_secondary(tmp_path)), "cwd": str(tmp_path)} + got = {} + + def build(*a, **k): + _observe(got) + return types.SimpleNamespace() + + with patch.object(server, "_profile_session_db", return_value=(None, False)), \ + patch.object(server, "_make_agent_in_context", build), \ + patch.object(server, "_init_session"), patch.object(server, "_transfer_db_to_agent"): + server._build_branch_agent(session, "branch-sid", "branch-key", [], "gui") + assert got == {"scope_bound": True, "backend": "docker"} + assert get_terminal_scope() is None + + def failing(*a, **k): + _observe(got) + raise RuntimeError("build blew up") + + with patch.object(server, "_make_agent_in_context", failing), pytest.raises(RuntimeError): + server._build_branch_agent(session, "branch-sid", "branch-key", [], "gui") + assert get_terminal_scope() is None + assert os.environ["TERMINAL_ENV"] == "local" + + +class _StopAfterPolicy(Exception): + pass + + +def _launch_turn_policy(launch_home): + """Run ``_prepare_turn_input`` for a launch-profile turn up to the terminal-scope bind; returns + the policy the terminal tool would use, with every bound scope released afterwards.""" + st = server._TurnRun(agent=None, one_turn_restore=None, terminal_callback=None, receipt_committed=False) + + def stop(*a): + raise _StopAfterPolicy() + + try: + with patch.object(server, "_hermes_home", launch_home), \ + patch.object(server, "_served_profile_homes", {launch_home.parent / "other"}), \ + patch.object(server, "_wire_callbacks", stop): + with pytest.raises(_StopAfterPolicy): + server._prepare_turn_input("launch-sid", {"session_key": "launch-key"}, st, "hello", []) + assert get_terminal_scope() is not None + return tt._get_env_config() + finally: + from tools.approval_context import reset_current_session_key + from tools.terminal_scope import reset_terminal_scope + if st.scopes.terminal is not None: + reset_terminal_scope(st.scopes.terminal) + if st.scopes.approval is not None: + reset_current_session_key(st.scopes.approval) + server._clear_session_context(st.scopes.session_tokens) + + +def test_launch_turn_keeps_env_only_ssh_policy_once_multiplexing_is_active(tmp_path, monkeypatch): + launch = tmp_path / "launch" + launch.mkdir() + (launch / "config.yaml").write_text("{}\n", encoding="utf-8") + monkeypatch.setenv("HERMES_HOME", str(launch)) + monkeypatch.setenv("TERMINAL_ENV", "ssh") + monkeypatch.setenv("TERMINAL_SSH_HOST", "example.test") + ltp.capture_launch_terminal_env() # multiplex activation: first secondary served + + cfg = _launch_turn_policy(launch) + assert (cfg["env_type"], cfg["ssh_host"]) == ("ssh", "example.test") + assert get_terminal_scope() is None + + +def test_launch_turn_ignores_ambient_terminal_env_written_after_activation(tmp_path, monkeypatch): + launch = tmp_path / "launch" + launch.mkdir() + (launch / "config.yaml").write_text("{}\n", encoding="utf-8") + monkeypatch.setenv("HERMES_HOME", str(launch)) + monkeypatch.setenv("TERMINAL_ENV", "ssh") + monkeypatch.setenv("TERMINAL_SSH_HOST", "example.test") + ltp.capture_launch_terminal_env() + # A secondary context later poisons the process env (the pre-#108440 latch shape). + monkeypatch.setenv("TERMINAL_ENV", "docker") + monkeypatch.setenv("TERMINAL_DOCKER_IMAGE", "bee/img:1") + + cfg = _launch_turn_policy(launch) + assert cfg["env_type"] == "ssh" + assert cfg["ssh_host"] == "example.test" + assert cfg.get("docker_image") != "bee/img:1" + assert os.environ["TERMINAL_ENV"] == "docker" # observed, never rewritten