Files
hermes-agent/tools/environments/ssh.py
teknium1 d007afe130 fix(file-sync): bound sync-back leaks by owner PID, make the cap overridable, cover every tar backend
Follow-up to the salvaged #114440 commits:

- Stale sweep: a dead owner's entry goes at once, but a live (or recycled)
  PID no longer exempts an entry from the 30 min age cutoff — a recycled PID
  must not pin a multi-GB leak forever. Liveness via psutil.pid_exists (the
  footgun scanner rejects os.kill(pid, 0): it terminates on Windows), so the
  os.name guard and the two extra helpers collapse into one.
- HERMES_SYNC_BACK_MAX_BYTES overrides the 2 GiB extraction cap for trees that
  legitimately exceed it (the reporter's 4.26 GB tree was downloaded and
  discarded on every attempt); the skip warning names the override.
- Vercel sandbox bulk download excludes *.sock like SSH/Modal/Daytona.
- Tests trimmed to two invariants per file; docs mention the cap override,
  the socket skip and the per-PID temp naming.
2026-09-18 09:30:16 -07:00

298 lines
16 KiB
Python

"""SSH remote execution environment with ControlMaster connection persistence."""
import contextlib
import hashlib
import logging
import os
import shlex
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Iterable
from tools.environments.base import BaseEnvironment, EnvironmentConnectionError
from tools.environments.base_output import _popen_bash
from tools.environments.file_sync import (
FileSyncManager, iter_sync_files, quoted_mkdir_command, quoted_rm_command, unique_parent_dirs)
from tools.environments.remote_common import (
bash_argv, client_env_with, load_hermes_env_vars, prepend_unset, resolve_passthrough_env, run_capture)
logger = logging.getLogger(__name__)
# Windows OpenSSH has no Unix-socket ControlMaster: ControlPath/ControlMaster options
# fail the connection outright ('getsockname failed: Not a socket'). Skip multiplexing there.
# Skip multiplexing there; each command pays a fresh connection but the backend works. See #73927.
_SSH_MULTIPLEX = os.name != "nt"
# Module-level binding: tests patch ``ssh._load_hermes_env_vars`` to fake the .env file.
_load_hermes_env_vars = load_hermes_env_vars
def _ensure_ssh_available() -> None:
"""Fail fast with a clear error when the SSH client is unavailable."""
for tool in ("ssh", "scp"):
if not shutil.which(tool):
raise RuntimeError(f"{tool.upper()} is not installed or not in PATH. "
"Install OpenSSH client: apt install openssh-client")
def _sync_error(reason: str, subject: str, what: str = "the SSH connection") -> EnvironmentConnectionError:
return EnvironmentConnectionError(
reason, retry_hint=f"{subject} failed — verify {what} is healthy, then retry.")
class SSHEnvironment(BaseEnvironment):
"""Run commands on a remote machine over SSH.
Spawn-per-call: every execute() spawns a fresh ``ssh ... bash -c`` process.
Session snapshot preserves env vars across calls; CWD persists via in-band
stdout markers. Uses SSH ControlMaster for connection reuse.
"""
# Passthrough values are re-forwarded on every command (see _run_bash), so like docker/local
# they stay out of the remote snapshot under multiplex.
_profile_scoped_passthrough = True
_sudo_nopasswd_probe_supported = True
def __init__(self, host: str, user: str, cwd: str = "~",
timeout: int = 60, port: int = 22, key_path: str = "",
probe_only: bool = False):
super().__init__(cwd=cwd, timeout=timeout)
self.host, self.user, self.port, self.key_path = host, user, port, key_path
self.control_dir = Path(tempfile.gettempdir()) / "hermes-ssh"
self.control_dir.mkdir(parents=True, exist_ok=True)
# Short, deterministic socket name: the path must stay under macOS's 104-byte sun_path
# limit (raw user@host:port + SSH's 16-byte suffix under a deep $TMPDIR exceeds it), and
# stability across reconnects keeps ControlMaster reuse working. A probe gets its own
# per-instance socket so its cleanup() can never close the agent's shared master.
socket_key = f"{user}@{host}:{port}"
if probe_only:
socket_key = f"{socket_key}:probe:{self._session_id}"
_socket_id = hashlib.sha256(socket_key.encode()).hexdigest()[:16]
self.control_socket = self.control_dir / f"{_socket_id}.sock"
_ensure_ssh_available()
self._establish_connection()
if probe_only:
self._sync_manager = None
return
self._remote_home = self._detect_remote_home()
self._ensure_remote_dirs()
self._sync_manager = FileSyncManager(
get_files_fn=lambda: iter_sync_files(f"{self._remote_home}/.hermes"),
upload_fn=self._scp_upload, delete_fn=self._ssh_delete,
bulk_upload_fn=self._ssh_bulk_upload, bulk_download_fn=self._ssh_bulk_download)
self._sync_manager.sync(force=True)
self.init_session()
def _control_socket_for(self, send_env: tuple[str, ...]) -> Path:
"""One ControlMaster per SendEnv name-set, beside the plain target socket: a mux master only
relays the env names it was itself started with and silently drops the rest, so a passthrough
command must ride a master that knows its names. scp/sync/probes keep the plain socket."""
plain = Path(self.control_socket)
if not send_env:
return plain
# <target-id[:8]><names-hash[:8]>.sock: same length as the plain socket (macOS's 104-byte
# sun_path cap) and prefix-globbable so cleanup() finds every sibling without extra state.
digest = hashlib.sha256(" ".join(send_env).encode()).hexdigest()[:8]
return plain.with_name(f"{plain.stem[:8]}{digest}.sock")
def _control_sockets(self) -> list[Path]:
"""The plain socket plus every SendEnv-set sibling (shared 8-char target prefix)."""
plain = Path(self.control_socket)
siblings = sorted(plain.parent.glob(f"{plain.stem[:8]}*.sock")) if plain.parent.is_dir() else []
return [plain, *(s for s in siblings if s != plain)]
def _target_flags(self, port_flag: str) -> list:
"""Port/key flags shared by ssh (``-p``) and scp (``-P``)."""
flags = [port_flag, str(self.port)] if self.port != 22 else []
return flags + (["-i", self.key_path] if self.key_path else [])
def _build_ssh_command(self, extra_args: list | None = None, send_env: Iterable[str] = ()) -> list:
send_env = tuple(sorted(send_env))
cmd = ["ssh"]
if _SSH_MULTIPLEX:
cmd.extend(["-o", f"ControlPath={self._control_socket_for(send_env)}",
"-o", "ControlMaster=auto", "-o", "ControlPersist=300"])
cmd.extend(["-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=10"])
# Names only; values ride the ssh client's own environment (never the remote command text).
cmd.extend(arg for name in send_env for arg in ("-o", f"SendEnv={name}"))
cmd.extend(self._target_flags("-p"))
cmd.extend(extra_args or [])
cmd.append(f"{self.user}@{self.host}")
return cmd
def _run_ssh(self, remote_cmd: str, timeout: float) -> subprocess.CompletedProcess:
"""Run one remote shell command over the multiplexed connection, capturing output."""
return run_capture(self._build_ssh_command() + [remote_cmd], timeout=timeout)
def _run_ssh_checked(self, remote_cmd: str, timeout: float, reason: str, subject: str) -> None:
result = self._run_ssh(remote_cmd, timeout=timeout)
if result.returncode != 0:
raise _sync_error(f"{reason}: {result.stderr.strip()}", subject)
def _establish_connection(self):
try:
result = self._run_ssh("echo 'SSH connection established'", timeout=15)
except subprocess.TimeoutExpired:
raise EnvironmentConnectionError(
f"SSH connection to {self.user}@{self.host} timed out",
retry_hint=(f"Check that {self.host} is up and reachable on port {self.port} "
"and that sshd is running, then retry."))
if result.returncode != 0:
error_msg = result.stderr.strip() or result.stdout.strip()
raise EnvironmentConnectionError(
f"SSH connection failed: {error_msg}",
retry_hint=(f"Check that {self.host} is up, sshd is running on port {self.port}, and "
f"{self.user} can log in with the configured key, then retry — "
"the connection is re-established automatically."))
def _detect_remote_home(self) -> str:
"""Detect the remote user's home directory."""
with contextlib.suppress(Exception):
result = self._run_ssh("echo $HOME", timeout=10)
if result.returncode == 0 and result.stdout.strip():
logger.debug("SSH: remote home = %s", result.stdout.strip())
return result.stdout.strip()
return "/root" if self.user == "root" else f"/home/{self.user}"
def _ensure_remote_dirs(self) -> None:
"""Create base ~/.hermes directory tree on remote in one SSH call."""
base = f"{self._remote_home}/.hermes"
self._run_ssh(quoted_mkdir_command([base, f"{base}/skills", f"{base}/credentials", f"{base}/cache"]),
timeout=10)
def _scp_upload(self, host_path: str, remote_path: str) -> None:
"""Upload a single file via scp over ControlMaster."""
self._run_ssh(f"mkdir -p {shlex.quote(str(Path(remote_path).parent))}", timeout=10)
scp_cmd = ["scp"] + (["-o", f"ControlPath={self.control_socket}"] if _SSH_MULTIPLEX else [])
scp_cmd += self._target_flags("-P") + [host_path, f"{self.user}@{self.host}:{remote_path}"]
result = run_capture(scp_cmd, timeout=30)
if result.returncode != 0:
raise _sync_error(f"scp failed: {result.stderr.strip()}", f"File sync to {self.user}@{self.host}")
def _ssh_bulk_upload(self, files: list[tuple[str, str]]) -> None:
"""Upload many files in one tar-over-SSH stream: local ``tar c`` piped through one SSH
connection to remote ``tar x``, after a single batched ``mkdir -p``."""
if not files:
return
base = f"{self._remote_home}/.hermes"
parents = unique_parent_dirs(files)
if parents:
self._run_ssh_checked(quoted_mkdir_command(parents), 30, "remote mkdir failed",
f"Remote directory setup on {self.host}")
# Symlink staging avoids fragile GNU tar --transform rules. On Windows
# without Developer Mode symlink creation raises OSError winerror 1314;
# only that case falls back to a plain copy, other OSErrors re-raise.
with tempfile.TemporaryDirectory(prefix="hermes-ssh-bulk-") as staging:
for host_path, remote_path in files:
try:
rel_remote = os.path.relpath(remote_path, base)
except ValueError as exc:
raise RuntimeError(f"remote path {remote_path!r} is not under sync base {base!r}") from exc
if rel_remote == "." or rel_remote.startswith("../"):
raise RuntimeError(f"remote path {remote_path!r} escapes sync base {base!r}")
staged = os.path.join(staging, rel_remote)
os.makedirs(os.path.dirname(staged), exist_ok=True)
try:
os.symlink(os.path.abspath(host_path), staged)
except OSError as e:
if getattr(e, "winerror", None) != 1314:
raise
shutil.copy2(host_path, staged)
# --no-overwrite-dir keeps tar from stamping the staging dir's mode onto
# existing dirs (e.g. /home/<user>); a umask-002 0775 home breaks sshd StrictModes.
ssh_cmd = self._build_ssh_command() + [f"tar xf - --no-overwrite-dir -C {shlex.quote(base)}"]
tar_proc = subprocess.Popen(["tar", "-chf", "-", "-C", staging, "."], stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
try:
ssh_proc = subprocess.Popen(ssh_cmd, stdin=tar_proc.stdout,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
except Exception:
tar_proc.kill()
tar_proc.wait()
raise
tar_proc.stdout.close() # let tar_proc receive SIGPIPE if ssh_proc exits early
try:
_, ssh_stderr = ssh_proc.communicate(timeout=120)
# communicate() (not wait()) drains stderr so tar can't deadlock on >PIPE_BUF errors.
if tar_proc.poll() is None:
_, tar_stderr_raw = tar_proc.communicate(timeout=10)
else:
tar_stderr_raw = tar_proc.stderr.read() if tar_proc.stderr else b""
except subprocess.TimeoutExpired:
for proc in (tar_proc, ssh_proc):
proc.kill()
for proc in (tar_proc, ssh_proc):
proc.wait() # kill both first, then reap: never wait on one while the other blocks
raise EnvironmentConnectionError(
"SSH bulk upload timed out",
retry_hint=f"Bulk file sync to {self.host} timed out — check the connection and retry.")
if tar_proc.returncode != 0:
raise RuntimeError(f"tar create failed (rc={tar_proc.returncode}): "
f"{tar_stderr_raw.decode(errors='replace').strip()}")
if ssh_proc.returncode != 0:
raise _sync_error(f"tar extract over SSH failed (rc={ssh_proc.returncode}): "
f"{ssh_stderr.decode(errors='replace').strip()}",
f"File sync over SSH to {self.host}", what="the connection")
logger.debug("SSH: bulk-uploaded %d file(s) via tar pipe", len(files))
def _ssh_bulk_download(self, dest: Path) -> None:
"""Download remote .hermes/ as a tar archive."""
# Tar from / with the full path so archive entries keep absolute paths
# (home/user/.hermes/skills/f.py), matching _pushed_hashes keys.
rel_base = f"{self._remote_home}/.hermes".lstrip("/")
# Live sockets inside .hermes (gateway.sock and friends) cannot be archived: tar prints
# "socket ignored" and some builds exit 2, which failed every sync-back and left a
# multi-GB temp tar behind on each retry. Exclude them up front.
ssh_cmd = self._build_ssh_command() + [
f"tar cf - --exclude='*.sock' -C / {shlex.quote(rel_base)}"]
with open(dest, "wb") as f:
result = subprocess.run(ssh_cmd, stdin=subprocess.DEVNULL, stdout=f, stderr=subprocess.PIPE, timeout=120)
if result.returncode != 0:
stderr = result.stderr.decode(errors="replace").strip()
# A socket not named *.sock is the only rc=2 we knowingly accept, and only when
# nothing else was reported — anchored to the diagnostic suffix so a filename merely
# containing "socket ignored" cannot sneak through. Every other status still fails.
diagnostic_lines = [line for line in stderr.splitlines() if line.strip()]
tolerated = result.returncode == 2 and bool(diagnostic_lines) and all(
line.endswith(": socket ignored") for line in diagnostic_lines)
if not tolerated:
raise _sync_error(f"SSH bulk download failed: {stderr}",
f"File sync from {self.host}")
def _ssh_delete(self, remote_paths: list[str]) -> None:
self._run_ssh_checked(quoted_rm_command(remote_paths), 10, "remote rm failed",
f"Remote file cleanup on {self.host}")
def _before_execute(self) -> None:
if self._sync_manager is not None:
self._sync_manager.sync() # rate-limited internally
def _run_bash(self, cmd_string: str, *, login: bool = False, timeout: int = 120,
stdin_data: str | None = None) -> subprocess.Popen:
"""Forward the passthrough allowlist (skill ``required_environment_variables`` +
``terminal.env_passthrough``) the way docker does: ``SendEnv`` carries the names, the ssh
client's env carries the values, so secrets never enter the remote ``bash -c`` argv. The
remote sshd must ``AcceptEnv`` them (#14091). Profile-scoped names missing from the active
scope are unset remotely so a shared host cannot serve another profile's value."""
values, unset_names = resolve_passthrough_env(hermes_env_loader=_load_hermes_env_vars)
cmd = self._build_ssh_command(send_env=values) + bash_argv(shlex.quote(prepend_unset(cmd_string, unset_names)), login)
client_env = client_env_with(values)
return _popen_bash(cmd, stdin_data, env=client_env) if client_env is not None else _popen_bash(cmd, stdin_data)
def cleanup(self):
if self._sync_manager:
logger.info("SSH: syncing files from sandbox...")
self._sync_manager.sync_back()
for socket in self._control_sockets():
if not socket.exists():
continue
with contextlib.suppress(OSError, subprocess.SubprocessError):
cmd = ["ssh", "-o", f"ControlPath={socket}", "-O", "exit", f"{self.user}@{self.host}"]
subprocess.run(cmd, capture_output=True, timeout=5, stdin=subprocess.DEVNULL)
with contextlib.suppress(OSError):
socket.unlink()