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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion agent/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
22 changes: 20 additions & 2 deletions agent/prompt_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
29 changes: 27 additions & 2 deletions agent/runtime_cwd.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,39 @@ 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:
p = Path(override).expanduser()
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():
Expand All @@ -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():
Expand Down
4 changes: 3 additions & 1 deletion agent/skill_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions contributors/emails/muhammad.gcs@gmail.com
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
muhifni
20 changes: 20 additions & 0 deletions cron/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
39 changes: 29 additions & 10 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)``.

Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -1651,23 +1670,23 @@ 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",
"on",
}:
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):
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand All @@ -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); "
Expand Down
40 changes: 33 additions & 7 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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 = {}
Expand Down Expand Up @@ -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:
Expand Down
8 changes: 7 additions & 1 deletion gateway/runtime_footer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 6 additions & 2 deletions gateway/slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
18 changes: 15 additions & 3 deletions hermes_cli/container_boot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading