tests/tui_gateway/test_multi_profile_hosting_fail_closed.py — through the registered RPC handlers: config.get for a secondary resolves only its own secrets and flips the process fail-closed; llm.oneshot / model.options bodies see the profile's home + secrets; a launch-profile agent build binds its own scope once multiplexing is active; CONTROL: a single-profile serve keeps the os.environ fall-through. tests/hermes_cli/test_web_multi_profile_scope.py — through the real FastAPI app: GET /api/config?profile=b expands only B's refs and never mutates os.environ; console `send` for B lands B's .env in the request scope, not the process env. All red on origin/main by source swap (control green), green on head. test_profile_terminal_scope_entrypoints follows the launch_profile_policy rename and releases the launch turn's new secret scope. tests/conftest.py resets the process-global hosting latch (_MULTIPLEX_ACTIVE, the frozen launch env, _served_profile_homes) per test: one request routed to a named profile otherwise left every later test in the file fail-closed.
176 lines
7.2 KiB
Python
176 lines
7.2 KiB
Python
"""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_profile_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._MULTIPLEX_ACTIVE", False)
|
|
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.secret is not None:
|
|
server.reset_secret_scope(st.scopes.secret)
|
|
if st.scopes.home is not None:
|
|
server.reset_hermes_home_override(st.scopes.home)
|
|
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.activate_multi_profile_hosting() # 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.activate_multi_profile_hosting()
|
|
# 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
|