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

@@ -161,6 +161,38 @@ def test_check_gateway_service_linger_skips_when_service_not_installed(monkeypat
assert issues == []
def test_doctor_reports_vercel_backend_diagnostics(monkeypatch, tmp_path):
monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
monkeypatch.setenv("TERMINAL_VERCEL_RUNTIME", "python3.13")
monkeypatch.setenv("TERMINAL_CONTAINER_DISK", "2048")
monkeypatch.setenv("VERCEL_TOKEN", "super-secret-value")
monkeypatch.delenv("VERCEL_PROJECT_ID", raising=False)
monkeypatch.setenv("VERCEL_TEAM_ID", "team")
monkeypatch.setattr(doctor_mod.importlib.util, "find_spec", lambda name: object() if name == "vercel" else None)
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
out = buf.getvalue()
assert "Vercel runtime" in out
assert "python3.13" in out
assert "Vercel custom disk unsupported" in out
assert "Vercel auth incomplete" in out
assert "VERCEL_PROJECT_ID" in out
assert "Vercel auth mode: incomplete access token" in out
assert "Vercel auth present env: VERCEL_TOKEN, VERCEL_TEAM_ID" in out
assert "Vercel auth missing env: VERCEL_PROJECT_ID" in out
assert "super-secret-value" not in out
assert "snapshot filesystem only" in out
# ── Memory provider section (doctor should only check the *active* provider) ──

View File

@@ -127,6 +127,13 @@ class TestConfigYamlRouting:
or "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE=True" in env_content
)
def test_terminal_vercel_runtime_goes_to_config_and_env(self, _isolated_hermes_home):
set_config_value("terminal.vercel_runtime", "python3.13")
config = _read_config(_isolated_hermes_home)
env_content = _read_env(_isolated_hermes_home)
assert "vercel_runtime: python3.13" in config
assert "TERMINAL_VERCEL_RUNTIME=python3.13" in env_content
# ---------------------------------------------------------------------------
# Empty / falsy values — regression tests for #4277

View File

@@ -1,5 +1,6 @@
"""Tests for setup.py configuration flows."""
import json
import os
import sys
import types
@@ -480,6 +481,83 @@ def test_modal_setup_persists_direct_mode_when_user_chooses_their_own_account(tm
assert config["terminal"]["modal_mode"] == "direct"
def test_vercel_setup_configures_access_token_auth(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("VERCEL_OIDC_TOKEN", "old-oidc")
monkeypatch.setitem(sys.modules, "vercel", types.ModuleType("vercel"))
config = load_config()
def fake_prompt_choice(question, choices, default=0):
if question == "Select terminal backend:":
return 5
raise AssertionError(f"Unexpected prompt_choice call: {question}")
prompt_values = iter(["python3.13", "yes", "2", "4096", "token", "project", "team"])
monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice)
monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: next(prompt_values))
from hermes_cli.setup import setup_terminal_backend
setup_terminal_backend(config)
assert config["terminal"]["backend"] == "vercel_sandbox"
assert config["terminal"]["vercel_runtime"] == "python3.13"
assert config["terminal"]["container_disk"] == 51200
assert os.environ["TERMINAL_VERCEL_RUNTIME"] == "python3.13"
assert "VERCEL_OIDC_TOKEN" not in os.environ
assert os.environ["VERCEL_TOKEN"] == "token"
assert os.environ["VERCEL_PROJECT_ID"] == "project"
assert os.environ["VERCEL_TEAM_ID"] == "team"
def test_vercel_setup_prefills_project_and_team_from_link_file(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
project_root = tmp_path / "project"
nested = project_root / "app" / "src"
nested.mkdir(parents=True)
vercel_dir = project_root / ".vercel"
vercel_dir.mkdir()
(vercel_dir / "project.json").write_text(
json.dumps({"projectId": "linked-project", "orgId": "linked-team"}),
encoding="utf-8",
)
monkeypatch.chdir(nested)
monkeypatch.setitem(sys.modules, "vercel", types.ModuleType("vercel"))
config = load_config()
config["terminal"]["container_disk"] = 999
def fake_prompt_choice(question, choices, default=0):
if question == "Select terminal backend:":
return 5
raise AssertionError(f"Unexpected prompt_choice call: {question}")
prompt_values = iter(["node24", "no", "1", "5120", "token", "", ""])
defaults = {}
def fake_prompt(message, default="", **kwargs):
defaults[message] = default
value = next(prompt_values)
return value or default
monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice)
monkeypatch.setattr("hermes_cli.setup.prompt", fake_prompt)
from hermes_cli.setup import setup_terminal_backend
setup_terminal_backend(config)
assert config["terminal"]["backend"] == "vercel_sandbox"
assert config["terminal"]["container_persistent"] is False
assert config["terminal"]["container_disk"] == 51200
assert "VERCEL_OIDC_TOKEN" not in os.environ
assert os.environ["VERCEL_TOKEN"] == "token"
assert os.environ["VERCEL_PROJECT_ID"] == "linked-project"
assert os.environ["VERCEL_TEAM_ID"] == "linked-team"
assert defaults[" Vercel project ID"] == "linked-project"
assert defaults[" Vercel team ID"] == "linked-team"
def test_resolve_hermes_chat_argv_prefers_which(monkeypatch):
from hermes_cli import setup as setup_mod

