From 80bff8a74fdcaba94c3849b565956dd1cf19c30d Mon Sep 17 00:00:00 2001 From: muhifni Date: Mon, 31 Aug 2026 10:53:22 +0700 Subject: [PATCH 1/4] fix(terminal): scope terminal config per turn under profile multiplexing A multiplexed Hermes process (gateway.multiplex_profiles, unified dashboard/TUI, or cron) serves several profiles at once, but terminal.* resolved through process-global TERMINAL_* env vars bridged ONCE at startup from the launch profile (gateway/run.py ~2700-2760) plus the one-shot _ensure_terminal_env_bridged() guard. Every routed profile therefore inherited the launch profile's backend, cwd, docker volumes, SSH target and shared-container key: a local profile ran inside another profile's docker sandbox (or a docker profile escaped to the host), and a container labeled profile A carried profile B's RW bind mounts. Fix: an authoritative per-profile terminal policy seam, mirroring agent/secret_scope.py: - tools/terminal_scope.py: ContextVar holding the routed profile's COMPLETE effective TERMINAL_* policy (defined defaults <- profile .env TERMINAL_* <- config.yaml terminal:). While bound, terminal_env() resolves ONLY from it - an omitted key yields the defined default, never os.environ. Unreadable/malformed policy installs a refusal scope; terminal_tool / execute_code refuse instead of running under ambient launch-process policy (fail closed). - Installed at every in-process profile boundary: gateway _profile_runtime_scope, tui_gateway session/build/turn scopes, cron per-job fire. The unscoped single-process path is byte-identical. - Every terminal.* consumer reads through the scope: terminal_tool (_get_env_config, _resolve_container_task_id shared key, orphan reaper lifetime, degraded mode), gateway/platforms/base.py docker media translation (volumes, shared key, persistence), runtime_cwd / agent_init / skill_utils / code_execution_tool / file_tools cwd anchors, prompt_builder / browser_tool / env_probe backend checks, gateway footer, @-refs and slash-command cwd. env_probe resolves the backend in the caller's context, since the probe worker thread does not inherit the ContextVar. Salvage of #99225 onto current main: adds the three ambient reads the PR missed (tools/file_tools.py TERMINAL_CWD, tools/browser_tool.py and tools/env_probe.py TERMINAL_ENV; shape from #79117) and trims the test module to the leak matrix driven through the real gateway boundary, omitted-key defaults, refusal, and boundary reset. Fixes #68559 Fixes #94200 Fixes #101132 Fixes #95470 Co-authored-by: x7peeps <9640837+x7peeps@users.noreply.github.com> Co-authored-by: Eva <239388517+100yenadmin@users.noreply.github.com> Co-authored-by: ExitMaster <292490062+ExitMaster@users.noreply.github.com> --- agent/agent_init.py | 4 +- agent/prompt_builder.py | 22 +- agent/runtime_cwd.py | 29 +- agent/skill_utils.py | 4 +- cron/scheduler.py | 20 ++ gateway/platforms/base.py | 39 ++- gateway/run.py | 40 ++- gateway/runtime_footer.py | 8 +- gateway/slash_commands.py | 8 +- tests/tools/test_terminal_scope_multiplex.py | 184 ++++++++++++ tools/browser_tool.py | 6 +- tools/code_execution_tool.py | 19 +- tools/env_probe.py | 26 +- tools/file_tools.py | 6 +- tools/terminal_scope.py | 298 +++++++++++++++++++ tools/terminal_tool.py | 123 +++++--- tui_gateway/server.py | 52 ++++ 17 files changed, 810 insertions(+), 78 deletions(-) create mode 100644 tests/tools/test_terminal_scope_multiplex.py create mode 100644 tools/terminal_scope.py diff --git a/agent/agent_init.py b/agent/agent_init.py index 94ab40b87629e..ea46efd1e2f3f 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -3028,8 +3028,10 @@ def _parse_prune_int(raw, default): except Exception as _ce_err: _ra().logger.debug("Context engine on_session_start: %s", _ce_err) + from agent.runtime_cwd import scope_terminal_cwd as _scope_terminal_cwd + agent._subdirectory_hints = SubdirectoryHintTracker( - working_dir=os.getenv("TERMINAL_CWD") or None, + working_dir=_scope_terminal_cwd() or None, ) agent._user_turn_count = 0 # Copilot x-initiator flag: first API call of a user turn sends "user" (#3040). diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index d636e78be8514..508c118a955dd 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -1173,6 +1173,24 @@ def _windows_marketing_version() -> str: ) +def _tenv_read(name: str, default: str = "") -> str: + """Scope-aware TERMINAL_* read (tools.terminal_scope.terminal_env). + + The per-turn terminal scope installed by the multiplexing gateway carries + the active profile's terminal settings; a raw os.getenv would read a value + a previous profile's turn pinned into the process env. + + Only an import failure falls back: an active refusal scope must raise — + swapping it for the ambient process value would defeat the fail-closed + boundary. + """ + try: + from tools.terminal_scope import terminal_env + except ImportError: + return os.getenv(name, default) + return terminal_env(name, default) + + def _probe_remote_backend(env_type: str) -> str | None: """Run a tiny introspection command inside the active terminal backend. @@ -1181,7 +1199,7 @@ def _probe_remote_backend(env_type: str) -> str | None: per process. Used only for non-local backends where the agent's tools operate on a different machine than the host Hermes runs on. """ - cwd_hint = os.getenv("TERMINAL_CWD", "") + cwd_hint = _tenv_read("TERMINAL_CWD", "") cache_key = (env_type, cwd_hint) cached = _BACKEND_PROBE_CACHE.get(cache_key) if cached is not None: @@ -1330,7 +1348,7 @@ def build_environment_hints() -> str: hints: list[str] = [] - backend = (os.getenv("TERMINAL_ENV") or "local").strip().lower() + backend = (_tenv_read("TERMINAL_ENV") or "local").strip().lower() is_remote_backend = backend in _REMOTE_TERMINAL_BACKENDS or _plugin_backend_is_remote(backend) if not is_remote_backend: diff --git a/agent/runtime_cwd.py b/agent/runtime_cwd.py index 712e38ed137ef..bcd776e65bf28 100644 --- a/agent/runtime_cwd.py +++ b/agent/runtime_cwd.py @@ -57,6 +57,31 @@ def _session_cwd_override() -> str: return str(value).strip() +def _terminal_cwd_env() -> str: + """Scope-aware TERMINAL_CWD read (tools.terminal_scope.terminal_env). + + Under gateway multiplexing the per-turn terminal scope carries the active + profile's cwd; the process-global env var may hold another profile's + value. Only an import failure falls back: an active refusal scope must + raise, not silently resolve the launch profile's cwd. + """ + try: + from tools.terminal_scope import terminal_env + except ImportError: + return os.environ.get("TERMINAL_CWD", "") + return terminal_env("TERMINAL_CWD", "") + + +def scope_terminal_cwd() -> str: + """Public wrapper — the scope-aware TERMINAL_CWD value (may be empty). + + Shared by agent_init / skill_utils / code_execution_tool so every cwd + consumer reads through the per-turn terminal scope under gateway + multiplexing instead of the process-global env var. + """ + return _terminal_cwd_env() + + def resolve_agent_cwd() -> Path: override = _session_cwd_override() if override: @@ -64,7 +89,7 @@ def resolve_agent_cwd() -> Path: if p.is_dir(): return p logger.warning("configured working directory does not exist: %s", override) - raw = os.environ.get("TERMINAL_CWD", "").strip() + raw = _terminal_cwd_env().strip() if raw: p = Path(raw).expanduser() if p.is_dir(): @@ -90,7 +115,7 @@ def resolve_context_cwd() -> Path | None: else: return p return None - raw = os.environ.get("TERMINAL_CWD", "").strip() + raw = _terminal_cwd_env().strip() if raw: p = Path(raw).expanduser() if not p.is_dir(): diff --git a/agent/skill_utils.py b/agent/skill_utils.py index 47837d14e4e28..19b8f0cc932f3 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -755,7 +755,9 @@ def find_project_root(start: Optional[Path] = None) -> Optional[Path]: """ try: if start is None: - env_cwd = os.environ.get("TERMINAL_CWD") + from agent.runtime_cwd import scope_terminal_cwd + + env_cwd = scope_terminal_cwd() start = Path(env_cwd) if env_cwd else Path.cwd() cur = Path(start).resolve() except OSError: diff --git a/cron/scheduler.py b/cron/scheduler.py index 68a33d50d5393..d356f8150641e 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -7394,6 +7394,22 @@ def _fire_claim_ownership_lost() -> bool: _scope_token = set_secret_scope( build_profile_secret_scope(_get_hermes_home()) ) + # Same isolation for terminal settings (third profile seam; see + # gateway/run.py _profile_runtime_scope): installs the firing + # profile's COMPLETE terminal policy for this fire — run, delivery, + # and bookkeeping — resetting in this function's finally alongside + # the secret scope. Without it the ticker thread reads the + # process-global TERMINAL_* env vars a concurrent profile's turn may + # have pinned (#68559). Resolution failure installs a refusal scope: + # terminal execution inside the fire raises instead of falling back + # to the launch process's ambient policy. + from tools.terminal_scope import ( + install_profile_terminal_scope, + ) + + _terminal_scope_token = install_profile_terminal_scope( + _get_hermes_home() + ) # Defer the cron agent's async-resource teardown until AFTER delivery. # run_job normally closes the agent (and reaps stale async clients) in # its finally block; doing that before _deliver_result runs means the @@ -7827,6 +7843,10 @@ def _fire_claim_ownership_lost() -> bool: # _deliver_result unscoped — do not move it back in a tidy-up. if _scope_token is not None: reset_secret_scope(_scope_token) + if _terminal_scope_token is not None: + from tools.terminal_scope import reset_terminal_scope + + reset_terminal_scope(_terminal_scope_token) def _notify_provider_jobs_changed() -> None: diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 5cc06ccd2fbbc..ddbd836040f27 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -1549,6 +1549,25 @@ def _path_is_within(path: Path, root: Path) -> bool: return False +def _tenv(name: str, default: str = "") -> str: + """Scope-aware TERMINAL_* read (tools.terminal_scope.terminal_env). + + Media-path translation runs in the gateway process concurrently for + several profiles; the per-turn terminal scope carries the ACTIVE + profile's terminal settings, while a raw os.getenv would read whatever + profile's config a previous turn pinned into the process env. + + Only an import failure falls back: an active refusal scope must raise — + reconstructing mounts/backends from ambient env under refusal would + rebuild another profile's terminal policy. + """ + try: + from tools.terminal_scope import terminal_env + except ImportError: + return os.getenv(name, default) + return terminal_env(name, default) + + def _parse_docker_volume_mounts() -> List[Tuple[Path, Path]]: """Parse configured Docker volume mounts into ``(host_path, container_path)``. @@ -1557,7 +1576,7 @@ def _parse_docker_volume_mounts() -> List[Tuple[Path, Path]]: Named volumes and non-absolute hosts are skipped because they cannot be resolved on the gateway host for media delivery. """ - raw = os.getenv("TERMINAL_DOCKER_VOLUMES", "").strip() + raw = _tenv("TERMINAL_DOCKER_VOLUMES", "").strip() if not raw: return [] try: @@ -1625,7 +1644,7 @@ def _docker_sandbox_dir_candidates(session_key: str = "") -> List[str]: except Exception: return ["default"] # Explicit trusted-profiles opt-in: one shared container identity. - shared = os.getenv("TERMINAL_DOCKER_SHARED_CONTAINER_KEY", "").strip() + shared = _tenv("TERMINAL_DOCKER_SHARED_CONTAINER_KEY", "").strip() if shared: candidates.append(sanitize_task_id_for_path(f"shared:{shared}")) try: @@ -1651,9 +1670,9 @@ def _default_docker_workspace_host_roots(session_key: str = "") -> List[Path]: actually resolves — the profile sandbox dir existing does not mean the file lives there when it was produced in a legacy per-session container. """ - if os.getenv("TERMINAL_ENV", "").strip().lower() != "docker": + if _tenv("TERMINAL_ENV", "").strip().lower() != "docker": return [] - if os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").strip().lower() not in { + if _tenv("TERMINAL_CONTAINER_PERSISTENT", "true").strip().lower() not in { "1", "true", "yes", @@ -1661,13 +1680,13 @@ def _default_docker_workspace_host_roots(session_key: str = "") -> List[Path]: }: return [] # Explicit cwd mount takes over /workspace when enabled. - if os.getenv("TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "false").strip().lower() in { + if _tenv("TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "false").strip().lower() in { "1", "true", "yes", "on", }: - cwd = os.getenv("TERMINAL_CWD") or os.getcwd() + cwd = _tenv("TERMINAL_CWD") or os.getcwd() try: host = Path(os.path.expanduser(cwd)).resolve(strict=False) except (OSError, RuntimeError, ValueError): @@ -1695,9 +1714,9 @@ def _docker_persistent_home_host_roots(session_key: str = "") -> List[Path]: produced a real host file the gateway couldn't find. Ordered best-first: the profile-scoped layout, then the legacy bug-window per-session layout. """ - if os.getenv("TERMINAL_ENV", "").strip().lower() != "docker": + if _tenv("TERMINAL_ENV", "").strip().lower() != "docker": return [] - if os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").strip().lower() not in { + if _tenv("TERMINAL_CONTAINER_PERSISTENT", "true").strip().lower() not in { "1", "true", "yes", @@ -1727,7 +1746,7 @@ def _cache_dir_container_mounts() -> List[Tuple[Path, Path]]: longer prefixes than the ``/root`` home mount, so longest-prefix matching picks the cache translation over the home translation for them. """ - if os.getenv("TERMINAL_ENV", "").strip().lower() != "docker": + if _tenv("TERMINAL_ENV", "").strip().lower() != "docker": return [] try: from tools.credential_files import get_cache_directory_mounts @@ -1748,7 +1767,7 @@ def _warn_unresolved_docker_media(candidate: Path, session_key: str, reason: str file seemingly vanished. Point at the sandbox/session mismatch instead. Gated to Docker mode so host-path rejections stay quiet. """ - if os.getenv("TERMINAL_ENV", "").strip().lower() != "docker": + if _tenv("TERMINAL_ENV", "").strip().lower() != "docker": return logger.warning( "Docker MEDIA path %s did not resolve to a host sandbox file (%s%s); " diff --git a/gateway/run.py b/gateway/run.py index 3f2b91d0c7e4d..f725118092173 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2546,6 +2546,19 @@ async def _reclaim_stale(runner: object) -> None: ) +def _terminal_scope_cwd(default: str = "") -> str: + """Scope-aware TERMINAL_CWD read for footer/context surfaces. + + Only an import failure falls back: an active refusal scope must raise, + not resolve the launch profile's cwd. + """ + try: + from tools.terminal_scope import terminal_env as _ts_env + except ImportError: + return os.environ.get("TERMINAL_CWD", default) + return _ts_env("TERMINAL_CWD", default) + + @_contextmanager def _profile_runtime_scope(profile_home: "Path"): """Scope config/skills/memory AND credentials to a profile for one turn. @@ -2576,11 +2589,19 @@ def _profile_runtime_scope(profile_home: "Path"): home_token = set_hermes_home_override(str(profile_home)) hydrate_profile_secret_sources(Path(profile_home)) secret_token = set_secret_scope(build_profile_secret_scope(Path(profile_home))) - try: - yield - finally: - reset_secret_scope(secret_token) - reset_hermes_home_override(home_token) + # Per-turn terminal scope (third seam of the profile boundary): installs + # the routed profile's COMPLETE terminal policy — never ambient env — via + # tools.terminal_scope. Without it terminal_tool reads the process-global + # TERMINAL_* vars a previous profile's turn may have pinned + # (first-writer-wins backend leak; #68559). + from tools.terminal_scope import install_and_reset_profile_terminal_scope + + with install_and_reset_profile_terminal_scope(Path(profile_home)): + try: + yield + finally: + reset_secret_scope(secret_token) + reset_hermes_home_override(home_token) def load_gateway_config_for_runner() -> "GatewayConfig": @@ -20253,7 +20274,12 @@ async def _prepare_inbound_message_text( from agent.context_references import preprocess_context_references_async from agent.model_metadata import get_model_context_length_async - _msg_cwd = os.environ.get("TERMINAL_CWD", os.path.expanduser("~")) + try: + from tools.terminal_scope import terminal_env as _ts_env + except ImportError: + _msg_cwd = os.environ.get("TERMINAL_CWD", os.path.expanduser("~")) + else: + _msg_cwd = _ts_env("TERMINAL_CWD", os.path.expanduser("~")) _msg_config_ctx = None _msg_cfg = None _msg_model_cfg = {} @@ -22847,7 +22873,7 @@ def _hyg_adopt_or_space_retry( model=agent_result.get("model"), context_tokens=agent_result.get("last_prompt_tokens", 0) or 0, context_length=agent_result.get("context_length") or None, - cwd=os.environ.get("TERMINAL_CWD", ""), + cwd=_terminal_scope_cwd(""), turn_seconds=_turn_seconds, ) except Exception as _footer_err: diff --git a/gateway/runtime_footer.py b/gateway/runtime_footer.py index 8719524d5a1dd..2526bc3e10281 100644 --- a/gateway/runtime_footer.py +++ b/gateway/runtime_footer.py @@ -138,7 +138,13 @@ def format_runtime_footer( if turn_seconds is not None and turn_seconds >= 0: parts.append(_format_latency(turn_seconds)) elif field == "cwd": - rel = _home_relative_cwd(cwd or os.environ.get("TERMINAL_CWD", "")) + try: + from tools.terminal_scope import terminal_env as _tenv + except ImportError: + env_cwd = os.environ.get("TERMINAL_CWD", "") + else: + env_cwd = _tenv("TERMINAL_CWD", "") + rel = _home_relative_cwd(cwd or env_cwd) if rel: parts.append(rel) # Unknown field names are silently ignored. diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 5493e849dcb59..717b213ca519a 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -3440,7 +3440,9 @@ async def _handle_rollback_command(self, event: MessageEvent) -> str: max_file_size_mb=cp_kwargs["checkpoint_max_file_size_mb"], ) - cwd = os.getenv("TERMINAL_CWD", str(Path.home())) + from tools.terminal_scope import terminal_env as _tenv + + cwd = _tenv("TERMINAL_CWD", str(Path.home())) arg = event.get_command_args().strip() # --all / --force: classic full restore, overwriting user edits too. @@ -3534,7 +3536,9 @@ async def _handle_diff_command(self, event: MessageEvent) -> str: elif low == "session": mode = "session" - cwd = os.getenv("TERMINAL_CWD", str(Path.home())) + from tools.terminal_scope import terminal_env as _tenv + + cwd = _tenv("TERMINAL_CWD", str(Path.home())) if mode == "session": return await self._gateway_session_diff(cwd, stat_only) diff --git a/tests/tools/test_terminal_scope_multiplex.py b/tests/tools/test_terminal_scope_multiplex.py new file mode 100644 index 0000000000000..fb48010b63231 --- /dev/null +++ b/tests/tools/test_terminal_scope_multiplex.py @@ -0,0 +1,184 @@ +"""Per-turn terminal scope isolation under profile multiplexing (#68559 class). + +One multiplexed process serves several profiles, but terminal.* used to +resolve through the process-global ``TERMINAL_*`` env vars bridged once at +startup — so every routed profile inherited the launch profile's backend, +cwd, docker mounts and shared-container key (#68559, #94200, #101132, +#95470). ``tools.terminal_scope`` installs the routed profile's COMPLETE +terminal policy as a ContextVar at each profile boundary; readers resolve +ONLY from it (omitted key → defined default, never ``os.environ``) and an +unresolvable policy fails closed. +""" + +import json +import os + +import pytest + +from tools.terminal_scope import ( + TerminalPolicyRefusal, + TerminalPolicyUnavailable, + get_terminal_scope, + install_profile_terminal_scope, + reset_terminal_scope, + set_terminal_scope, + terminal_env, +) + +_LAUNCH_CWD = "/home/launch-user/private" +_LAUNCH_VOLUMES = '["/host/secret:/data:rw"]' + + +@pytest.fixture(autouse=True) +def _polluted_launch_env(monkeypatch, tmp_path): + """Launch profile A bridged a docker backend with sensitive policy into + the process env; every test proves a routed profile observes none of it.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + monkeypatch.setenv("TERMINAL_ENV", "docker") + monkeypatch.setenv("TERMINAL_CWD", _LAUNCH_CWD) + monkeypatch.setenv("TERMINAL_DOCKER_VOLUMES", _LAUNCH_VOLUMES) + monkeypatch.setenv("TERMINAL_DOCKER_SHARED_CONTAINER_KEY", "alpha-shared") + monkeypatch.setenv("TERMINAL_SSH_HOST", "10.10.0.103") + monkeypatch.setattr("agent.secret_scope.build_profile_secret_scope", lambda _h: {}) + monkeypatch.setattr("hermes_cli.env_loader.hydrate_profile_secret_sources", lambda _h: None) + import tools.terminal_tool as tt + + monkeypatch.setattr(tt, "_terminal_config_bridge_attempted", True) + yield + + +def _profile(tmp_path, name, config_yaml="", dotenv=""): + home = tmp_path / "profiles" / name + home.mkdir(parents=True) + if config_yaml: + (home / "config.yaml").write_text(config_yaml, encoding="utf-8") + if dotenv: + (home / ".env").write_text(dotenv, encoding="utf-8") + return home + + +def test_no_scope_keeps_process_env_behavior(): + """Single-process CLI/TUI (no scope bound) is byte-identical to before.""" + assert terminal_env("TERMINAL_ENV") == "docker" + assert terminal_env("TERMINAL_SSH_HOST") == "10.10.0.103" + + +def test_scoped_read_never_falls_through_to_process_env(): + """Omitted key under a scope → defined default, NOT the ambient value.""" + token = set_terminal_scope({"TERMINAL_ENV": "local"}) + try: + assert terminal_env("TERMINAL_ENV") == "local" + assert terminal_env("TERMINAL_SSH_HOST") == "" + assert terminal_env("TERMINAL_DOCKER_VOLUMES", "[]") == "[]" + assert os.environ["TERMINAL_ENV"] == "docker" # never mutated + finally: + reset_terminal_scope(token) + + +@pytest.mark.parametrize( + "config_yaml,dotenv", + [ + pytest.param("terminal:\n backend: local\n cwd: {cwd}\n", "", id="config-yaml"), + pytest.param("", "TERMINAL_ENV=local\nTERMINAL_CWD={cwd}\n", id="dotenv-only"), + ], +) +def test_routed_turn_reads_every_terminal_consumer_from_profile( + tmp_path, config_yaml, dotenv +): + """Leak matrix through the REAL gateway boundary: a routed local profile + with its own cwd must be seen as such by every terminal.* consumer — + terminal_tool config, container key resolution, docker media translation, + file_tools/runtime_cwd cwd anchors, and the browser/env_probe backend + checks — with none of launch profile A's docker policy showing through.""" + import gateway.run as gw + import tools.terminal_tool as tt + from agent import runtime_cwd + from gateway.platforms import base as gbase + from tools import browser_tool, env_probe, file_tools + + b_cwd = tmp_path / "b-work" + b_cwd.mkdir() + home = _profile( + tmp_path, "bee", + config_yaml.format(cwd=b_cwd), dotenv.format(cwd=b_cwd), + ) + + with gw._profile_runtime_scope(home): + cfg = tt._get_env_config() + assert cfg["env_type"] == "local" + assert cfg["cwd"] == str(b_cwd) + assert cfg["docker_volumes"] == [] + assert cfg["docker_shared_container_key"] == "" + assert tt._resolve_container_task_id(None) == "default" + assert gbase._parse_docker_volume_mounts() == [] + assert not any( + "alpha-shared" in c for c in gbase._docker_sandbox_dir_candidates("agent:bee:x") + ) + assert file_tools._configured_terminal_cwd() == str(b_cwd) + assert runtime_cwd.resolve_agent_cwd() == b_cwd + assert browser_tool._is_local_backend() is True + # env_probe bails out with "" for remote backends; a local profile + # must not be treated as remote just because the launch env is docker. + assert env_probe._resolve_terminal_backend() == "local" + assert get_terminal_scope() is None + # Process env untouched — the launch profile's own turns are unchanged. + assert os.environ["TERMINAL_DOCKER_VOLUMES"] == _LAUNCH_VOLUMES + + +def test_profile_omitting_keys_gets_defaults_not_launch_values(tmp_path): + """#101132/#95470: a docker profile that does NOT set docker_volumes or + docker_shared_container_key must not inherit the launch profile's.""" + import gateway.run as gw + import tools.terminal_tool as tt + + home = _profile(tmp_path, "bee", "terminal:\n backend: docker\n") + with gw._profile_runtime_scope(home): + cfg = tt._get_env_config() + assert cfg["env_type"] == "docker" + assert cfg["docker_volumes"] == [] + assert cfg["docker_shared_container_key"] == "" + assert cfg["ssh_host"] == "" + assert cfg["cwd"] != _LAUNCH_CWD + assert json.loads(os.environ["TERMINAL_DOCKER_VOLUMES"]) # A unchanged + + +def test_malformed_profile_config_refuses_execution(tmp_path): + """Unresolvable policy → refusal scope; terminal_tool refuses instead of + running under the launch process's ambient policy (fail closed).""" + from tools.terminal_tool import terminal_tool + + home = _profile(tmp_path, "broken", "terminal: [unclosed\n") + token = install_profile_terminal_scope(home) + try: + assert isinstance(get_terminal_scope(), TerminalPolicyRefusal) + with pytest.raises(TerminalPolicyUnavailable): + terminal_env("TERMINAL_ENV") + result = terminal_tool(command="whoami") + assert "terminal policy unavailable" in result + finally: + reset_terminal_scope(token) + + +def test_gateway_runtime_scope_resets_on_error(tmp_path): + import gateway.run as gw + + home = _profile(tmp_path, "qa", "terminal:\n backend: local\n") + with pytest.raises(RuntimeError): + with gw._profile_runtime_scope(home): + assert terminal_env("TERMINAL_ENV") == "local" + raise RuntimeError("turn blew up") + assert get_terminal_scope() is None + + +def test_tui_and_cron_boundaries_bind_and_reset(tmp_path): + import tui_gateway.server as server + from tools.terminal_scope import install_and_reset_profile_terminal_scope + + home = _profile(tmp_path, "dash", "terminal:\n backend: local\n") + with server._session_profile_runtime_scope({"profile_home": str(home)}): + assert terminal_env("TERMINAL_ENV") == "local" + assert terminal_env("TERMINAL_SSH_HOST") == "" + assert get_terminal_scope() is None + with install_and_reset_profile_terminal_scope(home): # cron fire helper + assert terminal_env("TERMINAL_ENV") == "local" + assert get_terminal_scope() is None diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 87337b0c19758..063a3ad128205 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -1032,7 +1032,11 @@ def _is_local_backend() -> bool: return False # When terminal runs in a container, browser on host can access # internal networks the terminal can't → treat as non-local. - terminal_backend = os.getenv("TERMINAL_ENV", "local").strip().lower() + # Scope-aware: under gateway multiplexing the routed profile's backend + # lives in the per-turn terminal scope, not the process env (#68559). + from tools.terminal_scope import terminal_env + + terminal_backend = terminal_env("TERMINAL_ENV", "local").strip().lower() return terminal_backend in ("local", "") diff --git a/tools/code_execution_tool.py b/tools/code_execution_tool.py index 44b5188c5d3e5..470f956732619 100644 --- a/tools/code_execution_tool.py +++ b/tools/code_execution_tool.py @@ -1553,6 +1553,21 @@ def execute_code( "Use normal tool calls (terminal, read_file, write_file, ...) instead." ) + # Fail closed under a terminal-policy refusal scope (#68559): the routed + # profile's terminal policy could not be resolved and execute_code runs on + # the configured terminal backend — refuse rather than inheriting the + # launch process's ambient policy. + try: + from tools.terminal_scope import enforce_no_refusal + + enforce_no_refusal() + except Exception as refusal: + return tool_error( + f"execute_code refused: {refusal} " + "(profile terminal policy unresolved; fix the profile's " + "config.yaml / .env and retry)" + ) + if not code or not code.strip(): return tool_error( "No code provided. execute_code requires a non-empty 'code' " @@ -2282,7 +2297,9 @@ def _resolve_child_cwd(mode: str, staging_dir: str, task_id: str = "") -> str: session_cwd = None if session_cwd and os.path.isdir(session_cwd): return session_cwd - raw = os.environ.get("TERMINAL_CWD", "").strip() + from agent.runtime_cwd import scope_terminal_cwd + + raw = scope_terminal_cwd().strip() if raw: expanded = os.path.expanduser(raw) if os.path.isdir(expanded): diff --git a/tools/env_probe.py b/tools/env_probe.py index 32b8f6e6b664d..d1eaf08c12dcd 100644 --- a/tools/env_probe.py +++ b/tools/env_probe.py @@ -198,18 +198,23 @@ def _pip_python_version() -> Optional[str]: return None +def _resolve_terminal_backend() -> str: + """Scope-aware terminal backend name (``local`` when unresolvable).""" + try: + from tools.terminal_scope import terminal_env + + return (terminal_env("TERMINAL_ENV") or "local").strip().lower() + except Exception: # never let policy resolution break prompt building + logger.debug("terminal backend resolution failed", exc_info=True) + return "local" + + def _build_probe_line() -> str: """Build the one-liner. Returns "" when nothing notable is detected. Emit only when SOMETHING is off — the goal is to save the model from hitting an avoidable wall, not to narrate a healthy environment. """ - # Bail out if a remote terminal backend is configured; the host's - # Python state isn't where the agent's tools run. - backend = (os.getenv("TERMINAL_ENV") or "local").strip().lower() - if backend in _REMOTE_BACKENDS or _plugin_backend_is_remote(backend): - return "" - py3_ver = _python_version_of("python3") py_ver = _python_version_of("python") # for systems with a `python` alias py3_has_pip = _has_pip_module("python3") if py3_ver else False @@ -305,6 +310,15 @@ def get_environment_probe_line(*, force_refresh: bool = False) -> str: _PROBE_GEN += 1 _WAIT_ALREADY_TIMED_OUT = False + # Resolve the backend HERE, in the caller's context: under gateway + # multiplexing the routed profile's backend lives in the per-turn terminal + # scope, which the bare probe worker thread does not inherit (#68559). A + # remote backend answers "" without consulting the cache — the cached line + # describes the HOST toolchain, not where that profile's tools run. + backend = _resolve_terminal_backend() + if backend in _REMOTE_BACKENDS or _plugin_backend_is_remote(backend): + return "" + if _PROBE_DONE.is_set(): return _CACHED_LINE or "" diff --git a/tools/file_tools.py b/tools/file_tools.py index ddcb06030170a..a2a55236f314d 100644 --- a/tools/file_tools.py +++ b/tools/file_tools.py @@ -256,7 +256,11 @@ def _configured_terminal_cwd() -> str | None: relative to, which is exactly the ambiguity that misroutes worktree edits. Only an absolute, sentinel-free value is honored. """ - return _sentinel_free_abs_cwd(os.environ.get("TERMINAL_CWD")) + # Scope-aware: under gateway multiplexing the routed profile's cwd lives in + # the per-turn terminal scope, not the process env (#68559). + from agent.runtime_cwd import scope_terminal_cwd + + return _sentinel_free_abs_cwd(scope_terminal_cwd() or None) def _registered_task_cwd_override(task_id: str = "default") -> str | None: diff --git a/tools/terminal_scope.py b/tools/terminal_scope.py new file mode 100644 index 0000000000000..da79538ab73e1 --- /dev/null +++ b/tools/terminal_scope.py @@ -0,0 +1,298 @@ +"""Per-turn terminal scope: profile-scoped TERMINAL_* policy. + +The multiplexing gateway (and the unified dashboard/TUI, and cron) serve +several Hermes profiles from one process. Terminal settings were historically +mirrored into the process-global ``os.environ`` (first writer wins), so the +first profile to touch the terminal after startup pinned its backend — and +every other setting — onto all later turns: a ``local`` profile silently +executing inside another profile's docker sandbox, or the reverse (a sandbox +escape). Mirrors the isolation seam that ``agent/secret_scope.py`` provides +for credentials: a ContextVar holds the active profile's COMPLETE effective +``TERMINAL_*`` policy, installed at each in-process profile boundary. + +Two contracts distinguish this from a plain override dict: + +- **Authoritative projection.** While a scope is bound, ``terminal_env`` + resolves ONLY from that policy (built from defined defaults + the profile's + ``.env`` + its ``config.yaml`` explicit keys). Omitted keys resolve to the + defined default — never to ambient ``os.environ`` — so a routed profile can + neither inherit nor be escaped onto the launch process's mounts, SSH + targets, or resource policy (#68559). +- **Fail closed.** If the profile's policy cannot be resolved (unreadable or + malformed ``.env``/``config.yaml``), the install raises + :class:`TerminalPolicyUnavailable` and callers must install a *refusal* + scope; terminal execution under a refusal scope is rejected outright + rather than falling back to ambient authority. +""" + +from __future__ import annotations + +import logging +from contextlib import contextmanager +from contextvars import ContextVar, Token +from pathlib import Path +from typing import Any, Dict, Iterator, Optional + +logger = logging.getLogger(__name__) + +# ``None`` = no scope bound in this context; readers use the historical +# process-env behavior (single-process CLI/TUI, unaffected surfaces). +# A dict = the active profile's complete effective terminal policy. +# A TerminalPolicyRefusal = resolution failed; terminal execution must refuse. +_terminal_scope_var: ContextVar = ContextVar("hermes_terminal_scope", default=None) + + +class TerminalPolicyUnavailable(Exception): + """The routed profile's terminal policy could not be resolved. + + Raised when the profile's ``.env`` or ``config.yaml`` exists but cannot be + read/parsed. Callers must install the returned refusal scope instead of + continuing without a scope — executing under ambient process authority is + exactly the leak this module exists to close. + """ + + +class TerminalPolicyRefusal(Dict[str, str]): + """Marker scope installed when policy resolution failed. + + An (empty) dict subclass so existing dict-typed checks keep working, with + a flag that makes ``terminal_env`` raise before any value is served. + """ + + refused = True + + def __init__(self, reason: str) -> None: + super().__init__() + self.reason = reason + + +def set_terminal_scope(mapping: Optional[Dict[str, str]]) -> Token: + """Install *mapping* as the current context's terminal policy.""" + return _terminal_scope_var.set(mapping) + + +def install_refusal_scope(reason: str) -> Token: + """Install a refusal scope after :class:`TerminalPolicyUnavailable`. + + Terminal execution under this scope is rejected (fail closed) instead of + running under the launch process's ambient policy. + """ + return _terminal_scope_var.set(TerminalPolicyRefusal(reason)) + + +def reset_terminal_scope(token: Token) -> None: + _terminal_scope_var.reset(token) + + +def get_terminal_scope() -> Optional[Dict[str, str]]: + """The active scope mapping/refusal, or ``None`` when no scope is bound.""" + return _terminal_scope_var.get() + + +@contextmanager +def terminal_scope(mapping: Optional[Dict[str, str]]) -> Iterator[None]: + """Context manager form of set/reset_terminal_scope.""" + token = set_terminal_scope(mapping) + try: + yield + finally: + reset_terminal_scope(token) + + +def terminal_env(name: str, default: str = "") -> str: + """Authoritative read of a ``TERMINAL_*`` variable. + + - No scope bound: process env, then *default* (historical single-process + behavior — CLI/TUI surfaces that never route profiles are unchanged). + - Refusal scope bound: raise — policy is unavailable and execution must + fail closed, not fall back to ambient authority. + - Policy scope bound: resolve ONLY from the policy; a missing key yields + the *default* (which callers derive from defined defaults), never + ``os.environ``. + """ + scope = _terminal_scope_var.get() + if scope is None: + import os + + return os.environ.get(name, default) + if isinstance(scope, TerminalPolicyRefusal): + raise TerminalPolicyUnavailable( + f"terminal policy unavailable for this profile: {scope.reason}" + ) + value = scope.get(name) + if value is not None: + return str(value) + return default + + +def build_profile_terminal_scope(hermes_home: "Any") -> Dict[str, str]: + """Build the COMPLETE effective ``TERMINAL_*`` policy for a profile home. + + Projection order: defined defaults (``DEFAULT_CONFIG['terminal']``) ← the + profile's ``.env`` TERMINAL_* selections ← its ``config.yaml`` explicit + ``terminal:`` keys. The result is total: every key the terminal stack can + ask for resolves from this mapping, so a bound scope never widens back to + ambient process authority. Raises :class:`TerminalPolicyUnavailable` when + either file exists but cannot be read/parsed (fail closed). + """ + home = Path(hermes_home) + + from hermes_cli.config_defaults import DEFAULT_CONFIG + + defaults = DEFAULT_CONFIG.get("terminal") if isinstance( + DEFAULT_CONFIG, dict) else None + defaults = dict(defaults) if isinstance(defaults, dict) else {} + # Terminal keys whose env mirror exists but whose config default lives in + # the consuming tool rather than DEFAULT_CONFIG. These are the documented + # tool-level defaults (tools/terminal_tool.py); without them the + # projection would not be total and reads could observe nothing (which is + # correct) OR fall back ambiently (which is not). + defaults.setdefault("cwd", ".") # per-surface placeholder + defaults.setdefault("ssh_host", "") # remote backends: unset = none + defaults.setdefault("ssh_user", "") + defaults.setdefault("ssh_port", 22) + defaults.setdefault("ssh_key", "") + defaults.setdefault("docker_orphan_reaper", True) + defaults.setdefault("docker_persist_across_processes", True) + defaults.setdefault("sandbox_dir", "") # tool derives HERMES_HOME path + defaults.setdefault("lifetime_seconds", 300) + defaults.setdefault("docker_shared_container_key", "") + defaults.setdefault("home_mode", "auto") + + scope: Dict[str, str] = {} + + def _apply(cfg_key: str, value: Any) -> None: + if value is None: + return + # cwd placeholders (".", "auto", "cwd") are resolved per-surface + # later; they are not a policy value. + if cfg_key == "cwd" and str(value).strip() in {".", "auto", "cwd"}: + return + from hermes_cli.config import TERMINAL_CONFIG_ENV_MAP + + env_var = TERMINAL_CONFIG_ENV_MAP.get(cfg_key) + if env_var: + scope[env_var] = str(value) + + # 1) Defined defaults — the total baseline. + for cfg_key, value in defaults.items(): + _apply(cfg_key, value) + + # 2) The profile's .env TERMINAL_* selections. Fail closed on unreadable + # files (missing file = no selections, fine). + env_path = home / ".env" + if env_path.exists(): + # Pre-flight readability: load_env_file swallows OSError/UnicodeError + # by design (secret scope fails soft), but an unreadable profile .env + # is a policy-resolution failure here and must fail closed. + try: + env_path.read_bytes() + except Exception as exc: + raise TerminalPolicyUnavailable( + f"cannot read {env_path}: {exc}" + ) from exc + from agent.secret_scope import load_env_file + + selections = load_env_file(env_path) + for key, value in selections.items(): + if key.startswith("TERMINAL_"): + scope[key] = str(value) + + # 3) The profile's config.yaml explicit terminal keys. Read through the + # HERMES_HOME override so the profile's own file is consulted; a + # present-but-unparseable file fails closed (matches the gateway's + # _warn_config_parse_failure posture of refusing to guess policy). + from hermes_constants import ( + get_hermes_home_override, + reset_hermes_home_override, + set_hermes_home_override, + ) + + override_token = None + if get_hermes_home_override() != str(home): + override_token = set_hermes_home_override(home) + try: + config_path = home / "config.yaml" + if config_path.exists(): + # Parse the profile's file directly rather than through + # read_raw_config(): that helper collapses "missing" and + # "unparseable" into the same {} result. Here the file's existence + # is already established, so {} can only mean a parse failure — + # which must fail closed rather than silently projecting defaults. + from hermes_cli.config import fast_safe_load + + try: + with open(config_path, encoding="utf-8") as f: + raw = fast_safe_load(f) + except Exception as exc: + raise TerminalPolicyUnavailable( + f"cannot parse {config_path}: {exc}" + ) from exc + raw_terminal = raw.get("terminal") if isinstance(raw, dict) else None + if isinstance(raw_terminal, dict): + for cfg_key, value in raw_terminal.items(): + _apply(cfg_key, value) + except TerminalPolicyUnavailable: + raise + except Exception as exc: + raise TerminalPolicyUnavailable( + f"cannot resolve terminal config in {home}: {exc}" + ) from exc + finally: + if override_token is not None: + reset_hermes_home_override(override_token) + + return scope + + +def install_profile_terminal_scope(hermes_home: "Any") -> Token: + """Build AND install a profile's policy in one call. + + The single entry point for every profile boundary (gateway turn, TUI/ + dashboard turn, cron fire). On resolution failure this installs the + refusal scope instead of raising — the turn continues only in the sense + that terminal tools will refuse execution with the typed reason; it never + falls back to ambient process policy. + + Returns the token for ``reset_terminal_scope``. + """ + try: + return set_terminal_scope(build_profile_terminal_scope(hermes_home)) + except TerminalPolicyUnavailable as exc: + logger.warning("terminal policy unavailable: %s", exc) + return install_refusal_scope(str(exc)) + + +def enforce_no_refusal() -> None: + """Raise when the active scope is a refusal scope (fail closed). + + Execution paths (terminal tool, execute_code) call this before spawning + anything: under a refusal scope the profile's terminal policy could not be + resolved, and running with the launch process's ambient policy is exactly + the authority leak this module closes (#68559 requires refusal, not + fallback). Non-scoped and policy-scoped contexts pass silently. + """ + scope = _terminal_scope_var.get() + if isinstance(scope, TerminalPolicyRefusal): + raise TerminalPolicyUnavailable( + f"terminal policy unavailable for this profile: {scope.reason}" + ) + + +@contextmanager +def install_and_reset_profile_terminal_scope( + hermes_home: "Any", +) -> Iterator[None]: + """Install the profile's terminal policy for a bounded turn/fire. + + Single call for every in-process profile boundary (gateway turn, + dashboard/TUI turn, cron fire): builds the complete effective policy and + resets it on exit. Resolution failure installs the refusal scope for the + same duration — terminal execution inside the block raises (fail closed) + instead of inheriting the launch process's ambient policy. Never raises. + """ + token = install_profile_terminal_scope(hermes_home) + try: + yield + finally: + reset_terminal_scope(token) diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 689fcf156c877..4b16c7f0bd4e4 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -839,7 +839,7 @@ def _sudo_nopasswd_works() -> bool: cache) so an expired sudo timestamp cannot make a later command silently block waiting for a password. """ - terminal_env = os.getenv("TERMINAL_ENV", "local").strip().lower() or "local" + terminal_env = _tenv("TERMINAL_ENV", "local").strip().lower() or "local" if terminal_env != "local": return False @@ -1195,7 +1195,7 @@ def _maybe_reap_docker_orphans(container_config: Dict[str, Any]) -> None: # ``container_config`` only carries container_* keys, so read # lifetime_seconds from the env var the rest of the module uses. try: - lifetime = int(os.getenv("TERMINAL_LIFETIME_SECONDS", "300")) + lifetime = int(_tenv("TERMINAL_LIFETIME_SECONDS", "300")) except (TypeError, ValueError): lifetime = 300 lifetime = max(60, lifetime) @@ -1384,12 +1384,12 @@ def _session_isolation_enabled() -> bool: attach one live VM and delete it out from under each other). """ _ensure_terminal_env_bridged() - env_type = os.getenv("TERMINAL_ENV", "local") + env_type = _tenv("TERMINAL_ENV", "local") if env_type != "docker" and not _plugin_env_flag( env_type, "session_isolated_when_nonpersistent" ): return False - return os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() not in {"true", "1", "yes"} + return _tenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() not in {"true", "1", "yes"} def _docker_session_isolation_enabled() -> bool: @@ -1399,7 +1399,7 @@ def _docker_session_isolation_enabled() -> bool: selection, session-scoped container teardown) key off it; those must not fire for other backends. """ - if os.getenv("TERMINAL_ENV", "local") != "docker": + if _tenv("TERMINAL_ENV", "local") != "docker": return False return _session_isolation_enabled() @@ -1419,9 +1419,9 @@ def _docker_persistent_profile_scoped() -> bool: keep the session-scoped cache key that fixed the original leak. """ _ensure_terminal_env_bridged() - if os.getenv("TERMINAL_ENV", "local") != "docker": + if _tenv("TERMINAL_ENV", "local") != "docker": return False - return os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in {"true", "1", "yes"} + return _tenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in {"true", "1", "yes"} def _current_session_profile() -> str: @@ -1515,7 +1515,7 @@ def _resolve_container_task_id(task_id: Optional[str]) -> str: # Explicit opt-in: trusted profiles configuring the same # terminal.docker_shared_container_key share ONE container/cache # slot (and sandbox dir) regardless of profile name (#84671). - shared = os.getenv("TERMINAL_DOCKER_SHARED_CONTAINER_KEY", "").strip() + shared = _tenv("TERMINAL_DOCKER_SHARED_CONTAINER_KEY", "").strip() if shared: return f"shared:{shared}" profile = _current_session_profile() or "default" @@ -1528,7 +1528,7 @@ def _resolve_container_task_id(task_id: Optional[str]) -> str: # sessions land in "shared:" — splitting the very container the # setting exists to unify. if _docker_persistent_profile_scoped(): - shared = os.getenv("TERMINAL_DOCKER_SHARED_CONTAINER_KEY", "").strip() + shared = _tenv("TERMINAL_DOCKER_SHARED_CONTAINER_KEY", "").strip() if shared: return f"shared:{shared}" return "default" @@ -1607,6 +1607,10 @@ def _parse_env_var(name: str, default: str, converter: Any = int, type_label: st causes an unhandled ValueError that kills every terminal command. """ raw = os.getenv(name, default) + if name.startswith("TERMINAL_"): + # Scope-aware: under gateway multiplexing the active profile's + # per-turn scope overrides the process env. + raw = _tenv(name, default) try: return converter(raw) except (ValueError, json.JSONDecodeError): @@ -1632,7 +1636,7 @@ def _safe_getcwd() -> str: try: return os.getcwd() except (FileNotFoundError, PermissionError): - return os.getenv("TERMINAL_CWD") or os.path.expanduser("~") + return _tenv("TERMINAL_CWD") or os.path.expanduser("~") # Path prefixes that identify a *host* working directory which cannot exist @@ -1703,6 +1707,20 @@ def _is_unusable_container_cwd(cwd: str) -> bool: return False +def _tenv(name: str, default: str = "") -> str: + """Scope-aware read of a ``TERMINAL_*`` variable. + + Every terminal setting read in this module must go through this helper: + under gateway multiplexing the active profile's terminal config arrives + via a per-turn scope (``tools.terminal_scope``), and a raw ``os.getenv`` + would read whatever profile's config a previous turn pinned into the + process env (the cross-profile backend leak fixed here). + """ + from tools.terminal_scope import terminal_env + + return terminal_env(name, default) + + # One-shot guard for the config-fallback bridge below. Purely an # optimization: after the first attempt either TERMINAL_ENV is set (bridge # succeeded — merged config always carries terminal.backend) or the import @@ -1728,7 +1746,17 @@ def _ensure_terminal_env_bridged() -> None: be stale from ``hermes setup``). Environment values for omitted terminal keys are preserved. When no terminal section exists, exported/.env values keep working unchanged. + + A per-turn terminal scope (multiplexed gateway / profile-scoped cron) + suppresses this bridge entirely: the scope holds the active profile's + authoritative values and reads fall through ``_tenv`` — writing them into + the process-global ``os.environ`` would re-create the first-writer-wins + cross-profile leak the scope exists to fix. """ + from tools.terminal_scope import get_terminal_scope + + if get_terminal_scope() is not None: + return global _terminal_config_bridge_attempted if _terminal_config_bridge_attempted: return @@ -1762,9 +1790,9 @@ def _get_env_config() -> Dict[str, Any]: # Default image with Python and Node.js for maximum compatibility default_image = "nikolaik/python-nodejs:python3.11-nodejs20" _ensure_terminal_env_bridged() - env_type = os.getenv("TERMINAL_ENV", "local") + env_type = _tenv("TERMINAL_ENV", "local") - mount_docker_cwd = os.getenv("TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "false").lower() in {"true", "1", "yes"} + mount_docker_cwd = _tenv("TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "false").lower() in {"true", "1", "yes"} container_backend = _is_container_backend(env_type) docker_backend = env_type == "docker" @@ -1786,7 +1814,7 @@ def _get_env_config() -> Dict[str, Any]: docker_volumes = _parse_env_var("TERMINAL_DOCKER_VOLUMES", "[]", json.loads, "valid JSON") docker_env = _parse_env_var("TERMINAL_DOCKER_ENV", "{}", json.loads, "valid JSON") docker_extra_args = _parse_env_var("TERMINAL_DOCKER_EXTRA_ARGS", "[]", json.loads, "valid JSON") - docker_shm_size = os.getenv("TERMINAL_DOCKER_SHM_SIZE", "1g") + docker_shm_size = _tenv("TERMINAL_DOCKER_SHM_SIZE", "1g") else: docker_forward_env = [] docker_volumes = [] @@ -1810,13 +1838,13 @@ def _get_env_config() -> Dict[str, Any]: # If Docker cwd passthrough is explicitly enabled, remap the host path to # /workspace and track the original host path separately. Otherwise keep the # normal sandbox behavior and discard host paths. - cwd = os.getenv("TERMINAL_CWD", default_cwd) + cwd = _tenv("TERMINAL_CWD", default_cwd) from hermes_cli.config import _is_ssh_remote_tilde_cwd if cwd and not _is_ssh_remote_tilde_cwd(env_type, cwd): cwd = os.path.expanduser(cwd) host_cwd = None if env_type == "docker" and mount_docker_cwd: - docker_cwd_source = os.getenv("TERMINAL_CWD") or _safe_getcwd() + docker_cwd_source = _tenv("TERMINAL_CWD") or _safe_getcwd() candidate = os.path.abspath(os.path.expanduser(docker_cwd_source)) if ( any(candidate.startswith(p) for p in _HOST_CWD_PREFIXES) @@ -1834,41 +1862,41 @@ def _get_env_config() -> Dict[str, Any]: return { "env_type": env_type, - "modal_mode": coerce_modal_mode(os.getenv("TERMINAL_MODAL_MODE", "auto")), - "docker_image": os.getenv("TERMINAL_DOCKER_IMAGE", default_image), + "modal_mode": coerce_modal_mode(_tenv("TERMINAL_MODAL_MODE", "auto")), + "docker_image": _tenv("TERMINAL_DOCKER_IMAGE", default_image), "docker_forward_env": docker_forward_env, - "singularity_image": os.getenv("TERMINAL_SINGULARITY_IMAGE", f"docker://{default_image}"), - "modal_image": os.getenv("TERMINAL_MODAL_IMAGE", default_image), - "daytona_image": os.getenv("TERMINAL_DAYTONA_IMAGE", default_image), - "vercel_runtime": os.getenv("TERMINAL_VERCEL_RUNTIME", "").strip(), + "singularity_image": _tenv("TERMINAL_SINGULARITY_IMAGE", f"docker://{default_image}"), + "modal_image": _tenv("TERMINAL_MODAL_IMAGE", default_image), + "daytona_image": _tenv("TERMINAL_DAYTONA_IMAGE", default_image), + "vercel_runtime": _tenv("TERMINAL_VERCEL_RUNTIME", "").strip(), "cwd": cwd, "host_cwd": host_cwd, "docker_mount_cwd_to_workspace": mount_docker_cwd, "timeout": _parse_env_var("TERMINAL_TIMEOUT", "180"), "lifetime_seconds": _parse_env_var("TERMINAL_LIFETIME_SECONDS", "300"), # SSH-specific config - "ssh_host": os.getenv("TERMINAL_SSH_HOST", ""), - "ssh_user": os.getenv("TERMINAL_SSH_USER", ""), + "ssh_host": _tenv("TERMINAL_SSH_HOST", ""), + "ssh_user": _tenv("TERMINAL_SSH_USER", ""), "ssh_port": _parse_env_var("TERMINAL_SSH_PORT", "22"), - "ssh_key": os.getenv("TERMINAL_SSH_KEY", ""), + "ssh_key": _tenv("TERMINAL_SSH_KEY", ""), # Persistent shell: SSH defaults to the config-level persistent_shell # setting (true by default for non-local backends); local is always opt-in. # Per-backend env vars override if explicitly set. - "ssh_persistent": os.getenv( + "ssh_persistent": _tenv( "TERMINAL_SSH_PERSISTENT", - os.getenv("TERMINAL_PERSISTENT_SHELL", "true"), + _tenv("TERMINAL_PERSISTENT_SHELL", "true"), ).lower() in {"true", "1", "yes"}, - "local_persistent": os.getenv("TERMINAL_LOCAL_PERSISTENT", "false").lower() in {"true", "1", "yes"}, + "local_persistent": _tenv("TERMINAL_LOCAL_PERSISTENT", "false").lower() in {"true", "1", "yes"}, # Container resource config (applies to docker, singularity, modal, # daytona, and vercel_sandbox -- ignored for local/ssh) "container_cpu": container_cpu, "container_memory": container_memory, # MB (default 5GB) "container_disk": container_disk, # MB (default 50GB) - "container_persistent": os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in {"true", "1", "yes"}, + "container_persistent": _tenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in {"true", "1", "yes"}, "docker_volumes": docker_volumes, "docker_env": docker_env, - "docker_run_as_host_user": os.getenv("TERMINAL_DOCKER_RUN_AS_HOST_USER", "false").lower() in {"true", "1", "yes"}, - "docker_network": os.getenv("TERMINAL_DOCKER_NETWORK", "true").lower() in {"true", "1", "yes"}, + "docker_run_as_host_user": _tenv("TERMINAL_DOCKER_RUN_AS_HOST_USER", "false").lower() in {"true", "1", "yes"}, + "docker_network": _tenv("TERMINAL_DOCKER_NETWORK", "true").lower() in {"true", "1", "yes"}, "docker_extra_args": docker_extra_args, "docker_shm_size": docker_shm_size, # Cross-process container reuse (issue #20561). The docs claim @@ -1877,17 +1905,17 @@ def _get_env_config() -> Dict[str, Any]: # attaching to it instead of always starting a fresh one. Set to # ``false`` for hard per-process isolation (no reuse, container is # removed on exit). - "docker_persist_across_processes": os.getenv( + "docker_persist_across_processes": _tenv( "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES", "true" ).lower() in {"true", "1", "yes"}, - "docker_shared_container_key": os.getenv( + "docker_shared_container_key": _tenv( "TERMINAL_DOCKER_SHARED_CONTAINER_KEY", "" ).strip(), # Startup orphan reaper for hermes-tagged containers left behind by # crashed / SIGKILL'd previous processes that bypassed atexit. # Conservative: only sweeps Exited containers older than 2× the # idle-reap window AND scoped to the current profile. Issue #20561. - "docker_orphan_reaper": os.getenv( + "docker_orphan_reaper": _tenv( "TERMINAL_DOCKER_ORPHAN_REAPER", "true" ).lower() in {"true", "1", "yes"}, } @@ -2877,6 +2905,15 @@ def terminal_tool( config = _get_env_config() env_type = "local" if _host_local else config["env_type"] + # Fail closed under a refusal scope (#68559): the routed profile's + # terminal policy could not be resolved, so executing with the launch + # process's ambient policy is forbidden — refuse with a typed, + # model-actionable error instead. + if not _host_local: + from tools.terminal_scope import enforce_no_refusal + + enforce_no_refusal() + # Use task_id for environment isolation. By default all subagent # task_ids collapse back to "default" so the top-level agent and # every delegate_task child share one container; only task_ids with @@ -3868,7 +3905,7 @@ def _read_script_in_env(script_path: str) -> Optional[str]: # warn (default) — return a structured degraded result the model # can act on (reason + retry hint, no traceback). # fail — preserve the historical error+traceback result. - degraded_mode = os.getenv("TERMINAL_DEGRADED_MODE", "warn").strip().lower() + degraded_mode = _tenv("TERMINAL_DEGRADED_MODE", "warn").strip().lower() if degraded_mode == "fail": import traceback tb_str = traceback.format_exc() @@ -4089,18 +4126,18 @@ def check_terminal_requirements() -> bool: default_img = "nikolaik/python-nodejs:python3.11-nodejs20" print( " TERMINAL_ENV: " - f"{os.getenv('TERMINAL_ENV', 'local')} " + f"{_tenv('TERMINAL_ENV', 'local')} " "(local/docker/singularity/modal/daytona/vercel_sandbox/ssh)" ) - print(f" TERMINAL_DOCKER_IMAGE: {os.getenv('TERMINAL_DOCKER_IMAGE', default_img)}") - print(f" TERMINAL_SINGULARITY_IMAGE: {os.getenv('TERMINAL_SINGULARITY_IMAGE', f'docker://{default_img}')}") - print(f" TERMINAL_MODAL_IMAGE: {os.getenv('TERMINAL_MODAL_IMAGE', default_img)}") - print(f" TERMINAL_DAYTONA_IMAGE: {os.getenv('TERMINAL_DAYTONA_IMAGE', default_img)}") - print(f" TERMINAL_CWD: {os.getenv('TERMINAL_CWD', _safe_getcwd())}") + print(f" TERMINAL_DOCKER_IMAGE: {_tenv('TERMINAL_DOCKER_IMAGE', default_img)}") + print(f" TERMINAL_SINGULARITY_IMAGE: {_tenv('TERMINAL_SINGULARITY_IMAGE', f'docker://{default_img}')}") + print(f" TERMINAL_MODAL_IMAGE: {_tenv('TERMINAL_MODAL_IMAGE', default_img)}") + print(f" TERMINAL_DAYTONA_IMAGE: {_tenv('TERMINAL_DAYTONA_IMAGE', default_img)}") + print(f" TERMINAL_CWD: {_tenv('TERMINAL_CWD', _safe_getcwd())}") from hermes_constants import display_hermes_home as _dhh - print(f" TERMINAL_SANDBOX_DIR: {os.getenv('TERMINAL_SANDBOX_DIR', f'{_dhh()}/sandboxes')}") - print(f" TERMINAL_TIMEOUT: {os.getenv('TERMINAL_TIMEOUT', '60')}") - print(f" TERMINAL_LIFETIME_SECONDS: {os.getenv('TERMINAL_LIFETIME_SECONDS', '300')}") + print(f" TERMINAL_SANDBOX_DIR: {_tenv('TERMINAL_SANDBOX_DIR', f'{_dhh()}/sandboxes')}") + print(f" TERMINAL_TIMEOUT: {_tenv('TERMINAL_TIMEOUT', '60')}") + print(f" TERMINAL_LIFETIME_SECONDS: {_tenv('TERMINAL_LIFETIME_SECONDS', '300')}") # --------------------------------------------------------------------------- diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 9a519659eb5d6..9897de8f3f08c 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -3415,6 +3415,7 @@ def _build() -> None: notify_registered = False home_token = None secret_token = None + build_terminal_token = None session_db = None owns_db = False profile_home = current.get("profile_home") @@ -3441,6 +3442,21 @@ def _build() -> None: secret_token = set_secret_scope(build_profile_secret_scope(Path(profile_home))) except Exception: pass + # Bind the profile's COMPLETE terminal policy for the agent + # build (fail-closed: malformed policy → refusal scope) so + # _make_agent's terminal probing / cwd hints resolve the + # routed profile, never the launch process (#98581 class). + try: + from tools.terminal_scope import ( + install_profile_terminal_scope, + reset_terminal_scope, + ) + + build_terminal_token = install_profile_terminal_scope( + Path(profile_home) + ) + except Exception: + build_terminal_token = None # DEDICATED handle — ours until _transfer_db_to_agent hands # it to the built agent in the finally below. Every path # that leaves this build without that transfer (the except @@ -3592,6 +3608,13 @@ def _build() -> None: reset_secret_scope(secret_token) except Exception: pass + if build_terminal_token is not None: + try: + from tools.terminal_scope import reset_terminal_scope + + reset_terminal_scope(build_terminal_token) + except Exception: + pass # _attach_worker already closed the worker if this session was # reaped mid-build; only the late notify registration can still # leak (session.close unregistered before _build registered it). @@ -6455,9 +6478,20 @@ def _session_profile_runtime_scope(session: dict): return home_token = set_hermes_home_override(profile_home) secret_token = set_secret_scope(build_profile_secret_scope(Path(profile_home))) + # Same authoritative terminal policy the gateway binds per turn (#68559): + # a docker-configured dashboard profile must never resolve the launch + # process's pinned env. Failure → refusal scope (fail closed). + from tools.terminal_scope import ( + install_profile_terminal_scope as _install_term_scope, + ) + + terminal_token = _install_term_scope(Path(profile_home)) try: yield finally: + from tools.terminal_scope import reset_terminal_scope + + reset_terminal_scope(terminal_token) reset_secret_scope(secret_token) reset_hermes_home_override(home_token) @@ -13224,6 +13258,20 @@ def run(): if _profile_home_str: home_token = set_hermes_home_override(_profile_home_str) secret_token = set_secret_scope(build_profile_secret_scope(Path(_profile_home_str))) + # Fourth profile seam: bind the session profile's COMPLETE + # terminal policy for this turn (dashboard/TUI analogue of the + # gateway's per-turn scope). #98581's unified-desktop + # reproduction ran a docker-configured profile on the host + # because terminal_tool read the launch process's pinned env. + # Failure installs a refusal scope → terminal tools raise + # (fail closed) instead of inheriting ambient policy. + from tools.terminal_scope import ( + install_profile_terminal_scope as _install_term_scope, + ) + + _terminal_scope_token = _install_term_scope(Path(_profile_home_str)) + else: + _terminal_scope_token = None # The sudo password callback is thread-local (tools.terminal_tool # _callback_tls), so wiring it on the build thread doesn't reach this # turn thread — terminal sudo prompts would fall through to /dev/tty @@ -14001,6 +14049,10 @@ def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None: reset_hermes_home_override(home_token) if secret_token is not None: reset_secret_scope(secret_token) + if _terminal_scope_token is not None: + from tools.terminal_scope import reset_terminal_scope + + reset_terminal_scope(_terminal_scope_token) _clear_session_context(session_tokens) _current_runtime_session_record.reset(runtime_session_token) reset_transport(transport_token) From ed3da7ea4ac0bba64f370b83f54b92b86c8f1947 Mon Sep 17 00:00:00 2001 From: 1052326311 <65798732+1052326311@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:51:20 +0800 Subject: [PATCH 2/4] fix(container): honor config.yaml multiplex_profiles at boot hermes_cli/container_boot.py resolved multiplexing from the GATEWAY_MULTIPLEX_PROFILES env var only, while the gateway runtime resolves env -> config.yaml -> default. A deployment enabling multiplex_profiles via config.yaml alone therefore auto-started every named profile's gateway slot at boot, which then crash-looped in the double-bind guard against the multiplexing default gateway. Resolve through load_gateway_config().multiplex_profiles (the shared resolver, so env override precedence is preserved) and fall back to the env var only when config loading fails. Salvage of #85437 (test module trimmed to config-only + env-override). Fixes #85413 --- hermes_cli/container_boot.py | 18 ++++++++-- tests/hermes_cli/test_container_boot.py | 45 +++++++++++++++++++++++-- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/hermes_cli/container_boot.py b/hermes_cli/container_boot.py index b0e9821b7b8dd..cc5c6ff0c0230 100644 --- a/hermes_cli/container_boot.py +++ b/hermes_cli/container_boot.py @@ -136,11 +136,23 @@ def reconcile_profile_gateways( # for every profile. Named slots must still be registered (so explicit # lifecycle management remains available), but booting them from their # persisted run intent would create additional multiplex owners. + # Keep the boot reconciler aligned with the gateway that will own these + # slots. The runtime resolver gives a recognized environment override + # precedence over config.yaml and otherwise preserves the configured value. + from gateway.config import load_gateway_config from utils import is_truthy_value - multiplex_profiles = is_truthy_value( - os.environ.get("GATEWAY_MULTIPLEX_PROFILES"), - ) + try: + multiplex_profiles = load_gateway_config().multiplex_profiles + except Exception: + log.warning( + "Unable to load gateway configuration during container boot; " + "using the GATEWAY_MULTIPLEX_PROFILES override if set.", + exc_info=True, + ) + multiplex_profiles = is_truthy_value( + os.environ.get("GATEWAY_MULTIPLEX_PROFILES"), + ) # Default profile — always register, even if nothing has ever # populated the root profile dir. The slot exists so diff --git a/tests/hermes_cli/test_container_boot.py b/tests/hermes_cli/test_container_boot.py index 5838ffdb45191..cb772eeb659f0 100644 --- a/tests/hermes_cli/test_container_boot.py +++ b/tests/hermes_cli/test_container_boot.py @@ -128,6 +128,49 @@ def test_running_profile_is_registered_and_autostarted(tmp_path: Path) -> None: assert not (svc / "down").exists() +@pytest.mark.parametrize( + "config_value,env_value,expected", + [ + pytest.param("true", None, "registered", id="config-only-multiplex"), + pytest.param("true", "false", "started", id="env-false-overrides-config"), + ], +) +def test_boot_honors_config_multiplex_profiles( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + config_value: str, + env_value: str | None, + expected: str, +) -> None: + """Container boot must resolve multiplex_profiles like the gateway does: + config.yaml opt-in honored (#85413), env override keeps precedence.""" + scandir = tmp_path / "run-service" + scandir.mkdir() + _make_profile(tmp_path, "coder", state="running") + (tmp_path / "config.yaml").write_text( + f"multiplex_profiles: {config_value}\n", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + if env_value is None: + monkeypatch.delenv("GATEWAY_MULTIPLEX_PROFILES", raising=False) + else: + monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", env_value) + + actions = reconcile_profile_gateways( + hermes_home=tmp_path, + scandir=scandir, + dry_run=False, + ) + + assert _named_actions(actions) == [ReconcileAction( + profile="coder", + prior_state="running", + action=expected, + )] + assert (scandir / "gateway-coder" / "down").exists() is (expected == "registered") + + def test_registered_profile_has_finish_script(tmp_path: Path) -> None: """The finish script must be written so s6 stops restarting on fatal config errors (exit 78 → exit 125). See #51228.""" @@ -299,5 +342,3 @@ def _write_lifecycle_sentinel(profile_dir: Path, payload: dict) -> None: (state_dir / "gateway.lifecycle.json").write_text(json.dumps(payload)) - - From efa8fecda06e3a696f7c9cf6ab5584ac45744cbd Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:37:02 -0700 Subject: [PATCH 3/4] docs: terminal settings resolve per profile under multiplexing Documents the per-profile terminal.* resolution and fail-closed refusal behavior introduced by the terminal scope seam (#68559 class). --- website/docs/user-guide/multi-profile-gateways.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/website/docs/user-guide/multi-profile-gateways.md b/website/docs/user-guide/multi-profile-gateways.md index 7feddd069a90e..253a5442d2f9c 100644 --- a/website/docs/user-guide/multi-profile-gateways.md +++ b/website/docs/user-guide/multi-profile-gateways.md @@ -213,7 +213,13 @@ keep working. Per-profile `.env` credential isolation is preserved and, if anything, stricter: a profile's keys are resolved from its own scope and are never unioned into a shared environment (this also means subprocesses like MCP servers and -Kanban workers only ever see their own profile's secrets). Kanban, +Kanban workers only ever see their own profile's secrets). Terminal settings +(`terminal.backend`, `terminal.cwd`, `terminal.docker_volumes`, +`terminal.docker_shared_container_key`, SSH targets, …) are likewise resolved +per profile on every routed turn: a profile that omits a terminal key gets the +documented default, never the launch profile's value, and a profile whose +`config.yaml`/`.env` cannot be parsed has terminal execution refused rather than +run under another profile's sandbox policy. Kanban, profile-scoped skills/memory/SOUL, and model routing all behave per-profile exactly as they do with separate gateways. From 3165a9198f14fb80f94b08ae178ce9ec7f7e7b35 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:37:18 -0700 Subject: [PATCH 4/4] chore: map contributor email for muhifni (#99225 salvage) --- contributors/emails/muhammad.gcs@gmail.com | 1 + 1 file changed, 1 insertion(+) create mode 100644 contributors/emails/muhammad.gcs@gmail.com diff --git a/contributors/emails/muhammad.gcs@gmail.com b/contributors/emails/muhammad.gcs@gmail.com new file mode 100644 index 0000000000000..d4d782ab1b706 --- /dev/null +++ b/contributors/emails/muhammad.gcs@gmail.com @@ -0,0 +1 @@ +muhifni