feat: add Vercel Sandbox backend
Adds Vercel Sandbox as a supported Hermes terminal backend alongside existing providers (Local, Docker, Modal, SSH, Daytona, Singularity). Uses the Vercel Python SDK to create/manage cloud microVMs, supports snapshot-based filesystem persistence keyed by task_id, and integrates with the existing BaseEnvironment shell contract and FileSyncManager for credential/skill syncing. Based on #17127 by @scotttrinh, cherry-picked onto current main.
This commit is contained in:
@@ -782,7 +782,7 @@ def check_dangerous_command(command: str, env_type: str,
|
||||
Returns:
|
||||
{"approved": True/False, "message": str or None, ...}
|
||||
"""
|
||||
if env_type in ("docker", "singularity", "modal", "daytona"):
|
||||
if env_type in ("docker", "singularity", "modal", "daytona", "vercel_sandbox"):
|
||||
return {"approved": True, "message": None}
|
||||
|
||||
# Hardline floor: commands with no recovery path (rm -rf /, mkfs, dd
|
||||
@@ -907,7 +907,7 @@ def check_all_command_guards(command: str, env_type: str,
|
||||
other was shown to the user.
|
||||
"""
|
||||
# Skip containers for both checks
|
||||
if env_type in ("docker", "singularity", "modal", "daytona"):
|
||||
if env_type in ("docker", "singularity", "modal", "daytona", "vercel_sandbox"):
|
||||
return {"approved": True, "message": None}
|
||||
|
||||
# Hardline floor: unconditional block for catastrophic commands
|
||||
|
||||
@@ -73,7 +73,24 @@ MAX_STDERR_BYTES = 10_000 # 10 KB
|
||||
|
||||
def check_sandbox_requirements() -> bool:
|
||||
"""Code execution sandbox requires a POSIX OS for Unix domain sockets."""
|
||||
return SANDBOX_AVAILABLE
|
||||
if not SANDBOX_AVAILABLE:
|
||||
return False
|
||||
|
||||
try:
|
||||
from tools.terminal_tool import (
|
||||
_check_vercel_sandbox_requirements,
|
||||
_get_env_config,
|
||||
)
|
||||
|
||||
config = _get_env_config()
|
||||
except Exception:
|
||||
logger.debug("Could not resolve terminal config for execute_code availability", exc_info=True)
|
||||
return False
|
||||
|
||||
if config.get("env_type") == "vercel_sandbox":
|
||||
return _check_vercel_sandbox_requirements(config)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -481,12 +498,13 @@ def _get_or_create_env(task_id: str):
|
||||
cwd = overrides.get("cwd") or config["cwd"]
|
||||
|
||||
container_config = None
|
||||
if env_type in ("docker", "singularity", "modal", "daytona"):
|
||||
if env_type in ("docker", "singularity", "modal", "daytona", "vercel_sandbox"):
|
||||
container_config = {
|
||||
"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),
|
||||
"vercel_runtime": config.get("vercel_runtime", ""),
|
||||
"docker_volumes": config.get("docker_volumes", []),
|
||||
"docker_run_as_host_user": config.get("docker_run_as_host_user", False),
|
||||
}
|
||||
|
||||
@@ -100,6 +100,10 @@ def _build_provider_env_blocklist() -> frozenset:
|
||||
"MODAL_TOKEN_ID",
|
||||
"MODAL_TOKEN_SECRET",
|
||||
"DAYTONA_API_KEY",
|
||||
"VERCEL_OIDC_TOKEN",
|
||||
"VERCEL_TOKEN",
|
||||
"VERCEL_PROJECT_ID",
|
||||
"VERCEL_TEAM_ID",
|
||||
})
|
||||
return frozenset(blocked)
|
||||
|
||||
|
||||
627
tools/environments/vercel_sandbox.py
Normal file
627
tools/environments/vercel_sandbox.py
Normal file
@@ -0,0 +1,627 @@
|
||||
"""Vercel Sandbox execution environment.
|
||||
|
||||
Uses the Vercel Python SDK to run commands in cloud sandboxes through Hermes'
|
||||
shared ``BaseEnvironment`` shell contract. When persistence is enabled, the
|
||||
backend stores task-scoped snapshot metadata under ``HERMES_HOME`` and restores
|
||||
new sandboxes from those snapshots on later task reuse.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import cache
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import shlex
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import httpx
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from tools.environments.base import (
|
||||
BaseEnvironment,
|
||||
_ThreadedProcessHandle,
|
||||
_load_json_store,
|
||||
_save_json_store,
|
||||
)
|
||||
from tools.environments.file_sync import (
|
||||
FileSyncManager,
|
||||
iter_sync_files,
|
||||
quoted_rm_command,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vercel.sandbox import Resources, Sandbox, SandboxStatus, WriteFile
|
||||
|
||||
DEFAULT_VERCEL_CWD = "/vercel/sandbox"
|
||||
_DEFAULT_CONTAINER_DISK_MB = 51200
|
||||
_CREATE_RETRY_ATTEMPTS = 3
|
||||
_WRITE_RETRY_ATTEMPTS = 3
|
||||
_TRANSIENT_STATUS_CODES = frozenset({408, 425, 429, 500, 502, 503, 504})
|
||||
_RETRY_BACKOFF_STEP = timedelta(milliseconds=100)
|
||||
_MIN_SANDBOX_TIMEOUT = timedelta(minutes=5)
|
||||
_MIN_RUNNING_WAIT = timedelta(seconds=1)
|
||||
_RUNNING_WAIT_TIMEOUT = timedelta(seconds=30)
|
||||
_RUNNING_WAIT_POLL_INTERVAL = timedelta(milliseconds=250)
|
||||
_STOP_TIMEOUT = timedelta(seconds=15)
|
||||
_STOP_POLL_INTERVAL = timedelta(milliseconds=500)
|
||||
_SNAPSHOT_STORE_NAME = "vercel_sandbox_snapshots.json"
|
||||
|
||||
|
||||
def _exception_chain(exc: BaseException) -> list[BaseException]:
|
||||
chain: list[BaseException] = []
|
||||
current: BaseException | None = exc
|
||||
seen: set[int] = set()
|
||||
while current is not None and id(current) not in seen:
|
||||
chain.append(current)
|
||||
seen.add(id(current))
|
||||
current = current.__cause__ or current.__context__
|
||||
return chain
|
||||
|
||||
|
||||
def _extract_status_code(exc: BaseException) -> int | None:
|
||||
response = getattr(exc, "response", None)
|
||||
for value in (getattr(exc, "status_code", None), getattr(response, "status_code", None)):
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _is_transient_vercel_error(exc: BaseException) -> bool:
|
||||
for error in _exception_chain(exc):
|
||||
status_code = _extract_status_code(error)
|
||||
if status_code in _TRANSIENT_STATUS_CODES:
|
||||
return True
|
||||
if isinstance(
|
||||
error,
|
||||
(httpx.NetworkError, httpx.ProtocolError, httpx.ReadError),
|
||||
):
|
||||
return True
|
||||
error_name = type(error).__name__.lower()
|
||||
if "ratelimit" in error_name or "servererror" in error_name:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _retry_vercel_call(
|
||||
label: str,
|
||||
callback,
|
||||
*,
|
||||
attempts: int,
|
||||
):
|
||||
backoff_seconds = _RETRY_BACKOFF_STEP.total_seconds()
|
||||
for attempt in range(1, attempts + 1):
|
||||
try:
|
||||
return callback()
|
||||
except Exception as exc:
|
||||
if attempt >= attempts or not _is_transient_vercel_error(exc):
|
||||
raise
|
||||
logger.warning(
|
||||
"Vercel: %s failed (%s); retrying %d/%d",
|
||||
label,
|
||||
exc,
|
||||
attempt,
|
||||
attempts,
|
||||
)
|
||||
time.sleep(backoff_seconds * attempt)
|
||||
|
||||
|
||||
def _coerce_text(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, bytes):
|
||||
return value.decode("utf-8", errors="replace")
|
||||
return str(value)
|
||||
|
||||
|
||||
def _extract_result_output(result: Any) -> str:
|
||||
try:
|
||||
return _coerce_text(result.output())
|
||||
except (AttributeError, TypeError):
|
||||
return _coerce_text(result)
|
||||
|
||||
|
||||
def _extract_result_returncode(result: Any) -> int:
|
||||
try:
|
||||
exit_code = result.exit_code
|
||||
except AttributeError:
|
||||
try:
|
||||
exit_code = result.returncode
|
||||
except AttributeError:
|
||||
return 1
|
||||
return exit_code if isinstance(exit_code, int) else 1
|
||||
|
||||
|
||||
def _snapshot_store_path() -> Path:
|
||||
return get_hermes_home() / _SNAPSHOT_STORE_NAME
|
||||
|
||||
|
||||
def _load_snapshots() -> dict:
|
||||
return _load_json_store(_snapshot_store_path())
|
||||
|
||||
|
||||
def _save_snapshots(data: dict) -> None:
|
||||
_save_json_store(_snapshot_store_path(), data)
|
||||
|
||||
|
||||
def _get_snapshot_id(task_id: str) -> str | None:
|
||||
if not task_id:
|
||||
return None
|
||||
snapshot_id = _load_snapshots().get(task_id)
|
||||
return snapshot_id if isinstance(snapshot_id, str) and snapshot_id else None
|
||||
|
||||
|
||||
def _store_snapshot(task_id: str, snapshot_id: str) -> None:
|
||||
if not task_id or not snapshot_id:
|
||||
return
|
||||
snapshots = _load_snapshots()
|
||||
snapshots[task_id] = snapshot_id
|
||||
_save_snapshots(snapshots)
|
||||
|
||||
|
||||
def _delete_snapshot(task_id: str, snapshot_id: str | None = None) -> None:
|
||||
if not task_id:
|
||||
return
|
||||
snapshots = _load_snapshots()
|
||||
existing = snapshots.get(task_id)
|
||||
if existing is None:
|
||||
return
|
||||
if snapshot_id is not None and existing != snapshot_id:
|
||||
return
|
||||
snapshots.pop(task_id, None)
|
||||
_save_snapshots(snapshots)
|
||||
|
||||
|
||||
def _extract_snapshot_id(snapshot: Any) -> str | None:
|
||||
for attr in ("snapshot_id", "snapshotId", "id"):
|
||||
value = getattr(snapshot, attr, None)
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
if isinstance(snapshot, dict):
|
||||
for key in ("snapshot_id", "snapshotId", "id"):
|
||||
value = snapshot.get(key)
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
@cache
|
||||
def _sandbox_status_type() -> type[SandboxStatus]:
|
||||
from vercel.sandbox import SandboxStatus
|
||||
|
||||
return SandboxStatus
|
||||
|
||||
|
||||
@cache
|
||||
def _terminal_sandbox_states() -> frozenset[SandboxStatus]:
|
||||
SandboxStatus = _sandbox_status_type()
|
||||
return frozenset(
|
||||
{
|
||||
SandboxStatus.ABORTED,
|
||||
SandboxStatus.FAILED,
|
||||
SandboxStatus.STOPPED,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _SandboxCreateParams:
|
||||
timeout: timedelta
|
||||
runtime: str | None = None
|
||||
resources: Resources | None = None
|
||||
|
||||
|
||||
class VercelSandboxEnvironment(BaseEnvironment):
|
||||
"""Vercel cloud sandbox backend."""
|
||||
|
||||
_stdin_mode = "heredoc"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
runtime: str | None = None,
|
||||
cwd: str = DEFAULT_VERCEL_CWD,
|
||||
timeout: int = 60,
|
||||
cpu: float = 1,
|
||||
memory: int = 5120,
|
||||
disk: int = _DEFAULT_CONTAINER_DISK_MB,
|
||||
persistent_filesystem: bool = True,
|
||||
task_id: str = "default",
|
||||
):
|
||||
requested_cwd = cwd
|
||||
super().__init__(cwd=cwd, timeout=timeout)
|
||||
|
||||
self._runtime = runtime or None
|
||||
self._persistent = persistent_filesystem
|
||||
self._task_id = task_id
|
||||
self._requested_cwd = requested_cwd
|
||||
self._lock = threading.Lock()
|
||||
self._sandbox: Sandbox | None = None
|
||||
self._workspace_root = DEFAULT_VERCEL_CWD
|
||||
self._remote_home = DEFAULT_VERCEL_CWD
|
||||
self._sync_manager: FileSyncManager | None = None
|
||||
self._create_params = self._build_create_params(cpu=cpu, memory=memory, disk=disk)
|
||||
|
||||
self._sandbox = self._create_sandbox()
|
||||
self._configure_attached_sandbox(requested_cwd=requested_cwd)
|
||||
self._sync_manager.sync(force=True)
|
||||
self.init_session()
|
||||
|
||||
def _build_create_params(self, *, cpu: float, memory: int, disk: int) -> _SandboxCreateParams:
|
||||
if disk not in (0, _DEFAULT_CONTAINER_DISK_MB):
|
||||
raise ValueError(
|
||||
"Vercel Sandbox does not support configurable container_disk. "
|
||||
"Use the default shared setting."
|
||||
)
|
||||
|
||||
from vercel.sandbox import Resources
|
||||
|
||||
sandbox_timeout = max(
|
||||
timedelta(seconds=max(self.timeout, 0)),
|
||||
_MIN_SANDBOX_TIMEOUT,
|
||||
)
|
||||
vcpus = math.floor(cpu) if cpu > 0 else None
|
||||
memory_mb = memory if memory > 0 else None
|
||||
resources = (
|
||||
Resources(vcpus=vcpus, memory=memory_mb)
|
||||
if vcpus is not None or memory_mb is not None
|
||||
else None
|
||||
)
|
||||
|
||||
return _SandboxCreateParams(
|
||||
timeout=sandbox_timeout,
|
||||
runtime=self._runtime,
|
||||
resources=resources,
|
||||
)
|
||||
|
||||
def _create_sandbox(self) -> Sandbox:
|
||||
from vercel.sandbox import Sandbox
|
||||
|
||||
snapshot_id = _get_snapshot_id(self._task_id) if self._persistent else None
|
||||
if snapshot_id:
|
||||
try:
|
||||
return _retry_vercel_call(
|
||||
"sandbox restore",
|
||||
lambda: Sandbox.create(
|
||||
timeout=self._create_params.timeout,
|
||||
runtime=self._create_params.runtime,
|
||||
resources=self._create_params.resources,
|
||||
source={"type": "snapshot", "snapshot_id": snapshot_id},
|
||||
),
|
||||
attempts=_CREATE_RETRY_ATTEMPTS,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Vercel: failed to restore snapshot %s for task %s; "
|
||||
"falling back to a fresh sandbox: %s",
|
||||
snapshot_id,
|
||||
self._task_id,
|
||||
exc,
|
||||
)
|
||||
_delete_snapshot(self._task_id, snapshot_id)
|
||||
|
||||
params = self._create_params
|
||||
return _retry_vercel_call(
|
||||
"sandbox create",
|
||||
lambda: Sandbox.create(
|
||||
timeout=params.timeout,
|
||||
runtime=params.runtime,
|
||||
resources=params.resources,
|
||||
),
|
||||
attempts=_CREATE_RETRY_ATTEMPTS,
|
||||
)
|
||||
|
||||
def _configure_attached_sandbox(self, *, requested_cwd: str) -> None:
|
||||
self._wait_for_running()
|
||||
self._workspace_root = self._detect_workspace_root()
|
||||
self._remote_home = self._detect_remote_home()
|
||||
|
||||
if self._remote_home == "/":
|
||||
container_base = "/.hermes"
|
||||
else:
|
||||
container_base = f"{self._remote_home.rstrip('/')}/.hermes"
|
||||
self._sync_manager = FileSyncManager(
|
||||
get_files_fn=lambda: iter_sync_files(container_base),
|
||||
upload_fn=self._vercel_upload,
|
||||
delete_fn=self._vercel_delete,
|
||||
bulk_upload_fn=self._vercel_bulk_upload,
|
||||
bulk_download_fn=self._vercel_bulk_download,
|
||||
)
|
||||
|
||||
if requested_cwd == "~":
|
||||
self.cwd = self._remote_home
|
||||
elif requested_cwd in ("", DEFAULT_VERCEL_CWD):
|
||||
self.cwd = self._workspace_root
|
||||
else:
|
||||
self.cwd = requested_cwd
|
||||
|
||||
def _detect_workspace_root(self) -> str:
|
||||
sandbox = self._sandbox
|
||||
if sandbox is None:
|
||||
raise RuntimeError("Vercel sandbox is not attached")
|
||||
cwd = sandbox.sandbox.cwd
|
||||
return cwd if cwd.startswith("/") else DEFAULT_VERCEL_CWD
|
||||
|
||||
def _detect_remote_home(self) -> str:
|
||||
sandbox = self._sandbox
|
||||
if sandbox is None:
|
||||
raise RuntimeError("Vercel sandbox is not attached")
|
||||
try:
|
||||
result = sandbox.run_command(
|
||||
"sh",
|
||||
["-lc", 'printf %s "$HOME"'],
|
||||
cwd=self._workspace_root,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Vercel: home detection failed for task %s: %s",
|
||||
self._task_id,
|
||||
exc,
|
||||
)
|
||||
return self._workspace_root
|
||||
|
||||
home = _extract_result_output(result).strip()
|
||||
if home.startswith("/"):
|
||||
return home
|
||||
return self._workspace_root
|
||||
|
||||
def _wait_for_running(self, timeout: timedelta = _RUNNING_WAIT_TIMEOUT) -> None:
|
||||
sandbox = self._sandbox
|
||||
if sandbox is None:
|
||||
raise RuntimeError("Vercel sandbox is not attached")
|
||||
SandboxStatus = _sandbox_status_type()
|
||||
status = sandbox.status
|
||||
if status is None or status == SandboxStatus.RUNNING:
|
||||
return
|
||||
if status in _terminal_sandbox_states():
|
||||
raise RuntimeError(f"Sandbox entered terminal state: {status}")
|
||||
|
||||
try:
|
||||
sandbox.wait_for_status(
|
||||
SandboxStatus.RUNNING,
|
||||
timeout=max(timeout, _MIN_RUNNING_WAIT),
|
||||
poll_interval=_RUNNING_WAIT_POLL_INTERVAL,
|
||||
)
|
||||
except TimeoutError as exc:
|
||||
status = sandbox.status
|
||||
if status in _terminal_sandbox_states():
|
||||
raise RuntimeError(f"Sandbox entered terminal state: {status}") from exc
|
||||
raise RuntimeError(
|
||||
f"Sandbox did not reach running state (last status: {status})"
|
||||
) from exc
|
||||
|
||||
def _close_sandbox_client(self, sandbox: Sandbox | None) -> None:
|
||||
if sandbox is None:
|
||||
return
|
||||
try:
|
||||
sandbox.client.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _stop_sandbox(self, sandbox: Sandbox | None) -> None:
|
||||
if sandbox is None:
|
||||
return
|
||||
try:
|
||||
sandbox.stop(
|
||||
blocking=True,
|
||||
timeout=_STOP_TIMEOUT,
|
||||
poll_interval=_STOP_POLL_INTERVAL,
|
||||
)
|
||||
except TypeError:
|
||||
try:
|
||||
sandbox.stop()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _snapshot_sandbox(self, sandbox: Sandbox) -> str | None:
|
||||
if not self._persistent or not self._task_id:
|
||||
return None
|
||||
try:
|
||||
snapshot = sandbox.snapshot()
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Vercel: filesystem snapshot failed for task %s: %s",
|
||||
self._task_id,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
snapshot_id = _extract_snapshot_id(snapshot)
|
||||
if not snapshot_id:
|
||||
logger.warning(
|
||||
"Vercel: filesystem snapshot for task %s did not return a snapshot id",
|
||||
self._task_id,
|
||||
)
|
||||
return None
|
||||
|
||||
_store_snapshot(self._task_id, snapshot_id)
|
||||
logger.info(
|
||||
"Vercel: saved filesystem snapshot %s for task %s",
|
||||
snapshot_id,
|
||||
self._task_id,
|
||||
)
|
||||
return snapshot_id
|
||||
|
||||
def _ensure_sandbox_ready(self) -> None:
|
||||
sandbox = self._sandbox
|
||||
requested_cwd = self.cwd or self._requested_cwd or DEFAULT_VERCEL_CWD
|
||||
|
||||
if sandbox is None:
|
||||
self._sandbox = self._create_sandbox()
|
||||
self._configure_attached_sandbox(requested_cwd=requested_cwd)
|
||||
return
|
||||
|
||||
try:
|
||||
sandbox.refresh()
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Vercel: sandbox refresh failed for task %s: %s; recreating",
|
||||
self._task_id,
|
||||
exc,
|
||||
)
|
||||
self._close_sandbox_client(sandbox)
|
||||
self._sandbox = self._create_sandbox()
|
||||
self._configure_attached_sandbox(requested_cwd=requested_cwd)
|
||||
return
|
||||
|
||||
status = sandbox.status
|
||||
if status in _terminal_sandbox_states():
|
||||
logger.warning(
|
||||
"Vercel: sandbox entered state %s for task %s; recreating",
|
||||
status,
|
||||
self._task_id,
|
||||
)
|
||||
self._close_sandbox_client(sandbox)
|
||||
self._sandbox = self._create_sandbox()
|
||||
self._configure_attached_sandbox(requested_cwd=requested_cwd)
|
||||
return
|
||||
|
||||
self._wait_for_running()
|
||||
|
||||
def _vercel_upload(self, host_path: str, remote_path: str) -> None:
|
||||
self._vercel_bulk_upload([(host_path, remote_path)])
|
||||
|
||||
def _vercel_bulk_upload(self, files: list[tuple[str, str]]) -> None:
|
||||
if not files:
|
||||
return
|
||||
|
||||
payload: list[WriteFile] = [
|
||||
{
|
||||
"path": remote_path,
|
||||
"content": Path(host_path).read_bytes(),
|
||||
}
|
||||
for host_path, remote_path in files
|
||||
]
|
||||
|
||||
sandbox = self._sandbox
|
||||
if sandbox is None:
|
||||
raise RuntimeError("Vercel sandbox is not attached")
|
||||
_retry_vercel_call(
|
||||
"write_files",
|
||||
lambda: sandbox.write_files(payload),
|
||||
attempts=_WRITE_RETRY_ATTEMPTS,
|
||||
)
|
||||
|
||||
def _vercel_delete(self, remote_paths: list[str]) -> None:
|
||||
if not remote_paths:
|
||||
return
|
||||
|
||||
sandbox = self._sandbox
|
||||
if sandbox is None:
|
||||
raise RuntimeError("Vercel sandbox is not attached")
|
||||
result = sandbox.run_command(
|
||||
"bash",
|
||||
["-lc", quoted_rm_command(remote_paths)],
|
||||
cwd=self._workspace_root,
|
||||
)
|
||||
if _extract_result_returncode(result) != 0:
|
||||
raise RuntimeError(
|
||||
f"Vercel delete failed: {_extract_result_output(result).strip()}"
|
||||
)
|
||||
|
||||
def _vercel_bulk_download(self, dest_tar_path: Path) -> None:
|
||||
remote_hermes = (
|
||||
"/.hermes"
|
||||
if self._remote_home == "/"
|
||||
else f"{self._remote_home.rstrip('/')}/.hermes"
|
||||
)
|
||||
archive_member = remote_hermes.lstrip("/")
|
||||
remote_tar = f"/tmp/.hermes_sync.{os.getpid()}.tar"
|
||||
sandbox = self._sandbox
|
||||
if sandbox is None:
|
||||
raise RuntimeError("Vercel sandbox is not attached")
|
||||
|
||||
try:
|
||||
result = sandbox.run_command(
|
||||
"bash",
|
||||
[
|
||||
"-lc",
|
||||
f"tar cf {shlex.quote(remote_tar)} -C / {shlex.quote(archive_member)}",
|
||||
],
|
||||
cwd=self._workspace_root,
|
||||
)
|
||||
if _extract_result_returncode(result) != 0:
|
||||
raise RuntimeError(
|
||||
f"Vercel bulk download failed: {_extract_result_output(result).strip()}"
|
||||
)
|
||||
|
||||
sandbox.download_file(remote_tar, dest_tar_path)
|
||||
finally:
|
||||
try:
|
||||
sandbox.run_command(
|
||||
"bash",
|
||||
["-lc", f"rm -f {shlex.quote(remote_tar)}"],
|
||||
cwd=self._workspace_root,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _before_execute(self) -> None:
|
||||
with self._lock:
|
||||
self._ensure_sandbox_ready()
|
||||
if self._sync_manager is not None:
|
||||
self._sync_manager.sync()
|
||||
|
||||
def _run_bash(
|
||||
self,
|
||||
cmd_string: str,
|
||||
*,
|
||||
login: bool = False,
|
||||
timeout: int = 120,
|
||||
stdin_data: str | None = None,
|
||||
):
|
||||
del timeout
|
||||
del stdin_data
|
||||
|
||||
sandbox = self._sandbox
|
||||
if sandbox is None:
|
||||
raise RuntimeError("Vercel sandbox is not attached")
|
||||
workspace_root = self._workspace_root
|
||||
lock = self._lock
|
||||
|
||||
def cancel() -> None:
|
||||
with lock:
|
||||
self._stop_sandbox(sandbox)
|
||||
|
||||
def exec_fn() -> tuple[str, int]:
|
||||
result = sandbox.run_command(
|
||||
"bash",
|
||||
["-lc" if login else "-c", cmd_string],
|
||||
cwd=workspace_root,
|
||||
)
|
||||
return _extract_result_output(result), _extract_result_returncode(result)
|
||||
|
||||
return _ThreadedProcessHandle(exec_fn, cancel_fn=cancel)
|
||||
|
||||
def cleanup(self):
|
||||
with self._lock:
|
||||
sandbox = self._sandbox
|
||||
sync_manager = self._sync_manager
|
||||
if sandbox is not None and sync_manager is not None:
|
||||
try:
|
||||
sync_manager.sync_back()
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Vercel: sync_back failed for task %s: %s",
|
||||
self._task_id,
|
||||
exc,
|
||||
)
|
||||
self._sandbox = None
|
||||
self._sync_manager = None
|
||||
|
||||
if sandbox is None:
|
||||
return
|
||||
|
||||
snapshot_id = self._snapshot_sandbox(sandbox)
|
||||
# Always stop the sandbox during cleanup to avoid resource leaks,
|
||||
# matching the Modal and Daytona patterns.
|
||||
self._stop_sandbox(sandbox)
|
||||
self._close_sandbox_client(sandbox)
|
||||
@@ -380,12 +380,13 @@ def _get_file_ops(task_id: str = "default") -> ShellFileOperations:
|
||||
logger.info("Creating new %s environment for task %s...", env_type, task_id[:8])
|
||||
|
||||
container_config = None
|
||||
if env_type in ("docker", "singularity", "modal", "daytona"):
|
||||
if env_type in ("docker", "singularity", "modal", "daytona", "vercel_sandbox"):
|
||||
container_config = {
|
||||
"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),
|
||||
"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", []),
|
||||
|
||||
@@ -101,7 +101,9 @@ _PLATFORM_MAP = {
|
||||
}
|
||||
_ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
_EXCLUDED_SKILL_DIRS = frozenset((".git", ".github", ".hub"))
|
||||
_REMOTE_ENV_BACKENDS = frozenset({"docker", "singularity", "modal", "ssh", "daytona"})
|
||||
_REMOTE_ENV_BACKENDS = frozenset(
|
||||
{"docker", "singularity", "modal", "ssh", "daytona", "vercel_sandbox"}
|
||||
)
|
||||
_secret_capture_callback = None
|
||||
|
||||
|
||||
|
||||
@@ -2,16 +2,19 @@
|
||||
"""
|
||||
Terminal Tool Module
|
||||
|
||||
A terminal tool that executes commands in local, Docker, Modal, SSH, Singularity, and Daytona environments.
|
||||
Supports local execution, containerized backends, and Modal cloud sandboxes, including managed gateway mode.
|
||||
A terminal tool that executes commands in local, Docker, Modal, SSH,
|
||||
Singularity, Daytona, and Vercel Sandbox environments. Supports local
|
||||
execution, containerized backends, and cloud sandboxes, including managed
|
||||
Modal mode.
|
||||
|
||||
Environment Selection (via TERMINAL_ENV environment variable):
|
||||
- "local": Execute directly on the host machine (default, fastest)
|
||||
- "docker": Execute in Docker containers (isolated, requires Docker)
|
||||
- "modal": Execute in Modal cloud sandboxes (direct Modal or managed gateway)
|
||||
- "vercel_sandbox": Execute in Vercel Sandbox cloud sandboxes
|
||||
|
||||
Features:
|
||||
- Multiple execution backends (local, docker, modal)
|
||||
- Multiple execution backends (local, docker, modal, vercel_sandbox)
|
||||
- Background task support
|
||||
- VM/container lifecycle management
|
||||
- Automatic cleanup after inactivity
|
||||
@@ -114,6 +117,68 @@ DISK_USAGE_WARNING_THRESHOLD_GB = _safe_parse_import_env(
|
||||
float,
|
||||
"number",
|
||||
)
|
||||
_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
|
||||
|
||||
has_oidc = bool(os.getenv("VERCEL_OIDC_TOKEN"))
|
||||
has_token = bool(os.getenv("VERCEL_TOKEN"))
|
||||
has_project = bool(os.getenv("VERCEL_PROJECT_ID"))
|
||||
has_team = bool(os.getenv("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 _check_disk_usage_warning():
|
||||
@@ -744,9 +809,10 @@ def _transform_sudo_command(command: str | None) -> tuple[str | None, str | None
|
||||
should prepend sudo_stdin to their stdin_data and pass the merged bytes to
|
||||
Popen's stdin pipe.
|
||||
|
||||
Callers that cannot pipe subprocess stdin (modal, daytona) must embed the
|
||||
password in the command string themselves; see their execute() methods for
|
||||
how they handle the non-None sudo_stdin case.
|
||||
Callers that cannot pipe subprocess stdin (modal, daytona,
|
||||
vercel_sandbox) must embed the password in the command string
|
||||
themselves; see their execute() methods for how they handle the
|
||||
non-None sudo_stdin case.
|
||||
|
||||
If SUDO_PASSWORD is not set and in interactive mode (HERMES_INTERACTIVE=1):
|
||||
Prompts user for password with 45s timeout, caches for session.
|
||||
@@ -910,13 +976,15 @@ def _get_env_config() -> Dict[str, Any]:
|
||||
|
||||
mount_docker_cwd = os.getenv("TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "false").lower() in ("true", "1", "yes")
|
||||
|
||||
# Default cwd: local uses the host's current directory, everything
|
||||
# else starts in the user's home (~ resolves to whatever account
|
||||
# is running inside the container/remote).
|
||||
# Default cwd: local uses the host's current directory, ssh uses the
|
||||
# remote home, Vercel uses its documented workspace root, and everything
|
||||
# else starts in the backend's default root-like cwd.
|
||||
if env_type == "local":
|
||||
default_cwd = os.getcwd()
|
||||
elif env_type == "ssh":
|
||||
default_cwd = "~"
|
||||
elif env_type == "vercel_sandbox":
|
||||
default_cwd = _VERCEL_SANDBOX_DEFAULT_CWD
|
||||
else:
|
||||
default_cwd = "/root"
|
||||
|
||||
@@ -938,7 +1006,7 @@ def _get_env_config() -> Dict[str, Any]:
|
||||
):
|
||||
host_cwd = candidate
|
||||
cwd = "/workspace"
|
||||
elif env_type in ("modal", "docker", "singularity", "daytona") and cwd:
|
||||
elif env_type in ("modal", "docker", "singularity", "daytona", "vercel_sandbox") and cwd:
|
||||
# Host paths and relative paths that won't work inside containers
|
||||
is_host_path = any(cwd.startswith(p) for p in host_prefixes)
|
||||
is_relative = not os.path.isabs(cwd) # e.g. "." or "src/"
|
||||
@@ -956,6 +1024,7 @@ def _get_env_config() -> Dict[str, Any]:
|
||||
"singularity_image": os.getenv("TERMINAL_SINGULARITY_IMAGE", f"docker://{default_image}"),
|
||||
"modal_image": os.getenv("TERMINAL_MODAL_IMAGE", default_image),
|
||||
"daytona_image": os.getenv("TERMINAL_DAYTONA_IMAGE", default_image),
|
||||
"vercel_runtime": os.getenv("TERMINAL_VERCEL_RUNTIME", "").strip(),
|
||||
"cwd": cwd,
|
||||
"host_cwd": host_cwd,
|
||||
"docker_mount_cwd_to_workspace": mount_docker_cwd,
|
||||
@@ -974,7 +1043,8 @@ def _get_env_config() -> Dict[str, Any]:
|
||||
os.getenv("TERMINAL_PERSISTENT_SHELL", "true"),
|
||||
).lower() in ("true", "1", "yes"),
|
||||
"local_persistent": os.getenv("TERMINAL_LOCAL_PERSISTENT", "false").lower() in ("true", "1", "yes"),
|
||||
# Container resource config (applies to docker, singularity, modal, daytona -- ignored for local/ssh)
|
||||
# Container resource config (applies to docker, singularity, modal,
|
||||
# daytona, and vercel_sandbox -- ignored for local/ssh)
|
||||
"container_cpu": _parse_env_var("TERMINAL_CONTAINER_CPU", "1", float, "number"),
|
||||
"container_memory": _parse_env_var("TERMINAL_CONTAINER_MEMORY", "5120"), # MB (default 5GB)
|
||||
"container_disk": _parse_env_var("TERMINAL_CONTAINER_DISK", "51200"), # MB (default 50GB)
|
||||
@@ -1002,8 +1072,9 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
|
||||
Create an execution environment for sandboxed command execution.
|
||||
|
||||
Args:
|
||||
env_type: One of "local", "docker", "singularity", "modal", "daytona", "ssh"
|
||||
image: Docker/Singularity/Modal image name (ignored for local/ssh)
|
||||
env_type: One of "local", "docker", "singularity", "modal",
|
||||
"daytona", "vercel_sandbox", "ssh"
|
||||
image: Docker/Singularity/Modal image name (ignored for local/ssh/vercel)
|
||||
cwd: Working directory
|
||||
timeout: Default command timeout
|
||||
ssh_config: SSH connection config (for env_type="ssh")
|
||||
@@ -1107,6 +1178,21 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
|
||||
persistent_filesystem=persistent, task_id=task_id,
|
||||
)
|
||||
|
||||
elif env_type == "vercel_sandbox":
|
||||
from tools.environments.vercel_sandbox import (
|
||||
VercelSandboxEnvironment as _VercelSandboxEnvironment,
|
||||
)
|
||||
return _VercelSandboxEnvironment(
|
||||
runtime=cc.get("vercel_runtime") or None,
|
||||
cwd=cwd,
|
||||
timeout=timeout,
|
||||
cpu=cpu,
|
||||
memory=memory,
|
||||
disk=disk,
|
||||
persistent_filesystem=persistent,
|
||||
task_id=task_id,
|
||||
)
|
||||
|
||||
elif env_type == "ssh":
|
||||
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")
|
||||
@@ -1120,7 +1206,10 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
|
||||
)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown environment type: {env_type}. Use 'local', 'docker', 'singularity', 'modal', 'daytona', or 'ssh'")
|
||||
raise ValueError(
|
||||
f"Unknown environment type: {env_type}. Use 'local', 'docker', "
|
||||
f"'singularity', 'modal', 'daytona', 'vercel_sandbox', or 'ssh'"
|
||||
)
|
||||
|
||||
|
||||
def _cleanup_inactive_envs(lifetime_seconds: int = 300):
|
||||
@@ -1654,13 +1743,14 @@ def terminal_tool(
|
||||
}
|
||||
|
||||
container_config = None
|
||||
if env_type in ("docker", "singularity", "modal", "daytona"):
|
||||
if env_type in ("docker", "singularity", "modal", "daytona", "vercel_sandbox"):
|
||||
container_config = {
|
||||
"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", []),
|
||||
@@ -1990,10 +2080,10 @@ def terminal_tool(
|
||||
|
||||
def check_terminal_requirements() -> bool:
|
||||
"""Check if all requirements for the terminal tool are met."""
|
||||
config = _get_env_config()
|
||||
env_type = config["env_type"]
|
||||
|
||||
try:
|
||||
config = _get_env_config()
|
||||
env_type = config["env_type"]
|
||||
|
||||
if env_type == "local":
|
||||
return True
|
||||
|
||||
@@ -2077,6 +2167,9 @@ def check_terminal_requirements() -> bool:
|
||||
|
||||
return True
|
||||
|
||||
elif env_type == "vercel_sandbox":
|
||||
return _check_vercel_sandbox_requirements(config)
|
||||
|
||||
elif env_type == "daytona":
|
||||
from daytona import Daytona # noqa: F401 — SDK presence check
|
||||
return os.getenv("DAYTONA_API_KEY") is not None
|
||||
@@ -2084,7 +2177,7 @@ def check_terminal_requirements() -> bool:
|
||||
else:
|
||||
logger.error(
|
||||
"Unknown TERMINAL_ENV '%s'. Use one of: local, docker, singularity, "
|
||||
"modal, daytona, ssh.",
|
||||
"modal, daytona, vercel_sandbox, ssh.",
|
||||
env_type,
|
||||
)
|
||||
return False
|
||||
@@ -2124,7 +2217,11 @@ if __name__ == "__main__":
|
||||
|
||||
print("\nEnvironment Variables:")
|
||||
default_img = "nikolaik/python-nodejs:python3.11-nodejs20"
|
||||
print(f" TERMINAL_ENV: {os.getenv('TERMINAL_ENV', 'local')} (local/docker/singularity/modal/daytona/ssh)")
|
||||
print(
|
||||
" TERMINAL_ENV: "
|
||||
f"{os.getenv('TERMINAL_ENV', 'local')} "
|
||||
"(local/docker/singularity/modal/daytona/vercel_sandbox/ssh)"
|
||||
)
|
||||
print(f" TERMINAL_DOCKER_IMAGE: {os.getenv('TERMINAL_DOCKER_IMAGE', default_img)}")
|
||||
print(f" TERMINAL_SINGULARITY_IMAGE: {os.getenv('TERMINAL_SINGULARITY_IMAGE', f'docker://{default_img}')}")
|
||||
print(f" TERMINAL_MODAL_IMAGE: {os.getenv('TERMINAL_MODAL_IMAGE', default_img)}")
|
||||
|
||||
Reference in New Issue
Block a user