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:
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
70
hermes_cli/vercel_auth.py
Normal 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",
|
||||
),
|
||||
)
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user