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:
@@ -296,6 +296,30 @@ class TestRootLevelProviderOverride:
|
||||
# Root-level "opencode-go" must NOT leak through
|
||||
assert cfg["model"]["provider"] != "opencode-go"
|
||||
|
||||
def test_terminal_vercel_runtime_bridged_to_env(self, tmp_path, monkeypatch):
|
||||
"""Classic CLI must expose terminal.vercel_runtime to terminal_tool.py."""
|
||||
import yaml
|
||||
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.delenv("TERMINAL_VERCEL_RUNTIME", raising=False)
|
||||
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text(yaml.safe_dump({
|
||||
"terminal": {
|
||||
"backend": "vercel_sandbox",
|
||||
"vercel_runtime": "python3.13",
|
||||
},
|
||||
}))
|
||||
|
||||
import cli
|
||||
monkeypatch.setattr(cli, "_hermes_home", hermes_home)
|
||||
cfg = cli.load_cli_config()
|
||||
|
||||
assert cfg["terminal"]["vercel_runtime"] == "python3.13"
|
||||
assert os.environ["TERMINAL_VERCEL_RUNTIME"] == "python3.13"
|
||||
|
||||
def test_normalize_root_model_keys_moves_to_model(self):
|
||||
"""_normalize_root_model_keys migrates root keys into model section."""
|
||||
from hermes_cli.config import _normalize_root_model_keys
|
||||
|
||||
@@ -33,6 +33,11 @@ def _simulate_config_bridge(cfg: dict, initial_env: dict | None = None):
|
||||
"backend": "TERMINAL_ENV",
|
||||
"cwd": "TERMINAL_CWD",
|
||||
"timeout": "TERMINAL_TIMEOUT",
|
||||
"vercel_runtime": "TERMINAL_VERCEL_RUNTIME",
|
||||
"container_persistent": "TERMINAL_CONTAINER_PERSISTENT",
|
||||
"container_cpu": "TERMINAL_CONTAINER_CPU",
|
||||
"container_memory": "TERMINAL_CONTAINER_MEMORY",
|
||||
"container_disk": "TERMINAL_CONTAINER_DISK",
|
||||
}
|
||||
for cfg_key, env_var in terminal_env_map.items():
|
||||
if cfg_key in terminal_cfg:
|
||||
@@ -240,3 +245,24 @@ class TestTildeExpansion:
|
||||
}
|
||||
result = _simulate_config_bridge(cfg)
|
||||
assert result["TERMINAL_CWD"] == os.path.expanduser("~/nested")
|
||||
|
||||
|
||||
class TestVercelTerminalBridge:
|
||||
def test_vercel_terminal_settings_bridge(self):
|
||||
cfg = {
|
||||
"terminal": {
|
||||
"backend": "vercel_sandbox",
|
||||
"vercel_runtime": "python3.13",
|
||||
"container_persistent": True,
|
||||
"container_cpu": 2,
|
||||
"container_memory": 4096,
|
||||
"container_disk": 51200,
|
||||
}
|
||||
}
|
||||
result = _simulate_config_bridge(cfg, {"MESSAGING_CWD": "/from/env"})
|
||||
assert result["TERMINAL_ENV"] == "vercel_sandbox"
|
||||
assert result["TERMINAL_VERCEL_RUNTIME"] == "python3.13"
|
||||
assert result["TERMINAL_CONTAINER_PERSISTENT"] == "True"
|
||||
assert result["TERMINAL_CONTAINER_CPU"] == "2"
|
||||
assert result["TERMINAL_CONTAINER_MEMORY"] == "4096"
|
||||
assert result["TERMINAL_CONTAINER_DISK"] == "51200"
|
||||
|
||||
@@ -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) ──
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -73,6 +73,10 @@ class TestContainerSkip:
|
||||
result = check_all_command_guards("rm -rf /", "daytona")
|
||||
assert result["approved"] is True
|
||||
|
||||
def test_vercel_sandbox_skips_both(self):
|
||||
result = check_all_command_guards("rm -rf /", "vercel_sandbox")
|
||||
assert result["approved"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tirith allow + safe command
|
||||
|
||||
@@ -132,6 +132,10 @@ class TestProviderEnvBlocklist:
|
||||
"MODAL_TOKEN_ID": "modal-id",
|
||||
"MODAL_TOKEN_SECRET": "modal-secret",
|
||||
"DAYTONA_API_KEY": "daytona-key",
|
||||
"VERCEL_OIDC_TOKEN": "vercel-oidc-token",
|
||||
"VERCEL_TOKEN": "vercel-token",
|
||||
"VERCEL_PROJECT_ID": "vercel-project",
|
||||
"VERCEL_TEAM_ID": "vercel-team",
|
||||
}
|
||||
result_env = _run_with_env(extra_os_env=leaked_vars)
|
||||
|
||||
@@ -287,6 +291,10 @@ class TestBlocklistCoverage:
|
||||
"MODAL_TOKEN_ID",
|
||||
"MODAL_TOKEN_SECRET",
|
||||
"DAYTONA_API_KEY",
|
||||
"VERCEL_OIDC_TOKEN",
|
||||
"VERCEL_TOKEN",
|
||||
"VERCEL_PROJECT_ID",
|
||||
"VERCEL_TEAM_ID",
|
||||
}
|
||||
assert extras.issubset(_HERMES_PROVIDER_ENV_BLOCKLIST)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ Covers the bugs discovered while setting up TBLite evaluation:
|
||||
4. ensurepip fix in Modal image builder
|
||||
5. No swe-rex dependency — uses native Modal SDK
|
||||
6. /home/ added to host prefix check
|
||||
7. Vercel sandbox cwd normalization
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -101,6 +102,26 @@ class TestCwdHandling:
|
||||
config = _tt_mod._get_env_config()
|
||||
assert config["cwd"] == "/root"
|
||||
|
||||
def test_host_path_replaced_for_vercel_sandbox(self, monkeypatch):
|
||||
"""Host paths should be discarded for Vercel Sandbox."""
|
||||
monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
|
||||
monkeypatch.setenv("TERMINAL_CWD", "/Users/someone/projects")
|
||||
config = _tt_mod._get_env_config()
|
||||
assert config["cwd"] == "/vercel/sandbox"
|
||||
|
||||
def test_relative_path_replaced_for_vercel_sandbox(self, monkeypatch):
|
||||
"""Relative cwd should not map into a remote Vercel sandbox."""
|
||||
monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
|
||||
monkeypatch.setenv("TERMINAL_CWD", "src")
|
||||
config = _tt_mod._get_env_config()
|
||||
assert config["cwd"] == "/vercel/sandbox"
|
||||
|
||||
def test_default_cwd_is_workspace_root_for_vercel_sandbox(self, monkeypatch):
|
||||
monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
|
||||
monkeypatch.delenv("TERMINAL_CWD", raising=False)
|
||||
config = _tt_mod._get_env_config()
|
||||
assert config["cwd"] == "/vercel/sandbox"
|
||||
|
||||
@pytest.mark.parametrize("backend", ["modal", "docker", "singularity", "daytona"])
|
||||
def test_default_cwd_is_root_for_container_backends(self, backend, monkeypatch):
|
||||
"""Container backends should default to /root, not ~."""
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import importlib
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
terminal_tool_module = importlib.import_module("tools.terminal_tool")
|
||||
|
||||
|
||||
@@ -8,11 +10,24 @@ def _clear_terminal_env(monkeypatch):
|
||||
"""Remove terminal env vars that could affect requirements checks."""
|
||||
keys = [
|
||||
"TERMINAL_ENV",
|
||||
"TERMINAL_CONTAINER_CPU",
|
||||
"TERMINAL_CONTAINER_DISK",
|
||||
"TERMINAL_CONTAINER_MEMORY",
|
||||
"TERMINAL_DOCKER_FORWARD_ENV",
|
||||
"TERMINAL_DOCKER_VOLUMES",
|
||||
"TERMINAL_LIFETIME_SECONDS",
|
||||
"TERMINAL_MODAL_MODE",
|
||||
"TERMINAL_SSH_HOST",
|
||||
"TERMINAL_SSH_PORT",
|
||||
"TERMINAL_SSH_USER",
|
||||
"TERMINAL_TIMEOUT",
|
||||
"TERMINAL_VERCEL_RUNTIME",
|
||||
"MODAL_TOKEN_ID",
|
||||
"MODAL_TOKEN_SECRET",
|
||||
"VERCEL_OIDC_TOKEN",
|
||||
"VERCEL_TOKEN",
|
||||
"VERCEL_PROJECT_ID",
|
||||
"VERCEL_TEAM_ID",
|
||||
"HOME",
|
||||
"USERPROFILE",
|
||||
]
|
||||
@@ -176,3 +191,126 @@ def test_modal_backend_managed_mode_without_feature_flag_logs_clear_error(monkey
|
||||
"paid Nous subscription is required" in record.getMessage()
|
||||
for record in caplog.records
|
||||
)
|
||||
|
||||
|
||||
def test_vercel_backend_without_sdk_logs_specific_error(monkeypatch, caplog):
|
||||
_clear_terminal_env(monkeypatch)
|
||||
monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
|
||||
monkeypatch.setattr(terminal_tool_module.importlib.util, "find_spec", lambda _name: None)
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
ok = terminal_tool_module.check_terminal_requirements()
|
||||
|
||||
assert ok is False
|
||||
assert any(
|
||||
"vercel is required for the Vercel Sandbox terminal backend" in record.getMessage()
|
||||
for record in caplog.records
|
||||
)
|
||||
|
||||
|
||||
def test_vercel_backend_without_auth_logs_specific_error(monkeypatch, caplog):
|
||||
_clear_terminal_env(monkeypatch)
|
||||
monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
|
||||
monkeypatch.setattr(terminal_tool_module.importlib.util, "find_spec", lambda _name: object())
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
ok = terminal_tool_module.check_terminal_requirements()
|
||||
|
||||
assert ok is False
|
||||
assert any(
|
||||
"no supported auth configuration was found" in record.getMessage()
|
||||
for record in caplog.records
|
||||
)
|
||||
|
||||
|
||||
def test_vercel_backend_accepts_oidc_auth(monkeypatch):
|
||||
_clear_terminal_env(monkeypatch)
|
||||
monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
|
||||
monkeypatch.setenv("VERCEL_OIDC_TOKEN", "oidc-token")
|
||||
monkeypatch.setattr(terminal_tool_module.importlib.util, "find_spec", lambda _name: object())
|
||||
|
||||
assert terminal_tool_module.check_terminal_requirements() is True
|
||||
|
||||
|
||||
def test_vercel_backend_accepts_token_tuple_auth(monkeypatch):
|
||||
_clear_terminal_env(monkeypatch)
|
||||
monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
|
||||
monkeypatch.setenv("VERCEL_TOKEN", "token")
|
||||
monkeypatch.setenv("VERCEL_PROJECT_ID", "project")
|
||||
monkeypatch.setenv("VERCEL_TEAM_ID", "team")
|
||||
monkeypatch.setattr(terminal_tool_module.importlib.util, "find_spec", lambda _name: object())
|
||||
|
||||
assert terminal_tool_module.check_terminal_requirements() is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("runtime", ["node24", "node22", "python3.13"])
|
||||
def test_vercel_backend_accepts_supported_runtimes(monkeypatch, runtime):
|
||||
_clear_terminal_env(monkeypatch)
|
||||
monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
|
||||
monkeypatch.setenv("TERMINAL_VERCEL_RUNTIME", runtime)
|
||||
monkeypatch.setenv("VERCEL_OIDC_TOKEN", "oidc-token")
|
||||
monkeypatch.setattr(terminal_tool_module.importlib.util, "find_spec", lambda _name: object())
|
||||
|
||||
assert terminal_tool_module.check_terminal_requirements() is True
|
||||
|
||||
|
||||
def test_vercel_backend_accepts_blank_runtime(monkeypatch):
|
||||
_clear_terminal_env(monkeypatch)
|
||||
monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
|
||||
monkeypatch.setenv("TERMINAL_VERCEL_RUNTIME", " ")
|
||||
monkeypatch.setenv("VERCEL_OIDC_TOKEN", "oidc-token")
|
||||
monkeypatch.setattr(terminal_tool_module.importlib.util, "find_spec", lambda _name: object())
|
||||
|
||||
assert terminal_tool_module.check_terminal_requirements() is True
|
||||
|
||||
|
||||
def test_vercel_backend_rejects_unsupported_runtime(monkeypatch, caplog):
|
||||
_clear_terminal_env(monkeypatch)
|
||||
monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
|
||||
monkeypatch.setenv("TERMINAL_VERCEL_RUNTIME", "node20")
|
||||
monkeypatch.setenv("VERCEL_OIDC_TOKEN", "oidc-token")
|
||||
monkeypatch.setattr(terminal_tool_module.importlib.util, "find_spec", lambda _name: object())
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
ok = terminal_tool_module.check_terminal_requirements()
|
||||
|
||||
assert ok is False
|
||||
assert any(
|
||||
"Vercel Sandbox runtime 'node20' is not supported" in record.getMessage()
|
||||
and "node24, node22, python3.13" in record.getMessage()
|
||||
for record in caplog.records
|
||||
)
|
||||
|
||||
|
||||
def test_vercel_backend_rejects_nondefault_disk(monkeypatch, caplog):
|
||||
_clear_terminal_env(monkeypatch)
|
||||
monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
|
||||
monkeypatch.setenv("TERMINAL_CONTAINER_DISK", "8192")
|
||||
monkeypatch.setenv("VERCEL_OIDC_TOKEN", "oidc-token")
|
||||
monkeypatch.setattr(terminal_tool_module.importlib.util, "find_spec", lambda _name: object())
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
ok = terminal_tool_module.check_terminal_requirements()
|
||||
|
||||
assert ok is False
|
||||
assert any(
|
||||
"does not support custom TERMINAL_CONTAINER_DISK=8192" in record.getMessage()
|
||||
for record in caplog.records
|
||||
)
|
||||
|
||||
|
||||
def test_vercel_backend_rejects_malformed_disk_without_raising(monkeypatch, caplog):
|
||||
_clear_terminal_env(monkeypatch)
|
||||
monkeypatch.setenv("TERMINAL_ENV", "vercel_sandbox")
|
||||
monkeypatch.setenv("TERMINAL_CONTAINER_DISK", "large")
|
||||
monkeypatch.setenv("VERCEL_OIDC_TOKEN", "oidc-token")
|
||||
monkeypatch.setattr(terminal_tool_module.importlib.util, "find_spec", lambda _name: object())
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
ok = terminal_tool_module.check_terminal_requirements()
|
||||
|
||||
assert ok is False
|
||||
assert any(
|
||||
"Invalid value for TERMINAL_CONTAINER_DISK" in record.getMessage()
|
||||
for record in caplog.records
|
||||
)
|
||||
|
||||
@@ -49,3 +49,68 @@ class TestTerminalRequirements:
|
||||
|
||||
assert "terminal" in names
|
||||
assert "execute_code" in names
|
||||
|
||||
def test_terminal_and_execute_code_tools_resolve_for_vercel_sandbox(self, monkeypatch):
|
||||
monkeypatch.setenv("VERCEL_OIDC_TOKEN", "oidc-token")
|
||||
monkeypatch.setattr(
|
||||
terminal_tool_module,
|
||||
"_get_env_config",
|
||||
lambda: {"env_type": "vercel_sandbox", "container_disk": 51200},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
terminal_tool_module.importlib.util,
|
||||
"find_spec",
|
||||
lambda _name: object(),
|
||||
)
|
||||
tools = get_tool_definitions(enabled_toolsets=["terminal", "code_execution"], quiet_mode=True)
|
||||
names = {tool["function"]["name"] for tool in tools}
|
||||
|
||||
assert "terminal" in names
|
||||
assert "execute_code" in names
|
||||
|
||||
def test_terminal_and_execute_code_tools_hide_for_unsupported_vercel_runtime(self, monkeypatch):
|
||||
monkeypatch.setenv("VERCEL_OIDC_TOKEN", "oidc-token")
|
||||
monkeypatch.setattr(
|
||||
terminal_tool_module,
|
||||
"_get_env_config",
|
||||
lambda: {
|
||||
"env_type": "vercel_sandbox",
|
||||
"container_disk": 51200,
|
||||
"vercel_runtime": "node20",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
terminal_tool_module.importlib.util,
|
||||
"find_spec",
|
||||
lambda _name: object(),
|
||||
)
|
||||
tools = get_tool_definitions(enabled_toolsets=["terminal", "code_execution"], quiet_mode=True)
|
||||
names = {tool["function"]["name"] for tool in tools}
|
||||
|
||||
assert "terminal" not in names
|
||||
assert "execute_code" not in names
|
||||
|
||||
def test_terminal_and_execute_code_tools_hide_for_vercel_without_auth(self, monkeypatch):
|
||||
monkeypatch.delenv("VERCEL_OIDC_TOKEN", raising=False)
|
||||
monkeypatch.delenv("VERCEL_TOKEN", raising=False)
|
||||
monkeypatch.delenv("VERCEL_PROJECT_ID", raising=False)
|
||||
monkeypatch.delenv("VERCEL_TEAM_ID", raising=False)
|
||||
monkeypatch.setattr(
|
||||
terminal_tool_module,
|
||||
"_get_env_config",
|
||||
lambda: {
|
||||
"env_type": "vercel_sandbox",
|
||||
"container_disk": 51200,
|
||||
"vercel_runtime": "node22",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
terminal_tool_module.importlib.util,
|
||||
"find_spec",
|
||||
lambda _name: object(),
|
||||
)
|
||||
tools = get_tool_definitions(enabled_toolsets=["terminal", "code_execution"], quiet_mode=True)
|
||||
names = {tool["function"]["name"] for tool in tools}
|
||||
|
||||
assert "terminal" not in names
|
||||
assert "execute_code" not in names
|
||||
|
||||
623
tests/tools/test_vercel_sandbox_environment.py
Normal file
623
tests/tools/test_vercel_sandbox_environment.py
Normal file
@@ -0,0 +1,623 @@
|
||||
"""Unit tests for the Vercel Sandbox terminal backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import io
|
||||
import re
|
||||
import sys
|
||||
import tarfile
|
||||
import threading
|
||||
import types
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class _FakeRunResult:
|
||||
def __init__(self, output: str | bytes = "", exit_code: int = 0):
|
||||
self._output = output
|
||||
self.exit_code = exit_code
|
||||
|
||||
def output(self) -> str | bytes:
|
||||
return self._output
|
||||
|
||||
|
||||
class _FakeSandboxStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
STOPPING = "stopping"
|
||||
STOPPED = "stopped"
|
||||
FAILED = "failed"
|
||||
ABORTED = "aborted"
|
||||
SNAPSHOTTING = "snapshotting"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _FakeSnapshot:
|
||||
snapshot_id: str
|
||||
|
||||
|
||||
class _FakeSandbox:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
cwd: str = "/vercel/sandbox",
|
||||
home: str = "/home/vercel",
|
||||
status: _FakeSandboxStatus = _FakeSandboxStatus.RUNNING,
|
||||
):
|
||||
self.sandbox = SimpleNamespace(cwd=cwd, id="sb-123")
|
||||
self.status = status
|
||||
self.home = home
|
||||
self.closed = 0
|
||||
self.client = SimpleNamespace(close=self._close)
|
||||
self.run_command_calls: list[tuple[str, list[str], dict]] = []
|
||||
self.run_command_side_effects: list[object] = []
|
||||
self.write_files_calls: list[list[dict[str, object]]] = []
|
||||
self.write_files_side_effects: list[object] = []
|
||||
self.download_file_calls: list[tuple[str, Path]] = []
|
||||
self.download_file_side_effects: list[object] = []
|
||||
self.download_file_content = b""
|
||||
self.stop_calls: list[tuple[tuple, dict]] = []
|
||||
self.snapshot_calls: list[tuple[tuple, dict]] = []
|
||||
self.snapshot_side_effects: list[object] = []
|
||||
self.snapshot_id = "snap_default"
|
||||
self.refresh_calls = 0
|
||||
self.wait_for_status_calls: list[tuple[object, object, object]] = []
|
||||
self.wait_for_status_side_effects: list[object] = []
|
||||
|
||||
def _close(self) -> None:
|
||||
self.closed += 1
|
||||
|
||||
def refresh(self) -> None:
|
||||
self.refresh_calls += 1
|
||||
|
||||
def wait_for_status(self, status: _FakeSandboxStatus | str, *, timeout, poll_interval) -> None:
|
||||
self.wait_for_status_calls.append((status, timeout, poll_interval))
|
||||
if self.wait_for_status_side_effects:
|
||||
effect = self.wait_for_status_side_effects.pop(0)
|
||||
if isinstance(effect, Exception):
|
||||
raise effect
|
||||
if callable(effect):
|
||||
effect(status, timeout, poll_interval)
|
||||
return
|
||||
self.status = _FakeSandboxStatus(status)
|
||||
|
||||
def run_command(self, cmd: str, args: list[str] | None = None, **kwargs):
|
||||
args = list(args or [])
|
||||
self.run_command_calls.append((cmd, args, kwargs))
|
||||
if self.run_command_side_effects:
|
||||
effect = self.run_command_side_effects.pop(0)
|
||||
if isinstance(effect, Exception):
|
||||
raise effect
|
||||
if callable(effect):
|
||||
return effect(cmd, args, kwargs)
|
||||
return effect
|
||||
script = args[1] if len(args) > 1 else ""
|
||||
if 'printf %s "$HOME"' in script:
|
||||
return _FakeRunResult(self.home)
|
||||
return _FakeRunResult("")
|
||||
|
||||
def write_files(self, files: list[dict[str, object]]) -> None:
|
||||
self.write_files_calls.append(files)
|
||||
if self.write_files_side_effects:
|
||||
effect = self.write_files_side_effects.pop(0)
|
||||
if isinstance(effect, Exception):
|
||||
raise effect
|
||||
if callable(effect):
|
||||
effect(files)
|
||||
|
||||
def download_file(self, remote_path: str, local_path) -> str:
|
||||
destination = Path(local_path)
|
||||
self.download_file_calls.append((remote_path, destination))
|
||||
if self.download_file_side_effects:
|
||||
effect = self.download_file_side_effects.pop(0)
|
||||
if isinstance(effect, Exception):
|
||||
raise effect
|
||||
if callable(effect):
|
||||
return effect(remote_path, destination)
|
||||
destination.write_bytes(self.download_file_content)
|
||||
return str(destination.resolve())
|
||||
|
||||
def stop(self, *args, **kwargs) -> None:
|
||||
self.stop_calls.append((args, kwargs))
|
||||
|
||||
def snapshot(self, *args, **kwargs):
|
||||
self.snapshot_calls.append((args, kwargs))
|
||||
if self.snapshot_side_effects:
|
||||
effect = self.snapshot_side_effects.pop(0)
|
||||
if isinstance(effect, Exception):
|
||||
raise effect
|
||||
if callable(effect):
|
||||
return effect(*args, **kwargs)
|
||||
if isinstance(effect, str):
|
||||
return _FakeSnapshot(effect)
|
||||
return effect
|
||||
return _FakeSnapshot(self.snapshot_id)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _FakeResources:
|
||||
vcpus: float | None = None
|
||||
memory: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _FakeWriteFile:
|
||||
path: str
|
||||
content: bytes
|
||||
|
||||
|
||||
class _FakeSDK:
|
||||
def __init__(self):
|
||||
self.create_kwargs: list[dict[str, object]] = []
|
||||
self.create_side_effects: list[object] = []
|
||||
self.sandboxes: list[_FakeSandbox] = []
|
||||
|
||||
@property
|
||||
def current(self) -> _FakeSandbox:
|
||||
return self.sandboxes[-1]
|
||||
|
||||
def create(self, **kwargs):
|
||||
self.create_kwargs.append(kwargs)
|
||||
if self.create_side_effects:
|
||||
effect = self.create_side_effects.pop(0)
|
||||
if isinstance(effect, Exception):
|
||||
raise effect
|
||||
if isinstance(effect, _FakeSandbox):
|
||||
self.sandboxes.append(effect)
|
||||
return effect
|
||||
sandbox = _FakeSandbox()
|
||||
self.sandboxes.append(sandbox)
|
||||
return sandbox
|
||||
|
||||
|
||||
def _cwd_result(body: str = "", *, cwd: str = "/vercel/sandbox", exit_code: int = 0):
|
||||
def _result(_cmd: str, args: list[str], _kwargs: dict):
|
||||
script = args[1] if len(args) > 1 else ""
|
||||
match = re.search(r"__HERMES_CWD_[A-Za-z0-9]+__", script)
|
||||
marker = match.group(0) if match else "__HERMES_CWD_MISSING__"
|
||||
prefix = f"{body}\n\n" if body else "\n"
|
||||
return _FakeRunResult(f"{prefix}{marker}{cwd}{marker}\n", exit_code)
|
||||
|
||||
return _result
|
||||
|
||||
|
||||
def _tar_bytes(entries: dict[str, bytes]) -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
with tarfile.open(fileobj=buffer, mode="w") as tar:
|
||||
for name, content in entries.items():
|
||||
info = tarfile.TarInfo(name)
|
||||
info.size = len(content)
|
||||
tar.addfile(info, io.BytesIO(content))
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def vercel_sdk(monkeypatch):
|
||||
fake_sdk = _FakeSDK()
|
||||
sandbox_mod = types.ModuleType("vercel.sandbox")
|
||||
sandbox_mod.Sandbox = types.SimpleNamespace(create=fake_sdk.create)
|
||||
sandbox_mod.Resources = _FakeResources
|
||||
sandbox_mod.WriteFile = _FakeWriteFile
|
||||
sandbox_mod.SandboxStatus = _FakeSandboxStatus
|
||||
|
||||
vercel_mod = types.ModuleType("vercel")
|
||||
vercel_mod.sandbox = sandbox_mod
|
||||
|
||||
monkeypatch.setitem(sys.modules, "vercel", vercel_mod)
|
||||
monkeypatch.setitem(sys.modules, "vercel.sandbox", sandbox_mod)
|
||||
return fake_sdk
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def vercel_module(vercel_sdk, monkeypatch):
|
||||
monkeypatch.setattr("tools.environments.base.is_interrupted", lambda: False)
|
||||
monkeypatch.setattr("tools.credential_files.get_credential_file_mounts", lambda: [])
|
||||
monkeypatch.setattr("tools.credential_files.iter_skills_files", lambda **kwargs: [])
|
||||
monkeypatch.setattr("tools.credential_files.iter_cache_files", lambda **kwargs: [])
|
||||
|
||||
module = importlib.import_module("tools.environments.vercel_sandbox")
|
||||
return importlib.reload(module)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def make_env(vercel_module, request):
|
||||
envs = []
|
||||
|
||||
def _cleanup_envs():
|
||||
for env in envs:
|
||||
env._sync_manager = None
|
||||
env.cleanup()
|
||||
|
||||
request.addfinalizer(_cleanup_envs)
|
||||
|
||||
def _factory(**kwargs):
|
||||
kwargs.setdefault("runtime", "node22")
|
||||
kwargs.setdefault("cwd", vercel_module.DEFAULT_VERCEL_CWD)
|
||||
kwargs.setdefault("timeout", 30)
|
||||
kwargs.setdefault("task_id", "task-123")
|
||||
env = vercel_module.VercelSandboxEnvironment(**kwargs)
|
||||
envs.append(env)
|
||||
return env
|
||||
|
||||
return _factory
|
||||
|
||||
|
||||
class TestStartup:
|
||||
def test_default_cwd_tracks_remote_workspace_root(self, make_env, vercel_sdk):
|
||||
sandbox = _FakeSandbox(cwd="/workspace")
|
||||
vercel_sdk.create_side_effects.append(sandbox)
|
||||
|
||||
env = make_env()
|
||||
|
||||
assert env.cwd == "/workspace"
|
||||
|
||||
def test_tilde_cwd_resolves_against_remote_home(self, make_env, vercel_sdk):
|
||||
sandbox = _FakeSandbox(home="/home/custom")
|
||||
vercel_sdk.create_side_effects.append(sandbox)
|
||||
|
||||
env = make_env(cwd="~")
|
||||
|
||||
assert env.cwd == "/home/custom"
|
||||
|
||||
def test_pending_sandbox_timeout_raises_descriptive_error(
|
||||
self, make_env, vercel_sdk
|
||||
):
|
||||
sandbox = _FakeSandbox(status=_FakeSandboxStatus.PENDING)
|
||||
sandbox.wait_for_status_side_effects.append(TimeoutError("still pending"))
|
||||
vercel_sdk.create_side_effects.append(sandbox)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Sandbox did not reach running state"):
|
||||
make_env()
|
||||
|
||||
|
||||
class TestFileSync:
|
||||
def test_initial_sync_uploads_managed_files_under_remote_home(
|
||||
self, make_env, vercel_sdk, monkeypatch, tmp_path
|
||||
):
|
||||
src = tmp_path / "token.txt"
|
||||
src.write_text("secret-token")
|
||||
monkeypatch.setattr(
|
||||
"tools.credential_files.get_credential_file_mounts",
|
||||
lambda: [
|
||||
{
|
||||
"host_path": str(src),
|
||||
"container_path": "/root/.hermes/credentials/token.txt",
|
||||
}
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr("tools.credential_files.iter_skills_files", lambda **kwargs: [])
|
||||
monkeypatch.setattr("tools.credential_files.iter_cache_files", lambda **kwargs: [])
|
||||
|
||||
make_env()
|
||||
|
||||
uploaded = vercel_sdk.current.write_files_calls[0]
|
||||
assert uploaded == [
|
||||
{
|
||||
"path": "/home/vercel/.hermes/credentials/token.txt",
|
||||
"content": b"secret-token",
|
||||
}
|
||||
]
|
||||
|
||||
def test_execute_resyncs_changed_managed_files(
|
||||
self, make_env, vercel_sdk, monkeypatch, tmp_path
|
||||
):
|
||||
src = tmp_path / "token.txt"
|
||||
src.write_text("secret-token")
|
||||
monkeypatch.setattr(
|
||||
"tools.credential_files.get_credential_file_mounts",
|
||||
lambda: [
|
||||
{
|
||||
"host_path": str(src),
|
||||
"container_path": "/root/.hermes/credentials/token.txt",
|
||||
}
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr("tools.credential_files.iter_skills_files", lambda **kwargs: [])
|
||||
monkeypatch.setattr("tools.credential_files.iter_cache_files", lambda **kwargs: [])
|
||||
|
||||
env = make_env()
|
||||
src.write_text("updated-secret-token")
|
||||
monkeypatch.setenv("HERMES_FORCE_FILE_SYNC", "1")
|
||||
vercel_sdk.current.run_command_side_effects.append(_cwd_result("hello"))
|
||||
|
||||
result = env.execute("echo hello")
|
||||
|
||||
assert result == {"output": "hello\n", "returncode": 0}
|
||||
assert vercel_sdk.current.write_files_calls[-1] == [
|
||||
{
|
||||
"path": "/home/vercel/.hermes/credentials/token.txt",
|
||||
"content": b"updated-secret-token",
|
||||
}
|
||||
]
|
||||
|
||||
def test_cleanup_syncs_back_snapshots_closes_and_is_idempotent(
|
||||
self, make_env, vercel_module, vercel_sdk, monkeypatch, tmp_path
|
||||
):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
src = tmp_path / "token.txt"
|
||||
src.write_text("host-token")
|
||||
monkeypatch.setattr(
|
||||
"tools.credential_files.get_credential_file_mounts",
|
||||
lambda: [
|
||||
{
|
||||
"host_path": str(src),
|
||||
"container_path": "/root/.hermes/credentials/token.txt",
|
||||
}
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"tools.credential_files.iter_skills_files",
|
||||
lambda **kwargs: [],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"tools.credential_files.iter_cache_files",
|
||||
lambda **kwargs: [],
|
||||
)
|
||||
env = make_env()
|
||||
sandbox = vercel_sdk.current
|
||||
sandbox.snapshot_id = "snap_cleanup"
|
||||
vercel_sdk.current.download_file_content = _tar_bytes(
|
||||
{
|
||||
"home/vercel/.hermes/credentials/token.txt": b"remote-token",
|
||||
"home/vercel/.hermes/credentials/new.txt": b"new-remote",
|
||||
"home/vercel/.hermes/unmapped/skip.txt": b"skip",
|
||||
}
|
||||
)
|
||||
|
||||
env.cleanup()
|
||||
env.cleanup()
|
||||
|
||||
assert src.read_text() == "remote-token"
|
||||
assert (tmp_path / "new.txt").read_text() == "new-remote"
|
||||
assert not (tmp_path / "skip.txt").exists()
|
||||
assert len(sandbox.snapshot_calls) == 1
|
||||
assert len(sandbox.stop_calls) == 1 # always stop after snapshot to avoid resource leaks
|
||||
assert sandbox.closed == 1
|
||||
assert vercel_module._load_snapshots() == {"task-123": "snap_cleanup"}
|
||||
|
||||
def test_cleanup_sync_back_failure_from_download_does_not_block_snapshot(
|
||||
self, make_env, vercel_sdk, monkeypatch, tmp_path
|
||||
):
|
||||
src = tmp_path / "token.txt"
|
||||
src.write_text("host-token")
|
||||
monkeypatch.setattr(
|
||||
"tools.credential_files.get_credential_file_mounts",
|
||||
lambda: [
|
||||
{
|
||||
"host_path": str(src),
|
||||
"container_path": "/root/.hermes/credentials/token.txt",
|
||||
}
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"tools.credential_files.iter_skills_files",
|
||||
lambda **kwargs: [],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"tools.credential_files.iter_cache_files",
|
||||
lambda **kwargs: [],
|
||||
)
|
||||
env = make_env()
|
||||
sandbox = vercel_sdk.current
|
||||
sandbox.run_command_side_effects.extend(
|
||||
[
|
||||
_FakeRunResult("tar failed", exit_code=2),
|
||||
_FakeRunResult(""),
|
||||
_FakeRunResult("tar failed", exit_code=2),
|
||||
_FakeRunResult(""),
|
||||
_FakeRunResult("tar failed", exit_code=2),
|
||||
_FakeRunResult(""),
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr("tools.environments.file_sync.time.sleep", lambda _delay: None)
|
||||
|
||||
env.cleanup()
|
||||
|
||||
assert src.read_text() == "host-token"
|
||||
assert len(sandbox.snapshot_calls) == 1
|
||||
assert sandbox.closed == 1
|
||||
assert len(sandbox.download_file_calls) == 0
|
||||
|
||||
|
||||
class TestExecute:
|
||||
def test_execute_runs_command_from_workspace_root_and_updates_cwd(
|
||||
self, make_env, vercel_sdk
|
||||
):
|
||||
env = make_env()
|
||||
vercel_sdk.current.run_command_side_effects.append(
|
||||
_cwd_result("/tmp", cwd="/tmp")
|
||||
)
|
||||
|
||||
result = env.execute("pwd", cwd="/tmp")
|
||||
|
||||
assert result == {"output": "/tmp\n", "returncode": 0}
|
||||
assert env.cwd == "/tmp"
|
||||
cmd, args, kwargs = vercel_sdk.current.run_command_calls[-1]
|
||||
assert cmd == "bash"
|
||||
assert args[0] == "-c"
|
||||
assert "cd /tmp" in args[1]
|
||||
assert kwargs["cwd"] == "/vercel/sandbox"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("make_unhealthy", "label"),
|
||||
[
|
||||
(
|
||||
lambda sandbox: setattr(
|
||||
sandbox, "status", _FakeSandboxStatus.STOPPED
|
||||
),
|
||||
"terminal state",
|
||||
),
|
||||
(
|
||||
lambda sandbox: setattr(
|
||||
sandbox,
|
||||
"refresh",
|
||||
lambda: (_ for _ in ()).throw(RuntimeError("refresh failed")),
|
||||
),
|
||||
"refresh failure",
|
||||
),
|
||||
],
|
||||
ids=["terminal-state", "refresh-failure"],
|
||||
)
|
||||
def test_execute_recreates_unhealthy_sandbox_before_running_command(
|
||||
self, make_env, vercel_sdk, make_unhealthy, label
|
||||
):
|
||||
env = make_env()
|
||||
original = vercel_sdk.current
|
||||
make_unhealthy(original)
|
||||
|
||||
replacement = _FakeSandbox()
|
||||
replacement.run_command_side_effects.extend(
|
||||
[
|
||||
_FakeRunResult(replacement.home),
|
||||
_cwd_result("hello"),
|
||||
]
|
||||
)
|
||||
vercel_sdk.create_side_effects.append(replacement)
|
||||
|
||||
result = env.execute("echo hello")
|
||||
|
||||
assert result == {"output": "hello\n", "returncode": 0}, label
|
||||
assert original.closed == 1
|
||||
assert vercel_sdk.current is replacement
|
||||
|
||||
def test_run_bash_handle_uses_captured_sandbox_for_exec_and_cancel(
|
||||
self, make_env
|
||||
):
|
||||
env = make_env()
|
||||
original = env._sandbox
|
||||
assert original is not None
|
||||
replacement = _FakeSandbox()
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
def blocking_command(_cmd: str, _args: list[str], _kwargs: dict):
|
||||
started.set()
|
||||
release.wait(timeout=5)
|
||||
return _FakeRunResult("done")
|
||||
|
||||
original.run_command_side_effects.append(blocking_command)
|
||||
|
||||
handle = env._run_bash("echo done")
|
||||
assert started.wait(timeout=1)
|
||||
|
||||
env._sandbox = replacement
|
||||
handle.kill()
|
||||
release.set()
|
||||
|
||||
assert handle.wait(timeout=2) == 0
|
||||
assert len(original.stop_calls) == 1
|
||||
assert replacement.stop_calls == []
|
||||
cmd, args, kwargs = original.run_command_calls[-1]
|
||||
assert cmd == "bash"
|
||||
assert args == ["-c", "echo done"]
|
||||
assert kwargs["cwd"] == "/vercel/sandbox"
|
||||
|
||||
|
||||
class TestSnapshotPersistence:
|
||||
def test_create_restores_from_saved_snapshot(
|
||||
self, make_env, vercel_module, vercel_sdk, monkeypatch, tmp_path
|
||||
):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
vercel_module._store_snapshot("task-123", "snap_saved")
|
||||
restored = _FakeSandbox(cwd="/restored")
|
||||
vercel_sdk.create_side_effects.append(restored)
|
||||
|
||||
env = make_env()
|
||||
|
||||
assert env.cwd == "/restored"
|
||||
assert vercel_sdk.create_kwargs[0]["source"] == {
|
||||
"type": "snapshot",
|
||||
"snapshot_id": "snap_saved",
|
||||
}
|
||||
assert vercel_module._load_snapshots() == {"task-123": "snap_saved"}
|
||||
|
||||
def test_restore_failure_prunes_snapshot_and_falls_back_to_fresh_sandbox(
|
||||
self, make_env, vercel_module, vercel_sdk, monkeypatch, tmp_path
|
||||
):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
vercel_module._store_snapshot("task-123", "snap_stale")
|
||||
fresh = _FakeSandbox(cwd="/fresh")
|
||||
vercel_sdk.create_side_effects.extend(
|
||||
[RuntimeError("snapshot missing"), fresh]
|
||||
)
|
||||
|
||||
env = make_env()
|
||||
|
||||
assert env.cwd == "/fresh"
|
||||
assert vercel_sdk.create_kwargs[0]["source"] == {
|
||||
"type": "snapshot",
|
||||
"snapshot_id": "snap_stale",
|
||||
}
|
||||
assert "source" not in vercel_sdk.create_kwargs[1]
|
||||
assert vercel_module._load_snapshots() == {}
|
||||
|
||||
def test_cleanup_stops_when_snapshot_fails_without_storing_metadata(
|
||||
self, make_env, vercel_module, vercel_sdk, monkeypatch, tmp_path
|
||||
):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
env = make_env()
|
||||
sandbox = vercel_sdk.current
|
||||
sandbox.snapshot_side_effects.append(RuntimeError("snapshot failed"))
|
||||
|
||||
env.cleanup()
|
||||
|
||||
assert len(sandbox.snapshot_calls) == 1
|
||||
assert len(sandbox.stop_calls) == 1
|
||||
assert sandbox.closed == 1
|
||||
assert vercel_module._load_snapshots() == {}
|
||||
|
||||
def test_non_persistent_cleanup_stops_without_snapshot(
|
||||
self, make_env, vercel_module, vercel_sdk, monkeypatch, tmp_path
|
||||
):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
env = make_env(persistent_filesystem=False)
|
||||
sandbox = vercel_sdk.current
|
||||
|
||||
env.cleanup()
|
||||
|
||||
assert sandbox.snapshot_calls == []
|
||||
assert len(sandbox.stop_calls) == 1
|
||||
assert sandbox.closed == 1
|
||||
assert vercel_module._load_snapshots() == {}
|
||||
|
||||
def test_persistent_cleanup_without_task_id_stops_without_snapshot(
|
||||
self, make_env, vercel_module, vercel_sdk, monkeypatch, tmp_path
|
||||
):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
env = make_env(task_id="")
|
||||
sandbox = vercel_sdk.current
|
||||
|
||||
env.cleanup()
|
||||
|
||||
assert sandbox.snapshot_calls == []
|
||||
assert len(sandbox.stop_calls) == 1
|
||||
assert sandbox.closed == 1
|
||||
assert vercel_module._load_snapshots() == {}
|
||||
|
||||
|
||||
class TestCleanup:
|
||||
def test_cleanup_continues_when_sync_back_raises(self, make_env, vercel_sdk):
|
||||
env = make_env()
|
||||
sandbox = vercel_sdk.current
|
||||
|
||||
class FailingSyncManager:
|
||||
def sync_back(self):
|
||||
raise RuntimeError("download failed")
|
||||
|
||||
env._sync_manager = FailingSyncManager()
|
||||
|
||||
env.cleanup()
|
||||
|
||||
assert len(sandbox.snapshot_calls) == 1
|
||||
assert sandbox.closed == 1
|
||||
Reference in New Issue
Block a user