From 9eb21bd28ae23b07aa885d19695c173ed66ef0d1 Mon Sep 17 00:00:00 2001 From: tnm Date: Tue, 17 Feb 2026 13:23:53 -0800 Subject: [PATCH 1/4] 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/4] 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: From 280cd73413abb47cd0378a5ea62139286c725090 Mon Sep 17 00:00:00 2001 From: tnm Date: Tue, 17 Feb 2026 14:44:40 -0800 Subject: [PATCH 3/4] feat: add provider capability contracts and CLI matrix --- README.md | 38 +-- pyproject.toml | 9 +- sandboxes/cli.py | 32 +- sandboxes/providers/vercel.py | 499 ------------------------------- sandboxes/sandbox.py | 47 +-- tests/conftest.py | 1 - tests/test_cli.py | 8 - tests/test_integration_vercel.py | 43 --- tests/test_vercel_provider.py | 192 ------------ 9 files changed, 14 insertions(+), 855 deletions(-) delete mode 100644 sandboxes/providers/vercel.py delete mode 100644 tests/test_integration_vercel.py delete mode 100644 tests/test_vercel_provider.py diff --git a/README.md b/README.md index 724ade1..9f8178d 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, Vercel, Sprites (Fly.io) +- **Current providers**: E2B, Modal, Daytona, Hopx, 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, Vercel) + # 1. Auto-detects available providers (e.g., E2B, Modal, Daytona) # 2. Creates a new sandbox with the first available provider # 3. Executes your command in that isolated environment # 4. Returns the result @@ -412,9 +412,6 @@ 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="..." @@ -439,9 +436,8 @@ 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. **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` +5. **Modal** - Looks for `~/.modal.toml` or `MODAL_TOKEN_ID` +6. **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. @@ -499,7 +495,6 @@ from sandboxes.providers import ( ModalProvider, DaytonaProvider, HopxProvider, - VercelProvider, SpritesProvider, CloudflareProvider, ) @@ -516,9 +511,6 @@ 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 @@ -535,7 +527,6 @@ 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` @@ -565,15 +556,7 @@ Each provider requires appropriate authentication: ```python import asyncio from sandboxes import Manager, SandboxConfig -from sandboxes.providers import ( - E2BProvider, - ModalProvider, - DaytonaProvider, - HopxProvider, - VercelProvider, - SpritesProvider, - CloudflareProvider, -) +from sandboxes.providers import E2BProvider, ModalProvider, DaytonaProvider, SpritesProvider, CloudflareProvider async def main(): # Initialize manager and register providers @@ -583,15 +566,6 @@ 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", @@ -985,4 +959,4 @@ MIT License - see [LICENSE](LICENSE) file for details. Built by [Cased](https://cased.com) -Thanks to the teams at E2B, Modal, Daytona, Hopx, Vercel, Fly.io (Sprites), and Cloudflare for their excellent sandbox platforms. +Thanks to the teams at E2B, Modal, Daytona, Hopx, Fly.io (Sprites), and Cloudflare for their excellent sandbox platforms. diff --git a/pyproject.toml b/pyproject.toml index 845fa97..6aab08a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,6 @@ dependencies = [ "e2b>=2.13.2", "daytona>=0.143.0", "hopx-ai>=0.5.0", - "vercel>=0.4.0", "httpx>=0.27.0", ] @@ -47,9 +46,9 @@ modal = [ hopx = [ "hopx-ai>=0.5.0", # Official Hopx SDK for secure cloud sandboxes ] -vercel = [ - "vercel>=0.4.0", # Official Vercel SDK with Sandbox APIs -] +# vercel = [ +# "vercel-sdk>=0.1.0", # When available +# ] # cloudflare = [ # "cloudflare-workers-sdk>=0.1.0", # When available # ] @@ -58,7 +57,6 @@ all = [ "e2b>=2.13.2", "modal==1.3.3", "hopx-ai>=0.5.0", - "vercel>=0.4.0", ] dev = [ "pytest>=7.4.0", @@ -157,7 +155,6 @@ 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 b4f7c61..838161d 100644 --- a/sandboxes/cli.py +++ b/sandboxes/cli.py @@ -39,13 +39,6 @@ def _provider_classes(): except ImportError: pass - try: - from sandboxes.providers.vercel import VercelProvider - - providers["vercel"] = VercelProvider - except ImportError: - pass - try: from sandboxes.providers.hopx import HopxProvider @@ -87,16 +80,6 @@ def get_provider(name: str): 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) @@ -118,7 +101,7 @@ def cli(): "--provider", "-p", default="daytona", - help="Provider to use (e.g. daytona, e2b, modal, vercel)", + help="Provider to use (e.g. daytona, e2b, modal)", ) @click.option("--image", "-i", help="Docker image or template") @click.option("--env", "-e", multiple=True, help="Environment variables (KEY=VALUE)") @@ -502,7 +485,6 @@ def providers(capabilities): ("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", @@ -526,17 +508,6 @@ def providers(capabilities): 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": @@ -606,7 +577,6 @@ def format_row(row: list[str]) -> str: 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( diff --git a/sandboxes/providers/vercel.py b/sandboxes/providers/vercel.py deleted file mode 100644 index 818ccf9..0000000 --- a/sandboxes/providers/vercel.py +++ /dev/null @@ -1,499 +0,0 @@ -"""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 3a59ddc..c5a4467 100644 --- a/sandboxes/sandbox.py +++ b/sandboxes/sandbox.py @@ -74,9 +74,8 @@ def _auto_configure(cls) -> None: 2. E2B 3. Sprites 4. Hopx - 5. Vercel - 6. Modal - 7. Cloudflare (experimental) + 5. Modal + 6. Cloudflare (experimental) The first registered provider becomes the default unless explicitly set. Users can override with Sandbox.configure(default_provider="..."). @@ -88,7 +87,6 @@ def _auto_configure(cls) -> None: HopxProvider, ModalProvider, SpritesProvider, - VercelProvider, ) manager = cls._manager @@ -132,29 +130,7 @@ def _auto_configure(cls) -> None: except Exception as e: logger.debug(f"Failed to register Hopx provider: {e}") - # 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) + # Try to register Modal (priority 5) if os.path.exists(os.path.expanduser("~/.modal.toml")) or os.getenv("MODAL_TOKEN_ID"): try: manager.register_provider("modal", ModalProvider, {}) @@ -162,7 +138,7 @@ def _auto_configure(cls) -> None: except Exception as e: logger.debug(f"Failed to register Modal provider: {e}") - # Try to register Cloudflare (priority 7 - experimental) + # Try to register Cloudflare (priority 6 - experimental) base_url = os.getenv("CLOUDFLARE_SANDBOX_BASE_URL") api_token = os.getenv("CLOUDFLARE_API_TOKEN") if base_url and api_token: @@ -188,9 +164,6 @@ 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, @@ -212,7 +185,6 @@ def configure( HopxProvider, ModalProvider, SpritesProvider, - VercelProvider, ) manager = cls._ensure_manager() @@ -230,17 +202,6 @@ 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 5cb255e..5d00b54 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -136,6 +136,5 @@ 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 8f8481d..49d266f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -191,12 +191,6 @@ def getenv_side_effect(key: str) -> str | None: 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 @@ -212,7 +206,6 @@ 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 @@ -231,7 +224,6 @@ def test_providers_command_with_capabilities(self): assert "Persistent" in result.output assert "Interactive Shell" in result.output assert "hopx" in result.output - assert "vercel" in result.output class TestCLIDepsFlag: diff --git a/tests/test_integration_vercel.py b/tests/test_integration_vercel.py deleted file mode 100644 index 35f8c17..0000000 --- a/tests/test_integration_vercel.py +++ /dev/null @@ -1,43 +0,0 @@ -"""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 deleted file mode 100644 index 6a0cb82..0000000 --- a/tests/test_vercel_provider.py +++ /dev/null @@ -1,192 +0,0 @@ -"""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 3bfd85fe295bd5204cb62c7dce79b6b93c8557e6 Mon Sep 17 00:00:00 2001 From: Ted Nyman Date: Tue, 17 Feb 2026 15:02:14 -0800 Subject: [PATCH 4/4] feat: add vercel sandbox provider (#20) --- README.md | 38 ++- pyproject.toml | 9 +- sandboxes/cli.py | 32 +- sandboxes/providers/vercel.py | 499 +++++++++++++++++++++++++++++++ sandboxes/sandbox.py | 47 ++- tests/conftest.py | 1 + tests/test_cli.py | 8 + tests/test_integration_vercel.py | 43 +++ tests/test_vercel_provider.py | 192 ++++++++++++ 9 files changed, 855 insertions(+), 14 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 838161d..b4f7c61 100644 --- a/sandboxes/cli.py +++ b/sandboxes/cli.py @@ -39,6 +39,13 @@ def _provider_classes(): except ImportError: pass + try: + from sandboxes.providers.vercel import VercelProvider + + providers["vercel"] = VercelProvider + except ImportError: + pass + try: from sandboxes.providers.hopx import HopxProvider @@ -80,6 +87,16 @@ def get_provider(name: str): 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) @@ -101,7 +118,7 @@ def cli(): "--provider", "-p", default="daytona", - help="Provider to use (e.g. daytona, e2b, modal)", + 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)") @@ -485,6 +502,7 @@ def providers(capabilities): ("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", @@ -508,6 +526,17 @@ def providers(capabilities): 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": @@ -577,6 +606,7 @@ def format_row(row: list[str]) -> str: 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( 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 49d266f..8f8481d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -191,6 +191,12 @@ def getenv_side_effect(key: str) -> str | None: 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 @@ -206,6 +212,7 @@ 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 @@ -224,6 +231,7 @@ def test_providers_command_with_capabilities(self): assert "Persistent" in result.output assert "Interactive Shell" in result.output assert "hopx" in result.output + assert "vercel" in result.output class TestCLIDepsFlag: 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()