A Desktop Docker session that registers an absolute host directory such as /mnt/... or /srv/... as its cwd skipped the /Users|/home/ drive-letter heuristic, so the mount check never ran and commands were wrapped with cd to that host path. Classify the mounted host directory as unusable before that heuristic, and remap it to /workspace in the live-env write and the per-command resolver.
177 lines
7.2 KiB
Python
177 lines
7.2 KiB
Python
"""Terminal backend configuration: scope-aware TERMINAL_* reads, env-var parsing,
|
|
container-cwd sanity checks, plugin-backend classification, and the ``_quiet``
|
|
best-effort block shared by the terminal_tool_* modules.
|
|
|
|
Split out of ``tools/terminal_tool.py``; every public/patched name is re-imported there,
|
|
so ``tools.terminal_tool.<name>`` keeps resolving (and monkeypatching) as before.
|
|
"""
|
|
|
|
import logging
|
|
import json
|
|
import os
|
|
import posixpath
|
|
import re
|
|
from contextlib import contextmanager
|
|
from typing import Any, overload
|
|
|
|
# Log-record parity with the origin module.
|
|
logger = logging.getLogger("tools.terminal_tool")
|
|
|
|
|
|
@contextmanager
|
|
def _quiet(label: str, *args, exc=Exception, level: int = logging.DEBUG):
|
|
"""Best-effort block: swallow *exc*, log *label* (``%``-formatted with *args*)
|
|
at *level* with the traceback. For janitor/advisory work that must never
|
|
take the terminal tool down."""
|
|
try:
|
|
yield
|
|
except exc:
|
|
logger.log(level, label, *args, exc_info=True)
|
|
|
|
|
|
def _parse_env_var(name: str, default: str, converter: Any = int, type_label: str = "integer"):
|
|
"""Parse an env var with *converter*, raising a clear ValueError on bad
|
|
values (e.g. TERMINAL_TIMEOUT=5m) instead of an opaque crash. TERMINAL_*
|
|
names are read scope-aware via :func:`_tenv`."""
|
|
raw = _tenv(name, default) if name.startswith("TERMINAL_") else os.getenv(name, default)
|
|
try:
|
|
return converter(raw)
|
|
except (ValueError, json.JSONDecodeError):
|
|
raise ValueError(
|
|
f"Invalid value for {name}: {raw!r} (expected {type_label}). "
|
|
f"Check ~/.hermes/.env or environment variables."
|
|
)
|
|
|
|
|
|
def _safe_getcwd() -> str:
|
|
"""``os.getcwd()`` tolerant of a deleted cwd (FileNotFoundError) or a macOS
|
|
TCC-protected one without Full Disk Access (PermissionError); falls back
|
|
to TERMINAL_CWD, then the home directory."""
|
|
try:
|
|
return os.getcwd()
|
|
except (FileNotFoundError, PermissionError):
|
|
return _tenv("TERMINAL_CWD") or os.path.expanduser("~")
|
|
|
|
|
|
# Host-cwd shapes that cannot exist inside a container sandbox: POSIX user dirs and ANY Windows
|
|
# drive path (``D:\\...``, ``e:/...``) as they leak toward a Linux ``-w`` flag.
|
|
_HOST_CWD_PREFIXES = ("/Users/", "/home/")
|
|
_WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:[\\/]")
|
|
|
|
|
|
def _is_host_cwd(path: str) -> bool:
|
|
return path.startswith(_HOST_CWD_PREFIXES) or bool(_WINDOWS_DRIVE_RE.match(path))
|
|
|
|
_CONTAINER_BACKENDS = frozenset({"docker", "singularity", "modal", "daytona", "vercel_sandbox"})
|
|
_BUILTIN_BACKENDS = _CONTAINER_BACKENDS | {"local", "ssh", "managed_modal"}
|
|
|
|
|
|
def _plugin_registry_lookup(env_type: str, fn_name: str, default, *args):
|
|
"""Call ``agent.terminal_env_registry.<fn_name>(env_type, *args)`` for a
|
|
plugin backend. Fail-soft: *default* for built-in/empty backends, when the
|
|
registry is unavailable, or when the provider raises — a misbehaving plugin
|
|
must never take the terminal tool down."""
|
|
if not env_type or env_type in _BUILTIN_BACKENDS:
|
|
return default
|
|
try:
|
|
import agent.terminal_env_registry as reg
|
|
|
|
return getattr(reg, fn_name)(env_type, *args)
|
|
except Exception:
|
|
return default
|
|
|
|
|
|
def _plugin_env_flag(env_type: str, attr: str, default=False):
|
|
"""Classification flag of a plugin-registered backend (fail-soft, see above)."""
|
|
return _plugin_registry_lookup(env_type, "provider_flag", default, attr, default)
|
|
|
|
|
|
def _is_container_backend(env_type: str) -> bool:
|
|
"""True for built-in container backends and plugins declaring ``is_container``."""
|
|
return env_type in _CONTAINER_BACKENDS or _plugin_env_flag(env_type, "is_container")
|
|
|
|
|
|
def _get_plugin_env_provider(env_type: str):
|
|
"""Return the registered plugin provider for *env_type*, or None."""
|
|
return _plugin_registry_lookup(env_type, "get_provider", None)
|
|
|
|
|
|
@overload
|
|
def coerce_ssh_remote_cwd(cwd: str, env_type: str | None) -> str: ...
|
|
@overload
|
|
def coerce_ssh_remote_cwd(cwd: None, env_type: str | None) -> None: ...
|
|
def coerce_ssh_remote_cwd(cwd: str | None, env_type: str | None) -> str | None:
|
|
"""Cwd to send to an SSH backend.
|
|
|
|
``~``-prefixed paths stay literal so the remote shell expands them to the
|
|
SSH user's home. The Hermes process's subprocess home (``/opt/data/home``
|
|
in the official Docker image) is a directory on the machine running
|
|
Hermes: it and anything under it are rewritten onto the remote ``~``, since
|
|
``cd`` into the host path exits 126 on the target. A subprocess home that is
|
|
the OS user's real home is left alone: a remote path may legitimately match
|
|
it. Other backends are unchanged.
|
|
"""
|
|
if not isinstance(cwd, str) or (env_type or "").strip().lower() != "ssh":
|
|
return cwd
|
|
text = cwd.strip()
|
|
if not text or text.startswith("~"):
|
|
return text or "~"
|
|
from hermes_constants import get_real_home, get_subprocess_home
|
|
|
|
home = get_subprocess_home()
|
|
if not home or not posixpath.isabs(text) or posixpath.normpath(home) == posixpath.normpath(get_real_home()):
|
|
return text
|
|
rel = posixpath.relpath(posixpath.normpath(text), posixpath.normpath(home))
|
|
if rel == ".":
|
|
return "~"
|
|
return text if rel == ".." or rel.startswith("../") else f"~/{rel}"
|
|
|
|
|
|
def _is_mounted_host_cwd(cwd: str, mounted_host: str | None) -> bool:
|
|
"""True when *cwd* is the host directory mounted at ``/workspace``.
|
|
|
|
Checked before the ``/Users`` / ``/home`` / drive-letter heuristic: a WSL
|
|
checkout under ``/mnt/...`` or an absolute path under ``/srv/...`` is
|
|
``os.path.isabs`` and would otherwise look like a valid container workdir.
|
|
"""
|
|
if not cwd or not isinstance(mounted_host, str) or not mounted_host:
|
|
return False
|
|
try:
|
|
left = os.path.normpath(os.path.abspath(os.path.expanduser(cwd)))
|
|
right = os.path.normpath(os.path.abspath(os.path.expanduser(mounted_host)))
|
|
except (OSError, ValueError):
|
|
return False
|
|
return left == right
|
|
|
|
|
|
def _is_unusable_container_cwd(cwd: str, *, mounted_host: str | None = None) -> bool:
|
|
"""True if *cwd* is a host or relative path that can't be a container
|
|
workdir: ``docker run -w`` needs an absolute in-sandbox path, otherwise the
|
|
container fails to start (exit 125). Windows drive paths aren't ``isabs``
|
|
on POSIX, so they're caught by the prefix check.
|
|
|
|
The directory mounted at ``/workspace`` is unusable inside the container
|
|
even when it matches none of those prefixes. That equality check runs
|
|
first so ``/mnt/...`` and ``/srv/...`` are not wrapped as ``cd`` targets.
|
|
"""
|
|
if not cwd:
|
|
return False
|
|
if _is_mounted_host_cwd(cwd, mounted_host):
|
|
return True
|
|
return _is_host_cwd(cwd) or not os.path.isabs(cwd)
|
|
|
|
|
|
def _tenv(name: str, default: str = "") -> str:
|
|
"""Scope-aware read of a ``TERMINAL_*`` variable. Every terminal setting
|
|
must go through this: under gateway multiplexing the active profile's
|
|
config arrives via a per-turn scope, and a raw ``os.getenv`` would read
|
|
whatever a previous turn pinned into the process env (cross-profile leak)."""
|
|
from tools.terminal_scope import terminal_env
|
|
|
|
return terminal_env(name, default)
|
|
|
|
|
|
def _tenv_bool(name: str, default: str) -> bool:
|
|
"""Scope-aware boolean ``TERMINAL_*`` read: true/1/yes (case-insensitive)."""
|
|
return _tenv(name, default).lower() in {"true", "1", "yes"}
|