View File

@@ -79,3 +79,33 @@ def test_show_status_reports_nous_auth_error(monkeypatch, capsys, tmp_path):
assert "Error: Refresh session has been revoked" in output
assert "Access exp:" in output
assert "Key exp:" in output
def test_show_status_reports_vercel_backend_contract(monkeypatch, capsys, tmp_path):
from hermes_cli import status as status_mod
import hermes_cli.auth as auth_mod
import hermes_cli.gateway as gateway_mod
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
monkeypatch.setenv("TERMINAL_VERCEL_RUNTIME", "python3.13")
monkeypatch.setenv("TERMINAL_CONTAINER_PERSISTENT", "true")
monkeypatch.setenv("VERCEL_OIDC_TOKEN", "oidc-token")
monkeypatch.setattr(status_mod.importlib.util, "find_spec", lambda name: object() if name == "vercel" else None)
monkeypatch.setattr(status_mod, "load_config", lambda: {"terminal": {"backend": "vercel_sandbox"}}, raising=False)
monkeypatch.setattr(auth_mod, "get_nous_auth_status", lambda: {}, raising=False)
monkeypatch.setattr(auth_mod, "get_codex_auth_status", lambda: {}, raising=False)
monkeypatch.setattr(auth_mod, "get_qwen_auth_status", lambda: {}, raising=False)
monkeypatch.setattr(gateway_mod, "find_gateway_pids", lambda exclude_pids=None: [], raising=False)
status_mod.show_status(SimpleNamespace(all=False, deep=False))
output = capsys.readouterr().out
assert "Backend: vercel_sandbox" in output
assert "Runtime: python3.13" in output
assert "Auth:" in output and "OIDC token via VERCEL_OIDC_TOKEN" in output
assert "Auth detail: mode: OIDC" in output
assert "Auth detail: active env: VERCEL_OIDC_TOKEN" in output
assert "oidc-token" not in output
assert "snapshot filesystem" in output
assert "live processes do not survive" in output

View File

@@ -371,6 +371,12 @@ class TestBuildSchemaFromConfig:
assert entry["type"] == "select"
assert "options" in entry
assert "local" in entry["options"]
assert "vercel_sandbox" in entry["options"]
runtime_entry = CONFIG_SCHEMA["terminal.vercel_runtime"]
assert runtime_entry["type"] == "select"
assert "node24" in runtime_entry["options"]
assert "python3.13" in runtime_entry["options"]
assert len(runtime_entry["options"]) >= 3
def test_empty_prefix_produces_correct_keys(self):
from hermes_cli.web_server import _build_schema_from_config