"""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.`` 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, }