Files
hermes-agent/tools/terminal_tool_config.py
kshitijk4poor 641c49f841 fix(tools): resolve ssh paths against the remote home, keep guards intact
Follow-up to the ssh path fix salvaged from #121686.

- Read the ssh anchor raw (session record, override, TERMINAL_CWD): the
  shared workspace-root helper expands ~ on the Hermes host, so
  TERMINAL_CWD='~/proj' still resolved into the container home.
- Resolve ~ to the remote home the SSH environment detects at connect,
  bringing the environment up through the file tools' own creator
  (_get_file_ops, same cwd and cache) when none is live. A failed bring-up
  is remembered per container for 30s so one call's several resolutions
  don't each retry; a live environment is always used first. SSHEnvironment
  now records whether the home was detected, and a guessed /home/<user>
  (echo $HOME failed) is not used. Results are
  absolute and stable from the first call (read tracking and staleness
  checks key on them), and '..' normalizes to the real target: relative
  traversal like ../../../etc/x from ~ was refused on main and slipped past
  the sensitive-path guard on the PR head.
- If the remote home cannot be detected, an ssh ~-path that climbs above ~
  cannot be classified; the write guard refuses it.
- ~user passes through for the remote shell instead of becoming ~/~user.
- coerce_ssh_remote_cwd maps paths under the host subprocess home onto ~/,
  except when that home is the OS user's real home.
- The outside-workspace warning compares in the remote namespace (it fired
  on every correct relative write when the anchor was ~).
- The backend type is looked up once per resolution again (the PR head did three per local path).
2026-09-26 06:20:21 +05:30

151 lines
6.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_unusable_container_cwd(cwd: str) -> 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."""
return bool(cwd) and (_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"}