Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 170 additions & 0 deletions tests/tui_gateway/test_profile_terminal_scope_entrypoints.py
Original file line number Diff line number Diff line change
@@ -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
24 changes: 18 additions & 6 deletions tools/terminal_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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)))
Expand Down
42 changes: 42 additions & 0 deletions tui_gateway/launch_terminal_policy.py
Original file line number Diff line number Diff line change
@@ -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 ``<launch home>/.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()
25 changes: 9 additions & 16 deletions tui_gateway/methods_prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"})
Expand Down
17 changes: 5 additions & 12 deletions tui_gateway/methods_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
7 changes: 6 additions & 1 deletion tui_gateway/prompt_turn.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions tui_gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading