Files
hermes-agent/tools/terminal_tool_backends.py

483 lines
19 KiB
Python

"""Execution-environment backends for the terminal tool: per-backend builders
(local/docker/singularity/modal/daytona/vercel/ssh/plugin), the config-to-
kwargs shapers, and the per-backend requirement checkers, both routed by
dispatch table.
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 importlib.util
import inspect
import shutil
import subprocess
from typing import Any, Dict, Optional
from tools.environments.local import LocalEnvironment as _LocalEnvironment
from tools.environments.managed_modal import (
ManagedModalEnvironment as _ManagedModalEnvironment,
)
from tools.environments.modal import ModalEnvironment as _ModalEnvironment
from tools.environments.singularity import (
SingularityEnvironment as _SingularityEnvironment,
)
from tools.environments.ssh import SSHEnvironment as _SSHEnvironment
from tools.tool_backend_helpers import (
has_direct_modal_credentials,
nous_tool_gateway_unavailable_message,
resolve_modal_backend_state,
)
# Log-record parity with the origin module.
logger = logging.getLogger("tools.terminal_tool")
_VERCEL_SANDBOX_DEFAULT_CWD = "/vercel/sandbox"
_SUPPORTED_VERCEL_RUNTIMES = ("node24", "node22", "python3.13")
def _is_supported_vercel_runtime(runtime: str) -> bool:
return not runtime or runtime in _SUPPORTED_VERCEL_RUNTIMES
def _check_vercel_sandbox_requirements(config: dict[str, Any]) -> bool:
"""Validate Vercel Sandbox terminal backend requirements."""
runtime = (config.get("vercel_runtime") or "").strip()
if not _is_supported_vercel_runtime(runtime):
supported = ", ".join(_SUPPORTED_VERCEL_RUNTIMES)
logger.error(
"Vercel Sandbox runtime %r is not supported. "
"Set TERMINAL_VERCEL_RUNTIME to one of: %s.",
runtime,
supported,
)
return False
disk = config.get("container_disk", 51200)
if disk not in {0, 51200}:
logger.error(
"Vercel Sandbox does not support custom TERMINAL_CONTAINER_DISK=%s. "
"Use the default shared setting (51200 MB).",
disk,
)
return False
if importlib.util.find_spec("vercel") is None:
logger.error(
"vercel is required for the Vercel Sandbox terminal backend: pip install vercel"
)
return False
from agent.secret_scope import get_secret
has_oidc = bool(get_secret("VERCEL_OIDC_TOKEN"))
has_token = bool(get_secret("VERCEL_TOKEN"))
has_project = bool(get_secret("VERCEL_PROJECT_ID"))
has_team = bool(get_secret("VERCEL_TEAM_ID"))
if has_oidc:
return True
if has_token or has_project or has_team:
if has_token and has_project and has_team:
return True
logger.error(
"Vercel Sandbox backend selected with token auth, but "
"VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID must all "
"be set together. VERCEL_OIDC_TOKEN is supported for one-off "
"local development only."
)
return False
logger.error(
"Vercel Sandbox backend selected but no supported auth configuration "
"was found. Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID "
"for normal use. VERCEL_OIDC_TOKEN is supported for one-off local "
"development only."
)
return False
def _get_modal_backend_state(modal_mode: object | None) -> Dict[str, Any]:
"""Resolve direct vs managed Modal backend selection."""
from tools.terminal_tool import is_managed_tool_gateway_ready
return resolve_modal_backend_state(
modal_mode,
has_direct=has_direct_modal_credentials(),
managed_ready=is_managed_tool_gateway_ready("modal"),
)
def _ssh_config_from_config(config: Dict[str, Any]) -> dict:
"""``ssh_config`` for :func:`_create_environment` (shared by terminal_tool
and the lazy :func:`ensure_task_env` bring-up)."""
return {
"host": config.get("ssh_host", ""),
"user": config.get("ssh_user", ""),
"port": config.get("ssh_port", 22),
"key": config.get("ssh_key", ""),
"persistent": config.get("ssh_persistent", False),
}
def _container_config_from_config(config: Dict[str, Any]) -> dict:
"""``container_config`` for :func:`_create_environment` (shared by
terminal_tool and the lazy :func:`ensure_task_env` bring-up)."""
return {
"container_cpu": config.get("container_cpu", 1),
"container_memory": config.get("container_memory", 5120),
"container_disk": config.get("container_disk", 51200),
"container_persistent": config.get("container_persistent", True),
"modal_mode": config.get("modal_mode", "auto"),
"vercel_runtime": config.get("vercel_runtime", ""),
"docker_volumes": config.get("docker_volumes", []),
"docker_mount_cwd_to_workspace": config.get("docker_mount_cwd_to_workspace", False),
"docker_forward_env": config.get("docker_forward_env", []),
"docker_env": config.get("docker_env", {}),
"docker_run_as_host_user": config.get("docker_run_as_host_user", False),
"docker_extra_args": config.get("docker_extra_args", []),
"docker_shm_size": config.get("docker_shm_size", "1g"),
"docker_network": config.get("docker_network", True),
"docker_persist_across_processes": config.get("docker_persist_across_processes", True),
"docker_shared_container_key": config.get("docker_shared_container_key", ""),
"docker_orphan_reaper": config.get("docker_orphan_reaper", True),
}
def _resources(cc: Dict[str, Any]) -> dict:
"""Common sandbox resource kwargs (cpu/memory in MB/disk in MB/persistence)."""
return {
"cpu": cc.get("container_cpu", 1),
"memory": cc.get("container_memory", 5120),
"disk": cc.get("container_disk", 51200),
"persistent_filesystem": cc.get("container_persistent", True),
}
def _build_local_env(*, cwd, timeout, **_):
return _LocalEnvironment(cwd=cwd, timeout=timeout)
def _build_docker_env(*, image, cwd, timeout, cc, task_id, host_cwd, **_):
# One-shot orphan reaper for labeled containers left behind by prior
# Hermes processes that died before atexit (SIGKILL / OOM / closed
# terminal); once per process, ``terminal.docker_orphan_reaper: false``
# disables it.
from tools.terminal_tool import _DockerEnvironment, _docker_session_isolation_enabled, _has_isolation_overrides, _maybe_reap_docker_orphans
_maybe_reap_docker_orphans(cc)
# Per-session container isolation: a session-keyed container must not
# outlive its session, so cross-process reuse/persist is disabled for it —
# cleanup_vm()/the idle reaper stop+rm it. The shared "default" container
# and RL/benchmark override sandboxes keep their existing lifecycle.
session_scoped = (
_docker_session_isolation_enabled()
and task_id != "default"
and not _has_isolation_overrides(task_id)
)
docker_env_obj = _DockerEnvironment(
image=image, cwd=cwd, timeout=timeout, task_id=task_id,
**_resources(cc),
volumes=cc.get("docker_volumes", []),
host_cwd=host_cwd,
auto_mount_cwd=cc.get("docker_mount_cwd_to_workspace", False),
forward_env=cc.get("docker_forward_env", []),
env=cc.get("docker_env", {}),
run_as_host_user=cc.get("docker_run_as_host_user", False),
network=cc.get("docker_network", True),
extra_args=cc.get("docker_extra_args", []),
persist_across_processes=(
False if session_scoped
else cc.get("docker_persist_across_processes", True)
),
shared_container_key=cc.get("docker_shared_container_key", ""),
shm_size=cc.get("docker_shm_size", "1g"),
)
# Marker read by is_persistent_env(): a session-scoped container survives
# BETWEEN turns (skip per-turn teardown) but is removed at session close /
# idle timeout. Guarded: test doubles may not accept attributes.
if session_scoped:
try:
docker_env_obj._session_scoped = True
except AttributeError:
pass
return docker_env_obj
def _build_singularity_env(*, image, cwd, timeout, cc, task_id, **_):
return _SingularityEnvironment(
image=image, cwd=cwd, timeout=timeout, task_id=task_id, **_resources(cc),
)
def _build_modal_env(*, image, cwd, timeout, cc, task_id, **_):
from tools.terminal_tool import managed_nous_tools_enabled
res = _resources(cc)
persistent = res["persistent_filesystem"]
sandbox_kwargs = {k: res[k] for k in ("cpu", "memory") if res[k] > 0}
if res["disk"] > 0:
try:
import modal
if "ephemeral_disk" in inspect.signature(modal.Sandbox.create).parameters:
sandbox_kwargs["ephemeral_disk"] = res["disk"]
except Exception:
pass
modal_state = _get_modal_backend_state(cc.get("modal_mode"))
if modal_state["selected_backend"] == "managed":
return _ManagedModalEnvironment(
image=image, cwd=cwd, timeout=timeout,
modal_sandbox_kwargs=sandbox_kwargs,
persistent_filesystem=persistent, task_id=task_id,
)
if modal_state["selected_backend"] != "direct":
if modal_state["managed_mode_blocked"]:
raise ValueError(
"Modal backend is configured for managed mode, but "
"Nous Tool Gateway access is not currently available and no direct "
"Modal credentials/config were found. "
+ nous_tool_gateway_unavailable_message(
"managed Modal execution",
)
+ " Choose TERMINAL_MODAL_MODE=direct/auto to use direct Modal credentials."
)
if modal_state["mode"] == "managed":
raise ValueError(
"Modal backend is configured for managed mode, but the managed tool gateway is unavailable. "
+ nous_tool_gateway_unavailable_message(
"managed Modal execution",
)
)
if modal_state["mode"] == "direct":
raise ValueError(
"Modal backend is configured for direct mode, but no direct Modal credentials/config were found."
)
message = "Modal backend selected but no direct Modal credentials/config was found."
if managed_nous_tools_enabled():
message = (
"Modal backend selected but no direct Modal credentials/config or managed tool gateway was found."
)
raise ValueError(message)
return _ModalEnvironment(
image=image, cwd=cwd, timeout=timeout,
modal_sandbox_kwargs=sandbox_kwargs,
persistent_filesystem=persistent, task_id=task_id,
)
def _build_daytona_env(*, image, cwd, timeout, cc, task_id, **_):
# Lazy import so daytona SDK is only required when backend is selected.
from tools.environments.daytona import DaytonaEnvironment as _DaytonaEnvironment
res = _resources(cc)
res["cpu"] = int(res["cpu"])
return _DaytonaEnvironment(image=image, cwd=cwd, timeout=timeout, task_id=task_id, **res)
def _build_vercel_env(*, cwd, timeout, cc, task_id, **_):
from tools.environments.vercel_sandbox import (
VercelSandboxEnvironment as _VercelSandboxEnvironment,
)
return _VercelSandboxEnvironment(
runtime=cc.get("vercel_runtime") or None,
cwd=cwd, timeout=timeout, task_id=task_id, **_resources(cc),
)
def _build_ssh_env(*, cwd, timeout, ssh_config, **_):
if not ssh_config or not ssh_config.get("host") or not ssh_config.get("user"):
raise ValueError("SSH environment requires ssh_host and ssh_user to be configured")
return _SSHEnvironment(
host=ssh_config["host"],
user=ssh_config["user"],
port=ssh_config.get("port", 22),
key_path=ssh_config.get("key", ""),
cwd=cwd,
timeout=timeout,
)
def _build_plugin_env(*, env_type, image, cwd, timeout, cc, task_id, **_):
from tools.terminal_tool import _get_plugin_env_provider
provider = _get_plugin_env_provider(env_type)
if provider is not None:
env_obj = provider.create_environment(
cwd=cwd, timeout=timeout, task_id=task_id,
image=image, container_config=cc,
)
# Stamp the backend name so path-resolution and progress surfaces
# can identify plugin backends without class-name sniffing.
try:
env_obj._hermes_backend_name = provider.name.strip().lower()
except AttributeError:
pass # test doubles may reject attributes
return env_obj
try:
from agent.terminal_env_registry import plugin_backend_names
plugin_names = plugin_backend_names()
except Exception:
plugin_names = []
extra = (
", " + ", ".join(f"'{n}'" for n in plugin_names) if plugin_names else ""
)
raise ValueError(
f"Unknown environment type: {env_type}. Use 'local', 'docker', "
f"'singularity', 'modal', 'daytona', 'vercel_sandbox', 'ssh'{extra}"
)
# Built-in backend -> builder. Anything else is looked up in the plugin registry.
_ENV_BUILDERS = {
"local": _build_local_env,
"docker": _build_docker_env,
"singularity": _build_singularity_env,
"modal": _build_modal_env,
"daytona": _build_daytona_env,
"vercel_sandbox": _build_vercel_env,
"ssh": _build_ssh_env,
}
def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
ssh_config: dict = None, container_config: dict = None,
local_config: dict = None,
task_id: str = "default",
host_cwd: Optional[str] = None):
"""Create an execution environment (instance with ``execute()``) for *env_type*.
``image`` is ignored for local/ssh/vercel; ``container_config`` carries the
container_*/docker_* resource keys; ``host_cwd`` is the host directory to
bind into Docker when cwd mounting is explicitly enabled. Unknown
``env_type`` values fall through to plugin-registered backends.
"""
builder = _ENV_BUILDERS.get(env_type, _build_plugin_env)
return builder(
env_type=env_type, image=image, cwd=cwd, timeout=timeout,
cc=container_config or {}, task_id=task_id,
ssh_config=ssh_config, host_cwd=host_cwd,
)
def _check_docker_requirements(config: Dict[str, Any]) -> bool:
from tools.environments.docker import find_docker
docker = find_docker()
if not docker:
logger.error("Docker executable not found in PATH or common install locations")
return False
result = subprocess.run([docker, "version"], capture_output=True, timeout=5, stdin=subprocess.DEVNULL)
return result.returncode == 0
def _check_singularity_requirements(config: Dict[str, Any]) -> bool:
executable = shutil.which("apptainer") or shutil.which("singularity")
if executable:
result = subprocess.run([executable, "--version"], capture_output=True, timeout=5, stdin=subprocess.DEVNULL)
return result.returncode == 0
return False
def _check_ssh_requirements(config: Dict[str, Any]) -> bool:
if not config.get("ssh_host") or not config.get("ssh_user"):
logger.error(
"SSH backend selected but TERMINAL_SSH_HOST and TERMINAL_SSH_USER "
"are not both set. Configure both or switch TERMINAL_ENV to 'local'."
)
return False
return True
def _check_modal_requirements(config: Dict[str, Any]) -> bool:
from tools.terminal_tool import managed_nous_tools_enabled
modal_state = _get_modal_backend_state(config.get("modal_mode"))
if modal_state["selected_backend"] == "managed":
return True
if modal_state["selected_backend"] != "direct":
if modal_state["managed_mode_blocked"]:
logger.error(
"Modal backend selected with TERMINAL_MODAL_MODE=managed, but "
"Nous Tool Gateway access is not currently available and no direct "
"Modal credentials/config were found. %s Choose "
"TERMINAL_MODAL_MODE=direct/auto to use direct Modal credentials.",
nous_tool_gateway_unavailable_message(
"managed Modal execution",
),
)
return False
if modal_state["mode"] == "managed":
logger.error(
"Modal backend selected with TERMINAL_MODAL_MODE=managed, but the managed "
"tool gateway is unavailable. %s",
nous_tool_gateway_unavailable_message(
"managed Modal execution",
),
)
return False
elif modal_state["mode"] == "direct":
if managed_nous_tools_enabled():
logger.error(
"Modal backend selected with TERMINAL_MODAL_MODE=direct, but no direct "
"Modal credentials/config were found. Configure Modal or choose "
"TERMINAL_MODAL_MODE=managed/auto."
)
else:
logger.error(
"Modal backend selected with TERMINAL_MODAL_MODE=direct, but no direct "
"Modal credentials/config were found. Configure Modal or choose "
"TERMINAL_MODAL_MODE=auto."
)
return False
else:
if managed_nous_tools_enabled():
logger.error(
"Modal backend selected but no direct Modal credentials/config or managed "
"tool gateway was found. Configure Modal, set up the managed gateway, "
"or choose a different TERMINAL_ENV."
)
else:
logger.error(
"Modal backend selected but no direct Modal credentials/config was found. "
"Configure Modal or choose a different TERMINAL_ENV."
)
return False
if importlib.util.find_spec("modal") is None:
logger.error("modal is required for direct modal terminal backend: pip install modal")
return False
return True
def _check_daytona_requirements(config: Dict[str, Any]) -> bool:
from daytona import Daytona # noqa: F401 — SDK presence check
from agent.secret_scope import get_secret
return get_secret("DAYTONA_API_KEY") is not None
def _check_plugin_requirements(config: Dict[str, Any]) -> bool:
from tools.terminal_tool import _get_plugin_env_provider
env_type = config["env_type"]
provider = _get_plugin_env_provider(env_type)
if provider is not None:
return bool(provider.check_requirements(config))
logger.error(
"Unknown TERMINAL_ENV '%s'. Use one of: local, docker, singularity, "
"modal, daytona, vercel_sandbox, ssh, or a plugin-registered backend.",
env_type,
)
return False
# Built-in backend -> requirements checker; unknown backends go to the plugin registry.
_REQUIREMENT_CHECKERS = {
"local": lambda config: True,
"docker": _check_docker_requirements,
"singularity": _check_singularity_requirements,
"ssh": _check_ssh_requirements,
"modal": _check_modal_requirements,
"vercel_sandbox": _check_vercel_sandbox_requirements,
"daytona": _check_daytona_requirements,
}