From 9eb21bd28ae23b07aa885d19695c173ed66ef0d1 Mon Sep 17 00:00:00 2001 From: tnm Date: Tue, 17 Feb 2026 13:23:53 -0800 Subject: [PATCH 1/2] feat: add vercel sandbox provider support --- README.md | 38 ++- pyproject.toml | 9 +- sandboxes/cli.py | 174 +++++++++-- sandboxes/providers/vercel.py | 499 +++++++++++++++++++++++++++++++ sandboxes/sandbox.py | 47 ++- tests/conftest.py | 1 + tests/test_cli.py | 35 ++- tests/test_integration_vercel.py | 43 +++ tests/test_vercel_provider.py | 192 ++++++++++++ 9 files changed, 1005 insertions(+), 33 deletions(-) create mode 100644 sandboxes/providers/vercel.py create mode 100644 tests/test_integration_vercel.py create mode 100644 tests/test_vercel_provider.py diff --git a/README.md b/README.md index 9f8178d..724ade1 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Universal library for AI code execution sandboxes. `sandboxes` provides a unified interface for sandboxed code execution across multiple providers: -- **Current providers**: E2B, Modal, Daytona, Hopx, Sprites (Fly.io) +- **Current providers**: E2B, Modal, Daytona, Hopx, Vercel, Sprites (Fly.io) - **Experimental**: Cloudflare (requires self-hosted Worker deployment) Write your code once and switch between providers with a single line change, or let the library automatically select a provider. @@ -104,7 +104,7 @@ async def main(): print(result.stdout) # Behind the scenes, run() does this: - # 1. Auto-detects available providers (e.g., E2B, Modal, Daytona) + # 1. Auto-detects available providers (e.g., E2B, Modal, Daytona, Vercel) # 2. Creates a new sandbox with the first available provider # 3. Executes your command in that isolated environment # 4. Returns the result @@ -412,6 +412,9 @@ export E2B_API_KEY="..." export MODAL_TOKEN_ID="..." # Or use `modal token set` export DAYTONA_API_KEY="..." export HOPX_API_KEY="hopx_live_." +export VERCEL_TOKEN="..." +export VERCEL_PROJECT_ID="..." +export VERCEL_TEAM_ID="..." export SPRITES_TOKEN="..." # Or use `sprite login` for CLI mode export CLOUDFLARE_SANDBOX_BASE_URL="https://your-worker.workers.dev" export CLOUDFLARE_API_TOKEN="..." @@ -436,8 +439,9 @@ When you call `Sandbox.create()` or `run()`, the library checks for providers in 2. **E2B** - Looks for `E2B_API_KEY` 3. **Sprites** - Looks for `SPRITES_TOKEN` or `sprite` CLI login 4. **Hopx** - Looks for `HOPX_API_KEY` -5. **Modal** - Looks for `~/.modal.toml` or `MODAL_TOKEN_ID` -6. **Cloudflare** *(experimental)* - Looks for `CLOUDFLARE_SANDBOX_BASE_URL` + `CLOUDFLARE_API_TOKEN` +5. **Vercel** - Looks for `VERCEL_TOKEN` + `VERCEL_PROJECT_ID` + `VERCEL_TEAM_ID` +6. **Modal** - Looks for `~/.modal.toml` or `MODAL_TOKEN_ID` +7. **Cloudflare** *(experimental)* - Looks for `CLOUDFLARE_SANDBOX_BASE_URL` + `CLOUDFLARE_API_TOKEN` **The first provider with valid credentials becomes the default.** Cloudflare requires deploying your own Worker. @@ -495,6 +499,7 @@ from sandboxes.providers import ( ModalProvider, DaytonaProvider, HopxProvider, + VercelProvider, SpritesProvider, CloudflareProvider, ) @@ -511,6 +516,9 @@ provider = DaytonaProvider() # Hopx - Uses HOPX_API_KEY env var provider = HopxProvider() +# Vercel - Uses VERCEL_TOKEN + VERCEL_PROJECT_ID + VERCEL_TEAM_ID env vars +provider = VercelProvider() + # Sprites - Uses SPRITES_TOKEN or sprite CLI login provider = SpritesProvider() # SDK mode with SPRITES_TOKEN provider = SpritesProvider(use_cli=True) # CLI mode with sprite login @@ -527,6 +535,7 @@ Each provider requires appropriate authentication: - **Modal**: Run `modal token set` to configure - **Daytona**: Set `DAYTONA_API_KEY` environment variable - **Hopx**: Set `HOPX_API_KEY` environment variable (format: `hopx_live_.`) +- **Vercel**: Set `VERCEL_TOKEN`, `VERCEL_PROJECT_ID`, and `VERCEL_TEAM_ID` - **Sprites**: Set `SPRITES_TOKEN` environment variable, or run `sprite login` for CLI mode - **Cloudflare** *(experimental)*: Deploy the [Cloudflare sandbox Worker](https://github.com/cloudflare/sandbox-sdk) and set `CLOUDFLARE_SANDBOX_BASE_URL`, `CLOUDFLARE_API_TOKEN`, and (optionally) `CLOUDFLARE_ACCOUNT_ID` @@ -556,7 +565,15 @@ Each provider requires appropriate authentication: ```python import asyncio from sandboxes import Manager, SandboxConfig -from sandboxes.providers import E2BProvider, ModalProvider, DaytonaProvider, SpritesProvider, CloudflareProvider +from sandboxes.providers import ( + E2BProvider, + ModalProvider, + DaytonaProvider, + HopxProvider, + VercelProvider, + SpritesProvider, + CloudflareProvider, +) async def main(): # Initialize manager and register providers @@ -566,6 +583,15 @@ async def main(): manager.register_provider("modal", ModalProvider, {}) manager.register_provider("daytona", DaytonaProvider, {}) manager.register_provider("hopx", HopxProvider, {}) + manager.register_provider( + "vercel", + VercelProvider, + { + "token": "...", + "project_id": "...", + "team_id": "...", + }, + ) manager.register_provider("sprites", SpritesProvider, {"use_cli": True}) manager.register_provider( "cloudflare", @@ -959,4 +985,4 @@ MIT License - see [LICENSE](LICENSE) file for details. Built by [Cased](https://cased.com) -Thanks to the teams at E2B, Modal, Daytona, Hopx, Fly.io (Sprites), and Cloudflare for their excellent sandbox platforms. +Thanks to the teams at E2B, Modal, Daytona, Hopx, Vercel, Fly.io (Sprites), and Cloudflare for their excellent sandbox platforms. diff --git a/pyproject.toml b/pyproject.toml index 6aab08a..845fa97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "e2b>=2.13.2", "daytona>=0.143.0", "hopx-ai>=0.5.0", + "vercel>=0.4.0", "httpx>=0.27.0", ] @@ -46,9 +47,9 @@ modal = [ hopx = [ "hopx-ai>=0.5.0", # Official Hopx SDK for secure cloud sandboxes ] -# vercel = [ -# "vercel-sdk>=0.1.0", # When available -# ] +vercel = [ + "vercel>=0.4.0", # Official Vercel SDK with Sandbox APIs +] # cloudflare = [ # "cloudflare-workers-sdk>=0.1.0", # When available # ] @@ -57,6 +58,7 @@ all = [ "e2b>=2.13.2", "modal==1.3.3", "hopx-ai>=0.5.0", + "vercel>=0.4.0", ] dev = [ "pytest>=7.4.0", @@ -155,6 +157,7 @@ markers = [ "modal: marks tests that require Modal API", "daytona: marks tests that require Daytona API", "hopx: marks tests that require Hopx API", + "vercel: marks tests that require Vercel API", "cloudflare: marks tests that require Cloudflare API", "slow: marks tests as slow (deselect with '-m \"not slow\"')", ] diff --git a/sandboxes/cli.py b/sandboxes/cli.py index 0d8d5b0..b4f7c61 100644 --- a/sandboxes/cli.py +++ b/sandboxes/cli.py @@ -1,6 +1,8 @@ #!/usr/bin/env python """CLI for cased-sandboxes.""" +from __future__ import annotations + import asyncio import json import os @@ -9,23 +11,68 @@ import click from . import __version__ +from .base import ProviderCapabilities + + +def _provider_classes(): + """Return provider name to provider class mapping.""" + providers: dict[str, type] = {} + + try: + from sandboxes.providers.e2b import E2BProvider + + providers["e2b"] = E2BProvider + except ImportError: + pass + + try: + from sandboxes.providers.modal import ModalProvider + + providers["modal"] = ModalProvider + except ImportError: + pass + + try: + from sandboxes.providers.daytona import DaytonaProvider + + providers["daytona"] = DaytonaProvider + except ImportError: + pass + + try: + from sandboxes.providers.vercel import VercelProvider + + providers["vercel"] = VercelProvider + except ImportError: + pass + + try: + from sandboxes.providers.hopx import HopxProvider + + providers["hopx"] = HopxProvider + except ImportError: + pass + + try: + from sandboxes.providers.sprites import SpritesProvider + + providers["sprites"] = SpritesProvider + except ImportError: + pass + + try: + from sandboxes.providers.cloudflare import CloudflareProvider + + providers["cloudflare"] = CloudflareProvider + except ImportError: + pass + + return providers def get_provider(name: str): """Get a provider instance by name.""" - from sandboxes.providers.cloudflare import CloudflareProvider - from sandboxes.providers.daytona import DaytonaProvider - from sandboxes.providers.e2b import E2BProvider - from sandboxes.providers.modal import ModalProvider - from sandboxes.providers.sprites import SpritesProvider - - providers = { - "e2b": E2BProvider, - "modal": ModalProvider, - "daytona": DaytonaProvider, - "sprites": SpritesProvider, - "cloudflare": CloudflareProvider, - } + providers = _provider_classes() if name not in providers: click.echo(f"āŒ Unknown provider: {name}", err=True) @@ -33,7 +80,24 @@ def get_provider(name: str): sys.exit(1) try: - return providers[name]() + provider_class = providers[name] + if name == "cloudflare": + return provider_class( + base_url=os.getenv("CLOUDFLARE_SANDBOX_BASE_URL", ""), + api_token=os.getenv("CLOUDFLARE_API_TOKEN") or os.getenv("CLOUDFLARE_API_KEY"), + account_id=os.getenv("CLOUDFLARE_ACCOUNT_ID"), + ) + if name == "vercel": + return provider_class( + token=( + os.getenv("VERCEL_TOKEN") + or os.getenv("VERCEL_API_TOKEN") + or os.getenv("VERCEL_ACCESS_TOKEN") + ), + project_id=os.getenv("VERCEL_PROJECT_ID"), + team_id=os.getenv("VERCEL_TEAM_ID"), + ) + return provider_class() except Exception as e: click.echo(f"āŒ Failed to initialize {name}: {e}", err=True) sys.exit(1) @@ -50,7 +114,12 @@ def cli(): @click.argument("command", required=False) @click.option("--file", "-f", type=click.Path(exists=True), help="Execute code from a file") @click.option("--language", "--lang", help="Language/runtime (python, node, go, etc.)") -@click.option("--provider", "-p", default="daytona", help="Provider to use (daytona, e2b, modal)") +@click.option( + "--provider", + "-p", + default="daytona", + help="Provider to use (e.g. daytona, e2b, modal, vercel)", +) @click.option("--image", "-i", help="Docker image or template") @click.option("--env", "-e", multiple=True, help="Environment variables (KEY=VALUE)") @click.option("--label", "-l", multiple=True, help="Labels (KEY=VALUE)") @@ -303,7 +372,7 @@ async def _list(): @cli.command() @click.argument("sandbox_id") -@click.option("--provider", "-p", required=True, help="Provider (e2b, modal, daytona)") +@click.option("--provider", "-p", required=True, help="Provider name (e.g. e2b, modal, daytona)") def destroy(sandbox_id, provider): """Destroy a sandbox. @@ -323,7 +392,7 @@ async def _destroy(): @cli.command() @click.argument("sandbox_id") @click.argument("command") -@click.option("--provider", "-p", required=True, help="Provider (e2b, modal, daytona)") +@click.option("--provider", "-p", required=True, help="Provider name (e.g. e2b, modal, daytona)") @click.option("--env", "-e", multiple=True, help="Environment variables (KEY=VALUE)") def exec(sandbox_id, command, provider, env): """Execute a command in an existing sandbox. @@ -419,17 +488,22 @@ async def _test(): @cli.command() -def providers(): +@click.option("--capabilities/--no-capabilities", default=False, help="Show capability matrix") +def providers(capabilities): """List available providers and their status.""" click.echo("\nAvailable Providers") click.echo("=" * 50) import shutil + provider_classes = _provider_classes() + providers = [ ("e2b", "E2B_API_KEY", "E2B cloud sandboxes", False), ("modal", "~/.modal.toml", "Modal serverless containers", False), ("daytona", "DAYTONA_API_KEY", "Daytona development environments", False), + ("vercel", "VERCEL_TOKEN + PROJECT_ID + TEAM_ID", "Vercel Sandboxes", False), + ("hopx", "HOPX_API_KEY", "Hopx secure cloud sandboxes", False), ( "sprites", "SPRITES_TOKEN or sprite CLI", @@ -452,10 +526,26 @@ def providers(): configured = bool(os.getenv("E2B_API_KEY")) elif name == "daytona": configured = bool(os.getenv("DAYTONA_API_KEY")) + elif name == "vercel": + configured = bool( + ( + os.getenv("VERCEL_TOKEN") + or os.getenv("VERCEL_API_TOKEN") + or os.getenv("VERCEL_ACCESS_TOKEN") + or os.getenv("VERCEL_OIDC_TOKEN") + ) + and os.getenv("VERCEL_PROJECT_ID") + and os.getenv("VERCEL_TEAM_ID") + ) + elif name == "hopx": + configured = bool(os.getenv("HOPX_API_KEY")) elif name == "sprites": configured = bool(os.getenv("SPRITES_TOKEN")) or shutil.which("sprite") is not None elif name == "cloudflare": - configured = bool(os.getenv("CLOUDFLARE_API_TOKEN") or os.getenv("CLOUDFLARE_API_KEY")) + configured = bool( + (os.getenv("CLOUDFLARE_API_TOKEN") or os.getenv("CLOUDFLARE_API_KEY")) + and os.getenv("CLOUDFLARE_SANDBOX_BASE_URL") + ) else: configured = False @@ -468,10 +558,56 @@ def providers(): if is_experimental: click.echo(" Note: Requires self-hosted Worker deployment") + if capabilities: + click.echo("\nCapability Matrix") + click.echo("=" * 50) + + headers = [ + "Provider", + "Persistent", + "Snapshot", + "Streaming", + "File Upload", + "Interactive Shell", + "GPU", + ] + + rows: list[list[str]] = [] + for name, _, _, _ in providers: + provider_class = provider_classes.get(name) + provider_capabilities = ( + provider_class.get_capabilities() if provider_class else ProviderCapabilities() + ) + rows.append( + [ + name, + "Y" if provider_capabilities.persistent else "-", + "Y" if provider_capabilities.snapshot else "-", + "Y" if provider_capabilities.streaming else "-", + "Y" if provider_capabilities.file_upload else "-", + "Y" if provider_capabilities.interactive_shell else "-", + "Y" if provider_capabilities.gpu else "-", + ] + ) + + col_widths = [ + max(len(headers[i]), *(len(row[i]) for row in rows)) for i in range(len(headers)) + ] + + def format_row(row: list[str]) -> str: + return " " + " | ".join(cell.ljust(col_widths[i]) for i, cell in enumerate(row)) + + click.echo(format_row(headers)) + click.echo(" " + "-+-".join("-" * width for width in col_widths)) + for row in rows: + click.echo(format_row(row)) + click.echo("\nšŸ’” To configure a provider:") click.echo(" E2B: export E2B_API_KEY=your_key") click.echo(" Modal: modal token set") click.echo(" Daytona: export DAYTONA_API_KEY=your_key") + click.echo(" Vercel: export VERCEL_TOKEN=... VERCEL_PROJECT_ID=... VERCEL_TEAM_ID=...") + click.echo(" Hopx: export HOPX_API_KEY=hopx_live_.") click.echo(" Sprites: sprite login (or export SPRITES_TOKEN=your_token)") click.echo( " Cloudflare (experimental): Deploy Worker from https://github.com/cloudflare/sandbox-sdk" diff --git a/sandboxes/providers/vercel.py b/sandboxes/providers/vercel.py new file mode 100644 index 0000000..818ccf9 --- /dev/null +++ b/sandboxes/providers/vercel.py @@ -0,0 +1,499 @@ +"""Vercel sandbox provider using the official vercel SDK.""" + +from __future__ import annotations + +import asyncio +import logging +import math +import os +import time +from collections.abc import AsyncIterator +from contextlib import suppress +from datetime import datetime +from typing import Any + +from ..base import ( + ExecutionResult, + ProviderCapabilities, + Sandbox, + SandboxConfig, + SandboxProvider, + SandboxState, +) +from ..exceptions import ProviderError, SandboxError, SandboxNotFoundError +from ..security import validate_download_path, validate_upload_path + +logger = logging.getLogger(__name__) + +try: + from vercel.oidc import get_credentials as get_vercel_credentials + from vercel.sandbox import AsyncSandbox as VercelSandbox + from vercel.sandbox.api_client import AsyncAPIClient + from vercel.sandbox.base_client import APIError as VercelAPIError + from vercel.sandbox.models import SandboxesResponse + + VERCEL_AVAILABLE = True +except ImportError: + VERCEL_AVAILABLE = False + VercelSandbox = None + AsyncAPIClient = None + VercelAPIError = Exception + SandboxesResponse = None + get_vercel_credentials = None + logger.warning("Vercel SDK not available - install with: pip install vercel") + + +class VercelProvider(SandboxProvider): + """Vercel sandbox provider implementation.""" + + CAPABILITIES = ProviderCapabilities( + persistent=True, + snapshot=True, + streaming=True, + file_upload=True, + interactive_shell=True, + ) + + def __init__( + self, + token: str | None = None, + project_id: str | None = None, + team_id: str | None = None, + **config, + ): + """Initialize Vercel provider.""" + super().__init__(**config) + + if not VERCEL_AVAILABLE: + raise ProviderError("Vercel SDK not installed") + + provided_token = ( + token + or os.getenv("VERCEL_TOKEN") + or os.getenv("VERCEL_API_TOKEN") + or os.getenv("VERCEL_ACCESS_TOKEN") + ) + provided_project_id = project_id or os.getenv("VERCEL_PROJECT_ID") + provided_team_id = team_id or os.getenv("VERCEL_TEAM_ID") + + try: + credentials = get_vercel_credentials( + token=provided_token, + project_id=provided_project_id, + team_id=provided_team_id, + ) + except RuntimeError as e: + raise ProviderError( + "Vercel credentials not provided. Set VERCEL_TOKEN, " + "VERCEL_PROJECT_ID, and VERCEL_TEAM_ID." + ) from e + + self.token = credentials.token + self.project_id = credentials.project_id + self.team_id = credentials.team_id + self.default_timeout_seconds = int(config.get("timeout", 300)) + self.default_runtime = config.get("runtime") + self.default_ports = list(config.get("ports", [])) + self.default_interactive = bool(config.get("interactive", False)) + + self._sandboxes: dict[str, dict[str, Any]] = {} + self._lock = asyncio.Lock() + + @property + def name(self) -> str: + """Provider name.""" + return "vercel" + + def _auth_kwargs(self) -> dict[str, str]: + return { + "token": self.token, + "project_id": self.project_id, + "team_id": self.team_id, + } + + @staticmethod + def _convert_state(vercel_state: str) -> SandboxState: + state_map = { + "pending": SandboxState.CREATING, + "running": SandboxState.RUNNING, + "stopping": SandboxState.STOPPING, + "stopped": SandboxState.STOPPED, + "snapshotting": SandboxState.STOPPING, + "failed": SandboxState.ERROR, + } + return state_map.get(vercel_state.lower(), SandboxState.ERROR) + + @staticmethod + def _to_datetime(timestamp_ms: int | None) -> datetime | None: + if timestamp_ms is None: + return None + return datetime.fromtimestamp(timestamp_ms / 1000) + + def _to_sandbox(self, vercel_sandbox, metadata: dict[str, Any]) -> Sandbox: + raw = vercel_sandbox.sandbox + routes = metadata.get("routes") or vercel_sandbox.routes + + return Sandbox( + id=vercel_sandbox.sandbox_id, + provider=self.name, + state=self._convert_state(raw.status), + labels=metadata.get("labels", {}), + created_at=metadata.get("created_at") or self._to_datetime(raw.created_at), + connection_info={"routes": routes}, + metadata={ + "status_raw": raw.status, + "runtime": raw.runtime, + "region": raw.region, + "timeout_ms": raw.timeout, + "memory_mb": raw.memory, + "vcpus": raw.vcpus, + "interactive_port": raw.interactive_port, + "last_accessed": metadata.get("last_accessed", time.time()), + }, + ) + + def _build_resources(self, config: SandboxConfig) -> dict[str, Any] | None: + resources = {} + if config.provider_config: + resources.update(config.provider_config.get("resources", {})) + + if config.memory_mb and "memory" not in resources: + resources["memory"] = config.memory_mb + + if config.cpu_cores and "vcpus" not in resources: + resources["vcpus"] = max(1, math.ceil(config.cpu_cores)) + + return resources or None + + @staticmethod + def _is_not_found(error: Exception) -> bool: + if isinstance(error, VercelAPIError) and getattr(error, "status_code", None) == 404: + return True + message = str(error).lower() + return "404" in message or "not found" in message + + async def _get_or_fetch_sdk_sandbox(self, sandbox_id: str): + metadata = self._sandboxes.get(sandbox_id, {}) + sdk_sandbox = metadata.get("vercel_sandbox") + if sdk_sandbox is not None: + return sdk_sandbox, metadata + + try: + sdk_sandbox = await VercelSandbox.get(sandbox_id=sandbox_id, **self._auth_kwargs()) + except Exception as e: + if self._is_not_found(e): + raise SandboxNotFoundError(f"Sandbox {sandbox_id} not found") from e + raise SandboxError(f"Failed to fetch sandbox {sandbox_id}: {e}") from e + + metadata.update( + { + "vercel_sandbox": sdk_sandbox, + "routes": sdk_sandbox.routes, + "created_at": metadata.get("created_at") + or self._to_datetime(sdk_sandbox.sandbox.created_at), + "last_accessed": time.time(), + "labels": metadata.get("labels", {}), + "env_vars": metadata.get("env_vars", {}), + "working_dir": metadata.get("working_dir"), + } + ) + + async with self._lock: + self._sandboxes[sandbox_id] = metadata + + return sdk_sandbox, metadata + + async def create_sandbox(self, config: SandboxConfig) -> Sandbox: + """Create a new Vercel sandbox.""" + try: + timeout_seconds = config.timeout_seconds or self.default_timeout_seconds + timeout_ms = max(1000, int(timeout_seconds * 1000)) + provider_config = config.provider_config or {} + + runtime = provider_config.get("runtime") or config.image or self.default_runtime + ports = provider_config.get("ports", self.default_ports) + source = provider_config.get("source") + interactive = provider_config.get("interactive", self.default_interactive) + resources = self._build_resources(config) + + vercel_sandbox = await VercelSandbox.create( + source=source, + ports=ports, + timeout=timeout_ms, + resources=resources, + runtime=runtime, + interactive=interactive, + **self._auth_kwargs(), + ) + + metadata = { + "vercel_sandbox": vercel_sandbox, + "labels": config.labels or {}, + "created_at": self._to_datetime(vercel_sandbox.sandbox.created_at), + "last_accessed": time.time(), + "routes": vercel_sandbox.routes, + "env_vars": dict(config.env_vars or {}), + "working_dir": config.working_dir, + "config": config, + } + + async with self._lock: + self._sandboxes[vercel_sandbox.sandbox_id] = metadata + + logger.info("Created Vercel sandbox %s", vercel_sandbox.sandbox_id) + + if config.setup_commands: + for cmd in config.setup_commands: + await self.execute_command(vercel_sandbox.sandbox_id, cmd) + + return self._to_sandbox(vercel_sandbox, metadata) + + except Exception as e: + raise SandboxError(f"Failed to create Vercel sandbox: {e}") from e + + async def get_sandbox(self, sandbox_id: str) -> Sandbox | None: + """Get sandbox by ID.""" + try: + vercel_sandbox, metadata = await self._get_or_fetch_sdk_sandbox(sandbox_id) + metadata["last_accessed"] = time.time() + return self._to_sandbox(vercel_sandbox, metadata) + except SandboxNotFoundError: + return None + except Exception as e: + raise SandboxError(f"Failed to get sandbox {sandbox_id}: {e}") from e + + async def list_sandboxes(self, labels: dict[str, str] | None = None) -> list[Sandbox]: + """List Vercel sandboxes for this project.""" + client = None + listed_sandboxes: list[Sandbox] = [] + + try: + client = AsyncAPIClient(team_id=self.team_id, token=self.token) + response_data = await client.request_json( + "GET", + "/v1/sandboxes", + query={"project": self.project_id, "limit": 100}, + ) + parsed = SandboxesResponse.model_validate(response_data) + + for listed in parsed.sandboxes: + metadata = self._sandboxes.get(listed.id, {}) + metadata.setdefault("labels", {}) + metadata.setdefault("env_vars", {}) + metadata["created_at"] = metadata.get("created_at") or self._to_datetime( + listed.created_at + ) + metadata["last_accessed"] = metadata.get("last_accessed", time.time()) + metadata.setdefault("routes", []) + + async with self._lock: + self._sandboxes[listed.id] = metadata + + if labels and not all(metadata["labels"].get(k) == v for k, v in labels.items()): + continue + + listed_sandboxes.append( + Sandbox( + id=listed.id, + provider=self.name, + state=self._convert_state(listed.status), + labels=metadata["labels"], + created_at=metadata["created_at"], + connection_info={"routes": metadata.get("routes", [])}, + metadata={ + "status_raw": listed.status, + "runtime": listed.runtime, + "region": listed.region, + "timeout_ms": listed.timeout, + "memory_mb": listed.memory, + "vcpus": listed.vcpus, + "interactive_port": listed.interactive_port, + "last_accessed": metadata["last_accessed"], + }, + ) + ) + + return listed_sandboxes + + except Exception as e: + logger.warning("Could not list Vercel sandboxes from API: %s", e) + + # Fallback to locally tracked sandboxes. + local_sandboxes = [] + for sandbox_id in list(self._sandboxes.keys()): + sandbox = await self.get_sandbox(sandbox_id) + if not sandbox: + continue + if labels and not all(sandbox.labels.get(k) == v for k, v in labels.items()): + continue + local_sandboxes.append(sandbox) + return local_sandboxes + + finally: + if client is not None: + with suppress(Exception): + await client.aclose() + + async def execute_command( + self, + sandbox_id: str, + command: str, + timeout: int | None = None, + env_vars: dict[str, str] | None = None, + ) -> ExecutionResult: + """Execute command in sandbox via sh -lc.""" + try: + vercel_sandbox, metadata = await self._get_or_fetch_sdk_sandbox(sandbox_id) + + merged_env = dict(metadata.get("env_vars", {})) + if env_vars: + merged_env.update(env_vars) + + working_dir = metadata.get("working_dir") + start = time.time() + + detached_cmd = await vercel_sandbox.run_command_detached( + "sh", + ["-lc", command], + cwd=working_dir, + env=merged_env or None, + ) + + try: + finished_cmd = ( + await asyncio.wait_for(detached_cmd.wait(), timeout=timeout) + if timeout + else await detached_cmd.wait() + ) + except TimeoutError: + with suppress(Exception): + await detached_cmd.kill() + stdout, stderr = await asyncio.gather(detached_cmd.stdout(), detached_cmd.stderr()) + duration_ms = int((time.time() - start) * 1000) + return ExecutionResult( + exit_code=-1, + stdout=stdout, + stderr=stderr, + duration_ms=duration_ms, + truncated=False, + timed_out=True, + ) + + stdout, stderr = await asyncio.gather(finished_cmd.stdout(), finished_cmd.stderr()) + duration_ms = int((time.time() - start) * 1000) + metadata["last_accessed"] = time.time() + + return ExecutionResult( + exit_code=finished_cmd.exit_code, + stdout=stdout, + stderr=stderr, + duration_ms=duration_ms, + truncated=False, + timed_out=False, + ) + + except SandboxNotFoundError: + raise + except Exception as e: + if self._is_not_found(e): + raise SandboxNotFoundError(f"Sandbox {sandbox_id} not found") from e + raise SandboxError(f"Failed to execute command in sandbox {sandbox_id}: {e}") from e + + async def stream_execution( + self, + sandbox_id: str, + command: str, + timeout: int | None = None, + env_vars: dict[str, str] | None = None, + ) -> AsyncIterator[str]: + """Stream command output from Vercel logs endpoint.""" + try: + vercel_sandbox, metadata = await self._get_or_fetch_sdk_sandbox(sandbox_id) + + merged_env = dict(metadata.get("env_vars", {})) + if env_vars: + merged_env.update(env_vars) + + working_dir = metadata.get("working_dir") + detached_cmd = await vercel_sandbox.run_command_detached( + "sh", + ["-lc", command], + cwd=working_dir, + env=merged_env or None, + ) + + async for log_line in detached_cmd.logs(): + if log_line.stream == "stderr": + yield f"[stderr]: {log_line.data}" + else: + yield log_line.data + + if timeout: + await asyncio.wait_for(detached_cmd.wait(), timeout=timeout) + else: + await detached_cmd.wait() + metadata["last_accessed"] = time.time() + + except SandboxNotFoundError: + raise + except Exception as e: + if self._is_not_found(e): + raise SandboxNotFoundError(f"Sandbox {sandbox_id} not found") from e + raise SandboxError( + f"Failed to stream command output in sandbox {sandbox_id}: {e}" + ) from e + + async def destroy_sandbox(self, sandbox_id: str) -> bool: + """Stop and remove sandbox.""" + try: + vercel_sandbox, _metadata = await self._get_or_fetch_sdk_sandbox(sandbox_id) + except SandboxNotFoundError: + return False + + try: + await vercel_sandbox.stop() + with suppress(Exception): + await vercel_sandbox.client.aclose() + self._sandboxes.pop(sandbox_id, None) + return True + except Exception as e: + if self._is_not_found(e): + self._sandboxes.pop(sandbox_id, None) + return False + raise SandboxError(f"Failed to destroy sandbox {sandbox_id}: {e}") from e + + async def upload_file(self, sandbox_id: str, local_path: str, sandbox_path: str) -> bool: + """Upload local file into sandbox filesystem.""" + try: + validated_path = validate_upload_path(local_path) + vercel_sandbox, _metadata = await self._get_or_fetch_sdk_sandbox(sandbox_id) + + with open(validated_path, "rb") as f: + content = f.read() + + await vercel_sandbox.write_files([{"path": sandbox_path, "content": content}]) + return True + + except SandboxNotFoundError: + raise + except Exception as e: + raise SandboxError(f"Failed to upload file to sandbox {sandbox_id}: {e}") from e + + async def download_file(self, sandbox_id: str, sandbox_path: str, local_path: str) -> bool: + """Download file from sandbox filesystem.""" + try: + validated_path = validate_download_path(local_path) + vercel_sandbox, _metadata = await self._get_or_fetch_sdk_sandbox(sandbox_id) + + content = await vercel_sandbox.read_file(sandbox_path) + if content is None: + raise SandboxNotFoundError(f"Sandbox file not found: {sandbox_path}") + + with open(validated_path, "wb") as f: + f.write(content) + return True + + except SandboxNotFoundError: + raise + except Exception as e: + raise SandboxError(f"Failed to download file from sandbox {sandbox_id}: {e}") from e diff --git a/sandboxes/sandbox.py b/sandboxes/sandbox.py index c5a4467..3a59ddc 100644 --- a/sandboxes/sandbox.py +++ b/sandboxes/sandbox.py @@ -74,8 +74,9 @@ def _auto_configure(cls) -> None: 2. E2B 3. Sprites 4. Hopx - 5. Modal - 6. Cloudflare (experimental) + 5. Vercel + 6. Modal + 7. Cloudflare (experimental) The first registered provider becomes the default unless explicitly set. Users can override with Sandbox.configure(default_provider="..."). @@ -87,6 +88,7 @@ def _auto_configure(cls) -> None: HopxProvider, ModalProvider, SpritesProvider, + VercelProvider, ) manager = cls._manager @@ -130,7 +132,29 @@ def _auto_configure(cls) -> None: except Exception as e: logger.debug(f"Failed to register Hopx provider: {e}") - # Try to register Modal (priority 5) + # Try to register Vercel (priority 5) + vercel_token = ( + os.getenv("VERCEL_TOKEN") + or os.getenv("VERCEL_API_TOKEN") + or os.getenv("VERCEL_ACCESS_TOKEN") + or os.getenv("VERCEL_OIDC_TOKEN") + ) + if vercel_token and os.getenv("VERCEL_PROJECT_ID") and os.getenv("VERCEL_TEAM_ID"): + try: + manager.register_provider( + "vercel", + VercelProvider, + { + "token": vercel_token, + "project_id": os.getenv("VERCEL_PROJECT_ID"), + "team_id": os.getenv("VERCEL_TEAM_ID"), + }, + ) + logger.info("Registered Vercel provider") + except Exception as e: + logger.debug(f"Failed to register Vercel provider: {e}") + + # Try to register Modal (priority 6) if os.path.exists(os.path.expanduser("~/.modal.toml")) or os.getenv("MODAL_TOKEN_ID"): try: manager.register_provider("modal", ModalProvider, {}) @@ -138,7 +162,7 @@ def _auto_configure(cls) -> None: except Exception as e: logger.debug(f"Failed to register Modal provider: {e}") - # Try to register Cloudflare (priority 6 - experimental) + # Try to register Cloudflare (priority 7 - experimental) base_url = os.getenv("CLOUDFLARE_SANDBOX_BASE_URL") api_token = os.getenv("CLOUDFLARE_API_TOKEN") if base_url and api_token: @@ -164,6 +188,9 @@ def configure( modal_token: str | None = None, daytona_api_key: str | None = None, hopx_api_key: str | None = None, + vercel_token: str | None = None, + vercel_project_id: str | None = None, + vercel_team_id: str | None = None, sprites_token: str | None = None, cloudflare_config: dict[str, str] | None = None, default_provider: str | None = None, @@ -185,6 +212,7 @@ def configure( HopxProvider, ModalProvider, SpritesProvider, + VercelProvider, ) manager = cls._ensure_manager() @@ -202,6 +230,17 @@ def configure( if hopx_api_key: manager.register_provider("hopx", HopxProvider, {"api_key": hopx_api_key}) + if vercel_token or vercel_project_id or vercel_team_id: + manager.register_provider( + "vercel", + VercelProvider, + { + "token": vercel_token, + "project_id": vercel_project_id, + "team_id": vercel_team_id, + }, + ) + if sprites_token: manager.register_provider("sprites", SpritesProvider, {"token": sprites_token}) diff --git a/tests/conftest.py b/tests/conftest.py index 5d00b54..5cb255e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -136,5 +136,6 @@ def pytest_configure(config): config.addinivalue_line("markers", "daytona: Tests specific to Daytona provider") config.addinivalue_line("markers", "modal: Tests specific to Modal provider") config.addinivalue_line("markers", "hopx: Tests specific to Hopx provider") + config.addinivalue_line("markers", "vercel: Tests specific to Vercel provider") config.addinivalue_line("markers", "slow: Slow tests that might take a while") config.addinivalue_line("markers", "cloudflare: Tests specific to Cloudflare provider") diff --git a/tests/test_cli.py b/tests/test_cli.py index 4cf386c..8f8481d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -176,19 +176,34 @@ def test_test_command_specific_provider(self, mock_async_run): def test_providers_command(self): """Test providers command.""" - with patch("os.getenv") as mock_getenv, patch("os.path.exists") as mock_exists: + with ( + patch("os.getenv") as mock_getenv, + patch("os.path.exists") as mock_exists, + patch("shutil.which") as mock_which, + ): def getenv_side_effect(key: str) -> str | None: if key == "E2B_API_KEY": return "test_key" if key in {"CLOUDFLARE_API_TOKEN", "CLOUDFLARE_API_KEY"}: return "cf_token" + if key == "CLOUDFLARE_SANDBOX_BASE_URL": + return "https://example.workers.dev" + if key == "HOPX_API_KEY": + return "hopx_live_key.secret" + if key == "VERCEL_TOKEN": + return "vercel_token" + if key == "VERCEL_PROJECT_ID": + return "project_123" + if key == "VERCEL_TEAM_ID": + return "team_123" if key == "DAYTONA_API_KEY": return None return None mock_getenv.side_effect = getenv_side_effect mock_exists.return_value = True # Modal config exists + mock_which.return_value = "/usr/local/bin/sprite" result = self.runner.invoke(cli, ["providers"]) @@ -197,9 +212,27 @@ def getenv_side_effect(key: str) -> str | None: assert "e2b" in result.output assert "modal" in result.output assert "daytona" in result.output + assert "vercel" in result.output + assert "hopx" in result.output assert "cloudflare" in result.output assert "Configured" in result.output + def test_providers_command_with_capabilities(self): + """Test providers command with capability matrix.""" + with ( + patch("os.getenv", return_value=None), + patch("os.path.exists", return_value=False), + patch("shutil.which", return_value=None), + ): + result = self.runner.invoke(cli, ["providers", "--capabilities"]) + + assert result.exit_code == 0 + assert "Capability Matrix" in result.output + assert "Persistent" in result.output + assert "Interactive Shell" in result.output + assert "hopx" in result.output + assert "vercel" in result.output + class TestCLIDepsFlag: """Test --deps flag functionality.""" diff --git a/tests/test_integration_vercel.py b/tests/test_integration_vercel.py new file mode 100644 index 0000000..35f8c17 --- /dev/null +++ b/tests/test_integration_vercel.py @@ -0,0 +1,43 @@ +"""Real integration tests for Vercel provider.""" + +import os + +import pytest + +from sandboxes import SandboxConfig +from sandboxes.providers.vercel import VercelProvider + + +@pytest.mark.integration +@pytest.mark.vercel +@pytest.mark.asyncio +async def test_create_execute_destroy_vercel(): + """Create, execute, list, and destroy a real Vercel sandbox.""" + token = ( + os.getenv("VERCEL_TOKEN") + or os.getenv("VERCEL_API_TOKEN") + or os.getenv("VERCEL_ACCESS_TOKEN") + ) + project_id = os.getenv("VERCEL_PROJECT_ID") + team_id = os.getenv("VERCEL_TEAM_ID") + + if not (token and project_id and team_id): + pytest.skip("Vercel credentials not configured") + + provider = VercelProvider(token=token, project_id=project_id, team_id=team_id) + sandbox = await provider.create_sandbox( + SandboxConfig( + image="node22", + labels={"test": "integration", "provider": "vercel"}, + ) + ) + + try: + result = await provider.execute_command(sandbox.id, "echo 'hello from vercel'") + assert result.success + assert "hello from vercel" in result.stdout + + sandboxes = await provider.list_sandboxes() + assert any(sb.id == sandbox.id for sb in sandboxes) + finally: + await provider.destroy_sandbox(sandbox.id) diff --git a/tests/test_vercel_provider.py b/tests/test_vercel_provider.py new file mode 100644 index 0000000..6a0cb82 --- /dev/null +++ b/tests/test_vercel_provider.py @@ -0,0 +1,192 @@ +"""Tests for the Vercel sandbox provider.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +import sandboxes.providers.vercel as vercel_module +from sandboxes.base import SandboxConfig +from sandboxes.exceptions import ProviderError +from sandboxes.providers.vercel import VercelProvider + + +def _make_sdk_sandbox(sandbox_id: str = "sb-vercel-123"): + raw = SimpleNamespace( + status="running", + runtime="node22", + region="iad1", + timeout=120_000, + memory=1024, + vcpus=1, + interactive_port=None, + created_at=1_700_000_000_000, + ) + + finished_command = SimpleNamespace( + exit_code=0, + stdout=AsyncMock(return_value="hello\n"), + stderr=AsyncMock(return_value=""), + ) + + async def iter_logs(): + yield SimpleNamespace(stream="stdout", data="line-1\n") + yield SimpleNamespace(stream="stderr", data="line-2\n") + + detached_command = SimpleNamespace( + wait=AsyncMock(return_value=finished_command), + stdout=AsyncMock(return_value="hello\n"), + stderr=AsyncMock(return_value=""), + logs=iter_logs, + kill=AsyncMock(), + ) + + return SimpleNamespace( + sandbox_id=sandbox_id, + sandbox=raw, + routes=[{"port": 3000, "url": "https://example.vercel.run"}], + run_command_detached=AsyncMock(return_value=detached_command), + write_files=AsyncMock(), + read_file=AsyncMock(return_value=b"downloaded-content"), + stop=AsyncMock(), + client=SimpleNamespace(aclose=AsyncMock()), + _detached_command=detached_command, + ) + + +def _install_vercel_mocks(monkeypatch, sdk_sandbox): + monkeypatch.setattr(vercel_module, "VERCEL_AVAILABLE", True) + monkeypatch.setattr( + vercel_module, + "get_vercel_credentials", + lambda **_kwargs: SimpleNamespace( + token="token-test", + project_id="project-test", + team_id="team-test", + ), + ) + monkeypatch.setattr( + vercel_module, + "VercelSandbox", + SimpleNamespace( + create=AsyncMock(return_value=sdk_sandbox), + get=AsyncMock(return_value=sdk_sandbox), + ), + ) + + listed = SimpleNamespace( + id=sdk_sandbox.sandbox_id, + status="running", + runtime="node22", + region="iad1", + timeout=120_000, + memory=1024, + vcpus=1, + interactive_port=None, + created_at=1_700_000_000_000, + ) + + class _MockClient: + def __init__(self, **_kwargs): + self.closed = False + + async def request_json(self, method: str, path: str, query: dict | None = None): + assert method == "GET" + assert path == "/v1/sandboxes" + assert query and query["project"] == "project-test" + return {"sandboxes": [{"id": sdk_sandbox.sandbox_id}]} + + async def aclose(self): + self.closed = True + + class _MockSandboxesResponse: + @staticmethod + def model_validate(_data): + return SimpleNamespace(sandboxes=[listed]) + + monkeypatch.setattr(vercel_module, "AsyncAPIClient", _MockClient) + monkeypatch.setattr(vercel_module, "SandboxesResponse", _MockSandboxesResponse) + + +@pytest.mark.asyncio +async def test_vercel_provider_happy_path(monkeypatch, tmp_path): + """Create, list, execute, stream, upload, download, and destroy sandbox.""" + sdk_sandbox = _make_sdk_sandbox() + _install_vercel_mocks(monkeypatch, sdk_sandbox) + + provider = VercelProvider(token="token", project_id="project", team_id="team") + config = SandboxConfig(labels={"env": "test"}, env_vars={"BASE": "1"}) + + sandbox = await provider.create_sandbox(config) + assert sandbox.id == sdk_sandbox.sandbox_id + assert sandbox.provider == "vercel" + + listed = await provider.list_sandboxes(labels={"env": "test"}) + assert len(listed) == 1 + assert listed[0].id == sdk_sandbox.sandbox_id + + result = await provider.execute_command( + sandbox.id, + "echo hello", + env_vars={"RUNTIME": "yes"}, + ) + assert result.success + assert "hello" in result.stdout + sdk_sandbox.run_command_detached.assert_called() + + chunks = [] + async for chunk in provider.stream_execution(sandbox.id, "echo stream"): + chunks.append(chunk) + assert "line-1" in "".join(chunks) + assert "[stderr]:" in "".join(chunks) + + upload_file = tmp_path / "upload.txt" + upload_file.write_text("upload-content") + uploaded = await provider.upload_file(sandbox.id, str(upload_file), "/workspace/upload.txt") + assert uploaded is True + sdk_sandbox.write_files.assert_called_once() + + download_file = tmp_path / "download.txt" + downloaded = await provider.download_file(sandbox.id, "/workspace/file.txt", str(download_file)) + assert downloaded is True + assert download_file.read_text() == "downloaded-content" + + destroyed = await provider.destroy_sandbox(sandbox.id) + assert destroyed is True + sdk_sandbox.stop.assert_called_once() + + +@pytest.mark.asyncio +async def test_vercel_execute_timeout(monkeypatch): + """Timeout should kill detached command and return timed_out result.""" + sdk_sandbox = _make_sdk_sandbox() + _install_vercel_mocks(monkeypatch, sdk_sandbox) + + async def _timeout(): + raise TimeoutError + + sdk_sandbox._detached_command.wait = AsyncMock(side_effect=_timeout) + sdk_sandbox.run_command_detached = AsyncMock(return_value=sdk_sandbox._detached_command) + + provider = VercelProvider(token="token", project_id="project", team_id="team") + sandbox = await provider.create_sandbox(SandboxConfig()) + result = await provider.execute_command(sandbox.id, "sleep 2", timeout=1) + + assert result.timed_out is True + assert result.exit_code == -1 + sdk_sandbox._detached_command.kill.assert_called_once() + + +def test_vercel_missing_credentials(monkeypatch): + """Provider should raise clear error when credentials are missing.""" + monkeypatch.setattr(vercel_module, "VERCEL_AVAILABLE", True) + + def _raise_credentials_error(**_kwargs): + raise RuntimeError("missing credentials") + + monkeypatch.setattr(vercel_module, "get_vercel_credentials", _raise_credentials_error) + + with pytest.raises(ProviderError, match="Vercel credentials not provided"): + VercelProvider() From 6571368ad58e33ca1c2e4e3a180bdf0525e17418 Mon Sep 17 00:00:00 2001 From: tnm Date: Tue, 17 Feb 2026 14:35:23 -0800 Subject: [PATCH 2/2] fix: add provider capabilities base contract --- sandboxes/base.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/sandboxes/base.py b/sandboxes/base.py index 5770ae5..3887296 100644 --- a/sandboxes/base.py +++ b/sandboxes/base.py @@ -92,13 +92,41 @@ class Sandbox: metadata: dict[str, Any] = field(default_factory=dict) +@dataclass(frozen=True) +class ProviderCapabilities: + """Feature flags exposed by a provider.""" + + persistent: bool = False + snapshot: bool = False + streaming: bool = False + file_upload: bool = False + interactive_shell: bool = False + gpu: bool = False + + def as_dict(self) -> dict[str, bool]: + """Return capabilities as a plain dictionary.""" + return asdict(self) + + class SandboxProvider(ABC): """Abstract base class for sandbox providers.""" + CAPABILITIES = ProviderCapabilities() + def __init__(self, **config): """Initialize provider with configuration.""" self.config = config + @classmethod + def get_capabilities(cls) -> ProviderCapabilities: + """Return static capabilities for this provider class.""" + return cls.CAPABILITIES + + @property + def capabilities(self) -> ProviderCapabilities: + """Return capabilities for this provider instance.""" + return self.get_capabilities() + @property @abstractmethod def name(self) -> str: