From ce53257016a33c47200a6f124db3c615fa4183dc Mon Sep 17 00:00:00 2001 From: tnm Date: Wed, 14 Jan 2026 23:14:31 -0800 Subject: [PATCH 01/10] feat: Add Sprites provider and 'sandboxes claude' command Add Fly.io Sprites as a sandbox provider: - Dual-mode: SDK (SPRITES_TOKEN) or CLI (sprite login) - Claude Code, Python 3.13, Node.js 22 pre-installed - Checkpoint/restore support for instant starts - 100GB persistent storage, ~$0.46/4hr session Add 'sandboxes claude' command for interactive Claude Code: sandboxes claude # Start Claude Code in sandbox sandboxes claude -n myproject --keep # Persistent environment Also adds 'sandboxes shell' for raw shell access. Security: env var key validation and shell value escaping --- README.md | 82 ++++- sandboxes/cli.py | 138 +++++++++ sandboxes/providers/__init__.py | 4 +- sandboxes/providers/daytona.py | 16 +- sandboxes/providers/e2b.py | 127 +++++++- sandboxes/providers/modal.py | 62 +++- sandboxes/providers/sprites.py | 518 ++++++++++++++++++++++++++++++++ sandboxes/sandbox.py | 38 ++- 8 files changed, 946 insertions(+), 39 deletions(-) create mode 100644 sandboxes/providers/sprites.py diff --git a/README.md b/README.md index d69aeaa..5901e02 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,57 @@ 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. Includes a Python API plus full-featured CLI for use from any runtime. +## Claude Code Integration + +Run [Claude Code](https://docs.anthropic.com/en/docs/claude-code) in a secure sandbox with one command: + +```bash +sandboxes claude +``` + +That's it. You get an interactive Claude Code session in an isolated environment with Python 3.13, Node.js 22, and 100GB of storage. + +### Setup + +```bash +# Install sandboxes +pip install cased-sandboxes + +# Install and login to Sprites (provides the sandbox) +curl https://sprites.dev/install.sh | bash +sprite login + +# Start Claude Code +sandboxes claude +``` + +### Persistent Development Environment + +```bash +# Create a named sandbox that persists +sandboxes claude -n myproject --keep + +# Work on your project... +# Exit when done (Ctrl+C or /exit) + +# Come back later - your files are still there +sandboxes claude -n myproject +``` + +### 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**: Keep your sandbox for ongoing work +- **Pre-configured**: Claude Code, Python, Node.js ready to go + ## Installation Add to your project: @@ -352,6 +397,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 +419,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 +480,7 @@ from sandboxes.providers import ( ModalProvider, DaytonaProvider, HopxProvider, + SpritesProvider, CloudflareProvider, ) @@ -448,6 +496,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 +512,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 +524,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 +541,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 +551,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 +669,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 +944,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..32fd906 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,136 @@ 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" ) +@cli.command() +@click.option("-n", "--name", default=None, help="Sandbox name (reuse existing)") +@click.option("--keep", is_flag=True, help="Keep sandbox after exit") +def claude(name: str | None, keep: bool): + """Start an interactive Claude Code session in a sandbox. + + This is the easiest way to use Claude Code safely: + + sandboxes claude + + Your sandbox has Claude Code, Python 3.13, and Node.js 22 pre-installed. + Just start coding! + + For a persistent dev environment: + + sandboxes claude -n myproject --keep + # Exit and come back later: + sandboxes claude -n myproject + """ + import subprocess + import shutil + + 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) + + # 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: + click.echo(f"Using sandbox: {name}") + created_new = False + + click.echo(f"\nšŸš€ Starting Claude Code...\n") + + try: + # Run claude directly in the sprite + subprocess.run(["sprite", "exec", "-s", name, "--", "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") + elif keep or not created_new: + click.echo(f"\nšŸ’” Reconnect anytime: sandboxes claude -n {name}") + + +@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 subprocess + import shutil + + if provider != "sprites": + click.echo(f"āŒ 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(f"\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/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..93f307e 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,14 @@ 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 + if all_env_vars: + exports = " && ".join([f"export {k}='{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..8f40895 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: + pass + + 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..67920de 100644 --- a/sandboxes/providers/modal.py +++ b/sandboxes/providers/modal.py @@ -64,15 +64,29 @@ 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 + if isinstance(image, str): + modal_image = modal.Image.from_registry(image) + else: + # Assume it's already a modal.Image + modal_image = 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 +135,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 +145,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 +153,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 +266,14 @@ 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 + if all_env_vars: + env_setup = " && ".join([f"export {k}='{v}'" for k, v in all_env_vars.items()]) command = f"{env_setup} && {command}" # Execute command in thread pool @@ -272,12 +287,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 + + # 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 = "" - # Wait for completion and get exit code - exit_code = await loop.run_in_executor(self._executor, lambda: process.wait()) + 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..de70795 --- /dev/null +++ b/sandboxes/providers/sprites.py @@ -0,0 +1,518 @@ +"""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: + 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 + """ + 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 + """ + 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/sandbox.py b/sandboxes/sandbox.py index 41cd68f..e8cdc74 100644 --- a/sandboxes/sandbox.py +++ b/sandboxes/sandbox.py @@ -69,9 +69,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 +83,7 @@ def _auto_configure(cls) -> None: E2BProvider, HopxProvider, ModalProvider, + SpritesProvider, ) manager = cls._manager @@ -102,7 +104,22 @@ def _auto_configure(cls) -> None: except Exception: pass - # 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" + print(f"āœ“ Registered Sprites provider ({mode} mode)") + except Exception: + pass + + # Try to register Hopx (priority 4) if os.getenv("HOPX_API_KEY"): try: manager.register_provider("hopx", HopxProvider, {}) @@ -110,7 +127,7 @@ def _auto_configure(cls) -> None: except Exception: pass - # 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, {}) @@ -118,7 +135,7 @@ def _auto_configure(cls) -> None: except Exception: pass - # 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: @@ -144,6 +161,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 +171,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 +181,7 @@ def configure( E2BProvider, HopxProvider, ModalProvider, + SpritesProvider, ) manager = cls._ensure_manager() @@ -180,6 +199,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) From 063374576710ec105c8a556cf2c6b6cc7674db44 Mon Sep 17 00:00:00 2001 From: tnm Date: Wed, 14 Jan 2026 23:22:44 -0800 Subject: [PATCH 02/10] fix: TTY support and resume for sandboxes claude --- README.md | 11 ++++++---- sandboxes/cli.py | 55 ++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 55 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 5901e02..2f2e2a4 100644 --- a/README.md +++ b/README.md @@ -42,14 +42,17 @@ sandboxes claude ### Persistent Development Environment ```bash -# Create a named sandbox that persists -sandboxes claude -n myproject --keep +# Create a named sandbox (automatically kept) +sandboxes claude -n myproject # Work on your project... -# Exit when done (Ctrl+C or /exit) +# 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 ``` ### Why Sandboxes? @@ -57,7 +60,7 @@ sandboxes claude -n myproject 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**: Keep your sandbox for ongoing work +- **Persistent**: Named sandboxes keep your files across sessions - **Pre-configured**: Claude Code, Python, Node.js ready to go ## Installation diff --git a/sandboxes/cli.py b/sandboxes/cli.py index 32fd906..5256c73 100644 --- a/sandboxes/cli.py +++ b/sandboxes/cli.py @@ -479,7 +479,8 @@ def providers(): @cli.command() @click.option("-n", "--name", default=None, help="Sandbox name (reuse existing)") @click.option("--keep", is_flag=True, help="Keep sandbox after exit") -def claude(name: str | None, keep: bool): +@click.option("--list", "list_sandboxes", is_flag=True, help="List existing Claude sandboxes") +def claude(name: str | None, keep: bool, list_sandboxes: bool): """Start an interactive Claude Code session in a sandbox. This is the easiest way to use Claude Code safely: @@ -494,6 +495,10 @@ def claude(name: str | None, keep: bool): sandboxes claude -n myproject --keep # Exit and come back later: sandboxes claude -n myproject + + To see existing sandboxes: + + sandboxes claude --list """ import subprocess import shutil @@ -504,6 +509,26 @@ def claude(name: str | None, keep: bool): 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 = [l for l in lines if 'claude-' in l or (name and name in l)] + if claude_sandboxes: + click.echo("Existing Claude sandboxes:") + for line in claude_sandboxes: + click.echo(f" {line}") + click.echo(f"\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 @@ -520,14 +545,30 @@ def claude(name: str | None, keep: bool): click.echo(f"āœ“ Created {name}") created_new = True else: - click.echo(f"Using sandbox: {name}") - created_new = False + # Check if sandbox exists, create if not + result = subprocess.run( + ["sprite", "list"], capture_output=True, text=True + ) + if name in result.stdout: + click.echo(f"Resuming sandbox: {name}") + created_new = False + 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(f"\nšŸš€ Starting Claude Code...\n") try: - # Run claude directly in the sprite - subprocess.run(["sprite", "exec", "-s", name, "--", "claude"]) + # Run claude directly in the sprite with TTY allocation + subprocess.run(["sprite", "exec", "-s", name, "-tty", "claude"]) except KeyboardInterrupt: click.echo("\n") finally: @@ -538,8 +579,8 @@ def claude(name: str | None, keep: bool): capture_output=True, ) click.echo("āœ“ Destroyed") - elif keep or not created_new: - click.echo(f"\nšŸ’” Reconnect anytime: sandboxes claude -n {name}") + else: + click.echo(f"\nšŸ’” Resume anytime: sandboxes claude -n {name}") @cli.command() From 564a48beb112164c77eaffadf28aa5a55a4763b7 Mon Sep 17 00:00:00 2001 From: tnm Date: Wed, 14 Jan 2026 23:27:09 -0800 Subject: [PATCH 03/10] feat: Add E2B provider support to sandboxes claude - Add -p/--provider flag to choose between sprites and e2b - E2B uses anthropic-claude-code template with Claude Code pre-installed - Full PTY bridge: stdin/stdout passthrough, terminal resize handling - Update README with E2B setup instructions --- README.md | 18 +++++- sandboxes/cli.py | 154 +++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 146 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 2f2e2a4..cefe077 100644 --- a/README.md +++ b/README.md @@ -25,13 +25,13 @@ sandboxes claude That's it. You get an interactive Claude Code session in an isolated environment with Python 3.13, Node.js 22, and 100GB of storage. -### Setup +### Setup (Sprites - recommended) ```bash # Install sandboxes pip install cased-sandboxes -# Install and login to Sprites (provides the sandbox) +# Install and login to Sprites curl https://sprites.dev/install.sh | bash sprite login @@ -39,6 +39,20 @@ sprite login sandboxes claude ``` +### Setup (E2B - alternative) + +```bash +# Install sandboxes with E2B +pip install cased-sandboxes e2b + +# 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 ```bash diff --git a/sandboxes/cli.py b/sandboxes/cli.py index 5256c73..d6ee289 100644 --- a/sandboxes/cli.py +++ b/sandboxes/cli.py @@ -476,30 +476,8 @@ def providers(): ) -@cli.command() -@click.option("-n", "--name", default=None, help="Sandbox name (reuse existing)") -@click.option("--keep", is_flag=True, help="Keep sandbox after exit") -@click.option("--list", "list_sandboxes", is_flag=True, help="List existing Claude sandboxes") -def claude(name: str | None, 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 - - Your sandbox has Claude Code, Python 3.13, and Node.js 22 pre-installed. - Just start coding! - - For a persistent dev environment: - - sandboxes claude -n myproject --keep - # Exit and come back later: - sandboxes claude -n myproject - - To see existing sandboxes: - - sandboxes claude --list - """ +def _run_claude_sprites(name: str | None, keep: bool, list_sandboxes: bool): + """Run Claude Code using Sprites provider.""" import subprocess import shutil @@ -583,6 +561,134 @@ def claude(name: str | None, keep: bool, list_sandboxes: bool): click.echo(f"\nšŸ’” Resume anytime: sandboxes claude -n {name}") +def _run_claude_e2b(): + """Run Claude Code using E2B provider.""" + import asyncio + import sys + import tty + import termios + import signal + + try: + from e2b import AsyncSandbox + from e2b.sandbox.commands.command_handle import PtySize + except ImportError: + click.echo("āŒ E2B SDK not installed. Install with:", err=True) + click.echo(" 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) + + async def run(): + click.echo("Creating E2B sandbox with Claude Code...") + + # Create sandbox with Claude Code template + sbx = await AsyncSandbox.create( + "anthropic-claude-code", + timeout=3600, # 1 hour + envs={"ANTHROPIC_API_KEY": os.getenv("ANTHROPIC_API_KEY", "")}, + ) + click.echo(f"āœ“ Created sandbox: {sbx.sandbox_id}") + click.echo(f"\nšŸš€ Starting Claude Code...\n") + + # Get terminal size + try: + size = os.get_terminal_size() + cols, rows = size.columns, size.lines + except OSError: + cols, rows = 80, 24 + + # Set up raw mode for terminal + old_settings = termios.tcgetattr(sys.stdin) + + try: + tty.setraw(sys.stdin.fileno()) + + # Create PTY with output handler + def on_data(data: bytes): + sys.stdout.buffer.write(data) + sys.stdout.buffer.flush() + + handle = await sbx.pty.create( + PtySize(cols=cols, rows=rows), + on_data=on_data, + envs={"ANTHROPIC_API_KEY": os.getenv("ANTHROPIC_API_KEY", "")}, + ) + + # Start claude + await sbx.pty.send_stdin(handle.pid, b"claude\n") + + # Handle terminal resize + def handle_resize(signum, frame): + try: + size = os.get_terminal_size() + asyncio.create_task( + sbx.pty.resize(handle.pid, PtySize(cols=size.columns, rows=size.lines)) + ) + except: + pass + + signal.signal(signal.SIGWINCH, handle_resize) + + # Read stdin and forward to PTY + import select + + while True: + if select.select([sys.stdin], [], [], 0.1)[0]: + data = sys.stdin.buffer.read(1024) + if data: + await sbx.pty.send_stdin(handle.pid, data) + else: + break + + except KeyboardInterrupt: + pass + finally: + # Restore terminal + termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings) + click.echo("\n\nšŸ—‘ļø Destroying sandbox...") + await sbx.kill() + click.echo("āœ“ Destroyed") + + asyncio.run(run()) + + +@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)") From 1ded6081fabea281e8eb5cb7435e1783d0041999 Mon Sep 17 00:00:00 2001 From: tnm Date: Thu, 15 Jan 2026 00:04:14 -0800 Subject: [PATCH 04/10] fix: E2B claude integration - auto-start claude, use SDK+CLI hybrid - Use Sandbox.create() instead of deprecated constructor - Write to /home/user/.bashrc to auto-run claude on connect - Update README to clarify E2B requires both SDK and CLI - Note persistent environments are Sprites only --- README.md | 5 ++- sandboxes/cli.py | 111 ++++++++++++++++------------------------------- 2 files changed, 40 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index cefe077..27bc00a 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,9 @@ sandboxes claude ### Setup (E2B - alternative) ```bash -# Install sandboxes with E2B +# Install sandboxes with E2B SDK and CLI pip install cased-sandboxes e2b +npm install -g @e2b/cli # Set your API keys export E2B_API_KEY=your_key @@ -53,7 +54,7 @@ export ANTHROPIC_API_KEY=your_key sandboxes claude -p e2b ``` -### Persistent Development Environment +### Persistent Development Environment (Sprites only) ```bash # Create a named sandbox (automatically kept) diff --git a/sandboxes/cli.py b/sandboxes/cli.py index d6ee289..e009d4e 100644 --- a/sandboxes/cli.py +++ b/sandboxes/cli.py @@ -562,16 +562,18 @@ def _run_claude_sprites(name: str | None, keep: bool, list_sandboxes: bool): def _run_claude_e2b(): - """Run Claude Code using E2B provider.""" - import asyncio - import sys - import tty - import termios - import signal + """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 AsyncSandbox - from e2b.sandbox.commands.command_handle import PtySize + from e2b import Sandbox except ImportError: click.echo("āŒ E2B SDK not installed. Install with:", err=True) click.echo(" pip install e2b", err=True) @@ -581,78 +583,39 @@ def _run_claude_e2b(): click.echo("āŒ E2B_API_KEY not set", err=True) sys.exit(1) - async def run(): - click.echo("Creating E2B sandbox with Claude Code...") - - # Create sandbox with Claude Code template - sbx = await AsyncSandbox.create( - "anthropic-claude-code", - timeout=3600, # 1 hour - envs={"ANTHROPIC_API_KEY": os.getenv("ANTHROPIC_API_KEY", "")}, - ) - click.echo(f"āœ“ Created sandbox: {sbx.sandbox_id}") - click.echo(f"\nšŸš€ Starting Claude Code...\n") - - # Get terminal size - try: - size = os.get_terminal_size() - cols, rows = size.columns, size.lines - except OSError: - cols, rows = 80, 24 + api_key = os.getenv("ANTHROPIC_API_KEY", "") + if not api_key: + click.echo("āŒ ANTHROPIC_API_KEY not set", err=True) + sys.exit(1) - # Set up raw mode for terminal - old_settings = termios.tcgetattr(sys.stdin) + click.echo("Creating E2B sandbox with Claude Code...") - try: - tty.setraw(sys.stdin.fileno()) - - # Create PTY with output handler - def on_data(data: bytes): - sys.stdout.buffer.write(data) - sys.stdout.buffer.flush() + # 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}") - handle = await sbx.pty.create( - PtySize(cols=cols, rows=rows), - on_data=on_data, - envs={"ANTHROPIC_API_KEY": os.getenv("ANTHROPIC_API_KEY", "")}, - ) + # Set up shell to run claude on connect + sbx.files.write("/home/user/.bashrc", "exec claude\n") - # Start claude - await sbx.pty.send_stdin(handle.pid, b"claude\n") + click.echo("\nšŸš€ Starting Claude Code...\n") - # Handle terminal resize - def handle_resize(signum, frame): - try: - size = os.get_terminal_size() - asyncio.create_task( - sbx.pty.resize(handle.pid, PtySize(cols=size.columns, rows=size.lines)) - ) - except: - pass - - signal.signal(signal.SIGWINCH, handle_resize) - - # Read stdin and forward to PTY - import select - - while True: - if select.select([sys.stdin], [], [], 0.1)[0]: - data = sys.stdin.buffer.read(1024) - if data: - await sbx.pty.send_stdin(handle.pid, data) - else: - break - - except KeyboardInterrupt: - pass - finally: - # Restore terminal - termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings) - click.echo("\n\nšŸ—‘ļø Destroying sandbox...") - await sbx.kill() + # 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") - - asyncio.run(run()) + except Exception: + click.echo("(sandbox may have already timed out)") @cli.command() From dca4fd62b4cba9ce19afb4c70305b701feb8281d Mon Sep 17 00:00:00 2001 From: tnm Date: Thu, 15 Jan 2026 00:11:12 -0800 Subject: [PATCH 05/10] style: fix lint and formatting issues --- sandboxes/cli.py | 46 ++++++++++++++++-------------------- sandboxes/providers/e2b.py | 2 +- sandboxes/providers/modal.py | 6 +---- 3 files changed, 23 insertions(+), 31 deletions(-) diff --git a/sandboxes/cli.py b/sandboxes/cli.py index e009d4e..cae6eee 100644 --- a/sandboxes/cli.py +++ b/sandboxes/cli.py @@ -478,8 +478,8 @@ def providers(): def _run_claude_sprites(name: str | None, keep: bool, list_sandboxes: bool): """Run Claude Code using Sprites provider.""" - import subprocess import shutil + import subprocess if not shutil.which("sprite"): click.echo("āŒ sprite CLI not found. Install with:", err=True) @@ -489,17 +489,17 @@ def _run_claude_sprites(name: str | None, keep: bool, list_sandboxes: bool): # List existing sandboxes if list_sandboxes: - result = subprocess.run( - ["sprite", "list"], capture_output=True, text=True - ) + result = subprocess.run(["sprite", "list"], capture_output=True, text=True) if result.returncode == 0: - lines = result.stdout.strip().split('\n') - claude_sandboxes = [l for l in lines if 'claude-' in l or (name and name in l)] + 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(f"\nResume with: sandboxes claude -n ") + click.echo("\nResume with: sandboxes claude -n ") else: click.echo("No Claude sandboxes found.") click.echo("Start one with: sandboxes claude") @@ -514,9 +514,7 @@ def _run_claude_sprites(name: str | None, keep: bool, list_sandboxes: bool): name = f"claude-{uuid.uuid4().hex[:8]}" click.echo(f"Creating sandbox: {name}") - result = subprocess.run( - ["sprite", "create", name], capture_output=True, text=True - ) + 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) @@ -524,17 +522,13 @@ def _run_claude_sprites(name: str | None, keep: bool, list_sandboxes: bool): created_new = True else: # Check if sandbox exists, create if not - result = subprocess.run( - ["sprite", "list"], capture_output=True, text=True - ) + result = subprocess.run(["sprite", "list"], capture_output=True, text=True) if name in result.stdout: click.echo(f"Resuming sandbox: {name}") created_new = False else: click.echo(f"Creating sandbox: {name}") - result = subprocess.run( - ["sprite", "create", name], capture_output=True, text=True - ) + 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) @@ -542,7 +536,7 @@ def _run_claude_sprites(name: str | None, keep: bool, list_sandboxes: bool): created_new = True keep = True # Named sandboxes are kept by default - click.echo(f"\nšŸš€ Starting Claude Code...\n") + click.echo("\nšŸš€ Starting Claude Code...\n") try: # Run claude directly in the sprite with TTY allocation @@ -620,9 +614,13 @@ def _run_claude_e2b(): @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( + "-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)") +@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. @@ -663,11 +661,11 @@ def shell(provider: str, name: str | None, keep: bool): sandboxes shell -n my-dev --keep """ - import subprocess import shutil + import subprocess if provider != "sprites": - click.echo(f"āŒ Interactive shell only supported for sprites provider", err=True) + click.echo("āŒ Interactive shell only supported for sprites provider", err=True) sys.exit(1) if not shutil.which("sprite"): @@ -681,9 +679,7 @@ def shell(provider: str, name: str | None, keep: bool): name = f"sandbox-{uuid.uuid4().hex[:8]}" click.echo(f"Creating sandbox: {name}") - result = subprocess.run( - ["sprite", "create", name], capture_output=True, text=True - ) + 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) @@ -693,7 +689,7 @@ def shell(provider: str, name: str | None, keep: bool): click.echo(f"Using sandbox: {name}") created_new = False - click.echo(f"\nšŸš€ Opening shell...\n") + click.echo("\nšŸš€ Opening shell...\n") try: subprocess.run(["sprite", "console", "-s", name]) diff --git a/sandboxes/providers/e2b.py b/sandboxes/providers/e2b.py index 8f40895..ac95faf 100644 --- a/sandboxes/providers/e2b.py +++ b/sandboxes/providers/e2b.py @@ -355,7 +355,7 @@ async def _execute_long_running( continue # Timeout - try to kill the process - try: + try: # noqa: SIM105 await e2b_sandbox.commands.run(f"kill {pid} 2>/dev/null || true", timeout=5) except Exception: pass diff --git a/sandboxes/providers/modal.py b/sandboxes/providers/modal.py index 67920de..a06979d 100644 --- a/sandboxes/providers/modal.py +++ b/sandboxes/providers/modal.py @@ -78,11 +78,7 @@ def _create_modal_sandbox(self, image: str | Any, cpu: float, memory: int, timeo app = modal.App.lookup("sandboxes-provider", create_if_missing=True) # Handle both string images and modal.Image objects - if isinstance(image, str): - modal_image = modal.Image.from_registry(image) - else: - # Assume it's already a modal.Image - modal_image = image + modal_image = modal.Image.from_registry(image) if isinstance(image, str) else image # Create Modal sandbox with specified resources sandbox = ModalSandbox.create( From 8735cd0be25a95c960f10647c25db5f0981854eb Mon Sep 17 00:00:00 2001 From: tnm Date: Thu, 15 Jan 2026 00:14:12 -0800 Subject: [PATCH 06/10] docs: move Installation above Claude Code, recommend uv --- README.md | 34 ++++++++++++++-------------------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 27bc00a..6ddd62e 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,18 @@ Universal library for AI code execution sandboxes. Write your code once and switch between providers with a single line change, or let the library automatically select a provider. Includes a Python API plus full-featured CLI for use from any runtime. +## Installation + +```bash +uv pip install cased-sandboxes +``` + +Or add to your project: + +```bash +uv add cased-sandboxes +``` + ## Claude Code Integration Run [Claude Code](https://docs.anthropic.com/en/docs/claude-code) in a secure sandbox with one command: @@ -28,9 +40,6 @@ That's it. You get an interactive Claude Code session in an isolated environment ### Setup (Sprites - recommended) ```bash -# Install sandboxes -pip install cased-sandboxes - # Install and login to Sprites curl https://sprites.dev/install.sh | bash sprite login @@ -42,8 +51,8 @@ sandboxes claude ### Setup (E2B - alternative) ```bash -# Install sandboxes with E2B SDK and CLI -pip install cased-sandboxes e2b +# Install E2B SDK and CLI +uv pip install e2b npm install -g @e2b/cli # Set your API keys @@ -78,21 +87,6 @@ Claude Code can read, write, and execute code. Running it in a sandbox means: - **Persistent**: Named sandboxes keep your files across sessions - **Pre-configured**: Claude Code, Python, Node.js ready to go -## Installation - -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.,: - -```bash -uv pip install cased-sandboxes -``` - ## Quick Start ### One-line Execution + Auto-select Provider From a221200daa2aab84e52e6f915fa7247d82152caa Mon Sep 17 00:00:00 2001 From: tnm Date: Thu, 15 Jan 2026 10:54:59 -0800 Subject: [PATCH 07/10] fix: Address code review issues across codebase - Replace print() with logging in sandbox.py auto-configure - Add exception logging instead of silent pass blocks - Fix shell injection in Daytona/Modal providers (env var escaping) - Fix CLI mode issues in Sprites provider (get_sandbox, checkpoints) - Fix set.remove() -> discard() in pool.py (prevent KeyError) - Fix semaphore._value private API access in retry.py - Fix imprecise sandbox name matching in CLI - Fix E2B .bashrc overwrite (use wrapper script) - Add --keep default for resumed named sandboxes --- sandboxes/cli.py | 14 ++++++++---- sandboxes/pool.py | 2 +- sandboxes/providers/daytona.py | 21 ++++++++++++++++-- sandboxes/providers/e2b.py | 6 +++--- sandboxes/providers/modal.py | 21 ++++++++++++++++-- sandboxes/providers/sprites.py | 31 +++++++++++++++++++++------ sandboxes/retry.py | 10 ++++++--- sandboxes/sandbox.py | 39 ++++++++++++++++++---------------- 8 files changed, 105 insertions(+), 39 deletions(-) diff --git a/sandboxes/cli.py b/sandboxes/cli.py index cae6eee..cab9ff9 100644 --- a/sandboxes/cli.py +++ b/sandboxes/cli.py @@ -523,9 +523,12 @@ def _run_claude_sprites(name: str | None, keep: bool, list_sandboxes: bool): else: # Check if sandbox exists, create if not result = subprocess.run(["sprite", "list"], capture_output=True, text=True) - if name in result.stdout: + # 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) @@ -570,7 +573,7 @@ def _run_claude_e2b(): from e2b import Sandbox except ImportError: click.echo("āŒ E2B SDK not installed. Install with:", err=True) - click.echo(" pip install e2b", err=True) + click.echo(" uv pip install e2b", err=True) sys.exit(1) if not os.getenv("E2B_API_KEY"): @@ -593,8 +596,11 @@ def _run_claude_e2b(): sandbox_id = sbx.sandbox_id click.echo(f"āœ“ Created sandbox: {sandbox_id}") - # Set up shell to run claude on connect - sbx.files.write("/home/user/.bashrc", "exec claude\n") + # 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") 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/daytona.py b/sandboxes/providers/daytona.py index 93f307e..a58f6c3 100644 --- a/sandboxes/providers/daytona.py +++ b/sandboxes/providers/daytona.py @@ -200,9 +200,26 @@ async def execute_command( if env_vars: all_env_vars.update(env_vars) - # Prepare command with environment variables + # Prepare command with environment variables (with proper escaping) if all_env_vars: - exports = " && ".join([f"export {k}='{v}'" for k, v in all_env_vars.items()]) + 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 ac95faf..96f26ff 100644 --- a/sandboxes/providers/e2b.py +++ b/sandboxes/providers/e2b.py @@ -355,10 +355,10 @@ async def _execute_long_running( continue # Timeout - try to kill the process - try: # noqa: SIM105 + try: await e2b_sandbox.commands.run(f"kill {pid} 2>/dev/null || true", timeout=5) - except Exception: - pass + 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( diff --git a/sandboxes/providers/modal.py b/sandboxes/providers/modal.py index a06979d..a7943c8 100644 --- a/sandboxes/providers/modal.py +++ b/sandboxes/providers/modal.py @@ -267,9 +267,26 @@ async def execute_command( if env_vars: all_env_vars.update(env_vars) - # Prepare command with environment variables + # Prepare command with environment variables (with proper escaping) if all_env_vars: - env_setup = " && ".join([f"export {k}='{v}'" for k, v in all_env_vars.items()]) + 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 diff --git a/sandboxes/providers/sprites.py b/sandboxes/providers/sprites.py index de70795..16f513e 100644 --- a/sandboxes/providers/sprites.py +++ b/sandboxes/providers/sprites.py @@ -174,12 +174,19 @@ async def get_sandbox(self, sandbox_id: str) -> Sandbox | None: # Try to access the sprite to check if it exists try: - 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) - ) + 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 = { @@ -430,7 +437,13 @@ async def create_checkpoint(self, sandbox_id: str, name: str | None = None) -> s 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() @@ -460,7 +473,13 @@ async def restore_checkpoint(self, sandbox_id: str, checkpoint_id: str) -> bool: 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() 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 e8cdc74..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.""" @@ -92,17 +95,17 @@ 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 Sprites (priority 3) # Check for SPRITES_TOKEN or sprite CLI @@ -115,25 +118,25 @@ def _auto_configure(cls) -> None: 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" - print(f"āœ“ Registered Sprites provider ({mode} mode)") - except Exception: - pass + 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 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 6 - experimental) base_url = os.getenv("CLOUDFLARE_SANDBOX_BASE_URL") @@ -149,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( From 4ad3e2463032b00f3cbac2be32ccd9056a8d6667 Mon Sep 17 00:00:00 2001 From: tnm Date: Thu, 15 Jan 2026 11:03:16 -0800 Subject: [PATCH 08/10] docs: Add sandboxes shell to README --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 6ddd62e..6118160 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,9 @@ 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? From fe281dd352e42ecc926278f1685327e429fdef32 Mon Sep 17 00:00:00 2001 From: tnm Date: Thu, 15 Jan 2026 11:05:08 -0800 Subject: [PATCH 09/10] style: Format cli.py with black --- sandboxes/cli.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sandboxes/cli.py b/sandboxes/cli.py index cab9ff9..d1de350 100644 --- a/sandboxes/cli.py +++ b/sandboxes/cli.py @@ -524,7 +524,9 @@ def _run_claude_sprites(name: str | None, keep: bool, list_sandboxes: bool): # 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()} + 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 From f50e09063508aa406f56c7ef3071b7fa70b1a248 Mon Sep 17 00:00:00 2001 From: Ted Nyman Date: Thu, 15 Jan 2026 11:07:05 -0800 Subject: [PATCH 10/10] Simpler README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6118160..9f8178d 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Run [Claude Code](https://docs.anthropic.com/en/docs/claude-code) in a secure sa sandboxes claude ``` -That's it. You get an interactive Claude Code session in an isolated environment with Python 3.13, Node.js 22, and 100GB of storage. +That's it. You get an interactive Claude Code session in an isolated, cloud environment. ### Setup (Sprites - recommended)