mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-07 08:02:23 +00:00
remove Vercel AI Gateway and Vercel Sandbox (#33067)
* remove Vercel AI Gateway provider and Vercel Sandbox terminal backend Both Vercel-hosted integrations are removed end-to-end. Users on the AI Gateway should switch to OpenRouter or one of the other aggregators (Nous Portal, Kilo Code). Users on the Vercel Sandbox backend should switch to Docker, Modal, Daytona, or SSH. What's removed: - `plugins/model-providers/ai-gateway/` provider plugin - `hermes_cli/vercel_auth.py` Vercel-Sandbox auth helper - `tools/environments/vercel_sandbox.py` terminal backend - `ai-gateway` provider wiring across auth, doctor, setup, models, config, status, providers, main, web_server, model_normalize, dump - `vercel_sandbox` backend wiring across terminal_tool, file_tools, code_execution_tool, file_operations, approval, skills_tool, environments/local, credential_files, lazy_deps, prompt_builder, cli, gateway/run - `AI_GATEWAY_BASE_URL` constant, `_AI_GATEWAY_HEADERS` auxiliary-client header set, run_agent base-URL header/reasoning special-cases - `[vercel]` pyproject extra and `vercel`/`vercel-workers` from uv.lock - env vars: `AI_GATEWAY_API_KEY`, `AI_GATEWAY_BASE_URL`, `VERCEL_TOKEN`, `VERCEL_PROJECT_ID`, `VERCEL_TEAM_ID`, `VERCEL_OIDC_TOKEN`, `TERMINAL_VERCEL_RUNTIME` - Tests: deletes test_ai_gateway_models.py and test_vercel_sandbox_environment.py; scrubs references across 23 surviving test files (no entire tests deleted unless they were dedicated to AI Gateway / Sandbox) - Docs: provider tables, env-var reference, setup guides, security notes, tool config, terminal-backend tables — English plus zh-Hans i18n parity - `hermes-agent` skill: provider table entry and remote-backend list What stays (intentional): - `popular-web-designs/templates/vercel.md` — CSS design reference, unrelated to Vercel-the-AI-product - `x-vercel-id` in `stream_diag.py` headers — generic Vercel CDN response header, useful diag signal on any Vercel-hosted endpoint - `vercel-labs/agent-browser` URL in browser config — lightpanda browser project, different OSS effort - `userStories.json` historical contributor entry mentioning Vercel Sandbox — archive, not active docs Validation: - 1153 tests in the 22 targeted files pass (`scripts/run_tests.sh`) - Full repo `py_compile` clean - Live import of every touched module + invariant check (no `ai-gateway` in `PROVIDER_REGISTRY`, no `_AI_GATEWAY_HEADERS`, no `vercel_sandbox` in `_REMOTE_TERMINAL_BACKENDS`) * test: convert profile-count check from change-detector to invariant The hardcoded "== 34" assertion broke when ai-gateway was removed. Per AGENTS.md change-detector-test guidance, assert the relationship (registry count >= number of plugin dirs) instead of a literal count. Counts shift when providers are added/removed; that's expected.
This commit is contained in:
parent
cb38ce28cb
commit
febc4cfec0
95 changed files with 111 additions and 3088 deletions
|
|
@ -73,10 +73,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -241,7 +241,7 @@ def test_container_backends_still_bypass(clean_session):
|
|||
|
||||
Hardline only protects environments with real host impact (local, ssh).
|
||||
"""
|
||||
for env in ("docker", "singularity", "modal", "daytona", "vercel_sandbox"):
|
||||
for env in ("docker", "singularity", "modal", "daytona"):
|
||||
r1 = check_dangerous_command("rm -rf /", env)
|
||||
assert r1["approved"] is True, f"container {env} should still bypass"
|
||||
r2 = check_all_command_guards("rm -rf /", env)
|
||||
|
|
@ -372,7 +372,7 @@ def test_sudo_stdin_guard_not_blocked_by_yolo(clean_session, monkeypatch):
|
|||
|
||||
def test_sudo_stdin_guard_container_bypass(clean_session):
|
||||
"""Containerized backends still bypass — they can't touch the host."""
|
||||
for env in ("docker", "singularity", "modal", "daytona", "vercel_sandbox"):
|
||||
for env in ("docker", "singularity", "modal", "daytona"):
|
||||
for cmd in _SUDO_STDIN_BLOCK:
|
||||
result = check_all_command_guards(cmd, env)
|
||||
assert result["approved"] is True, f"container {env} should bypass sudo guard on {cmd!r}"
|
||||
|
|
|
|||
|
|
@ -132,10 +132,6 @@ 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)
|
||||
|
||||
|
|
@ -291,10 +287,6 @@ 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,7 +7,6 @@ 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
|
||||
|
|
@ -102,26 +101,6 @@ 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 ~."""
|
||||
|
|
|
|||
|
|
@ -958,7 +958,7 @@ class TestSkillViewPrerequisites:
|
|||
|
||||
@pytest.mark.parametrize(
|
||||
"backend",
|
||||
["ssh", "daytona", "docker", "singularity", "modal", "vercel_sandbox"],
|
||||
["ssh", "daytona", "docker", "singularity", "modal"],
|
||||
)
|
||||
def test_remote_backend_becomes_available_after_local_secret_capture(
|
||||
self, tmp_path, monkeypatch, backend
|
||||
|
|
|
|||
|
|
@ -21,13 +21,8 @@ def _clear_terminal_env(monkeypatch):
|
|||
"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",
|
||||
]
|
||||
|
|
@ -191,126 +186,3 @@ 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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -64,68 +64,3 @@ 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
|
||||
|
|
|
|||
|
|
@ -1,606 +0,0 @@
|
|||
"""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:
|
||||
|
||||
@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
|
||||
Loading…
Add table
Add a link
Reference in a new issue