Files
hermes-agent/tools/environments/base.py

377 lines
14 KiB
Python
Raw Normal View History

2026-02-21 22:31:43 -08:00
"""Base class for all Hermes execution environment backends."""
from abc import ABC, abstractmethod
import logging
import os
import shlex
2026-02-21 22:31:43 -08:00
import subprocess
import threading
import time
import uuid
from pathlib import Path
from typing import Protocol, runtime_checkable
from hermes_constants import get_hermes_home
from tools.interrupt import is_interrupted
logger = logging.getLogger(__name__)
fix(cli): respect HERMES_HOME in all remaining hardcoded ~/.hermes paths Several files resolved paths via Path.home() / ".hermes" or os.path.expanduser("~/.hermes/..."), bypassing the HERMES_HOME environment variable. This broke isolation when running multiple Hermes instances with distinct HERMES_HOME directories. Replace all hardcoded paths with calls to get_hermes_home() from hermes_cli.config, consistent with the rest of the codebase. Files fixed: - tools/process_registry.py (processes.json) - gateway/pairing.py (pairing/) - gateway/sticker_cache.py (sticker_cache.json) - gateway/channel_directory.py (channel_directory.json, sessions.json) - gateway/config.py (gateway.json, config.yaml, sessions_dir) - gateway/mirror.py (sessions/) - gateway/hooks.py (hooks/) - gateway/platforms/base.py (image_cache/, audio_cache/, document_cache/) - gateway/platforms/whatsapp.py (whatsapp/session) - gateway/delivery.py (cron/output) - agent/auxiliary_client.py (auth.json) - agent/prompt_builder.py (SOUL.md) - cli.py (config.yaml, images/, pastes/, history) - run_agent.py (logs/) - tools/environments/base.py (sandboxes/) - tools/environments/modal.py (modal_snapshots.json) - tools/environments/singularity.py (singularity_snapshots.json) - tools/tts_tool.py (audio_cache) - hermes_cli/status.py (cron/jobs.json, sessions.json) - hermes_cli/gateway.py (logs/, whatsapp session) - hermes_cli/main.py (whatsapp/session) Tests updated to use HERMES_HOME env var instead of patching Path.home(). Closes #892 (cherry picked from commit 78ac1bba43b8b74a934c6172f2c29bb4d03164b9)
2026-03-11 07:31:41 +01:00
def get_sandbox_dir() -> Path:
"""Return the host-side root for all sandbox storage (Docker workspaces,
Singularity overlays/SIF cache, etc.).
fix(cli): respect HERMES_HOME in all remaining hardcoded ~/.hermes paths Several files resolved paths via Path.home() / ".hermes" or os.path.expanduser("~/.hermes/..."), bypassing the HERMES_HOME environment variable. This broke isolation when running multiple Hermes instances with distinct HERMES_HOME directories. Replace all hardcoded paths with calls to get_hermes_home() from hermes_cli.config, consistent with the rest of the codebase. Files fixed: - tools/process_registry.py (processes.json) - gateway/pairing.py (pairing/) - gateway/sticker_cache.py (sticker_cache.json) - gateway/channel_directory.py (channel_directory.json, sessions.json) - gateway/config.py (gateway.json, config.yaml, sessions_dir) - gateway/mirror.py (sessions/) - gateway/hooks.py (hooks/) - gateway/platforms/base.py (image_cache/, audio_cache/, document_cache/) - gateway/platforms/whatsapp.py (whatsapp/session) - gateway/delivery.py (cron/output) - agent/auxiliary_client.py (auth.json) - agent/prompt_builder.py (SOUL.md) - cli.py (config.yaml, images/, pastes/, history) - run_agent.py (logs/) - tools/environments/base.py (sandboxes/) - tools/environments/modal.py (modal_snapshots.json) - tools/environments/singularity.py (singularity_snapshots.json) - tools/tts_tool.py (audio_cache) - hermes_cli/status.py (cron/jobs.json, sessions.json) - hermes_cli/gateway.py (logs/, whatsapp session) - hermes_cli/main.py (whatsapp/session) Tests updated to use HERMES_HOME env var instead of patching Path.home(). Closes #892 (cherry picked from commit 78ac1bba43b8b74a934c6172f2c29bb4d03164b9)
2026-03-11 07:31:41 +01:00
Configurable via TERMINAL_SANDBOX_DIR. Defaults to {HERMES_HOME}/sandboxes/.
"""
custom = os.getenv("TERMINAL_SANDBOX_DIR")
if custom:
p = Path(custom)
else:
fix(cli): respect HERMES_HOME in all remaining hardcoded ~/.hermes paths Several files resolved paths via Path.home() / ".hermes" or os.path.expanduser("~/.hermes/..."), bypassing the HERMES_HOME environment variable. This broke isolation when running multiple Hermes instances with distinct HERMES_HOME directories. Replace all hardcoded paths with calls to get_hermes_home() from hermes_cli.config, consistent with the rest of the codebase. Files fixed: - tools/process_registry.py (processes.json) - gateway/pairing.py (pairing/) - gateway/sticker_cache.py (sticker_cache.json) - gateway/channel_directory.py (channel_directory.json, sessions.json) - gateway/config.py (gateway.json, config.yaml, sessions_dir) - gateway/mirror.py (sessions/) - gateway/hooks.py (hooks/) - gateway/platforms/base.py (image_cache/, audio_cache/, document_cache/) - gateway/platforms/whatsapp.py (whatsapp/session) - gateway/delivery.py (cron/output) - agent/auxiliary_client.py (auth.json) - agent/prompt_builder.py (SOUL.md) - cli.py (config.yaml, images/, pastes/, history) - run_agent.py (logs/) - tools/environments/base.py (sandboxes/) - tools/environments/modal.py (modal_snapshots.json) - tools/environments/singularity.py (singularity_snapshots.json) - tools/tts_tool.py (audio_cache) - hermes_cli/status.py (cron/jobs.json, sessions.json) - hermes_cli/gateway.py (logs/, whatsapp session) - hermes_cli/main.py (whatsapp/session) Tests updated to use HERMES_HOME env var instead of patching Path.home(). Closes #892 (cherry picked from commit 78ac1bba43b8b74a934c6172f2c29bb4d03164b9)
2026-03-11 07:31:41 +01:00
p = get_hermes_home() / "sandboxes"
p.mkdir(parents=True, exist_ok=True)
return p
2026-02-21 22:31:43 -08:00
@runtime_checkable
class ProcessHandle(Protocol):
"""Duck type for anything _run_bash returns.
subprocess.Popen satisfies this natively. SDK backends (Modal, Daytona)
return small adapters that wrap async/blocking calls in a thread + OS pipe.
"""
def poll(self) -> int | None: ...
def kill(self) -> None: ...
def wait(self, timeout: float | None = None) -> int: ...
@property
def stdout(self): ... # readable, iterable-of-str (for drain thread)
@property
def returncode(self) -> int | None: ...
2026-02-21 22:31:43 -08:00
class BaseEnvironment(ABC):
"""Common interface for all Hermes execution backends.
**Unified execution model (spawn-per-call):**
Backends implement ``_run_bash()`` the ONLY thing that differs per
backend. Everything else (command wrapping, CWD tracking, snapshot
management, timeout/interrupt handling, output collection) lives here.
Backends that cannot return a ProcessHandle (e.g. HTTP-based
ManagedModal) may override ``execute()`` directly and use
``_wrap_command()`` for command shaping only.
2026-02-21 22:31:43 -08:00
"""
def __init__(self, cwd: str, timeout: int, env: dict = None):
self.cwd = cwd
self.timeout = timeout
self.env = env or {}
self._snapshot_path: str | None = None
self._cwdfile_path: str | None = None
self._snapshot_ready: bool = False
self._session_id: str = ""
# ------------------------------------------------------------------
# Abstract — the ONLY thing backends implement
# ------------------------------------------------------------------
def _run_bash(self, cmd_string: str, *,
stdin_data: str | None = None) -> ProcessHandle:
"""Spawn ``bash -c <cmd_string>`` in the backend.
Returns a ProcessHandle (subprocess.Popen or equivalent adapter).
The caller owns polling, timeout, output collection, and cleanup.
If *stdin_data* is provided, write it to the process's stdin and
close. Backends that cannot pipe stdin (Modal, Daytona) must embed
it via heredoc in *cmd_string* before calling their SDK.
Subclasses MUST override this. The base implementation raises
NotImplementedError (not declared abstract so legacy backends that
still override execute() directly can be instantiated during migration).
"""
raise NotImplementedError(
f"{type(self).__name__} must implement _run_bash()"
)
def cleanup(self):
"""Release backend resources (container, instance, connection).
Subclasses should override. Base implementation cleans up snapshot
and cwdfile if they exist.
"""
pass
# ------------------------------------------------------------------
# Snapshot — login-shell env capture (called once at session init)
# ------------------------------------------------------------------
def _run_bash_login(self, cmd_string: str) -> ProcessHandle:
"""Spawn ``bash -l -c <cmd_string>`` for snapshot creation.
Defaults to ``_run_bash`` backends override this when the login
flag needs different handling (e.g. local adds ``-l`` to Popen args).
"""
return self._run_bash(cmd_string)
def init_session(self):
"""Capture the login-shell environment into a snapshot file.
Called once after ``__init__`` completes. If it fails, commands
still work they just don't get env restoration.
"""
self._session_id = uuid.uuid4().hex[:12]
self._snapshot_path = f"/tmp/hermes-snap-{self._session_id}.sh"
self._cwdfile_path = f"/tmp/hermes-cwd-{self._session_id}"
bootstrap = (
f"set +e\n"
f"export -p > {self._snapshot_path}\n"
f"if type declare >/dev/null 2>&1; then "
f"declare -f >> {self._snapshot_path} 2>/dev/null; fi\n"
f"alias -p >> {self._snapshot_path} 2>/dev/null || true\n"
f"echo 'set +e' >> {self._snapshot_path}\n"
f"echo 'set +u' >> {self._snapshot_path}\n"
f"pwd -P >| {self._cwdfile_path}\n"
)
try:
proc = self._run_bash_login(bootstrap)
result = self._wait_for_process(proc, timeout=15)
if result["returncode"] == 0:
self._snapshot_ready = True
logger.info(
"Snapshot created (session=%s)", self._session_id,
)
else:
logger.warning(
"Snapshot creation failed (rc=%d), commands will "
"run without env restoration", result["returncode"],
)
except Exception as e:
logger.warning("Snapshot creation failed: %s", e)
# Pick up the reported cwd if available
reported_cwd = self._read_file_in_env(self._cwdfile_path).strip()
if reported_cwd:
self.cwd = reported_cwd
# ------------------------------------------------------------------
# Command wrapping
# ------------------------------------------------------------------
def _wrap_command(self, command: str, cwd: str) -> str:
"""Wrap a user command with snapshot sourcing and CWD tracking.
Returns a bash script string.
"""
parts: list[str] = []
# 1. Source snapshot (if available)
if self._snapshot_ready and self._snapshot_path:
parts.append(
f"source {self._snapshot_path} 2>/dev/null || true"
)
# 2. cd to working directory
work_dir = cwd or self.cwd
if work_dir:
parts.append(f"cd {shlex.quote(work_dir)} || exit 1")
# 3. The actual command (eval to handle complex shell syntax)
escaped = command.replace("'", "'\\''")
parts.append(f"eval '{escaped}'")
# 4. Capture exit code, then record CWD
parts.append("__hermes_ec=$?")
if self._cwdfile_path:
parts.append(f"pwd -P >| {self._cwdfile_path}")
parts.append("exit $__hermes_ec")
return "\n".join(parts)
# ------------------------------------------------------------------
# Unified execute()
# ------------------------------------------------------------------
2026-02-21 22:31:43 -08:00
def execute(self, command: str, cwd: str = "", *,
timeout: int | None = None,
stdin_data: str | None = None) -> dict:
"""Execute a command, return ``{"output": str, "returncode": int}``."""
self._before_execute()
2026-02-21 22:31:43 -08:00
exec_command, sudo_stdin = self._prepare_command(command)
# Merge sudo stdin with caller stdin
effective_stdin: str | None = None
if sudo_stdin is not None and stdin_data is not None:
effective_stdin = sudo_stdin + stdin_data
elif sudo_stdin is not None:
effective_stdin = sudo_stdin
else:
effective_stdin = stdin_data
wrapped = self._wrap_command(exec_command, cwd)
effective_timeout = timeout or self.timeout
proc = self._run_bash(wrapped, stdin_data=effective_stdin)
result = self._wait_for_process(proc, timeout=effective_timeout)
# Update CWD from the cwdfile written by the wrapping template
self._update_cwd_from_file()
return result
# ------------------------------------------------------------------
# Process lifecycle (shared — not overridden except _kill_process)
# ------------------------------------------------------------------
def _wait_for_process(self, proc: ProcessHandle,
timeout: int) -> dict:
"""Poll process with interrupt checking, drain stdout, enforce timeout."""
output_chunks: list[str] = []
def _drain():
try:
for line in proc.stdout:
output_chunks.append(line)
except (ValueError, OSError):
pass
reader = threading.Thread(target=_drain, daemon=True)
reader.start()
deadline = time.monotonic() + timeout
while proc.poll() is None:
if is_interrupted():
self._kill_process(proc)
reader.join(timeout=2)
partial = "".join(output_chunks)
return {
"output": partial + "\n[Command interrupted]",
"returncode": 130,
}
if time.monotonic() > deadline:
self._kill_process(proc)
reader.join(timeout=2)
partial = "".join(output_chunks)
msg = f"\n[Command timed out after {timeout}s]"
return {
"output": (partial + msg) if partial else msg.lstrip(),
"returncode": 124,
}
time.sleep(0.2)
reader.join(timeout=5)
return {"output": "".join(output_chunks), "returncode": proc.returncode}
def _kill_process(self, proc: ProcessHandle):
"""Kill a process. Backends may override for process-group kill."""
try:
if hasattr(proc, "terminate"):
proc.terminate()
try:
proc.wait(timeout=1.0)
return
except Exception:
pass
proc.kill()
except (ProcessLookupError, PermissionError, OSError):
pass
# ------------------------------------------------------------------
# CWD tracking
# ------------------------------------------------------------------
def _update_cwd_from_file(self):
"""Read the cwdfile after process exit, update self.cwd."""
if not self._cwdfile_path:
return
try:
new_cwd = self._read_file_in_env(self._cwdfile_path).strip()
if new_cwd:
self.cwd = new_cwd
except Exception:
pass # CWD tracking is best-effort
def _read_file_in_env(self, path: str) -> str:
"""Read a file inside the backend's execution context.
Default: run ``cat <path>`` via _run_bash. Backends with faster
methods (local: ``open()``) should override.
"""
proc = self._run_bash(f"cat {shlex.quote(path)} 2>/dev/null")
result = self._wait_for_process(proc, timeout=5)
return result.get("output", "")
# ------------------------------------------------------------------
# Hooks for subclasses
# ------------------------------------------------------------------
def _before_execute(self):
"""Hook for pre-execution sync (SSH rsync, Modal file push, etc.)."""
pass
# ------------------------------------------------------------------
# Compat
# ------------------------------------------------------------------
2026-02-21 22:31:43 -08:00
def stop(self):
"""Alias for cleanup (compat with older callers)."""
self.cleanup()
def __del__(self):
try:
self.cleanup()
except Exception:
pass
# ------------------------------------------------------------------
# Shared helpers (kept for backward compat during migration)
2026-02-21 22:31:43 -08:00
# ------------------------------------------------------------------
def _prepare_command(self, command: str) -> tuple[str, str | None]:
"""Transform sudo commands if SUDO_PASSWORD is available."""
2026-02-21 22:31:43 -08:00
from tools.terminal_tool import _transform_sudo_command
return _transform_sudo_command(command)
def _build_run_kwargs(self, timeout: int | None,
stdin_data: str | None = None) -> dict:
"""Build common subprocess.run kwargs for non-interactive execution."""
kw = {
"text": True,
"timeout": timeout or self.timeout,
"encoding": "utf-8",
"errors": "replace",
"stdout": subprocess.PIPE,
"stderr": subprocess.STDOUT,
}
if stdin_data is not None:
kw["input"] = stdin_data
else:
kw["stdin"] = subprocess.DEVNULL
return kw
feat: execute_code runs on remote terminal backends (#5088) * feat: execute_code runs on remote terminal backends (Docker/SSH/Modal/Daytona/Singularity) When TERMINAL_ENV is not 'local', execute_code now ships the script to the remote environment and runs it there via the terminal backend -- the same container/sandbox/SSH session used by terminal() and file tools. Architecture: - Local backend: unchanged (UDS RPC, subprocess.Popen) - Remote backends: file-based RPC via execute_oneshot() polling - Script writes request files, parent polls and dispatches tool calls - Responses written atomically (tmp + rename) via base64/stdin - execute_oneshot() bypasses persistent shell lock for concurrency Changes: - tools/environments/base.py: add execute_oneshot() (delegates to execute()) - tools/environments/persistent_shell.py: override execute_oneshot() to bypass _shell_lock via _execute_oneshot(), enabling concurrent polling - tools/code_execution_tool.py: add file-based transport to generate_hermes_tools_module(), _execute_remote() with full env get-or-create, file shipping, RPC poll loop, output post-processing * fix: use _get_env_config() instead of raw TERMINAL_ENV env var Read terminal backend type through the canonical config resolution path (terminal_tool._get_env_config) instead of os.getenv directly. * fix: use echo piping instead of stdin_data for base64 writes Modal doesn't reliably deliver stdin_data to chained commands (base64 -d > file && mv), producing 0-byte files. Switch to echo 'base64' | base64 -d which works on all backends. Verified E2E on both Docker and Modal.
2026-04-04 12:57:49 -07:00
def execute_oneshot(self, command: str, cwd: str = "", *,
timeout: int | None = None,
stdin_data: str | None = None) -> dict:
"""Execute a command bypassing any persistent shell.
Safe for concurrent use alongside a long-running execute() call.
Backends that maintain a persistent shell (SSH, Local) override this
to route through their oneshot path, avoiding the shell lock.
Non-persistent backends delegate to execute().
"""
return self.execute(command, cwd=cwd, timeout=timeout,
stdin_data=stdin_data)
2026-02-21 22:31:43 -08:00
def _timeout_result(self, timeout: int | None) -> dict:
"""Standard return dict when a command times out."""
return {
"output": f"Command timed out after {timeout or self.timeout}s",
"returncode": 124,
}