Skip to content
Closed
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
53 changes: 53 additions & 0 deletions tests/tools/test_terminal_env_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", "")
36 changes: 36 additions & 0 deletions tests/tools/test_terminal_scope_multiplex.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
13 changes: 13 additions & 0 deletions tools/terminal_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions tui_gateway/prompt_turn.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
17 changes: 4 additions & 13 deletions tui_gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down