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:
Scott Trinh
2026-04-29 18:20:53 +05:30
committed by kshitij
parent 810d98e892
commit 5a1d4f6804
32 changed files with 2241 additions and 44 deletions

View File

@@ -499,6 +499,7 @@ DEFAULT_CONFIG = {
"singularity_image": "docker://nikolaik/python-nodejs:python3.11-nodejs20",
"modal_image": "nikolaik/python-nodejs:python3.11-nodejs20",
"daytona_image": "nikolaik/python-nodejs:python3.11-nodejs20",
"vercel_runtime": "node24",
# Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)
"container_cpu": 1,
"container_memory": 5120, # MB (default 5GB)
@@ -4193,6 +4194,9 @@ def show_config():
print(f" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}")
daytona_key = get_env_value('DAYTONA_API_KEY')
print(f" API key: {'configured' if daytona_key else '(not set)'}")
elif terminal.get('backend') == 'vercel_sandbox':
print(f" Vercel runtime: {terminal.get('vercel_runtime', 'node24')}")
print(f" Vercel auth: {'configured' if get_env_value('VERCEL_OIDC_TOKEN') or (get_env_value('VERCEL_TOKEN') and get_env_value('VERCEL_PROJECT_ID') and get_env_value('VERCEL_TEAM_ID')) else '(not set)'}")
elif terminal.get('backend') == 'ssh':
ssh_host = get_env_value('TERMINAL_SSH_HOST')
ssh_user = get_env_value('TERMINAL_SSH_USER')
@@ -4385,6 +4389,7 @@ def set_config_value(key: str, value: str):
"terminal.singularity_image": "TERMINAL_SINGULARITY_IMAGE",
"terminal.modal_image": "TERMINAL_MODAL_IMAGE",
"terminal.daytona_image": "TERMINAL_DAYTONA_IMAGE",
"terminal.vercel_runtime": "TERMINAL_VERCEL_RUNTIME",
"terminal.docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
"terminal.docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER",
"terminal.cwd": "TERMINAL_CWD",

View File

@@ -8,6 +8,7 @@ import os
import sys
import subprocess
import shutil
import importlib.util
from pathlib import Path
from hermes_cli.config import get_project_root, get_hermes_home, get_env_path
@@ -30,6 +31,7 @@ load_dotenv(PROJECT_ROOT / ".env", override=False, encoding="utf-8")
from hermes_cli.colors import Colors, color
from hermes_cli.models import _HERMES_USER_AGENT
from hermes_cli.vercel_auth import describe_vercel_auth
from hermes_constants import OPENROUTER_MODELS_URL
from utils import base_url_host_matches
@@ -863,6 +865,48 @@ def run_doctor(args):
check_fail("daytona SDK not installed", "(pip install daytona)")
issues.append("Install daytona SDK: pip install daytona")
# Vercel Sandbox (if using vercel_sandbox backend)
if terminal_env == "vercel_sandbox":
runtime = os.getenv("TERMINAL_VERCEL_RUNTIME", "node24").strip() or "node24"
if runtime in {"node24", "node22", "python3.13"}:
check_ok("Vercel runtime", f"({runtime})")
else:
check_fail("Vercel runtime unsupported", f"({runtime}; use node24, node22, or python3.13)")
issues.append("Set TERMINAL_VERCEL_RUNTIME to node24, node22, or python3.13")
disk = os.getenv("TERMINAL_CONTAINER_DISK", "51200").strip()
if disk in ("", "0", "51200"):
check_ok("Vercel disk setting", "(uses platform default)")
else:
check_fail("Vercel custom disk unsupported", "(reset terminal.container_disk to 51200)")
issues.append("Vercel Sandbox does not support custom container_disk; use the shared default 51200")
if importlib.util.find_spec("vercel") is not None:
check_ok("vercel SDK", "(installed)")
else:
check_fail("vercel SDK not installed", "(pip install 'hermes-agent[vercel]')")
issues.append("Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'")
auth_status = describe_vercel_auth()
if auth_status.ok:
check_ok("Vercel auth", f"({auth_status.label})")
elif auth_status.label.startswith("partial"):
check_fail("Vercel auth incomplete", f"({auth_status.label})")
issues.append("Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together")
else:
check_fail("Vercel auth not configured", f"({auth_status.label})")
issues.append(
"Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID"
)
for line in auth_status.detail_lines:
check_info(f"Vercel auth {line}")
persistent = os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in ("1", "true", "yes", "on")
if persistent:
check_info("Vercel persistence: snapshot filesystem only; live processes do not survive sandbox recreation")
else:
check_info("Vercel persistence: ephemeral filesystem")
# Node.js + agent-browser (for browser automation tools)
if shutil.which("node"):
check_ok("Node.js")

View File

@@ -12,6 +12,7 @@ Config files are stored in ~/.hermes/ for easy access.
"""
import importlib.util
import json
import logging
import os
import shutil
@@ -139,6 +140,7 @@ from hermes_cli.config import (
load_config,
save_config,
save_env_value,
remove_env_value,
get_env_value,
ensure_hermes_home,
)
@@ -655,6 +657,99 @@ def _prompt_container_resources(config: dict):
pass
def _prompt_vercel_sandbox_settings(config: dict):
"""Prompt for Vercel Sandbox settings without exposing unsupported disk sizing."""
terminal = config.setdefault("terminal", {})
print()
print_info("Vercel Sandbox settings:")
print_info(" Filesystem persistence uses Vercel snapshots.")
print_info(" Snapshots restore files only; live processes do not continue after sandbox recreation.")
current_runtime = terminal.get("vercel_runtime") or "node24"
runtime = prompt(" Runtime (node24, node22, python3.13)", current_runtime).strip() or current_runtime
if runtime not in {"node24", "node22", "python3.13"}:
print_warning(f"Unsupported Vercel runtime '{runtime}', keeping {current_runtime}.")
runtime = current_runtime if current_runtime in {"node24", "node22", "python3.13"} else "node24"
terminal["vercel_runtime"] = runtime
save_env_value("TERMINAL_VERCEL_RUNTIME", runtime)
current_persist = terminal.get("container_persistent", True)
persist_label = "yes" if current_persist else "no"
terminal["container_persistent"] = prompt(
" Persist filesystem with snapshots? (yes/no)", persist_label
).lower() in ("yes", "true", "y", "1")
current_cpu = terminal.get("container_cpu", 1)
cpu_str = prompt(" CPU cores", str(current_cpu))
try:
terminal["container_cpu"] = float(cpu_str)
except ValueError:
pass
current_mem = terminal.get("container_memory", 5120)
mem_str = prompt(" Memory in MB (5120 = 5GB)", str(current_mem))
try:
terminal["container_memory"] = int(mem_str)
except ValueError:
pass
if terminal.get("container_disk", 51200) not in (0, 51200):
print_warning("Vercel Sandbox does not support custom disk sizing; resetting container_disk to 51200.")
terminal["container_disk"] = 51200
print()
print_info("Vercel authentication:")
print_info(" Use a long-lived Vercel access token plus project/team IDs.")
linked_project = _read_nearest_vercel_project()
if linked_project:
print_info(" Found defaults in nearest .vercel/project.json.")
remove_env_value("VERCEL_OIDC_TOKEN")
token = prompt(" Vercel access token", get_env_value("VERCEL_TOKEN") or "", password=True)
project = prompt(
" Vercel project ID",
get_env_value("VERCEL_PROJECT_ID") or linked_project.get("projectId", ""),
)
team = prompt(
" Vercel team ID",
get_env_value("VERCEL_TEAM_ID") or linked_project.get("orgId", ""),
)
if token:
save_env_value("VERCEL_TOKEN", token)
if project:
save_env_value("VERCEL_PROJECT_ID", project)
if team:
save_env_value("VERCEL_TEAM_ID", team)
def _read_nearest_vercel_project(start: Path | None = None) -> dict[str, str]:
"""Read project/team defaults from the nearest Vercel link file."""
current = (start or Path.cwd()).resolve()
if current.is_file():
current = current.parent
for directory in (current, *current.parents):
project_file = directory / ".vercel" / "project.json"
if not project_file.exists():
continue
try:
data = json.loads(project_file.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
if not isinstance(data, dict):
return {}
return {
key: value
for key, value in {
"projectId": data.get("projectId"),
"orgId": data.get("orgId"),
}.items()
if isinstance(value, str) and value.strip()
}
return {}
# Tool categories and provider config are now in tools_config.py (shared
# between `hermes tools` and `hermes setup tools`).
@@ -1190,11 +1285,12 @@ def setup_terminal_backend(config: dict):
"Modal - serverless cloud sandbox",
"SSH - run on a remote machine",
"Daytona - persistent cloud development environment",
"Vercel Sandbox - cloud microVM with snapshot filesystem persistence",
]
idx_to_backend = {0: "local", 1: "docker", 2: "modal", 3: "ssh", 4: "daytona"}
backend_to_idx = {"local": 0, "docker": 1, "modal": 2, "ssh": 3, "daytona": 4}
idx_to_backend = {0: "local", 1: "docker", 2: "modal", 3: "ssh", 4: "daytona", 5: "vercel_sandbox"}
backend_to_idx = {"local": 0, "docker": 1, "modal": 2, "ssh": 3, "daytona": 4, "vercel_sandbox": 5}
next_idx = 5
next_idx = 6
if is_linux:
terminal_choices.append("Singularity/Apptainer - HPC-friendly container")
idx_to_backend[next_idx] = "singularity"
@@ -1443,6 +1539,39 @@ def setup_terminal_backend(config: dict):
_prompt_container_resources(config)
elif selected_backend == "vercel_sandbox":
print_success("Terminal backend: Vercel Sandbox")
print_info("Cloud microVM sandboxes with snapshot-backed filesystem persistence.")
print_info("Requires the optional SDK: pip install 'hermes-agent[vercel]'")
try:
__import__("vercel")
except ImportError:
print_info("Installing vercel SDK...")
import subprocess
uv_bin = shutil.which("uv")
if uv_bin:
result = subprocess.run(
[uv_bin, "pip", "install", "--python", sys.executable, "vercel"],
capture_output=True,
text=True,
)
else:
result = subprocess.run(
[sys.executable, "-m", "pip", "install", "vercel"],
capture_output=True,
text=True,
)
if result.returncode == 0:
print_success("vercel SDK installed")
else:
print_warning("Install failed — run manually: pip install 'hermes-agent[vercel]'")
if result.stderr:
print_info(f" Error: {result.stderr.strip().splitlines()[-1]}")
_prompt_vercel_sandbox_settings(config)
elif selected_backend == "ssh":
print_success("Terminal backend: SSH")
print_info("Run commands on a remote machine via SSH.")
@@ -1496,6 +1625,8 @@ def setup_terminal_backend(config: dict):
save_env_value("TERMINAL_ENV", selected_backend)
if selected_backend == "modal":
save_env_value("TERMINAL_MODAL_MODE", config["terminal"].get("modal_mode", "auto"))
if selected_backend == "vercel_sandbox":
save_env_value("TERMINAL_VERCEL_RUNTIME", config["terminal"].get("vercel_runtime", "node24"))
save_config(config)
print()
print_success(f"Terminal backend set to: {selected_backend}")

View File

@@ -7,16 +7,18 @@ Shows the status of all Hermes Agent components.
import os
import sys
import subprocess # noqa: F401 — re-exported for tests that monkeypatch status.subprocess to guard against regressions
import importlib.util
from pathlib import Path
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
from hermes_cli.auth import AuthError, resolve_provider
from hermes_cli.colors import Colors, color
from hermes_cli.config import cfg_get, get_env_path, get_env_value, get_hermes_home, load_config
from hermes_cli.config import get_env_path, get_env_value, get_hermes_home, load_config
from hermes_cli.models import provider_label
from hermes_cli.nous_subscription import get_nous_subscription_features
from hermes_cli.runtime_provider import resolve_requested_provider
from hermes_cli.vercel_auth import describe_vercel_auth
from hermes_constants import OPENROUTER_MODELS_URL
from tools.tool_backend_helpers import managed_nous_tools_enabled
@@ -300,15 +302,10 @@ def show_status(args):
print()
print(color("◆ Terminal Backend", Colors.CYAN, Colors.BOLD))
terminal_cfg = config.get("terminal", {}) if isinstance(config.get("terminal"), dict) else {}
terminal_env = os.getenv("TERMINAL_ENV", "")
if not terminal_env:
# Fall back to config file value when env var isn't set
# (hermes status doesn't go through cli.py's config loading)
try:
_cfg = load_config()
terminal_env = cfg_get(_cfg, "terminal", "backend", default="local")
except Exception:
terminal_env = "local"
terminal_env = terminal_cfg.get("backend", "local")
print(f" Backend: {terminal_env}")
if terminal_env == "ssh":
@@ -322,6 +319,23 @@ def show_status(args):
elif terminal_env == "daytona":
daytona_image = os.getenv("TERMINAL_DAYTONA_IMAGE", "nikolaik/python-nodejs:python3.11-nodejs20")
print(f" Daytona Image: {daytona_image}")
elif terminal_env == "vercel_sandbox":
runtime = os.getenv("TERMINAL_VERCEL_RUNTIME") or terminal_cfg.get("vercel_runtime") or "node24"
persist = os.getenv("TERMINAL_CONTAINER_PERSISTENT")
if persist is None:
persist_enabled = bool(terminal_cfg.get("container_persistent", True))
else:
persist_enabled = persist.lower() in ("1", "true", "yes", "on")
auth_status = describe_vercel_auth()
sdk_ok = importlib.util.find_spec("vercel") is not None
sdk_label = "installed" if sdk_ok else "missing (install: pip install 'hermes-agent[vercel]')"
print(f" Runtime: {runtime}")
print(f" SDK: {check_mark(sdk_ok)} {sdk_label}")
print(f" Auth: {check_mark(auth_status.ok)} {auth_status.label}")
for line in auth_status.detail_lines:
print(f" Auth detail: {line}")
print(f" Persistence: {'snapshot filesystem' if persist_enabled else 'ephemeral filesystem'}")
print(" Processes: live processes do not survive cleanup, snapshots, or sandbox recreation")
sudo_password = os.getenv("SUDO_PASSWORD", "")
print(f" Sudo: {check_mark(bool(sudo_password))} {'enabled' if sudo_password else 'disabled'}")

70
hermes_cli/vercel_auth.py Normal file
View File

@@ -0,0 +1,70 @@
"""Helpers for reporting Vercel Sandbox authentication state."""
from __future__ import annotations
import os
from dataclasses import dataclass
_TOKEN_TUPLE_VARS = ("VERCEL_TOKEN", "VERCEL_PROJECT_ID", "VERCEL_TEAM_ID")
@dataclass(frozen=True)
class VercelAuthStatus:
ok: bool
label: str
detail_lines: tuple[str, ...]
def _present(name: str) -> bool:
return bool(os.getenv(name))
def describe_vercel_auth() -> VercelAuthStatus:
"""Return Vercel auth status without exposing secret values."""
has_oidc = _present("VERCEL_OIDC_TOKEN")
token_states = {name: _present(name) for name in _TOKEN_TUPLE_VARS}
present_token_vars = tuple(name for name, present in token_states.items() if present)
missing_token_vars = tuple(name for name, present in token_states.items() if not present)
if has_oidc:
details = [
"mode: OIDC",
"active env: VERCEL_OIDC_TOKEN",
"note: OIDC tokens are development-only; use access-token auth for deployments and long-running processes",
]
if present_token_vars:
details.append(f"also present: {', '.join(present_token_vars)}")
return VercelAuthStatus(True, "OIDC token via VERCEL_OIDC_TOKEN", tuple(details))
if not missing_token_vars:
return VercelAuthStatus(
True,
"access token + project/team via VERCEL_TOKEN, VERCEL_PROJECT_ID, VERCEL_TEAM_ID",
(
"mode: access token",
"active env: VERCEL_TOKEN, VERCEL_PROJECT_ID, VERCEL_TEAM_ID",
),
)
if present_token_vars:
return VercelAuthStatus(
False,
f"partial access-token auth (missing {', '.join(missing_token_vars)})",
(
"mode: incomplete access token",
f"present env: {', '.join(present_token_vars)}",
f"missing env: {', '.join(missing_token_vars)}",
"recommended: set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together",
),
)
return VercelAuthStatus(
False,
"not configured",
(
"recommended: set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID",
"development-only alternative: set VERCEL_OIDC_TOKEN",
),
)

View File

@@ -253,7 +253,12 @@ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = {
"terminal.backend": {
"type": "select",
"description": "Terminal execution backend",
"options": ["local", "docker", "ssh", "modal", "daytona", "singularity"],
"options": ["local", "docker", "ssh", "modal", "daytona", "vercel_sandbox", "singularity"],
},
"terminal.vercel_runtime": {
"type": "select",
"description": "Vercel Sandbox runtime",
"options": ["node24", "node22", "python3.13"],
},
"terminal.modal_mode": {
"type": "select",
@@ -339,6 +344,7 @@ _CATEGORY_MERGE: Dict[str, str] = {
"human_delay": "display",
"dashboard": "display",
"code_execution": "agent",
"prompt_caching": "agent",
}
# Display order for tabs — unlisted categories sort alphabetically after these.