diff --git a/README.md b/README.md index d69aeaa..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 +- **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. @@ -17,19 +17,79 @@ Includes a Python API plus full-featured CLI for use from any runtime. ## Installation -Add to your project: +```bash +uv pip install cased-sandboxes +``` + +Or add to your project: ```bash uv add cased-sandboxes ``` -or install with your preferred Python package manager and use the CLI -for any language, e.g.,: +## Claude Code Integration + +Run [Claude Code](https://docs.anthropic.com/en/docs/claude-code) in a secure sandbox with one command: ```bash -uv pip install cased-sandboxes +sandboxes claude +``` + +That's it. You get an interactive Claude Code session in an isolated, cloud environment. + +### Setup (Sprites - recommended) + +```bash +# Install and login to Sprites +curl https://sprites.dev/install.sh | bash +sprite login + +# Start Claude Code +sandboxes claude +``` + +### Setup (E2B - alternative) + +```bash +# Install E2B SDK and CLI +uv pip install e2b +npm install -g @e2b/cli + +# Set your API keys +export E2B_API_KEY=your_key +export ANTHROPIC_API_KEY=your_key + +# Start Claude Code +sandboxes claude -p e2b +``` + +### Persistent Development Environment (Sprites only) + +```bash +# Create a named sandbox (automatically kept) +sandboxes claude -n myproject + +# Work on your project... +# Exit when done (/exit or Ctrl+C) + +# Come back later - your files are still there +sandboxes claude -n myproject + +# List your sandboxes +sandboxes claude --list + +# Or just get a raw shell (no Claude Code) +sandboxes shell -n mydev --keep ``` +### Why Sandboxes? + +Claude Code can read, write, and execute code. Running it in a sandbox means: +- **Safe**: Can't touch your local files or system +- **Isolated**: Each project gets its own environment +- **Persistent**: Named sandboxes keep your files across sessions +- **Pre-configured**: Claude Code, Python, Node.js ready to go + ## Quick Start ### One-line Execution + Auto-select Provider @@ -352,6 +412,7 @@ export E2B_API_KEY="..." export MODAL_TOKEN_ID="..." # Or use `modal token set` export DAYTONA_API_KEY="..." export HOPX_API_KEY="hopx_live_." +export SPRITES_TOKEN="..." # Or use `sprite login` for CLI mode export CLOUDFLARE_SANDBOX_BASE_URL="https://your-worker.workers.dev" export CLOUDFLARE_API_TOKEN="..." ``` @@ -373,9 +434,10 @@ When you call `Sandbox.create()` or `run()`, the library checks for providers in 1. **Daytona** - Looks for `DAYTONA_API_KEY` 2. **E2B** - Looks for `E2B_API_KEY` -3. **Hopx** - Looks for `HOPX_API_KEY` -4. **Modal** - Looks for `~/.modal.toml` or `MODAL_TOKEN_ID` -5. **Cloudflare** *(experimental)* - Looks for `CLOUDFLARE_SANDBOX_BASE_URL` + `CLOUDFLARE_API_TOKEN` +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` **The first provider with valid credentials becomes the default.** Cloudflare requires deploying your own Worker. @@ -433,6 +495,7 @@ from sandboxes.providers import ( ModalProvider, DaytonaProvider, HopxProvider, + SpritesProvider, CloudflareProvider, ) @@ -448,6 +511,10 @@ provider = DaytonaProvider() # Hopx - Uses HOPX_API_KEY env var provider = HopxProvider() +# 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 + # Cloudflare - Requires base_url and token provider = CloudflareProvider( base_url="https://your-worker.workers.dev", @@ -460,6 +527,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_.`) +- **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` > **Cloudflare setup tips (experimental)** @@ -471,6 +539,16 @@ Each provider requires appropriate authentication: > 3. Define a secret (e.g. `SANDBOX_API_TOKEN`) in Wrangler and reuse the same value for `CLOUDFLARE_API_TOKEN` locally. > 4. Set `CLOUDFLARE_SANDBOX_BASE_URL` to the Worker URL (e.g. `https://cf-sandbox.your-subdomain.workers.dev`). +> **Sprites (Fly.io) - Best for Claude Code** +> +> [Sprites](https://sprites.dev) are persistent Linux sandboxes with Claude Code pre-installed: +> - **Claude Code 2.0+** ready to go - just run `sandboxes claude` +> - **100GB persistent storage** - files persist across sessions +> - **Checkpoint/restore** - save and restore state in ~300ms +> - **~$0.46 for 4-hour session** - scale-to-zero billing +> +> See [Simon Willison's writeup](https://simonwillison.net/2026/Jan/9/sprites-dev/) for more details. + ## Advanced Usage ### Multi-Provider Orchestration @@ -478,7 +556,7 @@ Each provider requires appropriate authentication: ```python import asyncio from sandboxes import Manager, SandboxConfig -from sandboxes.providers import E2BProvider, ModalProvider, DaytonaProvider, CloudflareProvider +from sandboxes.providers import E2BProvider, ModalProvider, DaytonaProvider, SpritesProvider, CloudflareProvider async def main(): # Initialize manager and register providers @@ -488,6 +566,7 @@ async def main(): manager.register_provider("modal", ModalProvider, {}) manager.register_provider("daytona", DaytonaProvider, {}) manager.register_provider("hopx", HopxProvider, {}) + manager.register_provider("sprites", SpritesProvider, {"use_cli": True}) manager.register_provider( "cloudflare", CloudflareProvider, @@ -605,6 +684,12 @@ export DAYTONA_API_KEY="dtn_..." export MODAL_TOKEN_ID="..." export MODAL_TOKEN_SECRET="..." +# Hopx +export HOPX_API_KEY="hopx_live_..." + +# Sprites (or use `sprite login` for CLI mode) +export SPRITES_TOKEN="..." + # Cloudflare export CLOUDFLARE_SANDBOX_BASE_URL="https://your-worker.workers.dev" export CLOUDFLARE_API_TOKEN="..." @@ -874,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, 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/sandboxes/cli.py b/sandboxes/cli.py index 975206e..d1de350 100644 --- a/sandboxes/cli.py +++ b/sandboxes/cli.py @@ -15,11 +15,13 @@ def get_provider(name: str): 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, } @@ -420,10 +422,18 @@ def providers(): click.echo("\nAvailable Providers") click.echo("=" * 50) + import shutil + providers = [ ("e2b", "E2B_API_KEY", "E2B cloud sandboxes", False), ("modal", "~/.modal.toml", "Modal serverless containers", False), ("daytona", "DAYTONA_API_KEY", "Daytona development environments", False), + ( + "sprites", + "SPRITES_TOKEN or sprite CLI", + "Fly.io Sprites (Claude Code pre-installed)", + False, + ), ( "cloudflare", "CLOUDFLARE_API_TOKEN", @@ -440,6 +450,8 @@ def providers(): configured = bool(os.getenv("E2B_API_KEY")) elif name == "daytona": configured = bool(os.getenv("DAYTONA_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")) else: @@ -458,10 +470,250 @@ def providers(): 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(" Sprites: sprite login (or export SPRITES_TOKEN=your_token)") click.echo( " Cloudflare (experimental): Deploy Worker from https://github.com/cloudflare/sandbox-sdk" ) +def _run_claude_sprites(name: str | None, keep: bool, list_sandboxes: bool): + """Run Claude Code using Sprites provider.""" + import shutil + import subprocess + + if not shutil.which("sprite"): + click.echo("❌ sprite CLI not found. Install with:", err=True) + click.echo(" curl https://sprites.dev/install.sh | bash", err=True) + click.echo("\nThen run: sprite login", err=True) + sys.exit(1) + + # List existing sandboxes + if list_sandboxes: + result = subprocess.run(["sprite", "list"], capture_output=True, text=True) + if result.returncode == 0: + lines = result.stdout.strip().split("\n") + claude_sandboxes = [ + line for line in lines if "claude-" in line or (name and name in line) + ] + if claude_sandboxes: + click.echo("Existing Claude sandboxes:") + for line in claude_sandboxes: + click.echo(f" {line}") + click.echo("\nResume with: sandboxes claude -n ") + else: + click.echo("No Claude sandboxes found.") + click.echo("Start one with: sandboxes claude") + else: + click.echo(result.stdout) + return + + # Generate or use provided name + if not name: + import uuid + + name = f"claude-{uuid.uuid4().hex[:8]}" + click.echo(f"Creating sandbox: {name}") + + result = subprocess.run(["sprite", "create", name], capture_output=True, text=True) + if result.returncode != 0: + click.echo(f"❌ Failed to create sandbox: {result.stderr}", err=True) + sys.exit(1) + click.echo(f"✓ Created {name}") + created_new = True + else: + # Check if sandbox exists, create if not + result = subprocess.run(["sprite", "list"], capture_output=True, text=True) + # Parse output to find exact name match (avoid "claude" matching "claude-123") + existing_names = { + line.split()[0] for line in result.stdout.strip().split("\n") if line.strip() + } + if name in existing_names: + click.echo(f"Resuming sandbox: {name}") + created_new = False + keep = True # Named sandboxes are kept by default + else: + click.echo(f"Creating sandbox: {name}") + result = subprocess.run(["sprite", "create", name], capture_output=True, text=True) + if result.returncode != 0: + click.echo(f"❌ Failed to create sandbox: {result.stderr}", err=True) + sys.exit(1) + click.echo(f"✓ Created {name}") + created_new = True + keep = True # Named sandboxes are kept by default + + click.echo("\n🚀 Starting Claude Code...\n") + + try: + # Run claude directly in the sprite with TTY allocation + subprocess.run(["sprite", "exec", "-s", name, "-tty", "claude"]) + except KeyboardInterrupt: + click.echo("\n") + finally: + if not keep and created_new: + click.echo(f"\n🗑️ Destroying sandbox {name}...") + subprocess.run( + ["sprite", "destroy", "-s", name, "-force"], + capture_output=True, + ) + click.echo("✓ Destroyed") + else: + click.echo(f"\n💡 Resume anytime: sandboxes claude -n {name}") + + +def _run_claude_e2b(): + """Run Claude Code using E2B - SDK to create, CLI to connect.""" + import shutil + import subprocess + + # Check for E2B CLI + if not shutil.which("e2b"): + click.echo("❌ E2B CLI not found. Install with:", err=True) + click.echo(" npm install -g @e2b/cli", err=True) + sys.exit(1) + + try: + from e2b import Sandbox + except ImportError: + click.echo("❌ E2B SDK not installed. Install with:", err=True) + click.echo(" uv pip install e2b", err=True) + sys.exit(1) + + if not os.getenv("E2B_API_KEY"): + click.echo("❌ E2B_API_KEY not set", err=True) + sys.exit(1) + + api_key = os.getenv("ANTHROPIC_API_KEY", "") + if not api_key: + click.echo("❌ ANTHROPIC_API_KEY not set", err=True) + sys.exit(1) + + click.echo("Creating E2B sandbox with Claude Code...") + + # Create sandbox with SDK to pass env vars + sbx = Sandbox.create( + template="anthropic-claude-code", + timeout=3600, + envs={"ANTHROPIC_API_KEY": api_key}, + ) + sandbox_id = sbx.sandbox_id + click.echo(f"✓ Created sandbox: {sandbox_id}") + + # Set up a wrapper script to run claude on connect (avoid overwriting .bashrc) + sbx.files.write("/tmp/start-claude.sh", "#!/bin/bash\nexec claude\n") + sbx.commands.run("chmod +x /tmp/start-claude.sh") + # Append to .bashrc to run our script + sbx.commands.run("echo 'exec /tmp/start-claude.sh' >> /home/user/.bashrc") + + click.echo("\n🚀 Starting Claude Code...\n") + + # Connect with CLI for proper TTY handling + try: + subprocess.run(["e2b", "sandbox", "connect", sandbox_id]) + except KeyboardInterrupt: + pass + finally: + click.echo("\n🗑️ Destroying sandbox...") + try: + sbx.kill() + click.echo("✓ Destroyed") + except Exception: + click.echo("(sandbox may have already timed out)") + + +@cli.command() +@click.option("-n", "--name", default=None, help="Sandbox name (reuse existing, Sprites only)") +@click.option( + "-p", "--provider", default="sprites", type=click.Choice(["sprites", "e2b"]), help="Provider" +) +@click.option("--keep", is_flag=True, help="Keep sandbox after exit (Sprites only)") +@click.option( + "--list", "list_sandboxes", is_flag=True, help="List existing Claude sandboxes (Sprites only)" +) +def claude(name: str | None, provider: str, keep: bool, list_sandboxes: bool): + """Start an interactive Claude Code session in a sandbox. + + This is the easiest way to use Claude Code safely: + + sandboxes claude + + Using E2B instead of Sprites: + + sandboxes claude -p e2b + + For a persistent dev environment (Sprites only): + + sandboxes claude -n myproject + # Exit and come back later: + sandboxes claude -n myproject + + To see existing sandboxes: + + sandboxes claude --list + """ + if provider == "e2b": + if name or list_sandboxes: + click.echo("⚠️ Named sandboxes and --list only work with Sprites provider", err=True) + _run_claude_e2b() + else: + _run_claude_sprites(name, keep, list_sandboxes) + + +@cli.command() +@click.option("-p", "--provider", default="sprites", help="Provider (default: sprites)") +@click.option("-n", "--name", default=None, help="Sandbox name (reuse existing)") +@click.option("--keep", is_flag=True, help="Keep sandbox after exit") +def shell(provider: str, name: str | None, keep: bool): + """Open an interactive shell in a sandbox. + + For a raw shell (not Claude Code): + + sandboxes shell -n my-dev --keep + """ + import shutil + import subprocess + + if provider != "sprites": + click.echo("❌ Interactive shell only supported for sprites provider", err=True) + sys.exit(1) + + if not shutil.which("sprite"): + click.echo("❌ sprite CLI not found. Install with:", err=True) + click.echo(" curl https://sprites.dev/install.sh | bash", err=True) + sys.exit(1) + + if not name: + import uuid + + name = f"sandbox-{uuid.uuid4().hex[:8]}" + click.echo(f"Creating sandbox: {name}") + + result = subprocess.run(["sprite", "create", name], capture_output=True, text=True) + if result.returncode != 0: + click.echo(f"❌ Failed to create sandbox: {result.stderr}", err=True) + sys.exit(1) + click.echo(f"✓ Created {name}") + created_new = True + else: + click.echo(f"Using sandbox: {name}") + created_new = False + + click.echo("\n🚀 Opening shell...\n") + + try: + subprocess.run(["sprite", "console", "-s", name]) + except KeyboardInterrupt: + click.echo("\n") + finally: + if not keep and created_new: + click.echo(f"\n🗑️ Destroying sandbox {name}...") + subprocess.run( + ["sprite", "destroy", "-s", name, "-force"], + capture_output=True, + ) + click.echo("✓ Destroyed") + elif keep or not created_new: + click.echo(f"\n💡 Reconnect: sandboxes shell -n {name}") + + if __name__ == "__main__": cli() diff --git a/sandboxes/pool.py b/sandboxes/pool.py index 7885f0c..625572a 100644 --- a/sandboxes/pool.py +++ b/sandboxes/pool.py @@ -607,7 +607,7 @@ async def cleanup_idle(self): await self.provider.destroy_sandbox(conn_id) del self._connections[conn_id] del self._connection_metadata[conn_id] - self._idle_connections.remove(conn_id) + self._idle_connections.discard(conn_id) def get_metrics(self) -> dict[str, Any]: """Get pool metrics.""" diff --git a/sandboxes/providers/__init__.py b/sandboxes/providers/__init__.py index 42c8594..756ff4f 100644 --- a/sandboxes/providers/__init__.py +++ b/sandboxes/providers/__init__.py @@ -48,9 +48,9 @@ pass try: - from .cloudflare import CloudflareProvider + from .sprites import SpritesProvider - _providers["cloudflare"] = CloudflareProvider + _providers["sprites"] = SpritesProvider except ImportError: pass diff --git a/sandboxes/providers/daytona.py b/sandboxes/providers/daytona.py index 394192e..a58f6c3 100644 --- a/sandboxes/providers/daytona.py +++ b/sandboxes/providers/daytona.py @@ -53,6 +53,8 @@ def __init__(self, api_key: str | None = None, **config): self.default_language = config.get("default_language", "python") # Keep snapshot support for backwards compatibility self.default_snapshot = config.get("default_snapshot") + # Track sandbox metadata including env_vars + self._sandbox_metadata: dict[str, dict] = {} @property def name(self) -> str: @@ -137,6 +139,11 @@ async def create_sandbox(self, config: SandboxConfig) -> Sandbox: sandbox = self._to_sandbox(daytona_sandbox) + # Store env_vars for use in each command execution + self._sandbox_metadata[sandbox.id] = { + "env_vars": config.env_vars or {}, + } + # Run setup commands if provided if config.setup_commands: for cmd in config.setup_commands: @@ -188,9 +195,31 @@ async def execute_command( try: sandbox = self.client.get(sandbox_id) - # Prepare command with environment variables + # Combine stored env_vars with any passed env_vars + all_env_vars = dict(self._sandbox_metadata.get(sandbox_id, {}).get("env_vars", {})) if env_vars: - exports = " && ".join([f"export {k}='{v}'" for k, v in env_vars.items()]) + all_env_vars.update(env_vars) + + # Prepare command with environment variables (with proper escaping) + if all_env_vars: + import re + + def escape_shell_value(val: str) -> str: + """Escape single quotes for shell: ' -> '\\''""" + return val.replace("'", "'\\''") + + def validate_env_key(key: str) -> str: + """Validate env var key contains only safe characters.""" + if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", key): + raise ValueError(f"Invalid environment variable name: {key}") + return key + + exports = " && ".join( + [ + f"export {validate_env_key(k)}='{escape_shell_value(str(v))}'" + for k, v in all_env_vars.items() + ] + ) command = f"{exports} && {command}" # Execute command using process.exec diff --git a/sandboxes/providers/e2b.py b/sandboxes/providers/e2b.py index 9694f1e..96f26ff 100644 --- a/sandboxes/providers/e2b.py +++ b/sandboxes/providers/e2b.py @@ -59,9 +59,15 @@ def name(self) -> str: """Provider name.""" return "e2b" - async def _create_e2b_sandbox(self, template_id=None, env_vars=None): + async def _create_e2b_sandbox(self, template_id=None, env_vars=None, timeout=None): """Create E2B sandbox asynchronously.""" - return await E2BSandbox.create(template=template_id, envs=env_vars, api_key=self.api_key) + # timeout sets the sandbox lifetime in seconds + return await E2BSandbox.create( + template=template_id, + envs=env_vars, + api_key=self.api_key, + timeout=timeout or self.timeout, + ) def _to_sandbox(self, e2b_sandbox, metadata: dict[str, Any]) -> Sandbox: """Convert E2B sandbox to standard Sandbox.""" @@ -87,8 +93,11 @@ async def create_sandbox(self, config: SandboxConfig) -> Sandbox: or self.default_template ) - # Create sandbox asynchronously - e2b_sandbox = await self._create_e2b_sandbox(template_id, config.env_vars) + # Create sandbox asynchronously with timeout for sandbox lifetime + sandbox_timeout = config.timeout_seconds or self.timeout + e2b_sandbox = await self._create_e2b_sandbox( + template_id, config.env_vars, timeout=sandbox_timeout + ) # Store metadata metadata = { @@ -216,6 +225,14 @@ async def execute_command( metadata["last_accessed"] = time.time() start_time = time.time() + effective_timeout = timeout or self.timeout + + # For long-running commands (>60s), use background execution with polling + # to work around E2B SDK timeout issues + if effective_timeout > 60: + return await self._execute_long_running( + e2b_sandbox, command, effective_timeout, env_vars, start_time + ) # Execute command using AsyncSandbox.commands.run() # Pass envs directly to the run method @@ -223,7 +240,7 @@ async def execute_command( result = await e2b_sandbox.commands.run( command, envs=env_vars, - timeout=timeout or self.timeout, + timeout=effective_timeout, ) exit_code = result.exit_code stdout = result.stdout @@ -253,6 +270,106 @@ async def execute_command( logger.error(f"Failed to execute command in sandbox {sandbox_id}: {e}") raise SandboxError(f"Failed to execute command: {e}") from e + async def _execute_long_running( + self, + e2b_sandbox, + command: str, + timeout: int, + env_vars: dict[str, str] | None, + start_time: float, + ) -> ExecutionResult: + """Execute long-running command using background execution with polling. + + This works around E2B SDK timeout issues by running the command in background + and polling for completion. + """ + import uuid + + # Create unique output files + run_id = uuid.uuid4().hex[:8] + stdout_file = f"/tmp/cmd_{run_id}_stdout.txt" + stderr_file = f"/tmp/cmd_{run_id}_stderr.txt" + exit_file = f"/tmp/cmd_{run_id}_exit.txt" + + # Build wrapper command that captures output and exit code + # Use nohup and & for background execution + # Escape single quotes in command for shell + escaped_command = command.replace("'", "'\"'\"'") + wrapper = f""" +nohup sh -c '{escaped_command} > {stdout_file} 2> {stderr_file}; echo $? > {exit_file}' > /dev/null 2>&1 & +echo $! +""" + # Start the command in background + try: + result = await e2b_sandbox.commands.run(wrapper, envs=env_vars, timeout=10) + pid = result.stdout.strip() + except Exception as e: + logger.error(f"Failed to start background command: {e}") + raise + + # Poll for completion + poll_interval = 1.0 # seconds + deadline = time.time() + timeout + + while time.time() < deadline: + await asyncio.sleep(poll_interval) + + # Check if exit code file exists (command completed) + try: + check_result = await e2b_sandbox.commands.run( + f"cat {exit_file} 2>/dev/null || echo ''", timeout=5 + ) + exit_code_str = check_result.stdout.strip() + if exit_code_str: + # Command completed + exit_code = int(exit_code_str) + + # Read stdout + stdout_result = await e2b_sandbox.commands.run( + f"cat {stdout_file} 2>/dev/null || echo ''", timeout=10 + ) + stdout = stdout_result.stdout + + # Read stderr + stderr_result = await e2b_sandbox.commands.run( + f"cat {stderr_file} 2>/dev/null || echo ''", timeout=10 + ) + stderr = stderr_result.stdout + + # Cleanup temp files + await e2b_sandbox.commands.run( + f"rm -f {stdout_file} {stderr_file} {exit_file}", timeout=5 + ) + + duration_ms = int((time.time() - start_time) * 1000) + return ExecutionResult( + exit_code=exit_code, + stdout=stdout, + stderr=stderr, + duration_ms=duration_ms, + truncated=False, + timed_out=False, + ) + except Exception as poll_error: + logger.warning(f"Poll error: {poll_error}") + continue + + # Timeout - try to kill the process + try: + await e2b_sandbox.commands.run(f"kill {pid} 2>/dev/null || true", timeout=5) + except Exception as e: + logger.debug(f"Failed to kill timed-out process {pid}: {e}") + + duration_ms = int((time.time() - start_time) * 1000) + return ExecutionResult( + exit_code=-1, + stdout="", + stderr=f"Command timed out after {timeout} seconds", + duration_ms=duration_ms, + truncated=False, + timed_out=True, + ) + async def stream_execution( self, sandbox_id: str, diff --git a/sandboxes/providers/modal.py b/sandboxes/providers/modal.py index 081f97b..a7943c8 100644 --- a/sandboxes/providers/modal.py +++ b/sandboxes/providers/modal.py @@ -64,15 +64,25 @@ def name(self) -> str: """Provider name.""" return "modal" - def _create_modal_sandbox(self, image: str, cpu: float, memory: int, timeout: int): - """Create Modal sandbox synchronously.""" + def _create_modal_sandbox(self, image: str | Any, cpu: float, memory: int, timeout: int): + """Create Modal sandbox synchronously. + + Args: + image: Either a string (Docker registry image) or a modal.Image object + cpu: CPU cores to allocate + memory: Memory in MB + timeout: Timeout in seconds + """ # Modal sandboxes require an App context # Use a persistent app that creates itself if missing app = modal.App.lookup("sandboxes-provider", create_if_missing=True) + # Handle both string images and modal.Image objects + modal_image = modal.Image.from_registry(image) if isinstance(image, str) else image + # Create Modal sandbox with specified resources sandbox = ModalSandbox.create( - app=app, image=modal.Image.from_registry(image), cpu=cpu, memory=memory, timeout=timeout + app=app, image=modal_image, cpu=cpu, memory=memory, timeout=timeout ) return sandbox @@ -121,7 +131,7 @@ async def create_sandbox(self, config: SandboxConfig) -> Sandbox: self._executor, self._create_modal_sandbox, image, cpu, memory, timeout ) - # Store metadata + # Store metadata - include env_vars for use in each command metadata = { "modal_sandbox": modal_sandbox, "labels": config.labels or {}, @@ -131,6 +141,7 @@ async def create_sandbox(self, config: SandboxConfig) -> Sandbox: "image": image, "cpu": cpu, "memory": memory, + "env_vars": config.env_vars or {}, # Store for each command } async with self._lock: @@ -138,11 +149,6 @@ async def create_sandbox(self, config: SandboxConfig) -> Sandbox: logger.info(f"Created Modal sandbox {modal_sandbox.object_id}") - # Set environment variables if provided - if config.env_vars: - for key, value in config.env_vars.items(): - await self.execute_command(modal_sandbox.object_id, f"export {key}='{value}'") - # Run setup commands if config.setup_commands: for cmd in config.setup_commands: @@ -256,9 +262,31 @@ async def execute_command( modal_sandbox = metadata["modal_sandbox"] metadata["last_accessed"] = time.time() - # Prepare command with environment variables + # Combine stored env_vars with any passed env_vars + all_env_vars = dict(metadata.get("env_vars", {})) if env_vars: - env_setup = " && ".join([f"export {k}='{v}'" for k, v in env_vars.items()]) + all_env_vars.update(env_vars) + + # Prepare command with environment variables (with proper escaping) + if all_env_vars: + import re + + def escape_shell_value(val: str) -> str: + """Escape single quotes for shell: ' -> '\\''""" + return val.replace("'", "'\\''") + + def validate_env_key(key: str) -> str: + """Validate env var key contains only safe characters.""" + if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", key): + raise ValueError(f"Invalid environment variable name: {key}") + return key + + env_setup = " && ".join( + [ + f"export {validate_env_key(k)}='{escape_shell_value(str(v))}'" + for k, v in all_env_vars.items() + ] + ) command = f"{env_setup} && {command}" # Execute command in thread pool @@ -272,12 +300,27 @@ async def execute_command( lambda: modal_sandbox.exec("sh", "-c", command, timeout=timeout or self.timeout), ) - # Get output - stdout = process.stdout.read() if process.stdout else "" - stderr = process.stderr.read() if process.stderr else "" + # Wait for completion first - Modal SDK may require this before reading + # wait() might be sync or async + wait_result = process.wait() + exit_code = await wait_result if asyncio.iscoroutine(wait_result) else wait_result - # Wait for completion and get exit code - exit_code = await loop.run_in_executor(self._executor, lambda: process.wait()) + # Get output - Modal's read() may be sync or async depending on version + if process.stdout: + stdout_result = process.stdout.read() + stdout = ( + await stdout_result if asyncio.iscoroutine(stdout_result) else stdout_result + ) + else: + stdout = "" + + if process.stderr: + stderr_result = process.stderr.read() + stderr = ( + await stderr_result if asyncio.iscoroutine(stderr_result) else stderr_result + ) + else: + stderr = "" duration_ms = int((time.time() - start_time) * 1000) diff --git a/sandboxes/providers/sprites.py b/sandboxes/providers/sprites.py new file mode 100644 index 0000000..16f513e --- /dev/null +++ b/sandboxes/providers/sprites.py @@ -0,0 +1,537 @@ +"""Fly.io Sprites sandbox provider implementation.""" + +import asyncio +import logging +import os +import shutil +import subprocess +import time +import uuid +from collections.abc import AsyncIterator +from datetime import datetime +from typing import Any + +from ..base import ExecutionResult, Sandbox, SandboxConfig, SandboxProvider, SandboxState +from ..exceptions import ProviderError, SandboxError, SandboxNotFoundError + +logger = logging.getLogger(__name__) + +try: + from sprites import SpritesClient + + SPRITES_SDK_AVAILABLE = True +except ImportError: + SPRITES_SDK_AVAILABLE = False + SpritesClient = None + +# Check if sprite CLI is available +SPRITES_CLI_AVAILABLE = shutil.which("sprite") is not None + +SPRITES_AVAILABLE = SPRITES_SDK_AVAILABLE or SPRITES_CLI_AVAILABLE + +if not SPRITES_AVAILABLE: + logger.warning( + "Sprites not available - install SDK with: pip install sprites-py " + "or CLI with: curl https://sprites.dev/install.sh | bash" + ) + + +class SpritesProvider(SandboxProvider): + """Fly.io Sprites sandbox provider implementation. + + Sprites are persistent, hardware-isolated Linux sandboxes with: + - Fast startup (1-2 seconds) + - 100GB storage + - Checkpoint/restore support + - Automatic idle suspension + - Claude Code, Node.js 22, Python 3.13 pre-installed + + Supports two modes: + - SDK mode: Uses sprites-py with SPRITES_TOKEN + - CLI mode: Uses sprite CLI with existing login (sprite login) + """ + + def __init__(self, token: str | None = None, use_cli: bool = False, **config): + """Initialize Sprites provider. + + Args: + token: Sprites API token. If not provided, reads from SPRITES_TOKEN env var. + use_cli: Force using CLI instead of SDK (useful if logged in via sprite login) + **config: Additional configuration options + """ + super().__init__(**config) + + self.token = token or os.getenv("SPRITES_TOKEN") + self.use_cli = use_cli or not self.token + + if self.use_cli: + if not SPRITES_CLI_AVAILABLE: + raise ProviderError( + "Sprites CLI not found. Install with: curl https://sprites.dev/install.sh | bash" + ) + self.client = None + logger.info("Using Sprites CLI mode (sprite command)") + else: + if not SPRITES_SDK_AVAILABLE: + raise ProviderError( + "Sprites SDK not installed. Install with: pip install sprites-py" + ) + self.client = SpritesClient(token=self.token) + logger.info("Using Sprites SDK mode") + + # Default timeout for command execution + self.default_timeout = config.get("timeout", 300) + + # Track sandbox metadata including env_vars + self._sandbox_metadata: dict[str, dict[str, Any]] = {} + + @property + def name(self) -> str: + """Provider name.""" + return "sprites" + + def _generate_sprite_name(self) -> str: + """Generate a unique sprite name.""" + return f"sandbox-{uuid.uuid4().hex[:12]}" + + def _to_sandbox(self, sprite_name: str, metadata: dict[str, Any]) -> Sandbox: + """Convert sprite to standard Sandbox.""" + return Sandbox( + id=sprite_name, + provider=self.name, + state=SandboxState.RUNNING, # Sprites are always running or don't exist + labels=metadata.get("labels", {}), + created_at=metadata.get("created_at", datetime.now()), + metadata={ + "last_accessed": metadata.get("last_accessed", time.time()), + }, + ) + + async def _run_cli(self, *args: str, timeout: int | None = None) -> subprocess.CompletedProcess: + """Run sprite CLI command.""" + cmd = ["sprite", *args] + loop = asyncio.get_event_loop() + return await loop.run_in_executor( + None, + lambda: subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout or self.default_timeout, + ), + ) + + async def create_sandbox(self, config: SandboxConfig) -> Sandbox: + """Create a new sprite sandbox.""" + try: + # Generate unique name or use provided one + sprite_name = ( + config.provider_config.get("name") if config.provider_config else None + ) or self._generate_sprite_name() + + logger.info(f"Creating Sprites sandbox: {sprite_name}") + + if self.use_cli: + # Use CLI: sprite create + result = await self._run_cli("create", sprite_name) + if result.returncode != 0: + raise SandboxError(f"Failed to create sprite: {result.stderr}") + else: + # Use SDK + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, self.client.create_sprite, sprite_name) + + # Store metadata including env_vars + metadata = { + "labels": config.labels or {}, + "created_at": datetime.now(), + "last_accessed": time.time(), + "env_vars": config.env_vars or {}, + } + self._sandbox_metadata[sprite_name] = metadata + + logger.info(f"Created Sprites sandbox {sprite_name}") + + sandbox = self._to_sandbox(sprite_name, metadata) + + # Run setup commands if provided + if config.setup_commands: + for cmd in config.setup_commands: + await self.execute_command(sprite_name, cmd) + + return sandbox + + except Exception as e: + logger.error(f"Failed to create Sprites sandbox: {e}") + raise SandboxError(f"Failed to create sandbox: {e}") from e + + async def get_sandbox(self, sandbox_id: str) -> Sandbox | None: + """Get sandbox by ID (sprite name).""" + if sandbox_id in self._sandbox_metadata: + metadata = self._sandbox_metadata[sandbox_id] + metadata["last_accessed"] = time.time() + return self._to_sandbox(sandbox_id, metadata) + + # Try to access the sprite to check if it exists + try: + if self.use_cli: + # Use CLI: check if sprite exists by running a command + result = await self._run_cli("exec", "-s", sandbox_id, "--", "true", timeout=10) + if result.returncode != 0: + return None + else: + # Use SDK + sprite = self.client.sprite(sandbox_id) + # Run a quick command to verify it's accessible + loop = asyncio.get_event_loop() + await loop.run_in_executor( + None, lambda: sprite.run("true", capture_output=True, timeout=10) + ) + + # Create metadata for found sprite + metadata = { + "labels": {}, + "created_at": datetime.now(), + "last_accessed": time.time(), + "env_vars": {}, + } + self._sandbox_metadata[sandbox_id] = metadata + return self._to_sandbox(sandbox_id, metadata) + except Exception: + return None + + async def list_sandboxes(self, labels: dict[str, str] | None = None) -> list[Sandbox]: + """List tracked sandboxes.""" + # Sprites SDK doesn't have a list method, so we use local tracking + sandboxes = [] + + for sprite_name, metadata in self._sandbox_metadata.items(): + # Filter by labels if provided + if labels: + sandbox_labels = metadata.get("labels", {}) + if not all(sandbox_labels.get(k) == v for k, v in labels.items()): + continue + sandboxes.append(self._to_sandbox(sprite_name, metadata)) + + return sandboxes + + async def find_sandbox(self, labels: dict[str, str]) -> Sandbox | None: + """Find a running sandbox with matching labels for reuse.""" + sandboxes = await self.list_sandboxes(labels=labels) + if sandboxes: + # Return most recently accessed + sandboxes.sort( + key=lambda s: self._sandbox_metadata.get(s.id, {}).get("last_accessed", 0), + reverse=True, + ) + logger.info(f"Found existing sprite {sandboxes[0].id} with labels {labels}") + return sandboxes[0] + return None + + 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 the sprite.""" + try: + # Update last accessed time + if sandbox_id in self._sandbox_metadata: + self._sandbox_metadata[sandbox_id]["last_accessed"] = time.time() + + # Combine stored env_vars with any passed env_vars + all_env_vars = dict(self._sandbox_metadata.get(sandbox_id, {}).get("env_vars", {})) + if env_vars: + all_env_vars.update(env_vars) + + # Prepare command with environment variables + # Escape single quotes in values to prevent shell injection + if all_env_vars: + import re + + def escape_shell_value(val: str) -> str: + """Escape single quotes for shell: ' -> '\\''""" + return val.replace("'", "'\\''") + + def validate_env_key(key: str) -> str: + """Validate env var key contains only safe characters.""" + if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", key): + raise ValueError(f"Invalid environment variable name: {key}") + return key + + exports = " && ".join( + [ + f"export {validate_env_key(k)}='{escape_shell_value(str(v))}'" + for k, v in all_env_vars.items() + ] + ) + command = f"{exports} && {command}" + + start_time = time.time() + + if self.use_cli: + # Use CLI: sprite exec -s -- sh -c "" + result = await self._run_cli( + "exec", + "-s", + sandbox_id, + "--", + "sh", + "-c", + command, + timeout=timeout or self.default_timeout, + ) + stdout = result.stdout + stderr = result.stderr + returncode = result.returncode + else: + # Use SDK + sprite = self.client.sprite(sandbox_id) + loop = asyncio.get_event_loop() + result = await loop.run_in_executor( + None, + lambda: sprite.run( + "sh", + "-c", + command, + capture_output=True, + timeout=timeout or self.default_timeout, + ), + ) + stdout = ( + result.stdout.decode() + if isinstance(result.stdout, bytes) + else (result.stdout or "") + ) + stderr = ( + result.stderr.decode() + if isinstance(result.stderr, bytes) + else (result.stderr or "") + ) + returncode = result.returncode + + duration_ms = int((time.time() - start_time) * 1000) + + return ExecutionResult( + exit_code=returncode, + stdout=stdout, + stderr=stderr, + duration_ms=duration_ms, + truncated=False, + timed_out=False, + ) + + except Exception as e: + error_str = str(e).lower() + if "not found" in error_str or "does not exist" in error_str: + raise SandboxNotFoundError(f"Sprite {sandbox_id} not found") from e + logger.error(f"Failed to execute command in sprite {sandbox_id}: {e}") + raise SandboxError(f"Failed to execute command: {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 execution output.""" + # Sprites SDK doesn't support streaming directly, so we execute and yield chunks + result = await self.execute_command(sandbox_id, command, timeout, env_vars) + + # Yield output in chunks to simulate streaming + chunk_size = 256 + output = result.stdout + + for i in range(0, len(output), chunk_size): + yield output[i : i + chunk_size] + await asyncio.sleep(0.01) + + if result.stderr: + yield f"\n[Error]: {result.stderr}" + + async def destroy_sandbox(self, sandbox_id: str) -> bool: + """Destroy a sprite.""" + try: + if self.use_cli: + # Use CLI: sprite destroy -s -force + result = await self._run_cli("destroy", "-s", sandbox_id, "-force") + if result.returncode != 0: + combined = result.stdout + result.stderr + if "not found" not in combined.lower(): + raise SandboxError(f"Failed to delete sprite: {combined}") + else: + # Use SDK + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, self.client.delete_sprite, sandbox_id) + + # Remove from tracking + if sandbox_id in self._sandbox_metadata: + del self._sandbox_metadata[sandbox_id] + + logger.info(f"Destroyed Sprites sandbox {sandbox_id}") + return True + + except Exception as e: + error_str = str(e).lower() + if "not found" in error_str or "does not exist" in error_str: + # Already deleted + if sandbox_id in self._sandbox_metadata: + del self._sandbox_metadata[sandbox_id] + return True + logger.error(f"Failed to destroy sprite {sandbox_id}: {e}") + raise SandboxError(f"Failed to destroy sandbox: {e}") from e + + async def execute_commands( + self, + sandbox_id: str, + commands: list[str], + stop_on_error: bool = True, + timeout: int | None = None, + env_vars: dict[str, str] | None = None, + ) -> list[ExecutionResult]: + """Execute multiple commands in sequence.""" + results = [] + + for command in commands: + result = await self.execute_command(sandbox_id, command, timeout, env_vars) + results.append(result) + + if stop_on_error and not result.success: + logger.warning(f"Command failed, stopping sequence: {command}") + break + + return results + + async def get_or_create_sandbox(self, config: SandboxConfig) -> Sandbox: + """Get existing sandbox with matching labels or create new one.""" + # Try to find existing sandbox if labels provided + if config.labels: + existing = await self.find_sandbox(config.labels) + if existing: + return existing + + # Create new sandbox + return await self.create_sandbox(config) + + async def health_check(self) -> bool: + """Check if Sprites service is accessible.""" + try: + config = SandboxConfig() + sandbox = await self.create_sandbox(config) + result = await self.execute_command(sandbox.id, "echo 'health check'") + await self.destroy_sandbox(sandbox.id) + return result.success + except Exception as e: + logger.error(f"Sprites health check failed: {e}") + return False + + async def create_checkpoint(self, sandbox_id: str, name: str | None = None) -> str: + """Create a checkpoint of the sprite state. + + Args: + sandbox_id: The sprite name + name: Optional checkpoint name/description + + Returns: + Checkpoint ID + + Note: + Checkpoint operations require SDK mode (SPRITES_TOKEN). + """ + if self.use_cli: + raise SandboxError("Checkpoint operations require SDK mode. Set SPRITES_TOKEN env var.") + + try: + sprite = self.client.sprite(sandbox_id) + loop = asyncio.get_event_loop() + + # Create checkpoint - returns a stream of messages + checkpoint_id = None + stream = await loop.run_in_executor( + None, lambda: sprite.create_checkpoint(name or "checkpoint") + ) + for msg in stream: + if hasattr(msg, "checkpoint_id"): + checkpoint_id = msg.checkpoint_id + + logger.info(f"Created checkpoint {checkpoint_id} for sprite {sandbox_id}") + return checkpoint_id + + except Exception as e: + logger.error(f"Failed to create checkpoint for sprite {sandbox_id}: {e}") + raise SandboxError(f"Failed to create checkpoint: {e}") from e + + async def restore_checkpoint(self, sandbox_id: str, checkpoint_id: str) -> bool: + """Restore a sprite to a checkpoint. + + Args: + sandbox_id: The sprite name + checkpoint_id: The checkpoint ID to restore + + Returns: + True if successful + + Note: + Checkpoint operations require SDK mode (SPRITES_TOKEN). + """ + if self.use_cli: + raise SandboxError("Checkpoint operations require SDK mode. Set SPRITES_TOKEN env var.") + + try: + sprite = self.client.sprite(sandbox_id) + loop = asyncio.get_event_loop() + + # Restore checkpoint + await loop.run_in_executor(None, lambda: list(sprite.restore_checkpoint(checkpoint_id))) + + logger.info(f"Restored sprite {sandbox_id} to checkpoint {checkpoint_id}") + return True + + except Exception as e: + logger.error(f"Failed to restore checkpoint for sprite {sandbox_id}: {e}") + raise SandboxError(f"Failed to restore checkpoint: {e}") from e + + async def create_claude_code_checkpoint(self, sandbox_id: str) -> str: + """Create a checkpoint with Claude Code pre-installed. + + This is useful for creating reusable Sprites with Claude Code ready to go. + After calling this, you can restore from the checkpoint for instant starts. + + Args: + sandbox_id: The sprite name + + Returns: + Checkpoint ID that can be used with restore_checkpoint() + + Example: + # One-time setup + provider = SpritesProvider(token="...") + sandbox = await provider.create_sandbox(SandboxConfig()) + + # Install Node.js and Claude Code + await provider.execute_command(sandbox.id, + "curl -fsSL https://deb.nodesource.com/setup_20.x | bash -") + await provider.execute_command(sandbox.id, "apt-get install -y nodejs") + await provider.execute_command(sandbox.id, + "npm install -g @anthropic-ai/claude-code") + + # Checkpoint with Claude Code installed + checkpoint_id = await provider.create_claude_code_checkpoint(sandbox.id) + print(f"Claude Code checkpoint: {checkpoint_id}") + + # Later: instant restore with Claude Code ready + await provider.restore_checkpoint(sandbox.id, checkpoint_id) + # Claude Code is immediately available! + """ + # Verify Claude Code is installed before checkpointing + result = await self.execute_command(sandbox_id, "claude --version") + if not result.success: + raise SandboxError( + "Claude Code not installed. Install it first with: " + "npm install -g @anthropic-ai/claude-code" + ) + + return await self.create_checkpoint(sandbox_id, "claude-code-ready") diff --git a/sandboxes/retry.py b/sandboxes/retry.py index b02c594..82f3a54 100644 --- a/sandboxes/retry.py +++ b/sandboxes/retry.py @@ -566,8 +566,12 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): pass async def _reset_loop(self): - """Periodically reset the semaphore.""" + """Periodically release permits back to the semaphore.""" + import contextlib + while True: await asyncio.sleep(self.period) - # Reset available permits - self.semaphore._value = min(self.semaphore._value + 1, self.rate) + # Release a permit back to the semaphore (up to max rate) + # ValueError raised if semaphore already at max + with contextlib.suppress(ValueError): + self.semaphore.release() diff --git a/sandboxes/sandbox.py b/sandboxes/sandbox.py index 41cd68f..c5a4467 100644 --- a/sandboxes/sandbox.py +++ b/sandboxes/sandbox.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import os from collections.abc import AsyncIterator from typing import Any @@ -10,6 +11,8 @@ from .base import Sandbox as BaseSandbox from .manager import SandboxManager +logger = logging.getLogger(__name__) + class _SandboxAsyncContextManager: """Helper to make Sandbox.create() work with both await and async with.""" @@ -69,9 +72,10 @@ def _auto_configure(cls) -> None: Providers are registered in priority order: 1. Daytona 2. E2B - 3. Hopx - 4. Modal - 5. Cloudflare (experimental) + 3. Sprites + 4. Hopx + 5. Modal + 6. Cloudflare (experimental) The first registered provider becomes the default unless explicitly set. Users can override with Sandbox.configure(default_provider="..."). @@ -82,6 +86,7 @@ def _auto_configure(cls) -> None: E2BProvider, HopxProvider, ModalProvider, + SpritesProvider, ) manager = cls._manager @@ -90,35 +95,50 @@ def _auto_configure(cls) -> None: if os.getenv("DAYTONA_API_KEY"): try: manager.register_provider("daytona", DaytonaProvider, {}) - print("✓ Registered Daytona provider") - except Exception: - pass + logger.info("Registered Daytona provider") + except Exception as e: + logger.debug(f"Failed to register Daytona provider: {e}") # Try to register E2B (priority 2) if os.getenv("E2B_API_KEY"): try: manager.register_provider("e2b", E2BProvider, {}) - print("✓ Registered E2B provider") - except Exception: - pass + logger.info("Registered E2B provider") + except Exception as e: + logger.debug(f"Failed to register E2B provider: {e}") - # Try to register Hopx (priority 3) + # Try to register Sprites (priority 3) + # Check for SPRITES_TOKEN or sprite CLI + import shutil + + sprites_cli_available = shutil.which("sprite") is not None + if os.getenv("SPRITES_TOKEN") or sprites_cli_available: + try: + # Use CLI mode if no token but CLI is available + use_cli = not os.getenv("SPRITES_TOKEN") and sprites_cli_available + manager.register_provider("sprites", SpritesProvider, {"use_cli": use_cli}) + mode = "CLI" if use_cli else "SDK" + logger.info(f"Registered Sprites provider ({mode} mode)") + except Exception as e: + logger.debug(f"Failed to register Sprites provider: {e}") + + # Try to register Hopx (priority 4) if os.getenv("HOPX_API_KEY"): try: manager.register_provider("hopx", HopxProvider, {}) - print("✓ Registered Hopx provider") - except Exception: - pass + logger.info("Registered Hopx provider") + except Exception as e: + logger.debug(f"Failed to register Hopx provider: {e}") - # Try to register Modal (priority 4) + # 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, {}) - print("✓ Registered Modal provider") - except Exception: - pass + logger.info("Registered Modal provider") + except Exception as e: + logger.debug(f"Failed to register Modal provider: {e}") - # Try to register Cloudflare (priority 5 - 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: @@ -132,9 +152,9 @@ def _auto_configure(cls) -> None: "account_id": os.getenv("CLOUDFLARE_ACCOUNT_ID"), }, ) - print("✓ Registered Cloudflare provider (experimental)") - except Exception: - pass + logger.info("Registered Cloudflare provider (experimental)") + except Exception as e: + logger.debug(f"Failed to register Cloudflare provider: {e}") @classmethod def configure( @@ -144,6 +164,7 @@ def configure( modal_token: str | None = None, daytona_api_key: str | None = None, hopx_api_key: str | None = None, + sprites_token: str | None = None, cloudflare_config: dict[str, str] | None = None, default_provider: str | None = None, ) -> None: @@ -153,8 +174,8 @@ def configure( Example: Sandbox.configure( e2b_api_key="...", - hopx_api_key="...", - default_provider="hopx" + sprites_token="...", + default_provider="sprites" ) """ from .providers import ( @@ -163,6 +184,7 @@ def configure( E2BProvider, HopxProvider, ModalProvider, + SpritesProvider, ) manager = cls._ensure_manager() @@ -180,6 +202,9 @@ def configure( if hopx_api_key: manager.register_provider("hopx", HopxProvider, {"api_key": hopx_api_key}) + if sprites_token: + manager.register_provider("sprites", SpritesProvider, {"token": sprites_token}) + if cloudflare_config: manager.register_provider("cloudflare", CloudflareProvider, cloudflare_config)