Background processes spawned with terminal(background=true) are killed from three agent-lifecycle sweeps: agent release()'s kill_all, a gateway turn timeout's kill_started_since, and agent close's owned-process loop. Jobs the user explicitly wants to outlive the session (overnight batches, watchful daemons) had no way to opt out. Add terminal(background=true, persist_on_release=true): - ProcessSession.persist_on_release, stamped by spawn_local/spawn_via_env, carried in crash-recovery checkpoints and exposed via list_sessions() - kill_all skips persisted sessions only for lifecycle sources (_LIFECYCLE_KILL_SOURCES: kill_all, gateway_turn_timeout, agent_close); explicit operator stops (process_manage kill, /stop slash + RPC mirror, CLI /stop) now pass distinct sources so they still reach persisted jobs - the agent_close owned-process loop in _close_task_resources skips persisted sessions the same way - gateway shutdown keeps killing persisted jobs (source=gateway_shutdown): the host is going away and survivors would become PPID=1 orphans Co-authored-by: salvaged from #109846 (persist_on_release plumbing) and extended to the turn-timeout and agent_close paths.
1558 lines
74 KiB
Python
1558 lines
74 KiB
Python
#!/usr/bin/env python3
|
||
"""Terminal tool: run shell commands in the configured backend.
|
||
|
||
Backends (``TERMINAL_ENV``): local (default), docker, singularity, modal
|
||
(direct or managed gateway), daytona, vercel_sandbox, ssh, plus
|
||
plugin-registered backends. Handles background processes, sandbox lifecycle
|
||
(per-task cache, idle reaper, atexit teardown) and sudo password plumbing.
|
||
Cloud-sandbox persistent filesystems preserve working state across sandbox
|
||
recreation but do NOT guarantee the same live sandbox or long-running
|
||
processes survive cleanup, idle reaping, or Hermes exit.
|
||
|
||
Companion modules (re-exported here, so ``tools.terminal_tool.<name>`` stays the
|
||
import/patch target): ``terminal_tool_config`` (TERMINAL_* reads, ``_quiet``),
|
||
``terminal_tool_backends`` (env builders + requirement checkers),
|
||
``terminal_tool_lifecycle`` (reaper/teardown/ensure_task_env),
|
||
``terminal_tool_sudo`` (sudo password + shell rewrites), ``terminal_tool_guards``
|
||
(pre-exec blocks), ``terminal_tool_background`` (background spawn),
|
||
``terminal_tool_result`` (foreground result post-processing).
|
||
"""
|
||
|
||
import json
|
||
import logging
|
||
import os
|
||
import sys
|
||
import time
|
||
import threading
|
||
import atexit
|
||
from dataclasses import dataclass
|
||
from typing import Optional, Dict, Any, List
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _redact_terminal_error_text(value: Any) -> str:
|
||
"""Force-redact text before serializing a terminal error envelope."""
|
||
from agent.redact import redact_sensitive_text
|
||
|
||
return redact_sensitive_text("" if value is None else str(value), force=True)
|
||
|
||
|
||
from tools.registry import tool_error
|
||
from tools.terminal_tool_lifecycle import (
|
||
_check_disk_usage_warning, _cleanup_inactive_envs, _create_configured_env,
|
||
_evict_environment_for_task, cleanup_all_environments, ensure_task_env,
|
||
)
|
||
from tools.terminal_tool_config import (
|
||
_is_container_backend, _is_host_cwd, _is_unusable_container_cwd, _parse_env_var,
|
||
_plugin_env_flag, _quiet, _safe_getcwd, _tenv, _tenv_bool,
|
||
)
|
||
from tools.terminal_tool_backends import (
|
||
_REQUIREMENT_CHECKERS, _VERCEL_SANDBOX_DEFAULT_CWD, _check_plugin_requirements,
|
||
_record_unavailable_reason, terminal_backend_unavailable_reason, # noqa: F401 — re-exported
|
||
)
|
||
# display_hermes_home imported lazily at call site (stale-module safety during hermes update)
|
||
from tools.tool_backend_helpers import coerce_modal_mode, managed_nous_tools_enabled
|
||
|
||
|
||
def _safe_parse_import_env(name: str, default: Any, converter, type_label: str):
|
||
"""Parse a module-level numeric env var; a malformed value must never make
|
||
the module unloadable at import time (CLI, ACP, tests, tool discovery)."""
|
||
raw = os.getenv(name)
|
||
if raw is None or raw == "":
|
||
return default
|
||
try:
|
||
return converter(raw)
|
||
except (TypeError, ValueError):
|
||
logger.warning(
|
||
"Invalid value for %s: %r (expected %s). Falling back to %r.",
|
||
name, raw, type_label, default,
|
||
)
|
||
return default
|
||
|
||
|
||
# Hard cap on foreground timeout; override via TERMINAL_MAX_FOREGROUND_TIMEOUT env var.
|
||
FOREGROUND_MAX_TIMEOUT = _safe_parse_import_env("TERMINAL_MAX_FOREGROUND_TIMEOUT", 600, int, "integer")
|
||
|
||
# Disk usage warning threshold (in GB)
|
||
DISK_USAGE_WARNING_THRESHOLD_GB = _safe_parse_import_env("TERMINAL_DISK_WARNING_GB", 500.0, float, "number")
|
||
|
||
|
||
# Approval / sudo-prompt UI callbacks (CLI registers prompt_toolkit-aware
|
||
# ones). Thread-local so overlapping ACP sessions, each on its own executor
|
||
# thread, can't stomp on each other (GHSA-qg5c-hvr5-hjgr). Gateway mode
|
||
# resolves approvals via the per-session queue in tools.approval instead.
|
||
_callback_tls = threading.local()
|
||
|
||
|
||
def _get_sudo_password_callback():
|
||
return getattr(_callback_tls, "sudo_password", None)
|
||
|
||
|
||
def _get_approval_callback():
|
||
return getattr(_callback_tls, "approval", None)
|
||
|
||
|
||
def set_sudo_password_callback(cb):
|
||
"""Register the CLI's sudo password prompt callback (per-thread slot)."""
|
||
_callback_tls.sudo_password = cb
|
||
|
||
|
||
def set_approval_callback(cb):
|
||
"""Register the dangerous-command approval prompt callback (per-thread slot)."""
|
||
_callback_tls.approval = cb
|
||
|
||
|
||
def _current_session_key() -> str:
|
||
"""Active gateway/WebUI session key, or "" outside sessions (ContextVar with
|
||
the ``get_session_env`` os.environ fallback for CLI/cron/tests)."""
|
||
from gateway.session_context import get_session_env
|
||
|
||
return get_session_env("HERMES_SESSION_KEY", "")
|
||
|
||
|
||
def _current_session_profile() -> str:
|
||
"""Active session's Hermes profile name, or "" (same lookup discipline as
|
||
:func:`_current_session_key`)."""
|
||
from gateway.session_context import get_session_env
|
||
|
||
return get_session_env("HERMES_SESSION_PROFILE", "")
|
||
|
||
|
||
from tools.approval import (
|
||
check_all_command_guards as _check_all_guards_impl,
|
||
)
|
||
|
||
|
||
def _docker_volume_uses_host_path(volume_spec: str) -> bool:
|
||
"""Return True when a docker volume spec bind-mounts a host path."""
|
||
if not isinstance(volume_spec, str):
|
||
return False
|
||
vol = volume_spec.strip()
|
||
return bool(vol) and (
|
||
vol.startswith(("/", "~", "./", "../")) or
|
||
(len(vol) >= 3 and vol[1] == ":" and vol[2] in ("/", "\\"))
|
||
)
|
||
|
||
|
||
def _docker_has_host_access(config: Dict[str, Any]) -> bool:
|
||
"""Return True when a Docker sandbox exposes host paths through bind mounts."""
|
||
if config.get("env_type") != "docker":
|
||
return False
|
||
if config.get("host_cwd") and config.get("docker_mount_cwd_to_workspace"):
|
||
return True
|
||
return any(_docker_volume_uses_host_path(vol) for vol in config.get("docker_volumes", []))
|
||
|
||
|
||
def _check_all_guards(command: str, env_type: str,
|
||
has_host_access: bool = False) -> dict:
|
||
"""Delegate to consolidated guard (tirith + dangerous cmd) with CLI callback."""
|
||
return _check_all_guards_impl(command, env_type,
|
||
approval_callback=_get_approval_callback(),
|
||
has_host_access=has_host_access)
|
||
|
||
|
||
from tools.environments.base import EnvironmentConnectionError
|
||
|
||
|
||
# Tool description for LLM
|
||
TERMINAL_TOOL_DESCRIPTION = """Execute shell commands. The host OS, shell, and terminal backend are stated in your environment section — write commands for THAT platform. Filesystem, current working directory, and exported environment variables persist between calls.
|
||
|
||
Do NOT use cat/head/tail (use read_file), grep/rg/find/ls (use search_files), sed/awk (use patch), or echo/heredoc file creation (use write_file). Reserve terminal for: builds, installs, git, processes, scripts, network, package managers — anything that needs a shell. Output is auto-truncated with the full text saved to a file — never pipe through tail/head to shorten it.
|
||
Environment state persists: activate a virtualenv or export variables once per session, not before every command.
|
||
|
||
Foreground (default): returns INSTANTLY when the command finishes, even with a high timeout — set timeout generously for long builds and fixed waits.
|
||
Background: set background=true (returns a session_id) only for commands that must keep running independently after this tool call returns; add notify=true for bounded tasks, leave silent only for servers/daemons that never exit. Do not start sleep, timers, cooldowns, delays, or polling loops with background=true — to wait a fixed time, run the wait as a normal foreground command with a high enough timeout. After starting a server, verify readiness with a health check in a separate call (no blind sleep loops); manage with process(action="poll"/"wait").
|
||
Working directory: use 'workdir' for per-command cwd; when a command changes the session cwd (cd, pushd), trust the result's "cwd" field instead of prefixing every command with 'cd'.
|
||
PTY: pty=true + background=true for interactive CLIs (they hang without a terminal); drive them with process(action="write"/"submit"). Local backend only.
|
||
Persist: background=true, persist_on_release=true keeps the job alive across agent lifecycle cleanup (session end, /new, compression, error recovery, stop-on-max-iterations). Use ONLY for long-running jobs the user explicitly wants to outlive the conversation; the user can still stop it on purpose.
|
||
"""
|
||
|
||
# Environment lifecycle state.
|
||
_active_environments: Dict[str, Any] = {}
|
||
_last_activity: Dict[str, float] = {}
|
||
_env_lock = threading.Lock()
|
||
_creation_locks: Dict[str, threading.Lock] = {} # Per-task locks for sandbox creation
|
||
_creation_locks_lock = threading.Lock() # Protects _creation_locks dict itself
|
||
_cleanup_thread = None
|
||
_cleanup_running = False
|
||
|
||
# Once-per-process guard for the docker orphan reaper.
|
||
_docker_orphan_reaper_ran = False
|
||
_docker_orphan_reaper_lock = threading.Lock()
|
||
|
||
|
||
def _maybe_reap_docker_orphans(container_config: Dict[str, Any]) -> None:
|
||
"""Run the docker orphan reaper once per process, if enabled.
|
||
|
||
Sweeps Exited containers labeled ``hermes-agent=1`` for the current
|
||
profile — leftovers of Hermes processes that died without firing
|
||
``atexit`` (SIGKILL, OOM, closed terminal). Conservative: only containers
|
||
older than ``2 × lifetime_seconds``, profile-scoped. Gates:
|
||
``terminal.docker_orphan_reaper: false`` (operator opt-out, e.g. several
|
||
Hermes processes sharing a profile) and the once-per-interpreter flag so
|
||
parallel subagent / RL-rollout calls don't re-sweep.
|
||
"""
|
||
global _docker_orphan_reaper_ran
|
||
if not container_config.get("docker_orphan_reaper", True):
|
||
return
|
||
if _docker_orphan_reaper_ran: # double-checked locking
|
||
return
|
||
with _docker_orphan_reaper_lock:
|
||
if _docker_orphan_reaper_ran:
|
||
return
|
||
_docker_orphan_reaper_ran = True
|
||
|
||
# 2 × lifetime gives sibling processes a grace window; floor at 60s so
|
||
# TERMINAL_LIFETIME_SECONDS=0 can't instant-reap a sibling's own setup.
|
||
# container_config only carries container_* keys, so read the env var.
|
||
try:
|
||
lifetime = int(_tenv("TERMINAL_LIFETIME_SECONDS", "300"))
|
||
except (TypeError, ValueError):
|
||
lifetime = 300
|
||
max_age = max(60, lifetime) * 2
|
||
|
||
try:
|
||
from tools.environments.docker import reap_orphan_containers, _container_identity
|
||
except ImportError:
|
||
return
|
||
# Never fail the env-creation path because of a janitor problem.
|
||
with _quiet("Docker orphan reaper raised"):
|
||
profile = _container_identity(container_config.get("docker_shared_container_key", ""))
|
||
removed = reap_orphan_containers(max_age_seconds=max_age, profile_filter=profile)
|
||
if removed:
|
||
logger.info(
|
||
"Docker orphan reaper removed %d stale container(s) for profile %s",
|
||
removed, profile,
|
||
)
|
||
|
||
|
||
# Per-task environment overrides (never exposed to the model). RL/benchmark
|
||
# envs and ACP register a custom image / cwd for a task_id BEFORE the agent
|
||
# loop; sandbox creation consults this first, then the TERMINAL_* env vars.
|
||
_task_env_overrides: Dict[str, Dict[str, Any]] = {}
|
||
|
||
# Per-session cwd records: the durable source of truth for "which directory
|
||
# is THIS session in". Keyed by the raw session/task key, NOT the collapsed
|
||
# container id — the env is shared across sessions, so cwd state stored on
|
||
# it is a global mutable timeshared between sessions (the wrong-worktree bug
|
||
# class). Written after every completed command and on cwd-override
|
||
# registration; readers resolve against it before any env-side cwd.
|
||
_session_cwd: Dict[str, str] = {}
|
||
_session_cwd_lock = threading.Lock()
|
||
|
||
# Subagent → parent container aliasing. delegate_task children have their own
|
||
# task_id but must share the PARENT's container; under per-session isolation
|
||
# the collapse-to-"default" shortcut no longer provides that, so the spawn
|
||
# site registers an explicit alias.
|
||
_container_aliases: Dict[str, str] = {}
|
||
_container_alias_lock = threading.Lock()
|
||
|
||
|
||
def record_session_cwd(session_key: Optional[str], cwd: Optional[str]) -> None:
|
||
"""Record *cwd* as *session_key*'s working directory (after a completed
|
||
command, or on workspace-override registration). None/empty keys collapse
|
||
to ``"default"``; non-string / empty cwds are ignored."""
|
||
if not isinstance(cwd, str) or not cwd.strip():
|
||
return
|
||
key = str(session_key or "default")
|
||
with _session_cwd_lock:
|
||
if _session_cwd.get(key) != cwd:
|
||
_session_cwd[key] = cwd
|
||
|
||
|
||
def get_session_cwd(session_key: Optional[str]) -> Optional[str]:
|
||
"""Recorded cwd for *session_key*, or None. No fallback chain on purpose:
|
||
callers decide what an absent record means. None/empty keys read ``"default"``."""
|
||
with _session_cwd_lock:
|
||
return _session_cwd.get(str(session_key or "default"))
|
||
|
||
|
||
def clear_session_cwd(session_key: str) -> None:
|
||
"""Drop a session's cwd record (session teardown)."""
|
||
with _session_cwd_lock:
|
||
_session_cwd.pop(session_key, None)
|
||
|
||
|
||
def _sanitize_cwd_for_live_env(env: Any, new_cwd: str) -> Optional[str]:
|
||
"""Cwd to write into a LIVE cached env, or None to leave it untouched.
|
||
|
||
On container backends a raw host path (a desktop/TUI session registering its
|
||
workspace, e.g. ``C:\\Users\\me`` or ``/Users/me/workspace``) cannot be the
|
||
in-sandbox workdir: every file-tools ``_exec`` wrapper does
|
||
``builtin cd -- <env.cwd> || exit 126``, so a host cwd poisons all later
|
||
file operations with an unrelated ``cd:`` error. The creation paths already
|
||
sanitize this (``_is_unusable_container_cwd`` guards); the live-env write
|
||
here is the one remaining unsanitized site. When the host path is the one
|
||
mounted at ``/workspace`` (docker cwd passthrough), the session's directory
|
||
is still reachable — remap instead of discarding, mirroring the env-creation
|
||
remap in ``terminal_tool()``. Non-container backends apply the override
|
||
verbatim (ACP project-root switching must keep working).
|
||
"""
|
||
env_type = getattr(env, "env_type", None)
|
||
if not env_type or not _is_container_backend(env_type):
|
||
return new_cwd
|
||
if not _is_unusable_container_cwd(new_cwd):
|
||
return new_cwd
|
||
host_mount = getattr(env, "host_cwd", None)
|
||
if isinstance(host_mount, str) and host_mount:
|
||
candidate = os.path.abspath(os.path.expanduser(new_cwd))
|
||
mounted = os.path.abspath(os.path.expanduser(host_mount))
|
||
if candidate == mounted:
|
||
return "/workspace"
|
||
return None
|
||
|
||
|
||
def register_task_env_overrides(task_id: str, overrides: Dict[str, Any]):
|
||
"""Register per-task sandbox overrides (``docker_image``/``modal_image``/
|
||
``singularity_image``/``daytona_image``, ``env_type``, ``cwd``) before the
|
||
agent loop runs.
|
||
|
||
A ``cwd`` override takes effect immediately: it becomes the session's
|
||
recorded cwd (until a ``cd`` changes it) and any live env's cwd is updated
|
||
too, so env-side seeding stays consistent (ACP switching project root
|
||
mid-session via ``session/load``). The session record keeps the RAW path
|
||
(host workspaces are tracked there on purpose); only the live-env write is
|
||
sanitized, since a host cwd can never be a container workdir.
|
||
"""
|
||
_task_env_overrides[task_id] = overrides
|
||
|
||
new_cwd = overrides.get("cwd")
|
||
if isinstance(new_cwd, str) and new_cwd.strip():
|
||
record_session_cwd(task_id, new_cwd)
|
||
# Live env may be cached under the raw task_id (per-session surfaces)
|
||
# or the collapsed container id (isolation-keyed rollouts); try both so
|
||
# a CWD-only override (which collapses to "default") still finds it.
|
||
container_id = _resolve_container_task_id(task_id)
|
||
with _env_lock:
|
||
env = _active_environments.get(task_id) or _active_environments.get(container_id)
|
||
if env is not None and getattr(env, "cwd", None) is not None:
|
||
sanitized = _sanitize_cwd_for_live_env(env, new_cwd)
|
||
if sanitized is not None:
|
||
env.cwd = sanitized
|
||
|
||
|
||
def clear_task_env_overrides(task_id: str):
|
||
"""Drop a task's overrides, cwd record and container alias (rollout cleanup)."""
|
||
_task_env_overrides.pop(task_id, None)
|
||
clear_session_cwd(task_id)
|
||
with _container_alias_lock:
|
||
_container_aliases.pop(task_id, None)
|
||
|
||
|
||
def register_container_alias(child_task_id: str, parent_task_id: Optional[str]) -> None:
|
||
"""Make *child_task_id* resolve to *parent_task_id*'s container (called at
|
||
delegate_task spawn). A missing parent id aliases to ``"default"``."""
|
||
if not child_task_id:
|
||
return
|
||
with _container_alias_lock:
|
||
_container_aliases[child_task_id] = str(parent_task_id or "default")
|
||
|
||
|
||
def _resolve_container_alias(task_id: str) -> str:
|
||
"""Follow the child→parent alias chain (cycle-safe) for *task_id*."""
|
||
seen = set()
|
||
key = task_id
|
||
with _container_alias_lock:
|
||
while key in _container_aliases and key not in seen:
|
||
seen.add(key)
|
||
key = _container_aliases[key]
|
||
return key
|
||
|
||
|
||
_ISOLATION_OVERRIDE_KEYS = frozenset({
|
||
"docker_image", "modal_image", "singularity_image",
|
||
"daytona_image", "env_type",
|
||
})
|
||
|
||
|
||
def _has_isolation_overrides(task_id: Optional[str]) -> bool:
|
||
"""True when *task_id* registered image/env_type overrides — the single
|
||
"isolated RL/benchmark rollout" predicate shared by key resolution and
|
||
container creation so the two can't drift."""
|
||
if not task_id or task_id not in _task_env_overrides:
|
||
return False
|
||
return bool(set(_task_env_overrides[task_id].keys()) & _ISOLATION_OVERRIDE_KEYS)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class _SessionScope:
|
||
"""Backend identity + scoping predicates for one call, read once.
|
||
|
||
``env_type`` is the scope-aware TERMINAL_ENV; ``persistent`` is
|
||
``TERMINAL_CONTAINER_PERSISTENT``. Derived predicates:
|
||
|
||
* ``session_isolated`` — non-persistent sandboxes get per-session identities:
|
||
``container_persistent: false`` means state must not survive or be shared
|
||
across sessions, so one shared sandbox contradicts it. Docker, plus plugin
|
||
backends declaring ``session_isolated_when_nonpersistent`` (sandboxes resumed
|
||
by name, where a shared deterministic name would let two ephemeral runs
|
||
attach one VM and delete it under each other).
|
||
* ``docker_session_isolated`` — docker-only view: the workspace mount and
|
||
session-scoped teardown paths must not fire for other backends.
|
||
* ``docker_profile_scoped`` — docker + ``container_persistent: true``: ONE
|
||
long-lived container per profile shared by every session (CLI, gateway,
|
||
WebUI). The session-key fallback in :func:`_resolve_container_task_id` stops
|
||
cross-profile SSH reuse; ungated it fragmented persistent Docker into one
|
||
container per gateway session, so this restores profile scoping for exactly
|
||
this backend/mode.
|
||
"""
|
||
env_type: str
|
||
persistent: bool
|
||
|
||
@property
|
||
def session_isolated(self) -> bool:
|
||
if self.env_type != "docker" and not _plugin_env_flag(
|
||
self.env_type, "session_isolated_when_nonpersistent"
|
||
):
|
||
return False
|
||
return not self.persistent
|
||
|
||
@property
|
||
def docker_session_isolated(self) -> bool:
|
||
return self.env_type == "docker" and self.session_isolated
|
||
|
||
@property
|
||
def docker_profile_scoped(self) -> bool:
|
||
return self.env_type == "docker" and self.persistent
|
||
|
||
|
||
def _session_scope() -> _SessionScope:
|
||
"""Bridge config → env once, then snapshot the backend scope for this call."""
|
||
_ensure_terminal_env_bridged()
|
||
return _SessionScope(
|
||
env_type=_tenv("TERMINAL_ENV", "local"),
|
||
persistent=_tenv_bool("TERMINAL_CONTAINER_PERSISTENT", "true"),
|
||
)
|
||
|
||
|
||
def _docker_session_isolation_enabled() -> bool:
|
||
"""See :attr:`_SessionScope.docker_session_isolated` (used by the docker builder)."""
|
||
return _session_scope().docker_session_isolated
|
||
|
||
|
||
def _routed_home_task_key(profile_scoped: bool) -> Optional[str]:
|
||
"""Key for a session-less task serving a routed (non-launch) profile home, else None.
|
||
|
||
A multiplexed host runs every profile's cron jobs without a session key; collapsing them all onto
|
||
``"default"`` made profile B's cron tool calls reuse the environment the launch profile's job
|
||
created (its ``.env`` residue, its bridged ``TERMINAL_*``, its shell), so B ran with A's settings.
|
||
Persistent Docker keys the profile name exactly like B's session-bound work, so B keeps ONE
|
||
container instead of a second one per home path.
|
||
"""
|
||
from hermes_constants import get_hermes_home_override, profile_name_for_home
|
||
from tools.environments.local import _is_routed_home
|
||
|
||
override = get_hermes_home_override()
|
||
if not override or not _is_routed_home(override):
|
||
return None
|
||
profile = profile_name_for_home(override) if profile_scoped else None
|
||
if profile:
|
||
return "default" if profile == "default" else f"profile:{profile}"
|
||
try:
|
||
return f"home:{os.path.realpath(override)}"
|
||
except OSError:
|
||
return f"home:{override}"
|
||
|
||
|
||
def _resolve_container_task_id(task_id: Optional[str]) -> str:
|
||
"""Map a tool-call ``task_id`` to the ``_active_environments`` key. Order matters —
|
||
earlier branches are authoritative where they apply:
|
||
|
||
1. Image/``env_type`` overrides (RL/benchmark rollouts) key their own sandbox;
|
||
CWD-only overrides (ACP workspace tracking) are NOT isolation signals.
|
||
2. Per-session isolation (docker + ``container_persistent: false``): each
|
||
session's task_id is its own key (a fresh chat gets a fresh sandbox with only
|
||
ITS mounts); delegate_task children follow the alias registry to the parent.
|
||
3. Session key present (WebUI per-session, gateway per-message): persistent
|
||
Docker is PROFILE-scoped — ``shared:<key>`` opt-in, else ``profile:<name>``,
|
||
with the default profile staying literally ``"default"`` so CLI and
|
||
default-profile gateway sessions share ONE container; other backends key
|
||
``session:<key>`` so switching profiles can't reuse another profile's
|
||
SSHEnvironment on the wrong host.
|
||
4. No session key (CLI, cron): ``shared:<key>`` when opted in (else a CLI run of a
|
||
keyed profile would split from its gateway sessions); a routed multiplexed profile
|
||
keys its own home (``profile:<name>`` under persistent Docker, matching branch 3);
|
||
else ``"default"``, which subagent ids collapse onto to share the parent's container.
|
||
"""
|
||
if task_id and _has_isolation_overrides(task_id):
|
||
return task_id
|
||
scope = _session_scope()
|
||
if task_id and scope.session_isolated:
|
||
return _resolve_container_alias(task_id)
|
||
# Per-session isolation: when a session key is present (the WebUI streaming layer sets it per-session,
|
||
# the gateway per-message via contextvars), scope the container to it so switching profiles can't reuse
|
||
# a previous profile's SSHEnvironment and silently run commands on the wrong remote host. Subagents
|
||
# inherit the same session key, so they still collapse onto the parent's container (the #16177
|
||
# shared-container intent). CLI mode has no session key and falls through to "default", behaviour
|
||
# unchanged. See commit e00f940a9. This runs *after* the isolation-override and
|
||
# docker/container_persistent branches above: those paths already key containers per task_id, so they
|
||
# stay authoritative where they apply and this only covers the cases that would otherwise collapse to
|
||
# the shared "default" key (notably SSH).
|
||
session_key = _current_session_key()
|
||
shared = _tenv("TERMINAL_DOCKER_SHARED_CONTAINER_KEY", "").strip() if scope.docker_profile_scoped else ""
|
||
if shared:
|
||
# 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).
|
||
return f"shared:{shared}"
|
||
if not session_key:
|
||
return _routed_home_task_key(scope.docker_profile_scoped) or "default"
|
||
if not scope.docker_profile_scoped:
|
||
return f"session:{session_key}"
|
||
profile = _current_session_profile() or "default"
|
||
return "default" if profile == "default" else f"profile:{profile}"
|
||
|
||
|
||
def resolve_task_overrides(task_id: Optional[str]) -> Dict[str, Any]:
|
||
"""Return the env overrides for *task_id*, raw key first then collapsed.
|
||
|
||
``register_task_env_overrides`` writes under the *raw* task/session id, but
|
||
a CWD-only override collapses (:func:`_resolve_container_task_id`) to the
|
||
shared ``"default"`` container. Callers must therefore read the raw id
|
||
FIRST and only fall back to the collapsed container id, or the originating
|
||
session's override is silently dropped. Single source of that lookup so
|
||
the terminal and file layers can't drift apart.
|
||
"""
|
||
raw = task_id or "default"
|
||
return (
|
||
_task_env_overrides.get(raw)
|
||
or _task_env_overrides.get(_resolve_container_task_id(raw))
|
||
or {}
|
||
)
|
||
|
||
|
||
# Backends that take an image, keyed to the override/config key carrying it.
|
||
_IMAGE_KEY_BY_BACKEND = {
|
||
"docker": "docker_image",
|
||
"singularity": "singularity_image",
|
||
"modal": "modal_image",
|
||
"daytona": "daytona_image",
|
||
}
|
||
|
||
|
||
def _select_image(env_type: str, overrides: Dict[str, Any], config: Dict[str, Any]) -> str:
|
||
"""Image for *env_type*: per-task override first, then config; "" for imageless backends."""
|
||
key = _IMAGE_KEY_BY_BACKEND.get(env_type)
|
||
if key is None:
|
||
return ""
|
||
return overrides.get(key) or config[key]
|
||
|
||
|
||
def _lookup_active_env(effective_task_id: str, task_id: Optional[str]):
|
||
"""Return the cached env for the collapsed id, else for the raw task_id, else None.
|
||
|
||
Caller holds ``_env_lock``. Per-session surfaces (ACP/gateway/dashboard)
|
||
with a CWD-only override collapse to ``"default"`` for container sharing,
|
||
yet an env may already be cached under the originating task_id; honor it
|
||
instead of spawning a duplicate. Refreshes ``_last_activity`` on a hit.
|
||
"""
|
||
for key in (effective_task_id, task_id):
|
||
if key and key in _active_environments:
|
||
_last_activity[key] = time.time()
|
||
return _active_environments[key]
|
||
return None
|
||
|
||
|
||
def _resolve_task_host_cwd(config: Dict[str, Any], task_id: Optional[str]) -> Optional[str]:
|
||
"""Host directory to bind-mount at ``/workspace`` for *task_id*'s container.
|
||
|
||
Single owner of the cwd-mount policy for every creation site. Shared-
|
||
container mode: the ``TERMINAL_CWD``-derived ``config["host_cwd"]``.
|
||
Per-session isolation (docker + ``container_persistent: false``): only
|
||
the SESSION's own registered workspace may mount — the process env var is
|
||
a launch artifact that outlives the session that set it, so deriving a
|
||
fresh session's mount from it would leak the previous session's directory.
|
||
Overrides tagged ``cwd_source: "process"`` are refused for the same reason;
|
||
``cwd_source: "session"`` or untagged (ACP/RL) overrides mount.
|
||
"""
|
||
if config.get("env_type") != "docker" or not config.get("docker_mount_cwd_to_workspace"):
|
||
return None
|
||
# Top-level CLI parent ("default") is a single-session process — legacy behavior.
|
||
if not _docker_session_isolation_enabled() or _resolve_container_task_id(task_id) == "default":
|
||
return config.get("host_cwd")
|
||
overrides = resolve_task_overrides(task_id)
|
||
candidate = overrides.get("cwd")
|
||
if overrides.get("cwd_source") == "process" or not isinstance(candidate, str) or not candidate.strip():
|
||
return None
|
||
candidate = os.path.abspath(os.path.expanduser(candidate))
|
||
# Must exist on the host and not already be an in-container path.
|
||
if not os.path.isdir(candidate) or candidate.startswith(("/workspace", "/root")):
|
||
return None
|
||
return candidate
|
||
|
||
|
||
# One-shot guard for the config-fallback bridge: after the first attempt
|
||
# either TERMINAL_ENV is set or the import failed, so retrying is wasted work.
|
||
_terminal_config_bridge_attempted = False
|
||
|
||
|
||
def _ensure_terminal_env_bridged() -> None:
|
||
"""Backfill TERMINAL_* env vars from config.yaml when no launcher did.
|
||
|
||
CLI, gateway and TUI/dashboard PTY launches bridge ``terminal.*`` into env vars
|
||
at startup; processes that skip those paths (``hermes serve``, Desktop
|
||
in-process agents, desktop cron ticker, ACP) would otherwise fall back to the
|
||
local backend even when config selects docker — running on the host the user
|
||
meant to sandbox. Explicit keys in the ``terminal`` section override matching
|
||
env values (possibly stale from ``hermes setup``); env values for omitted keys
|
||
are preserved. Without a terminal section an existing TERMINAL_ENV is kept and
|
||
defaults are backfilled only when none is set. A per-turn terminal scope
|
||
suppresses the bridge entirely: writing scope values into the process-global
|
||
env would re-create the first-writer-wins cross-profile leak the scope fixes.
|
||
|
||
Ambient ``os.environ`` is the *launch* profile's authority only. Under a
|
||
context-local ``HERMES_HOME`` override (multiplexed dashboard / gateway
|
||
secondary profile), this bridge is a no-op — otherwise the first unscoped
|
||
call under that override would latch the secondary profile's ``terminal.*``
|
||
into process-global env and poison later unscoped launch-profile turns
|
||
(#107422 residual of #68559). Routed profiles must bind a terminal scope
|
||
instead (same rule as ``env_loader._reapply_terminal_config_bridge``).
|
||
|
||
terminal_tool reads ALL terminal settings from os.environ (TERMINAL_*). See #61115, #65696.
|
||
"""
|
||
from tools.terminal_scope import get_terminal_scope
|
||
|
||
if get_terminal_scope() is not None:
|
||
return
|
||
# Never write a secondary profile's terminal.* into process-global env.
|
||
from hermes_constants import get_hermes_home_override
|
||
|
||
if get_hermes_home_override() is not None:
|
||
return
|
||
global _terminal_config_bridge_attempted
|
||
if _terminal_config_bridge_attempted:
|
||
return
|
||
_terminal_config_bridge_attempted = True
|
||
# Never let a config problem take the terminal tool down.
|
||
with _quiet("terminal config → env fallback bridge failed"):
|
||
from hermes_cli.config import apply_terminal_config_to_env, read_raw_config
|
||
|
||
raw_config = read_raw_config()
|
||
if isinstance(raw_config.get("terminal"), dict):
|
||
apply_terminal_config_to_env(env=None, override=True)
|
||
elif "TERMINAL_ENV" not in os.environ:
|
||
apply_terminal_config_to_env(env=None, override=False)
|
||
|
||
|
||
# Default cwd per backend; anything else (container backends, plugins) is "/root".
|
||
_DEFAULT_CWD_BY_BACKEND = {"ssh": "~", "vercel_sandbox": _VERCEL_SANDBOX_DEFAULT_CWD}
|
||
|
||
|
||
def _resolve_config_cwd(env_type: str, mount_docker_cwd: bool) -> tuple:
|
||
"""``(cwd, host_cwd)`` from TERMINAL_CWD for *env_type*.
|
||
|
||
Container backends are sanity-checked: with Docker cwd passthrough the host
|
||
path is remapped to /workspace and tracked as host_cwd; otherwise host paths
|
||
are discarded in favor of the backend default.
|
||
"""
|
||
default_cwd = _safe_getcwd() if env_type == "local" else _DEFAULT_CWD_BY_BACKEND.get(env_type, "/root")
|
||
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:
|
||
candidate = os.path.abspath(os.path.expanduser(_tenv("TERMINAL_CWD") or _safe_getcwd()))
|
||
if (
|
||
_is_host_cwd(candidate)
|
||
or (os.path.isabs(candidate) and os.path.isdir(candidate) and not candidate.startswith(("/workspace", "/root")))
|
||
):
|
||
host_cwd = candidate
|
||
cwd = "/workspace"
|
||
elif _is_container_backend(env_type) and cwd and _is_unusable_container_cwd(cwd) and cwd != default_cwd:
|
||
logger.info("Ignoring TERMINAL_CWD=%r for %s backend "
|
||
"(host/relative path won't work in sandbox). Using %r instead.",
|
||
cwd, env_type, default_cwd)
|
||
cwd = default_cwd
|
||
return cwd, host_cwd
|
||
|
||
|
||
def _get_env_config() -> Dict[str, Any]:
|
||
"""Resolve the terminal configuration dict from TERMINAL_* env vars."""
|
||
default_image = "nikolaik/python-nodejs:python3.11-nodejs20"
|
||
_ensure_terminal_env_bridged()
|
||
env_type = _tenv("TERMINAL_ENV", "local")
|
||
mount_docker_cwd = _tenv_bool("TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "false")
|
||
|
||
# Container/docker-only payloads are parsed only when such a backend is
|
||
# selected: a stale or invalid Docker value bridged from config.yaml must
|
||
# not make local terminal/execute_code unusable.
|
||
if _is_container_backend(env_type):
|
||
container_cpu = _parse_env_var("TERMINAL_CONTAINER_CPU", "1", float, "number")
|
||
container_memory = _parse_env_var("TERMINAL_CONTAINER_MEMORY", "5120")
|
||
container_disk = _parse_env_var("TERMINAL_CONTAINER_DISK", "51200")
|
||
else:
|
||
container_cpu, container_memory, container_disk = 1.0, 5120, 51200
|
||
|
||
if env_type == "docker":
|
||
docker_forward_env = _parse_env_var("TERMINAL_DOCKER_FORWARD_ENV", "[]", json.loads, "valid JSON")
|
||
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 = _tenv("TERMINAL_DOCKER_SHM_SIZE", "1g")
|
||
else:
|
||
docker_forward_env, docker_volumes, docker_env, docker_extra_args, docker_shm_size = [], [], {}, [], "1g"
|
||
|
||
cwd, host_cwd = _resolve_config_cwd(env_type, mount_docker_cwd)
|
||
|
||
return {
|
||
"env_type": env_type,
|
||
"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": _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": _tenv("TERMINAL_SSH_HOST", ""),
|
||
"ssh_user": _tenv("TERMINAL_SSH_USER", ""),
|
||
"ssh_port": _parse_env_var("TERMINAL_SSH_PORT", "22"),
|
||
"ssh_key": _tenv("TERMINAL_SSH_KEY", ""),
|
||
# Persistent shell: SSH defaults to the config-level persistent_shell
|
||
# setting; local is always opt-in. Per-backend env vars override.
|
||
"ssh_persistent": _tenv_bool(
|
||
"TERMINAL_SSH_PERSISTENT", _tenv("TERMINAL_PERSISTENT_SHELL", "true"),
|
||
),
|
||
"local_persistent": _tenv_bool("TERMINAL_LOCAL_PERSISTENT", "false"),
|
||
# Container resources (MB); ignored for local/ssh.
|
||
"container_cpu": container_cpu,
|
||
"container_memory": container_memory,
|
||
"container_disk": container_disk,
|
||
"container_persistent": _tenv_bool("TERMINAL_CONTAINER_PERSISTENT", "true"),
|
||
"docker_volumes": docker_volumes,
|
||
"docker_env": docker_env,
|
||
"docker_run_as_host_user": _tenv_bool("TERMINAL_DOCKER_RUN_AS_HOST_USER", "false"),
|
||
"docker_snap_compat": _tenv_bool("TERMINAL_DOCKER_SNAP_COMPAT", "false"),
|
||
"docker_network": _tenv_bool("TERMINAL_DOCKER_NETWORK", "true"),
|
||
"docker_extra_args": docker_extra_args,
|
||
"docker_shm_size": docker_shm_size,
|
||
# Cross-process reuse: attach to a labeled container at startup
|
||
# instead of starting fresh; false = per-process isolation.
|
||
"docker_persist_across_processes": _tenv_bool("TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES", "true"),
|
||
"docker_shared_container_key": _tenv("TERMINAL_DOCKER_SHARED_CONTAINER_KEY", "").strip(),
|
||
"docker_orphan_reaper": _tenv_bool("TERMINAL_DOCKER_ORPHAN_REAPER", "true"),
|
||
}
|
||
|
||
|
||
def _cleanup_thread_worker():
|
||
"""Background thread worker that periodically cleans up inactive environments."""
|
||
while _cleanup_running:
|
||
with _quiet("Error in cleanup thread", level=logging.WARNING):
|
||
_cleanup_inactive_envs(_get_env_config()["lifetime_seconds"])
|
||
for _ in range(60):
|
||
if not _cleanup_running:
|
||
break
|
||
time.sleep(1)
|
||
|
||
|
||
def _start_cleanup_thread():
|
||
"""Start the background cleanup thread if not already running."""
|
||
global _cleanup_thread, _cleanup_running
|
||
|
||
with _env_lock:
|
||
if _cleanup_thread is None or not _cleanup_thread.is_alive():
|
||
_cleanup_running = True
|
||
_cleanup_thread = threading.Thread(target=_cleanup_thread_worker, daemon=True)
|
||
_cleanup_thread.start()
|
||
|
||
|
||
def _stop_cleanup_thread():
|
||
"""Stop the background cleanup thread."""
|
||
global _cleanup_running
|
||
_cleanup_running = False
|
||
if _cleanup_thread is not None:
|
||
try:
|
||
_cleanup_thread.join(timeout=5)
|
||
except (SystemExit, KeyboardInterrupt):
|
||
pass
|
||
|
||
|
||
def _atexit_cleanup():
|
||
"""Stop the cleanup thread and shut down all remaining sandboxes on exit."""
|
||
_stop_cleanup_thread()
|
||
if _active_environments:
|
||
logger.info("Shutting down %d remaining sandbox(es)...", len(_active_environments))
|
||
# Snapshot BEFORE cleanup_all_environments empties the dict, then
|
||
# block briefly so docker stop/rm completes before the interpreter
|
||
# exits — otherwise daemon cleanup threads die mid-`docker stop` and
|
||
# Exited containers pile up on the host.
|
||
envs_to_wait = list(_active_environments.values())
|
||
cleanup_all_environments()
|
||
for env in envs_to_wait:
|
||
wait_fn = getattr(env, "wait_for_cleanup", None)
|
||
if wait_fn is not None:
|
||
with _quiet("wait_for_cleanup raised on exit"): # never block shutdown on a bad backend
|
||
wait_fn(timeout=15.0)
|
||
# Workers of envs the idle reaper already detached are not in the registry (#86317).
|
||
if "tools.environments.docker" in sys.modules:
|
||
with _quiet("teardown drain raised on exit"):
|
||
sys.modules["tools.environments.docker"].DockerEnvironment.wait_for_all_teardowns(timeout=15.0)
|
||
|
||
atexit.register(_atexit_cleanup)
|
||
|
||
|
||
def _command_requires_pipe_stdin(command: str) -> bool:
|
||
"""True when PTY mode would break a stdin-driven command: `gh auth login
|
||
--with-token` waits for EOF on piped stdin, and under a PTY
|
||
`process.submit()` only sends a newline, so it hangs forever."""
|
||
normalized = " ".join(command.lower().split())
|
||
return normalized.startswith("gh auth login") and "--with-token" in normalized
|
||
|
||
|
||
from tools.terminal_tool_guards import (
|
||
_foreground_background_guidance, _safe_command_preview, _validate_workdir,
|
||
gateway_lifecycle_block, self_repo_block,
|
||
)
|
||
from tools.terminal_tool_background import _YIELDED_NOTE, spawn_background_process, yield_to_background_handler
|
||
from tools.terminal_tool_result import finalize_foreground_result
|
||
|
||
|
||
def _resolve_notification_flag_conflict(*, notify_on_complete: bool, watch_patterns, background: bool) -> tuple:
|
||
"""Resolve notify_on_complete + watch_patterns both set: drop watch_patterns
|
||
(combined they produce duplicate async notifications — one per match plus
|
||
one on exit — that can spam the user long after the process ends).
|
||
Returns ``(watch_patterns_to_use, conflict_note)``; note is "" without conflict."""
|
||
if background and notify_on_complete and watch_patterns:
|
||
return None, (
|
||
"watch_patterns ignored because notify_on_complete=True; "
|
||
"these two flags produce duplicate notifications when combined"
|
||
)
|
||
return watch_patterns, ""
|
||
|
||
|
||
def _resolve_command_cwd(
|
||
*,
|
||
workdir: Optional[str],
|
||
default_cwd: str,
|
||
session_key: Optional[str] = None,
|
||
env_type: Optional[str] = None,
|
||
) -> str:
|
||
"""cwd for a command: explicit ``workdir`` > the session's own cwd record >
|
||
``default_cwd``.
|
||
|
||
The record is written after every completed command of THIS session, so
|
||
it is the session's ``cd`` state with no shared-env ambiguity. On
|
||
container backends a recorded HOST path (a desktop/TUI surface registering
|
||
its workspace) is unusable in the sandbox — ``cd <host path>`` fails with
|
||
exit 126 — so it is discarded in favor of ``default_cwd``.
|
||
|
||
Same guard class as the env-creation sanitizers (#50636, #54447); this is the per-command sibling site.
|
||
"""
|
||
if workdir:
|
||
return workdir
|
||
recorded = get_session_cwd(session_key)
|
||
if recorded and _is_container_backend(env_type) and _is_unusable_container_cwd(recorded):
|
||
logger.info(
|
||
"Ignoring recorded session cwd %r for %s backend "
|
||
"(host/relative path won't work in sandbox). Using %r instead.",
|
||
recorded, env_type, default_cwd,
|
||
)
|
||
return default_cwd
|
||
return recorded or default_cwd
|
||
|
||
|
||
def _error_json(error: str, *, exit_code: int = -1, status: Optional[str] = None, **extra) -> str:
|
||
"""The terminal error envelope: ``output``/``exit_code``/``error`` (+ ``status``, extras)."""
|
||
body: Dict[str, Any] = {"output": "", "exit_code": exit_code, "error": error}
|
||
if status is not None:
|
||
body["status"] = status
|
||
body.update(extra)
|
||
return json.dumps(body, ensure_ascii=False)
|
||
|
||
|
||
def _fatal_error_json(e: BaseException) -> str:
|
||
"""Log the traceback and return the redacted error+traceback envelope.
|
||
|
||
Exception text can embed the failing command line (and any secrets inline
|
||
in it), so both fields are force-redacted before reaching the model.
|
||
"""
|
||
import traceback
|
||
tb_str = traceback.format_exc()
|
||
logger.error("terminal_tool exception:\n%s", tb_str)
|
||
return json.dumps({
|
||
"output": "",
|
||
"exit_code": -1,
|
||
"error": _redact_terminal_error_text(f"Failed to execute command: {e}"),
|
||
"traceback": _redact_terminal_error_text(tb_str),
|
||
"status": "error"
|
||
}, ensure_ascii=False)
|
||
|
||
|
||
class _Rejected(Exception):
|
||
"""Carries a finished tool-result JSON out of the planning/guard helpers, so
|
||
each early-return site is one ``raise`` instead of an isinstance-checked
|
||
``str | plan`` union at the caller."""
|
||
|
||
def __init__(self, result_json: str):
|
||
super().__init__(result_json)
|
||
self.result_json = result_json
|
||
|
||
|
||
@dataclass
|
||
class _ApprovalVerdict:
|
||
"""Outcome of the pre-exec guard pass.
|
||
|
||
``note`` is the audit note attached to the result. ``approved_run`` is True
|
||
when the user explicitly approved (or pre-confirmed via ``force``); it drives
|
||
the clean-interrupt-slate clear before ``env.execute`` so an approved command
|
||
can't be SIGINT-killed by a bit that landed during the approval-wait.
|
||
"""
|
||
note: Optional[str] = None
|
||
approved_run: bool = False
|
||
|
||
|
||
def _run_approval_guards(command: str, env_type: str, config: Dict[str, Any], *, force: bool) -> _ApprovalVerdict:
|
||
"""Run tirith + dangerous-command guards; ``force`` skips them entirely.
|
||
Raises :class:`_Rejected` when the command may not run (denied, or pending
|
||
gateway approval)."""
|
||
if force:
|
||
return _ApprovalVerdict(approved_run=True)
|
||
approval = _check_all_guards(command, env_type, has_host_access=_docker_has_host_access(config))
|
||
if not approval["approved"]:
|
||
if approval.get("status") == "pending_approval": # gateway ask mode
|
||
raise _Rejected(_error_json(
|
||
"", status="pending_approval",
|
||
approval_pending=True,
|
||
command=approval.get("command", command),
|
||
description=approval.get("description", "command flagged"),
|
||
pattern_key=approval.get("pattern_key", ""),
|
||
smart_denied=approval.get("smart_denied", False),
|
||
allow_permanent=approval.get("allow_permanent", True),
|
||
))
|
||
desc = approval.get("description", "command flagged")
|
||
fallback_msg = (
|
||
f"Command denied: {desc}. "
|
||
"Use the approval prompt to allow it, or rephrase the command."
|
||
)
|
||
raise _Rejected(_error_json(approval.get("message", fallback_msg), status="blocked",
|
||
**({"user_summary": approval["user_summary"]} if approval.get("user_summary") else {})))
|
||
desc = approval.get("description", "flagged as dangerous")
|
||
if approval.get("user_approved"):
|
||
return _ApprovalVerdict(
|
||
note=f"Command required approval ({desc}) and was approved by the user.",
|
||
approved_run=True,
|
||
)
|
||
if approval.get("smart_approved"):
|
||
return _ApprovalVerdict(note=f"Command was flagged ({desc}) and auto-approved by smart approval.")
|
||
return _ApprovalVerdict()
|
||
|
||
|
||
@dataclass
|
||
class _ExecPlan:
|
||
"""Per-call execution parameters resolved before any environment is touched."""
|
||
config: Dict[str, Any]
|
||
env_type: str
|
||
effective_task_id: str
|
||
image: str
|
||
cwd: str
|
||
host_cwd: Optional[str]
|
||
effective_timeout: int
|
||
# Set when a foreground call asked for more than FOREGROUND_MAX_TIMEOUT and was promoted to a
|
||
# tracked background process instead of being refused (the requested seconds, for the note).
|
||
promoted_from_foreground_timeout: Optional[int] = None
|
||
|
||
|
||
_PROMOTED_NOTE = (
|
||
"Requested foreground timeout {requested}s exceeds the {cap}s cap, so this command was started as a "
|
||
"tracked background process with notify_on_complete=true instead of being refused. Do NOT re-run it. "
|
||
"Its completion (exit code + output tail) arrives as a notification; poll with "
|
||
"process(action=\"poll\", session_id=...) if you need it sooner."
|
||
)
|
||
|
||
|
||
def _plan_execution(
|
||
command: Any, *, task_id: Optional[str], timeout: Optional[int],
|
||
background: bool, _host_local: bool,
|
||
) -> _ExecPlan:
|
||
"""Resolve backend, env-cache key, image, cwd and timeout for one call.
|
||
|
||
Raises :class:`_Rejected` when the call is rejected up front (non-string
|
||
command, non-positive or over-cap timeout, a foreground command that must
|
||
run in the background).
|
||
"""
|
||
if not isinstance(command, str):
|
||
logger.warning("Rejected invalid terminal command value: %s", type(command).__name__)
|
||
raise _Rejected(_error_json(
|
||
f"Invalid command: expected string, got {type(command).__name__}", status="error",
|
||
))
|
||
|
||
config = _get_env_config()
|
||
env_type = "local" if _host_local else config["env_type"]
|
||
|
||
# Fail closed under a refusal scope: the routed profile's terminal
|
||
# policy could not be resolved, so running with the launch process's
|
||
# ambient policy is forbidden.
|
||
# See #68559.
|
||
if not _host_local:
|
||
from tools.terminal_scope import enforce_no_refusal
|
||
|
||
enforce_no_refusal()
|
||
|
||
effective_task_id = _resolve_container_task_id(task_id)
|
||
if _host_local:
|
||
# Control-plane children run beside this interpreter, never inside
|
||
# the configured Docker/SSH backend; keep their env cache separate.
|
||
effective_task_id = f"host-local-{effective_task_id}"
|
||
|
||
# Per-task overrides (RL/benchmark envs, ACP workspace cwd) win over
|
||
# the global env-var config; ``resolve_task_overrides`` reads the raw
|
||
# task id first, then the collapsed container id.
|
||
overrides = resolve_task_overrides(task_id)
|
||
image = _select_image(env_type, overrides, config)
|
||
|
||
cwd = overrides.get("cwd") or get_session_cwd(task_id) or config["cwd"]
|
||
host_cwd = _resolve_task_host_cwd(config, task_id)
|
||
# config["cwd"] was sanitized for container backends in _get_env_config
|
||
# but an override / session record is raw: a host path would reach
|
||
# `docker run -w` and fail with exit 125. Re-apply the guard to the
|
||
# resolved cwd; when the host path IS this session's mounted workspace,
|
||
# remap to /workspace instead of discarding it.
|
||
if _is_container_backend(env_type) and _is_unusable_container_cwd(cwd):
|
||
remapped = "/workspace" if host_cwd else config["cwd"]
|
||
if cwd != remapped:
|
||
logger.info(
|
||
"Remapping host/relative cwd override %r for %s backend "
|
||
"(won't exist in sandbox). Using %r instead.",
|
||
cwd, env_type, remapped,
|
||
)
|
||
cwd = remapped
|
||
# Reject non-positive timeouts before deadline math: ``timeout or
|
||
# default`` would silently turn 0 into the default, and a negative
|
||
# value is truthy and would fire an immediate "-Ns" timeout.
|
||
if timeout is not None and timeout <= 0:
|
||
raise _Rejected(tool_error(f"timeout must be a positive number of seconds (got {timeout})."))
|
||
promoted = None
|
||
if not background:
|
||
# An over-cap foreground timeout is a bounded job the caller wants to wait for (test suites,
|
||
# builds). Refusing it only bought a mechanical retry: 454 refusals in one run, every one
|
||
# re-sent lower/split/background. Promote to a tracked background process instead; the
|
||
# caller is told in the result. The `&`/nohup/server guidance below stays a refusal: those
|
||
# need the command itself rewritten, which the tool cannot do safely.
|
||
# The detachment guidance applies whether or not the call is promoted: a promoted `cmd &`
|
||
# would start a tracked shell that exits at once while its payload runs untracked.
|
||
guidance = _foreground_background_guidance(command)
|
||
if guidance:
|
||
raise _Rejected(_error_json(guidance, status="error"))
|
||
if timeout and timeout > FOREGROUND_MAX_TIMEOUT:
|
||
promoted = timeout
|
||
|
||
return _ExecPlan(
|
||
config=config, env_type=env_type, effective_task_id=effective_task_id,
|
||
image=image, cwd=cwd, host_cwd=host_cwd, effective_timeout=timeout or config["timeout"],
|
||
promoted_from_foreground_timeout=promoted,
|
||
)
|
||
|
||
|
||
_PROMOTED_NOTE_POLL_ONLY = (
|
||
"Requested foreground timeout {requested}s exceeds the {cap}s cap, so this command was started as a "
|
||
"tracked background process instead of being refused. Do NOT re-run it. This session cannot receive "
|
||
"completion notifications, so poll it with process(action=\"poll\", session_id=...) until it exits."
|
||
)
|
||
|
||
|
||
def _with_promoted_note(result_json: str, requested_timeout: int) -> str:
|
||
"""Attach the foreground->background promotion note to a spawn result (unchanged on error). The
|
||
note only promises a notification when the spawn actually kept notify_on_complete (finite sessions
|
||
such as one-shot runners cannot route one back; the spawn already said so and cleared the flag)."""
|
||
try:
|
||
data = json.loads(result_json)
|
||
except (TypeError, ValueError):
|
||
return result_json
|
||
if not isinstance(data, dict) or data.get("error"):
|
||
return result_json
|
||
template = _PROMOTED_NOTE if data.get("notify_on_complete") else _PROMOTED_NOTE_POLL_ONLY
|
||
data["promoted_from_foreground"] = template.format(requested=requested_timeout, cap=FOREGROUND_MAX_TIMEOUT)
|
||
return json.dumps(data, ensure_ascii=False)
|
||
|
||
|
||
def _acquire_env(plan: _ExecPlan, task_id: Optional[str]) -> Any:
|
||
"""Cached env for the task, else create it under the per-task creation lock.
|
||
|
||
Concurrent calls for the same task_id wait for the first sandbox instead
|
||
of each creating their own; the cache is re-checked under that lock.
|
||
Raises :class:`_Rejected` with the ``"disabled"`` envelope when creation
|
||
raises ImportError.
|
||
"""
|
||
_start_cleanup_thread()
|
||
env_type, eff = plan.env_type, plan.effective_task_id
|
||
|
||
with _env_lock:
|
||
env: Any = _lookup_active_env(eff, task_id)
|
||
if env is not None:
|
||
return env
|
||
|
||
with _creation_locks_lock:
|
||
task_lock = _creation_locks.setdefault(eff, threading.Lock())
|
||
|
||
with task_lock:
|
||
with _env_lock:
|
||
env = _lookup_active_env(eff, task_id)
|
||
if env is not None:
|
||
return env
|
||
|
||
if env_type == "singularity":
|
||
_check_disk_usage_warning()
|
||
logger.info("Creating new %s environment for task %s...", env_type, eff[:8])
|
||
try:
|
||
new_env = _create_configured_env(
|
||
plan.config, env_type, image=plan.image, cwd=plan.cwd,
|
||
timeout=plan.effective_timeout, task_id=eff, host_cwd=plan.host_cwd,
|
||
local_config=(
|
||
{"persistent": plan.config.get("local_persistent", False)}
|
||
if env_type == "local" else None
|
||
),
|
||
)
|
||
except ImportError as e:
|
||
raise _Rejected(_error_json(
|
||
_redact_terminal_error_text(f"Terminal tool disabled: environment creation failed ({e})"),
|
||
status="disabled",
|
||
))
|
||
|
||
with _env_lock:
|
||
_active_environments[eff] = new_env
|
||
_last_activity[eff] = time.time()
|
||
logger.info("%s environment ready for task %s", env_type, eff[:8])
|
||
return new_env
|
||
|
||
|
||
def _yield_kwargs(command: str, **ctx) -> dict:
|
||
"""``env.execute`` kwargs enabling yield-to-background (local backend only)."""
|
||
handler = yield_to_background_handler(command=command, **ctx)
|
||
return {"yield_handler": handler} if handler is not None else {}
|
||
|
||
|
||
def _run_foreground(
|
||
command: str, env: Any, plan: _ExecPlan, *,
|
||
task_id: Optional[str], session_id: Optional[str], session_key: str,
|
||
workdir: Optional[str], approval_note: Optional[str], clear_interrupt: bool,
|
||
) -> str:
|
||
"""Execute in the foreground with retry on transient errors, then finalize."""
|
||
max_retries = 3
|
||
env_type, eff, effective_timeout = plan.env_type, plan.effective_task_id, plan.effective_timeout
|
||
|
||
# Clean interrupt slate for an approved command, ONCE before the retry
|
||
# loop: drop a stale bit that landed during the approval-wait so it
|
||
# can't SIGINT the just-approved run. Do NOT re-clear inside the loop —
|
||
# a genuine interrupt during the backoff sleep must survive and abort
|
||
# the next attempt (rc 130).
|
||
if clear_interrupt:
|
||
from tools.interrupt import clear_current_thread_interrupt
|
||
clear_current_thread_interrupt()
|
||
|
||
for retry_count in range(max_retries + 1):
|
||
try:
|
||
command_cwd = _resolve_command_cwd(
|
||
workdir=workdir, default_cwd=plan.cwd, session_key=session_key, env_type=env_type,
|
||
)
|
||
# bounded_capture: model-facing output keeps a head/tail window
|
||
# while streaming so a verbose command can't OOM the gateway;
|
||
# internal env.execute() consumers stay unbounded.
|
||
result = env.execute(
|
||
command, timeout=effective_timeout, cwd=command_cwd, bounded_capture=True,
|
||
**_yield_kwargs(command, env_type=env_type, cwd=command_cwd, effective_task_id=eff,
|
||
task_id=task_id, session_key=session_key),
|
||
)
|
||
break
|
||
except Exception as e:
|
||
if "timeout" in str(e).lower():
|
||
return _error_json(f"Command timed out after {effective_timeout} seconds", exit_code=124)
|
||
# Retry on transient errors
|
||
if retry_count < max_retries:
|
||
wait_time = 2 ** (retry_count + 1)
|
||
logger.warning("Execution error, retrying in %ds (attempt %d/%d) - Command: %s - Error: %s: %s - Task: %s, Backend: %s",
|
||
wait_time, retry_count + 1, max_retries, _safe_command_preview(command), type(e).__name__, e, eff, env_type)
|
||
time.sleep(wait_time)
|
||
continue
|
||
logger.error("Execution failed after %d retries - Command: %s - Error: %s: %s - Task: %s, Backend: %s",
|
||
max_retries, _safe_command_preview(command), type(e).__name__, e, eff, env_type)
|
||
return _error_json(_redact_terminal_error_text(f"Command execution failed: {type(e).__name__}: {e}"))
|
||
|
||
if result.get("yielded_session_id"):
|
||
return json.dumps({
|
||
"output": result.get("output", ""), "exit_code": None, "error": None,
|
||
"status": "yielded_to_background", "session_id": result["yielded_session_id"],
|
||
"pid": result.get("pid"), "notify_on_complete": True, "note": _YIELDED_NOTE,
|
||
}, ensure_ascii=False)
|
||
return finalize_foreground_result(
|
||
command=command, result=result, env=env, env_type=env_type, effective_task_id=eff,
|
||
task_id=task_id, session_id=session_id, session_key=session_key, workdir=workdir,
|
||
command_cwd=command_cwd, approval_note=approval_note,
|
||
)
|
||
|
||
|
||
# Floor for the pre-exec guard's share of the command deadline: a short command timeout
|
||
# (1s in tests, a few seconds in practice) must not turn the guard's own cold-start cost
|
||
# (module imports, git probes under load) into a refusal; the wedge it bounds lasted an hour.
|
||
_PRE_EXEC_GUARD_MIN_TIMEOUT_S = 30
|
||
|
||
|
||
def _pre_exec_block(
|
||
command: str, *, env: Any, env_type: str, cwd: str,
|
||
workdir: Optional[str], session_key: str,
|
||
) -> None:
|
||
"""Raise :class:`_Rejected` with the blocked-result JSON when the command must not run.
|
||
|
||
Order matters: gateway lifecycle first (protects the running gateway),
|
||
then the dangerous-workdir check, then the self-repo guard (local only).
|
||
"""
|
||
blocked = gateway_lifecycle_block(
|
||
command=command, env=env, env_type=env_type, cwd=cwd, workdir=workdir, session_key=session_key,
|
||
)
|
||
if blocked:
|
||
raise _Rejected(blocked)
|
||
if workdir:
|
||
workdir_error = _validate_workdir(workdir)
|
||
if workdir_error:
|
||
logger.warning("Blocked dangerous workdir: %s (command: %s)",
|
||
workdir[:200], _safe_command_preview(command))
|
||
raise _Rejected(_error_json(workdir_error, status="blocked"))
|
||
if env_type == "local":
|
||
blocked = self_repo_block(command=command, cwd=cwd, workdir=workdir, session_key=session_key)
|
||
if blocked:
|
||
raise _Rejected(blocked)
|
||
|
||
|
||
_PTY_DISABLED_REASON = (
|
||
"PTY disabled for this command because it expects piped stdin/EOF "
|
||
"(for example gh auth login --with-token). For local background "
|
||
"processes, call process(action='close') after writing so it receives "
|
||
"EOF."
|
||
)
|
||
|
||
|
||
def _degraded_result(e: EnvironmentConnectionError, task_id: Optional[str]) -> str:
|
||
"""Infrastructure failure (SSH host down, Docker daemon unreachable), distinct
|
||
from a nonzero exit. ``terminal.degraded_mode``: warn (default) returns a
|
||
structured degraded result with a retry hint; fail preserves the historical
|
||
error+traceback result."""
|
||
if _tenv("TERMINAL_DEGRADED_MODE", "warn").strip().lower() == "fail":
|
||
return _fatal_error_json(e)
|
||
logger.warning("terminal backend degraded: %s", e.reason)
|
||
# Evict the possibly-broken backend so the next call re-creates it.
|
||
with _quiet("degraded-env eviction failed"):
|
||
_evict_environment_for_task(task_id)
|
||
return json.dumps({
|
||
"output": "",
|
||
"exit_code": -1,
|
||
"status": "degraded",
|
||
"reason": e.reason,
|
||
"retry_hint": e.retry_hint,
|
||
"error": f"Terminal backend degraded: {e.reason}",
|
||
}, ensure_ascii=False)
|
||
|
||
|
||
def terminal_tool(
|
||
command: str,
|
||
background: bool = False,
|
||
timeout: Optional[int] = None,
|
||
task_id: Optional[str] = None,
|
||
session_id: Optional[str] = None,
|
||
force: bool = False,
|
||
workdir: Optional[str] = None,
|
||
pty: bool = False,
|
||
notify_on_complete: bool = False,
|
||
watch_patterns: Optional[List[str]] = None,
|
||
_host_local: bool = False,
|
||
_completion_output_chars: int = 0,
|
||
heartbeat: int = 0,
|
||
persist_on_release: bool = False,
|
||
) -> str:
|
||
"""Execute *command* in the configured terminal environment; returns a JSON string.
|
||
|
||
``force`` (internal, not in the model schema) skips the dangerous-command
|
||
check after the user confirmed. ``workdir`` is per-command and never
|
||
recorded as the session cwd. ``pty`` applies to the local backend only.
|
||
``notify_on_complete`` and ``watch_patterns`` are mutually exclusive
|
||
background-only flags: on conflict watch_patterns is dropped. watch_patterns
|
||
is hard rate-limited (1 notification / 15s / process) and auto-disabled
|
||
after repeated strikes or a lifetime cap, promoting to notify_on_complete —
|
||
use it only for rare one-shot signals on long-lived processes. ``heartbeat`` (seconds,
|
||
background-only, implies notify_on_complete) emits a "still running + output since last
|
||
time" event every N seconds so the agent stays current on a long job without polling.
|
||
``persist_on_release`` (background-only) keeps the process alive across agent-lifecycle
|
||
cleanup — session end, context compression, error recovery, max-iteration stop — all of
|
||
which kill the task's background processes; the user can still stop it on purpose via
|
||
process_manage kill (#41225).
|
||
``_completion_output_chars`` (internal) sizes the completion notification's output for a
|
||
spawner whose output is the payload (a bot DM's reply); 0 keeps the usual tail.
|
||
``_host_local`` forces the local backend for Hermes-owned control-plane
|
||
children (kept in a separate env cache from the configured backend).
|
||
"""
|
||
try:
|
||
plan = _plan_execution(
|
||
command, task_id=task_id, timeout=timeout, background=background, _host_local=_host_local,
|
||
)
|
||
env = _acquire_env(plan, task_id)
|
||
env_type, cwd, effective_task_id = plan.env_type, plan.cwd, plan.effective_task_id
|
||
|
||
# Session key for cwd records: the contextvar doesn't cross tool-worker
|
||
# threads, so fall back to the raw task_id (the top-level agent's
|
||
# session_key) as a stable anchor.
|
||
from tools.approval import get_current_session_key
|
||
|
||
session_key = get_current_session_key(default="") or (task_id or "")
|
||
|
||
# The supervised-gateway identity probe ends in a kernel process query
|
||
# (psutil create_time) that has wedged for the better part of an hour on
|
||
# macOS; ``env.execute`` is already behind ``run_bounded_sync`` but this
|
||
# chain ran ahead of it, so the tool call never returned and the cron
|
||
# slot stayed occupied (#111922). Share the command's own deadline. A
|
||
# guard that never rendered a verdict fails CLOSED: these checks apply
|
||
# unconditionally (``force`` cannot bypass them), so the command is
|
||
# refused with a retryable error instead of running unguarded.
|
||
from agent.deadline import run_bounded_sync
|
||
from tools.interrupt import acting_for_tid
|
||
|
||
# The guard chain runs on the deadline worker; keep it answerable to /stop
|
||
# aimed at this tool thread (a remote-backend script read polls is_interrupted()).
|
||
guard_timeout = max(plan.effective_timeout, _PRE_EXEC_GUARD_MIN_TIMEOUT_S)
|
||
_acting_token = acting_for_tid.set(threading.current_thread().ident)
|
||
try:
|
||
bounded_guard = run_bounded_sync(
|
||
lambda: _pre_exec_block(
|
||
command, env=env, env_type=env_type, cwd=cwd, workdir=workdir, session_key=session_key,
|
||
),
|
||
guard_timeout,
|
||
label="terminal.pre-exec-guard",
|
||
)
|
||
finally:
|
||
acting_for_tid.reset(_acting_token)
|
||
if bounded_guard.timed_out:
|
||
raise _Rejected(_error_json(
|
||
f"Terminal pre-execution guard did not finish within {guard_timeout}s "
|
||
"(process-identity probe wedged); the command was not run. Retry the call.",
|
||
status="error",
|
||
))
|
||
# Pre-exec security checks (tirith + dangerous command detection);
|
||
# force=True means the user already confirmed.
|
||
verdict = _run_approval_guards(command, env_type, plan.config, force=force)
|
||
|
||
pty_disabled = pty and _command_requires_pipe_stdin(command)
|
||
if plan.promoted_from_foreground_timeout is not None:
|
||
# Promotion implies notify_on_complete; watch_patterns is a background-only flag the
|
||
# caller could not have meant for a foreground call, and the two are exclusive anyway.
|
||
background, notify_on_complete, watch_patterns = True, True, None
|
||
if background:
|
||
result = spawn_background_process(
|
||
command=command, env=env, env_type=env_type, effective_task_id=effective_task_id,
|
||
task_id=task_id, session_key=session_key, workdir=workdir, cwd=cwd,
|
||
effective_pty=pty and not pty_disabled, notify_on_complete=notify_on_complete,
|
||
watch_patterns=watch_patterns, approval_note=verdict.note,
|
||
pty_disabled_reason=_PTY_DISABLED_REASON if pty_disabled else None,
|
||
completion_output_chars=_completion_output_chars,
|
||
heartbeat_seconds=heartbeat,
|
||
persist_on_release=persist_on_release,
|
||
)
|
||
if plan.promoted_from_foreground_timeout is not None:
|
||
result = _with_promoted_note(result, plan.promoted_from_foreground_timeout)
|
||
return result
|
||
return _run_foreground(
|
||
command, env, plan,
|
||
task_id=task_id, session_id=session_id, session_key=session_key,
|
||
workdir=workdir, approval_note=verdict.note, clear_interrupt=verdict.approved_run,
|
||
)
|
||
except _Rejected as r:
|
||
return r.result_json
|
||
except EnvironmentConnectionError as e:
|
||
return _degraded_result(e, task_id)
|
||
except Exception as e:
|
||
return _fatal_error_json(e)
|
||
|
||
|
||
def check_terminal_requirements() -> bool:
|
||
"""Check if all requirements for the terminal tool are met. The reason for a failure is kept for
|
||
:func:`terminal_backend_unavailable_reason` (CLI startup notice / doctor)."""
|
||
try:
|
||
config = _get_env_config()
|
||
checker = _REQUIREMENT_CHECKERS.get(config["env_type"], _check_plugin_requirements)
|
||
return checker(config)
|
||
except Exception as e:
|
||
logger.error("Terminal requirements check failed: %s", e, exc_info=True)
|
||
_record_unavailable_reason(f"the requirements check failed: {e}")
|
||
return False
|
||
|
||
|
||
from tools.registry import registry
|
||
|
||
TERMINAL_SCHEMA = {
|
||
"name": "terminal",
|
||
"description": TERMINAL_TOOL_DESCRIPTION,
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"command": {
|
||
"type": "string",
|
||
"description": "The shell command to execute"
|
||
},
|
||
"background": {
|
||
"type": "boolean",
|
||
"description": "Run in the background, returning a session_id. Pair with notify=true for anything with a defined end (tests, builds, deploys) — without it the process runs silently. Only servers/watchers/daemons that never exit should stay silent. Short commands: prefer foreground with a generous timeout.",
|
||
"default": False
|
||
},
|
||
"timeout": {
|
||
"type": "integer",
|
||
"description": f"Max seconds to wait (default: 180, foreground max: {FOREGROUND_MAX_TIMEOUT}). Returns INSTANTLY when command finishes — set high for long tasks, you won't wait unnecessarily. A foreground timeout above {FOREGROUND_MAX_TIMEOUT}s runs the command as a tracked background process with notify_on_complete=true instead (the result says so; do not re-run it).",
|
||
"minimum": 1
|
||
},
|
||
"workdir": {
|
||
"type": "string",
|
||
"description": "Working directory for this command (absolute path). Defaults to the session working directory."
|
||
},
|
||
"pty": {
|
||
"type": "boolean",
|
||
"description": "With background=true: run in a pseudo-terminal for interactive CLI tools (Codex, Claude Code, Python REPL). Local backend only. Default: false.",
|
||
"default": False
|
||
},
|
||
"notify": {
|
||
"description": "With background=true: notify=true fires exactly one notification when the process exits (the right choice for nearly every bounded task — builds, tests, deploys). notify=['pattern', ...] instead notifies when a line matches a pattern — ONLY for one-shot readiness signals on processes that never exit (e.g. ['Application startup complete']); rate-limited and auto-disabled if it over-fires. Omit for silent daemons.",
|
||
"anyOf": [
|
||
{"type": "boolean"},
|
||
{"type": "array", "items": {"type": "string"}}
|
||
]
|
||
},
|
||
"heartbeat": {
|
||
"type": "integer",
|
||
"minimum": 60,
|
||
"description": "With background=true: also notify every N seconds (min 60) with the output since the last notice. For long jobs you must react to mid-run (merge trains, full suites); implies notify=true."
|
||
},
|
||
"persist_on_release": {
|
||
"type": "boolean",
|
||
"default": False,
|
||
"description": "With background=true: keep the process alive across agent lifecycle cleanup (session end, /new, context compression, error recovery, max-iteration stop). Use ONLY for long-running jobs the user explicitly wants to outlive the conversation (overnight batches, watchful daemons); it still dies with the host process, and the user (or a later turn via process kill) can stop it on purpose. Default false."
|
||
}
|
||
# Legacy aliases (unadvertised, still accepted): notify_on_complete
|
||
# (bool) and watch_patterns (list). notify=true|[...] maps onto
|
||
# them in the dispatch wrapper; explicit notify wins on conflict.
|
||
},
|
||
"required": ["command"]
|
||
}
|
||
}
|
||
|
||
|
||
def _handle_terminal(args, **kw):
|
||
from agent.terminal_approval_batch import validate_prepared_terminal
|
||
validate_prepared_terminal(args)
|
||
# Models sometimes send execute_code's ``code`` here; name the stray
|
||
# argument and the right tool instead of failing on command=None.
|
||
if "command" not in args and "code" in args:
|
||
return tool_error(
|
||
"terminal received a 'code' parameter, but it requires a shell "
|
||
"command in 'command'. Use execute_code(code=...) for Python; "
|
||
"for shell, retry as terminal(command=...)."
|
||
)
|
||
# `notify` is the advertised interface (true → notify_on_complete,
|
||
# [...] → watch_patterns); the legacy args stay accepted, explicit
|
||
# `notify` wins. Background-only modifiers on a foreground call fail
|
||
# with the corrected call instead of being silently ignored.
|
||
notify = args.get("notify")
|
||
notify_on_complete = args.get("notify_on_complete", False)
|
||
watch_patterns = args.get("watch_patterns")
|
||
heartbeat = args.get("heartbeat") or 0
|
||
persist_on_release = bool(args.get("persist_on_release", False))
|
||
if not isinstance(heartbeat, int) or isinstance(heartbeat, bool) or heartbeat < 0:
|
||
return tool_error("heartbeat must be a whole number of seconds (min 60).")
|
||
if not args.get("background", False):
|
||
if notify or watch_patterns or notify_on_complete or heartbeat:
|
||
return tool_error(
|
||
"notify/heartbeat only apply to background commands (foreground "
|
||
"results return directly). Either drop them, or run as "
|
||
"terminal(command=..., background=true, notify=...)."
|
||
)
|
||
if args.get("pty", False):
|
||
return tool_error(
|
||
"pty requires background=true (a PTY session is interacted "
|
||
"with via process(action='write'/'submit'), which needs a "
|
||
"tracked background process). Retry as terminal(command=..., "
|
||
"background=true, pty=true)."
|
||
)
|
||
if persist_on_release:
|
||
return tool_error(
|
||
"persist_on_release only applies to background commands (a foreground "
|
||
"process is awaited inline and has nothing to persist). Retry as "
|
||
"terminal(command=..., background=true, persist_on_release=true)."
|
||
)
|
||
if notify is not None:
|
||
if isinstance(notify, bool):
|
||
notify_on_complete = notify
|
||
watch_patterns = None
|
||
elif isinstance(notify, list):
|
||
watch_patterns = notify
|
||
notify_on_complete = False
|
||
else:
|
||
return tool_error(
|
||
"notify must be true/false (notify on exit) or a list of "
|
||
"strings (notify on output pattern match)."
|
||
)
|
||
if heartbeat:
|
||
notify_on_complete = True # the heartbeat rides the completion delivery path
|
||
return terminal_tool(
|
||
command=args.get("command"),
|
||
background=args.get("background", False),
|
||
timeout=args.get("timeout"),
|
||
task_id=kw.get("task_id"),
|
||
session_id=kw.get("session_id"),
|
||
workdir=args.get("workdir"),
|
||
pty=args.get("pty", False),
|
||
notify_on_complete=notify_on_complete,
|
||
watch_patterns=watch_patterns,
|
||
heartbeat=heartbeat,
|
||
persist_on_release=persist_on_release,
|
||
)
|
||
|
||
|
||
registry.register(
|
||
name="terminal",
|
||
toolset="terminal",
|
||
schema=TERMINAL_SCHEMA,
|
||
handler=_handle_terminal,
|
||
check_fn=check_terminal_requirements,
|
||
emoji="💻",
|
||
max_result_size_chars=100_000,
|
||
)
|
||
|
||
|
||
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
|
||
# Names external plugins imported from this module before the Sep 2026 decomposition.
|
||
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
|
||
# The whole block is removed by reverting the commit that added it.
|
||
from pathlib import Path # noqa: F401,E402
|
||
import importlib.util # noqa: F401,E402
|
||
import platform # noqa: F401,E402
|
||
import re # noqa: F401,E402
|
||
import shlex # noqa: F401,E402
|
||
import shutil # noqa: F401,E402
|
||
import stat # noqa: F401,E402
|
||
import subprocess # noqa: F401,E402
|
||
import sys # noqa: F401,E402
|
||
|
||
|
||
_PLUGIN_COMPAT_LAZY = {
|
||
'cleanup_vm': ('tools.terminal_tool_lifecycle', 'cleanup_vm'),
|
||
'env_var_enabled': ('utils', 'env_var_enabled'),
|
||
'get_active_env': ('tools.terminal_tool_lifecycle', 'get_active_env'),
|
||
'has_direct_modal_credentials': ('tools.tool_backend_helpers', 'has_direct_modal_credentials'),
|
||
'is_interrupted': ('tools.interrupt', 'is_interrupted'),
|
||
'is_managed_tool_gateway_ready': ('tools.managed_tool_gateway', 'is_managed_tool_gateway_ready'),
|
||
'is_persistent_env': ('tools.terminal_tool_lifecycle', 'is_persistent_env'),
|
||
'nous_tool_gateway_unavailable_message': ('tools.tool_backend_helpers', 'nous_tool_gateway_unavailable_message'),
|
||
'resolve_modal_backend_state': ('tools.tool_backend_helpers', 'resolve_modal_backend_state'),
|
||
'strip_inert_heredoc_bodies': ('tools.shell_heredoc', 'strip_inert_heredoc_bodies'),
|
||
}
|
||
|
||
|
||
def __getattr__(name): # PEP 562 — lazy so no import cycles
|
||
target = _PLUGIN_COMPAT_LAZY.get(name)
|
||
if target is None:
|
||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||
import importlib
|
||
from hermes_cli.plugin_compat import warn_once
|
||
warn_once(__name__, name, *target)
|
||
return getattr(importlib.import_module(target[0]), target[1])
|
||
# ---- END PLUGIN-COMPAT ----
|