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>
This commit is contained in:
muhifni
2026-08-31 10:53:22 +07:00
committed by Teknium
parent d2085389d6
commit 1cd736ff63
17 changed files with 810 additions and 78 deletions
+3 -1
View File
@@ -3031,8 +3031,10 @@ def init_agent(
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).
+20 -2
View File
@@ -1173,6 +1173,24 @@ _WINDOWS_BASH_SHELL_HINT = (
)
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:
+27 -2
View File
@@ -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():
+3 -1
View File
@@ -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:
+20
View File
@@ -7394,6 +7394,22 @@ def _run_one_job_body(
_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 _run_one_job_body(
# _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:
+29 -10
View File
@@ -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); "
+33 -7
View File
@@ -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":
@@ -20261,7 +20282,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
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 = {}
@@ -22855,7 +22881,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
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:
+7 -1
View File
@@ -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.
+6 -2
View File
@@ -3440,7 +3440,9 @@ class GatewaySlashCommandsMixin:
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 @@ class GatewaySlashCommandsMixin:
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)
@@ -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
+5 -1
View File
@@ -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", "")
+18 -1
View File
@@ -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):
+20 -6
View File
@@ -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 ""
+5 -1
View File
@@ -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:
+298
View File
@@ -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)
+80 -43
View File
@@ -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:<key>" — 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 terminal_tool(
# 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 @@ if __name__ == "__main__":
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')}")
# ---------------------------------------------------------------------------
+52
View File
@@ -3415,6 +3415,7 @@ def _start_agent_build(sid: str, session: dict) -> 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 _start_agent_build(sid: str, session: dict) -> 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 _start_agent_build(sid: str, session: dict) -> 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).
@@ -6457,9 +6480,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)
@@ -13226,6 +13260,20 @@ def _run_prompt_submit(
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
@@ -14003,6 +14051,10 @@ def _run_prompt_submit(
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)