diff --git a/README.md b/README.md index 724ade1..d5f0ae5 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Universal library for AI code execution sandboxes. `sandboxes` provides a unified interface for sandboxed code execution across multiple providers: -- **Current providers**: E2B, Modal, Daytona, Hopx, Vercel, Sprites (Fly.io) +- **Current providers**: E2B, Modal, Daytona, Hopx, Vercel, Sprites (Fly.io), Tenki - **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. @@ -418,6 +418,7 @@ export VERCEL_TEAM_ID="..." export SPRITES_TOKEN="..." # Or use `sprite login` for CLI mode export CLOUDFLARE_SANDBOX_BASE_URL="https://your-worker.workers.dev" export CLOUDFLARE_API_TOKEN="..." +export TENKI_API_KEY="..." ``` Then just use: @@ -442,6 +443,7 @@ When you call `Sandbox.create()` or `run()`, the library checks for providers in 5. **Vercel** - Looks for `VERCEL_TOKEN` + `VERCEL_PROJECT_ID` + `VERCEL_TEAM_ID` 6. **Modal** - Looks for `~/.modal.toml` or `MODAL_TOKEN_ID` 7. **Cloudflare** *(experimental)* - Looks for `CLOUDFLARE_SANDBOX_BASE_URL` + `CLOUDFLARE_API_TOKEN` +8. **Tenki** - Looks for `TENKI_API_KEY` **The first provider with valid credentials becomes the default.** Cloudflare requires deploying your own Worker. @@ -502,6 +504,7 @@ from sandboxes.providers import ( VercelProvider, SpritesProvider, CloudflareProvider, + TenkiProvider, ) # E2B - Uses E2B_API_KEY env var @@ -528,6 +531,9 @@ provider = CloudflareProvider( base_url="https://your-worker.workers.dev", api_token="your-token", ) + +# Tenki - Uses TENKI_API_KEY env var +provider = TenkiProvider() ``` Each provider requires appropriate authentication: @@ -538,6 +544,7 @@ Each provider requires appropriate authentication: - **Vercel**: Set `VERCEL_TOKEN`, `VERCEL_PROJECT_ID`, and `VERCEL_TEAM_ID` - **Sprites**: Set `SPRITES_TOKEN` environment variable, or run `sprite login` for CLI mode - **Cloudflare** *(experimental)*: Deploy the [Cloudflare sandbox Worker](https://github.com/cloudflare/sandbox-sdk) and set `CLOUDFLARE_SANDBOX_BASE_URL`, `CLOUDFLARE_API_TOKEN`, and (optionally) `CLOUDFLARE_ACCOUNT_ID` +- **Tenki**: Install the SDK with `uv pip install "cased-sandboxes[tenki]"` (pulls the `tenki` package) and set `TENKI_API_KEY` (get one at [tenki.cloud](https://tenki.cloud)) > **Cloudflare setup tips (experimental)** > @@ -720,6 +727,9 @@ export SPRITES_TOKEN="..." export CLOUDFLARE_SANDBOX_BASE_URL="https://your-worker.workers.dev" export CLOUDFLARE_API_TOKEN="..." export CLOUDFLARE_ACCOUNT_ID="..." # Optional + +# Tenki +export TENKI_API_KEY="..." ``` ## Multi-Language Support @@ -985,4 +995,4 @@ MIT License - see [LICENSE](LICENSE) file for details. Built by [Cased](https://cased.com) -Thanks to the teams at E2B, Modal, Daytona, Hopx, Vercel, Fly.io (Sprites), and Cloudflare for their excellent sandbox platforms. +Thanks to the teams at E2B, Modal, Daytona, Hopx, Vercel, Fly.io (Sprites), Cloudflare, and Tenki for their excellent sandbox platforms. diff --git a/pyproject.toml b/pyproject.toml index f152538..68222d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ readme = "README.md" homepage = "https://github.com/cased/sandboxes" repository = "https://github.com/cased/sandboxes" documentation = "https://github.com/cased/sandboxes" -keywords = ["sandbox", "ai", "code-execution", "e2b", "modal", "daytona", "hopx", "vercel", "cloudflare"] +keywords = ["sandbox", "ai", "code-execution", "e2b", "modal", "daytona", "hopx", "vercel", "cloudflare", "tenki"] classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", @@ -50,6 +50,9 @@ hopx = [ vercel = [ "vercel>=0.4.0", # Official Vercel SDK with Sandbox APIs ] +tenki = [ + "tenki>=0.5.1", # Official Tenki Cloud Python SDK (Connect/gRPC) +] # cloudflare = [ # "cloudflare-workers-sdk>=0.1.0", # When available # ] @@ -59,6 +62,7 @@ all = [ "modal==1.3.3", "hopx-ai>=0.5.0", "vercel>=0.4.0", + "tenki>=0.5.1", ] dev = [ "pytest>=7.4.0", @@ -159,6 +163,7 @@ markers = [ "hopx: marks tests that require Hopx API", "vercel: marks tests that require Vercel API", "cloudflare: marks tests that require Cloudflare API", + "tenki: marks tests that require Tenki API", "slow: marks tests as slow (deselect with '-m \"not slow\"')", ] diff --git a/sandboxes/cli.py b/sandboxes/cli.py index b4f7c61..5b3c3c2 100644 --- a/sandboxes/cli.py +++ b/sandboxes/cli.py @@ -67,6 +67,13 @@ def _provider_classes(): except ImportError: pass + try: + from sandboxes.providers.tenki import TenkiProvider + + providers["tenki"] = TenkiProvider + except ImportError: + pass + return providers @@ -516,6 +523,7 @@ def providers(capabilities): "Cloudflare Workers + Containers (⚠️ experimental)", True, ), + ("tenki", "TENKI_API_KEY", "Tenki managed microVM sandboxes", False), ] for name, auth, description, is_experimental in providers: @@ -546,6 +554,8 @@ def providers(capabilities): (os.getenv("CLOUDFLARE_API_TOKEN") or os.getenv("CLOUDFLARE_API_KEY")) and os.getenv("CLOUDFLARE_SANDBOX_BASE_URL") ) + elif name == "tenki": + configured = bool(os.getenv("TENKI_API_KEY") or os.getenv("TENKI_AUTH_TOKEN")) else: configured = False @@ -612,6 +622,7 @@ def format_row(row: list[str]) -> str: click.echo( " Cloudflare (experimental): Deploy Worker from https://github.com/cloudflare/sandbox-sdk" ) + click.echo(" Tenki: export TENKI_API_KEY=your_key") def _run_claude_sprites(name: str | None, keep: bool, list_sandboxes: bool): diff --git a/sandboxes/providers/__init__.py b/sandboxes/providers/__init__.py index 756ff4f..ba21af9 100644 --- a/sandboxes/providers/__init__.py +++ b/sandboxes/providers/__init__.py @@ -54,6 +54,13 @@ except ImportError: pass +try: + from .tenki import TenkiProvider + + _providers["tenki"] = TenkiProvider +except ImportError: + pass + def get_provider(name: str) -> type[SandboxProvider] | None: """Get a provider class by name.""" diff --git a/sandboxes/providers/tenki.py b/sandboxes/providers/tenki.py new file mode 100644 index 0000000..a9fb2be --- /dev/null +++ b/sandboxes/providers/tenki.py @@ -0,0 +1,480 @@ +"""Tenki Sandboxes provider implementation. + +Tenki (https://tenki.cloud) is a managed microVM sandbox platform built for +agentic workloads. Its control plane is a Connect/gRPC API (``SandboxService``) +and command execution runs on a data plane reached over the sandbox gateway -- +there is no plain REST surface. This provider is therefore a thin adapter over +the official ``tenki`` async SDK (``AsyncClient`` / ``AsyncSandbox``), which +speaks the real protocol, primes data-plane access, and handles auth. + +Auth uses a Tenki API key supplied via ``TENKI_API_KEY`` (or passed explicitly). +Install the SDK with the ``tenki`` extra: ``pip install "cased-sandboxes[tenki]"``. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +import os +import warnings +from collections.abc import AsyncIterator +from typing import TYPE_CHECKING, Any + +from ..base import ( + ExecutionResult, + ProviderCapabilities, + Sandbox, + SandboxConfig, + SandboxProvider, + SandboxState, +) +from ..exceptions import ( + ProviderError, + SandboxAuthenticationError, + SandboxError, + SandboxNotFoundError, + SandboxTimeoutError, +) +from ..security import validate_download_path, validate_upload_path + +if TYPE_CHECKING: # pragma: no cover - typing only + from tenki import AsyncClient, AsyncSandbox + +logger = logging.getLogger(__name__) + +# Canonical Tenki session states (see SandboxSession.state) mapped to the +# library's provider-agnostic states. +_STATE_MAP = { + "UNSPECIFIED": SandboxState.CREATING, + "CREATING": SandboxState.CREATING, + "PROVISIONING": SandboxState.CREATING, + "STARTING": SandboxState.STARTING, + "RESUMING": SandboxState.STARTING, + "RUNNING": SandboxState.RUNNING, + "PAUSING": SandboxState.STOPPING, + "PAUSED": SandboxState.STOPPED, + "STOPPED": SandboxState.STOPPED, + "TERMINATING": SandboxState.STOPPING, + "TERMINATED": SandboxState.TERMINATED, + "FAILED": SandboxState.ERROR, + "ERROR": SandboxState.ERROR, +} + +# Keys accepted from SandboxConfig.provider_config and forwarded verbatim to +# AsyncClient.create(). Everything else in provider_config is ignored. +_CREATE_PASSTHROUGH = ( + "tags", + "sticky", + "idle_timeout_minutes", + "snapshot_id", + "disk_size_gb", + "clone_repo_url", + "enable_opencode", + "allow_inbound", + "allow_outbound", + "pause_retention", + "wait", + "wait_for_runtime", + "github_token", + "ssh_authorized_keys", + "volumes", + # Added in tenki 0.5.x: build-from-template-spec and setup-phase inputs. + "from_template_spec", + "setup_env", + "setup_secrets", +) + + +class TenkiProvider(SandboxProvider): + """Interact with the Tenki Sandboxes platform via its official async SDK.""" + + CAPABILITIES = ProviderCapabilities( + persistent=True, + snapshot=True, + streaming=True, + file_upload=True, + interactive_shell=True, + ) + + def __init__( + self, + api_key: str | None = None, + *, + base_url: str | None = None, + gateway_url: str | None = None, + workspace_id: str | None = None, + default_image: str | None = None, + client: AsyncClient | None = None, + **config, + ) -> None: + """Initialize the Tenki provider. + + Args: + api_key: Tenki API key. Falls back to ``TENKI_API_KEY`` / + ``TENKI_AUTH_TOKEN``. + base_url: Control-plane API URL. Falls back to the SDK's resolution + (``TENKI_API_ENDPOINT`` then the public endpoint). + gateway_url: Sandbox gateway URL for the data plane. Falls back to + the SDK's resolution (derived from ``base_url``). + workspace_id: Default workspace scope for created/listed sandboxes. + Falls back to ``TENKI_WORKSPACE_ID``. + default_image: Default image/template launched when a config does + not specify one. Falls back to ``TENKI_SANDBOX_IMAGE``. + client: Pre-built ``AsyncClient`` (primarily for testing / reuse). + **config: Additional configuration forwarded to the base class. + """ + # tenki 0.5.x removed project scoping, so project_id would otherwise be + # absorbed by **config and silently widen listings to the workspace. + if "project_id" in config: + config.pop("project_id") + warnings.warn( + "TenkiProvider(project_id=...) is ignored: tenki 0.5.x removed " + "project scoping; use workspace_id instead.", + DeprecationWarning, + stacklevel=2, + ) + + super().__init__(**config) + + self.api_key = api_key or os.getenv("TENKI_API_KEY") or os.getenv("TENKI_AUTH_TOKEN") + if not self.api_key and client is None: + raise ProviderError( + "Tenki API key not provided (set TENKI_API_KEY or pass api_key=...)" + ) + + self.base_url = base_url or os.getenv("TENKI_API_ENDPOINT") or os.getenv("TENKI_API_URL") + self.gateway_url = gateway_url or os.getenv("TENKI_SANDBOX_GATEWAY_URL") + self.workspace_id = workspace_id or os.getenv("TENKI_WORKSPACE_ID") + self.default_image = default_image or os.getenv("TENKI_SANDBOX_IMAGE") + + self._client = client + # Cache AsyncSandbox handles by id so command/file ops reuse the primed + # data-plane connection instead of re-fetching on every call. + self._sandboxes: dict[str, AsyncSandbox] = {} + # Ids currently being terminated; guards _resolve from returning or + # re-caching a handle mid-destroy. + self._destroying: set[str] = set() + self._lock = asyncio.Lock() + + @property + def name(self) -> str: + return "tenki" + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + async def create_sandbox(self, config: SandboxConfig) -> Sandbox: + """Create a new Tenki sandbox (session).""" + client = await self._get_client() + + image = ( + config.image + or (config.provider_config.get("template") if config.provider_config else None) + or (config.provider_config.get("image") if config.provider_config else None) + or self.default_image + ) + + kwargs: dict[str, Any] = { + "workspace_id": self.workspace_id, + "env": config.env_vars or None, + # SandboxConfig.labels are key/value, so they map to Tenki metadata. + "metadata": config.labels or None, + "memory_mb": config.memory_mb, + "cpu_cores": int(config.cpu_cores) if config.cpu_cores is not None else None, + } + if image: + kwargs["image"] = image + if config.timeout_seconds: + kwargs["max_duration"] = config.timeout_seconds + if config.provider_config: + for key in _CREATE_PASSTHROUGH: + if key in config.provider_config: + kwargs[key] = config.provider_config[key] + + kwargs = {k: v for k, v in kwargs.items() if v is not None} + + try: + sb = await client.create(**kwargs) + except Exception as exc: # noqa: BLE001 - normalized below + raise self._map_error(exc) from exc + + async with self._lock: + self._sandboxes[sb.id] = sb + + sandbox = self._to_sandbox(sb, labels=config.labels or {}) + logger.info("Created Tenki sandbox %s (image=%s)", sandbox.id, image) + + for command in config.setup_commands: + await self.execute_command(sandbox.id, command) + + return sandbox + + async def get_sandbox(self, sandbox_id: str) -> Sandbox | None: + """Get a sandbox by ID, or None if it does not exist.""" + client = await self._get_client() + try: + sb = await client.get(sandbox_id) + except Exception as exc: # noqa: BLE001 - normalized below + if self._is_not_found(exc): + return None + raise self._map_error(exc) from exc + + async with self._lock: + self._sandboxes[sandbox_id] = sb + return self._to_sandbox(sb, labels=dict(sb.info.metadata)) + + async def list_sandboxes(self, labels: dict[str, str] | None = None) -> list[Sandbox]: + """List sandboxes, optionally filtered by labels (matched on metadata).""" + client = await self._get_client() + try: + # list() pages through the whole workspace; passing workspace_id=None + # lets the SDK fall back to the token's default workspace. + handles = await client.list(workspace_id=self.workspace_id) + except Exception as exc: # noqa: BLE001 - normalized below + raise self._map_error(exc) from exc + + sandboxes: list[Sandbox] = [] + for sb in handles: + metadata = dict(sb.info.metadata) + if labels and not all(metadata.get(k) == v for k, v in labels.items()): + continue + async with self._lock: + self._sandboxes[sb.id] = sb + sandboxes.append(self._to_sandbox(sb, labels=metadata)) + return sandboxes + + async def destroy_sandbox(self, sandbox_id: str) -> bool: + """Destroy (terminate) a sandbox.""" + try: + sb = await self._resolve(sandbox_id) + except SandboxNotFoundError: + return False + + # Evict before the close() yield point so concurrent _resolve callers + # cannot observe a handle that is being terminated. + async with self._lock: + self._sandboxes.pop(sandbox_id, None) + self._destroying.add(sandbox_id) + + try: + await sb.close_if_open() + except Exception as exc: # noqa: BLE001 - normalized below + if self._is_not_found(exc): + return False + raise self._map_error(exc) from exc + finally: + async with self._lock: + self._destroying.discard(sandbox_id) + + logger.info("Destroyed Tenki sandbox %s", sandbox_id) + return True + + # ------------------------------------------------------------------ + # Execution + # ------------------------------------------------------------------ + async def execute_command( + self, + sandbox_id: str, + command: str, + timeout: int | None = None, + env_vars: dict[str, str] | None = None, + ) -> ExecutionResult: + """Execute a shell command in the sandbox and return its result.""" + sb = await self._resolve(sandbox_id) + try: + result = await sb.shell(command, timeout=timeout, env=env_vars or None) + except Exception as exc: # noqa: BLE001 - normalized below + if self._is_timeout(exc): + return ExecutionResult( + exit_code=124, + stdout="", + stderr=str(exc), + duration_ms=None, + timed_out=True, + ) + raise self._map_error(exc) from exc + + return ExecutionResult( + exit_code=result.exit_code, + stdout=result.stdout_text, + stderr=result.stderr_text, + duration_ms=result.duration_ms, + timed_out=bool(result.reason and "timeout" in result.reason.lower()), + ) + + async def stream_execution( + self, + sandbox_id: str, + command: str, + timeout: int | None = None, + env_vars: dict[str, str] | None = None, + ) -> AsyncIterator[str]: + """Stream command stdout over the data plane, then flush any stderr.""" + sb = await self._resolve(sandbox_id) + try: + proc = await sb.start( + "bash", "-lc", command, env=env_vars or None, timeout=timeout + ) + except Exception as exc: # noqa: BLE001 - normalized below + raise self._map_error(exc) from exc + + finished = False + try: + await proc.close_stdin() + async for chunk in proc.stdout: + if chunk: + yield chunk.decode(errors="replace") + if timeout is not None: + try: + result = await asyncio.wait_for(proc.wait(), timeout=timeout) + except TimeoutError as exc: + raise SandboxTimeoutError( + f"Tenki command timed out after {timeout}s" + ) from exc + else: + result = await proc.wait() + finished = True + if result.stderr: + yield f"\n[stderr]: {result.stderr_text}" + except Exception as exc: # noqa: BLE001 - normalized below + raise self._map_error(exc) from exc + finally: + if not finished: + # Generator abandoned mid-stream (break/cancel/GC): don't leak + # the remote process. + with contextlib.suppress(Exception): + await proc.kill() + + # ------------------------------------------------------------------ + # Files + # ------------------------------------------------------------------ + async def upload_file(self, sandbox_id: str, local_path: str, sandbox_path: str) -> bool: + """Upload a local file into the sandbox.""" + validated = validate_upload_path(local_path) + sb = await self._resolve(sandbox_id) + try: + await sb.fs.upload(str(validated), sandbox_path) + except Exception as exc: # noqa: BLE001 - normalized below + raise self._map_error(exc) from exc + logger.info("Uploaded %s to %s in sandbox %s", validated, sandbox_path, sandbox_id) + return True + + async def download_file(self, sandbox_id: str, sandbox_path: str, local_path: str) -> bool: + """Download a file from the sandbox to the local filesystem.""" + validated = validate_download_path(local_path) + sb = await self._resolve(sandbox_id) + try: + await sb.fs.download(sandbox_path, str(validated)) + except Exception as exc: # noqa: BLE001 - normalized below + raise self._map_error(exc) from exc + logger.info("Downloaded %s from sandbox %s to %s", sandbox_path, sandbox_id, validated) + return True + + # ------------------------------------------------------------------ + # Misc + # ------------------------------------------------------------------ + async def health_check(self) -> bool: + """Check whether the Tenki API is reachable and the key is valid.""" + try: + client = await self._get_client() + await client.who_am_i() + return True + except Exception: # noqa: BLE001 - health check never raises + return False + + async def aclose(self) -> None: + """Close the underlying SDK client and release its channel.""" + async with self._lock: + client = self._client + self._client = None + self._sandboxes.clear() + if client is not None: + await client.close() + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + async def _get_client(self) -> AsyncClient: + if self._client is None: + try: + from tenki import AsyncClient + except ImportError as exc: # pragma: no cover - import guard + raise ProviderError( + 'tenki SDK not installed; run pip install "cased-sandboxes[tenki]"' + ) from exc + self._client = AsyncClient( + auth_token=self.api_key, + base_url=self.base_url, + gateway_url=self.gateway_url, + ) + return self._client + + async def _resolve(self, sandbox_id: str) -> AsyncSandbox: + """Return a cached AsyncSandbox handle, fetching it if not cached.""" + async with self._lock: + if sandbox_id in self._destroying: + raise SandboxNotFoundError(f"Tenki sandbox not found: {sandbox_id}") + cached = self._sandboxes.get(sandbox_id) + if cached is not None: + return cached + + client = await self._get_client() + try: + sb = await client.get(sandbox_id) + except Exception as exc: # noqa: BLE001 - normalized below + if self._is_not_found(exc): + raise SandboxNotFoundError(f"Tenki sandbox not found: {sandbox_id}") from exc + raise self._map_error(exc) from exc + + async with self._lock: + if sandbox_id in self._destroying: + raise SandboxNotFoundError(f"Tenki sandbox not found: {sandbox_id}") + self._sandboxes[sandbox_id] = sb + return sb + + def _to_sandbox(self, sb: AsyncSandbox, labels: dict[str, str]) -> Sandbox: + """Convert an AsyncSandbox handle into a canonical Sandbox.""" + info = sb.info + state = _STATE_MAP.get(str(info.state).upper(), SandboxState.ERROR) + return Sandbox( + id=info.id, + provider=self.name, + state=state, + labels=dict(info.metadata) or labels, + created_at=None, + connection_info={ + "workspace_id": info.workspace_id, + }, + metadata={ + "name": info.name, + "raw_state": info.state, + "runtime_state": info.runtime_state, + "cpu_cores": info.cpu_cores, + "memory_mb": info.memory_mb, + "tags": list(info.tags), + "sticky": info.sticky, + }, + ) + + @staticmethod + def _is_not_found(exc: Exception) -> bool: + return type(exc).__name__ in {"SessionNotFoundError", "FileNotFoundError"} + + @staticmethod + def _is_timeout(exc: Exception) -> bool: + return type(exc).__name__ in {"CommandTimeoutError", "PrimitiveTimeoutError"} + + @staticmethod + def _map_error(exc: Exception) -> SandboxError: + """Translate an SDK/transport exception into a library exception.""" + if isinstance(exc, SandboxError): + return exc + name = type(exc).__name__ + if name in {"UnauthorizedError", "PermissionDeniedError", "MissingAuthTokenError"}: + return SandboxAuthenticationError(f"Tenki authentication failed: {exc}") + if name in {"SessionNotFoundError", "FileNotFoundError"}: + return SandboxNotFoundError(f"Tenki resource not found: {exc}") + if name in {"CommandTimeoutError", "PrimitiveTimeoutError"}: + return SandboxTimeoutError(f"Tenki command timed out: {exc}") + return SandboxError(f"Tenki error ({name}): {exc}") diff --git a/sandboxes/sandbox.py b/sandboxes/sandbox.py index 3a59ddc..cfd812b 100644 --- a/sandboxes/sandbox.py +++ b/sandboxes/sandbox.py @@ -77,6 +77,7 @@ def _auto_configure(cls) -> None: 5. Vercel 6. Modal 7. Cloudflare (experimental) + 8. Tenki The first registered provider becomes the default unless explicitly set. Users can override with Sandbox.configure(default_provider="..."). @@ -88,6 +89,7 @@ def _auto_configure(cls) -> None: HopxProvider, ModalProvider, SpritesProvider, + TenkiProvider, VercelProvider, ) @@ -180,6 +182,14 @@ def _auto_configure(cls) -> None: except Exception as e: logger.debug(f"Failed to register Cloudflare provider: {e}") + # Try to register Tenki (priority 8) + if os.getenv("TENKI_API_KEY") or os.getenv("TENKI_AUTH_TOKEN"): + try: + manager.register_provider("tenki", TenkiProvider, {}) + logger.info("Registered Tenki provider") + except Exception as e: + logger.debug(f"Failed to register Tenki provider: {e}") + @classmethod def configure( cls, @@ -193,6 +203,7 @@ def configure( vercel_team_id: str | None = None, sprites_token: str | None = None, cloudflare_config: dict[str, str] | None = None, + tenki_api_key: str | None = None, default_provider: str | None = None, ) -> None: """ @@ -212,6 +223,7 @@ def configure( HopxProvider, ModalProvider, SpritesProvider, + TenkiProvider, VercelProvider, ) @@ -247,6 +259,9 @@ def configure( if cloudflare_config: manager.register_provider("cloudflare", CloudflareProvider, cloudflare_config) + if tenki_api_key: + manager.register_provider("tenki", TenkiProvider, {"api_key": tenki_api_key}) + if default_provider: manager.default_provider = default_provider diff --git a/tests/test_provider_capabilities.py b/tests/test_provider_capabilities.py index 932d2fb..230c214 100644 --- a/tests/test_provider_capabilities.py +++ b/tests/test_provider_capabilities.py @@ -6,6 +6,7 @@ from sandboxes.providers.hopx import HopxProvider from sandboxes.providers.modal import ModalProvider from sandboxes.providers.sprites import SpritesProvider +from sandboxes.providers.tenki import TenkiProvider from sandboxes.providers.vercel import VercelProvider @@ -68,6 +69,14 @@ def test_provider_capability_matrix_contract(): "interactive_shell": True, "gpu": False, }, + "tenki": { + "persistent": True, + "snapshot": True, + "streaming": True, + "file_upload": True, + "interactive_shell": True, + "gpu": False, + }, } observed = { @@ -78,6 +87,7 @@ def test_provider_capability_matrix_contract(): "sprites": SpritesProvider.get_capabilities().as_dict(), "cloudflare": CloudflareProvider.get_capabilities().as_dict(), "vercel": VercelProvider.get_capabilities().as_dict(), + "tenki": TenkiProvider.get_capabilities().as_dict(), } assert observed == expected diff --git a/tests/test_tenki_provider.py b/tests/test_tenki_provider.py new file mode 100644 index 0000000..c7c5561 --- /dev/null +++ b/tests/test_tenki_provider.py @@ -0,0 +1,499 @@ +"""Tests for the Tenki sandbox provider. + +The provider is a thin adapter over the official ``tenki`` async SDK, so these +tests inject a fake ``AsyncClient`` (via ``client=``) that mimics the slice of +the SDK surface the provider touches. The provider classifies SDK errors by +class name, so the fakes raise exceptions named exactly like the SDK's +(``SessionNotFoundError`` etc.). +""" + +import asyncio +import os +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from sandboxes.base import SandboxConfig, SandboxState +from sandboxes.exceptions import ( + ProviderError, + SandboxAuthenticationError, + SandboxError, + SandboxNotFoundError, + SandboxTimeoutError, +) +from sandboxes.providers.tenki import TenkiProvider + + +# -------------------------------------------------------------------------- +# SDK-lookalike exceptions (matched by class name inside the provider) +# -------------------------------------------------------------------------- +class SessionNotFoundError(Exception): + pass + + +class UnauthorizedError(Exception): + pass + + +class CommandTimeoutError(Exception): + pass + + +# -------------------------------------------------------------------------- +# Fakes mimicking tenki.AsyncClient / AsyncSandbox +# -------------------------------------------------------------------------- +def _info(sandbox_id="sbx-123", state="RUNNING", metadata=None): + return SimpleNamespace( + id=sandbox_id, + name="demo", + state=state, + workspace_id="ws-1", + cpu_cores=2, + memory_mb=4096, + disk_size_gb=10, + metadata=metadata or {}, + tags=(), + sticky=False, + runtime_state="UNSPECIFIED", + ) + + +def _result(exit_code=0, stdout=b"", stderr=b"", duration_ms=None, reason=None): + return SimpleNamespace( + exit_code=exit_code, + stdout=stdout, + stderr=stderr, + duration_ms=duration_ms, + reason=reason, + stdout_text=stdout.decode(errors="replace"), + stderr_text=stderr.decode(errors="replace"), + ) + + +class _AsyncChunks: + def __init__(self, chunks): + self._chunks = list(chunks) + + def __aiter__(self): + self._i = 0 + return self + + async def __anext__(self): + if self._i >= len(self._chunks): + raise StopAsyncIteration + chunk = self._chunks[self._i] + self._i += 1 + return chunk + + +class FakeFS: + def __init__(self): + self.uploads = [] + self.downloads = [] + + async def upload(self, local_path, remote_path, *, chunk_bytes=1024 * 1024): + self.uploads.append((local_path, remote_path)) + + async def download(self, remote_path, local_path): + self.downloads.append((remote_path, local_path)) + Path(local_path).write_bytes(b"downloaded") + + +class FakeProcess: + def __init__(self, stdout_chunks, stderr=b"", wait_error=None, wait_hang=False): + self.stdout = _AsyncChunks(stdout_chunks) + self._result = _result(stderr=stderr) + self._wait_error = wait_error + self._wait_hang = wait_hang + self.killed = False + + async def close_stdin(self): + pass + + async def wait(self, timeout=None): + if self._wait_hang: + await asyncio.sleep(5) + if self._wait_error is not None: + raise self._wait_error + return self._result + + async def kill(self): + self.killed = True + + +class FakeSandbox: + def __init__(self, info, *, shell_result=None, shell_error=None, process=None): + self._info = info + self.fs = FakeFS() + self._shell_result = shell_result if shell_result is not None else _result() + self._shell_error = shell_error + self._process = process + self.closed = False + self.shell_calls = [] + + @property + def id(self): + return self._info.id + + @property + def info(self): + return self._info + + async def shell(self, command, timeout=None, env=None): + self.shell_calls.append((command, timeout, env)) + if self._shell_error is not None: + raise self._shell_error + return self._shell_result + + async def start(self, *argv, env=None, timeout=None): + return self._process + + async def close_if_open(self): + self.closed = True + + +class FakeClient: + def __init__( + self, + *, + sandboxes=None, + create_result=None, + get_error=None, + list_result=None, + whoami_error=None, + ): + self.sandboxes = sandboxes or {} + self.create_result = create_result + self.get_error = get_error + self.list_result = list_result if list_result is not None else [] + self.whoami_error = whoami_error + self.create_calls = [] + self.list_calls = [] + self.closed = False + + async def create(self, **kwargs): + self.create_calls.append(kwargs) + return self.create_result + + async def get(self, sandbox_id): + if self.get_error is not None: + raise self.get_error + if sandbox_id in self.sandboxes: + return self.sandboxes[sandbox_id] + raise SessionNotFoundError(sandbox_id) + + async def list(self, *, workspace_id=None, tags=None, sticky=None): + self.list_calls.append({"workspace_id": workspace_id, "tags": tags, "sticky": sticky}) + return list(self.list_result) + + async def who_am_i(self): + if self.whoami_error is not None: + raise self.whoami_error + return SimpleNamespace(owner_id="u", owner_type="USER") + + async def close(self): + self.closed = True + + +def _provider(client, **kwargs) -> TenkiProvider: + return TenkiProvider(client=client, **kwargs) + + +# -------------------------------------------------------------------------- +# Construction / auth +# -------------------------------------------------------------------------- +def test_requires_api_key(monkeypatch): + """Constructing without a key/client (and without env) should fail.""" + monkeypatch.delenv("TENKI_API_KEY", raising=False) + monkeypatch.delenv("TENKI_AUTH_TOKEN", raising=False) + with pytest.raises(ProviderError): + TenkiProvider() + + +def test_reads_api_key_from_env(monkeypatch): + """The API key should be picked up from the environment.""" + monkeypatch.setenv("TENKI_API_KEY", "tk-env-key") + provider = TenkiProvider() + assert provider.api_key == "tk-env-key" + assert provider.name == "tenki" + + +# -------------------------------------------------------------------------- +# Lifecycle + execution +# -------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_tenki_happy_path(): + """Create, get, list, execute, and destroy a Tenki sandbox.""" + sb = FakeSandbox( + _info("sbx-123", metadata={"task": "demo"}), + shell_result=_result(exit_code=0, stdout=b"hi\n", duration_ms=12), + ) + client = FakeClient(create_result=sb, sandboxes={"sbx-123": sb}, list_result=[sb]) + provider = _provider(client) + + config = SandboxConfig( + image="python", + env_vars={"FOO": "bar"}, + labels={"task": "demo"}, + memory_mb=4096, + cpu_cores=2, + ) + sandbox = await provider.create_sandbox(config) + assert sandbox.id == "sbx-123" + assert sandbox.state == SandboxState.RUNNING + assert sandbox.provider == "tenki" + + # create() should forward the mapped fields to the SDK. + call = client.create_calls[0] + assert call["image"] == "python" + assert call["env"] == {"FOO": "bar"} + assert call["metadata"] == {"task": "demo"} + assert call["memory_mb"] == 4096 + assert call["cpu_cores"] == 2 + # tenki 0.5.x dropped projects; create() no longer accepts project_id. + assert "project_id" not in call + + fetched = await provider.get_sandbox("sbx-123") + assert fetched is not None and fetched.id == "sbx-123" + + listed = await provider.list_sandboxes() + assert any(s.id == "sbx-123" for s in listed) + + result = await provider.execute_command("sbx-123", "echo hi") + assert result.success + assert result.stdout == "hi\n" + assert result.duration_ms == 12 + assert sb.shell_calls[-1][0] == "echo hi" + + assert await provider.destroy_sandbox("sbx-123") is True + assert sb.closed is True + assert "sbx-123" not in provider._sandboxes # noqa: SLF001 - intentional probe + + +def test_project_id_warns_and_is_ignored(): + """project_id was removed upstream; passing it warns instead of silently applying.""" + with pytest.warns(DeprecationWarning, match="project_id"): + provider = TenkiProvider(client=FakeClient(), project_id="proj-1") + assert "project_id" not in provider.config + assert not hasattr(provider, "project_id") + + +@pytest.mark.asyncio +async def test_workspace_scope_is_forwarded(monkeypatch): + """workspace_id (arg or env) scopes both create() and list().""" + monkeypatch.setenv("TENKI_WORKSPACE_ID", "ws-env") + sb = FakeSandbox(_info("sbx-1")) + client = FakeClient(create_result=sb, list_result=[sb]) + provider = _provider(client) + assert provider.workspace_id == "ws-env" + + await provider.create_sandbox(SandboxConfig()) + assert client.create_calls[0]["workspace_id"] == "ws-env" + + await provider.list_sandboxes() + assert client.list_calls[0]["workspace_id"] == "ws-env" + + +@pytest.mark.asyncio +async def test_list_without_workspace_uses_default_scope(monkeypatch): + """Without a workspace_id the SDK's own default-workspace scope applies.""" + monkeypatch.delenv("TENKI_WORKSPACE_ID", raising=False) + sb = FakeSandbox(_info("sbx-1")) + client = FakeClient(list_result=[sb]) + provider = _provider(client) + + listed = await provider.list_sandboxes() + assert [s.id for s in listed] == ["sbx-1"] + assert client.list_calls == [{"workspace_id": None, "tags": None, "sticky": None}] + + +@pytest.mark.asyncio +async def test_get_sandbox_missing_returns_none(): + """get_sandbox should return None when the session does not exist.""" + provider = _provider(FakeClient()) + assert await provider.get_sandbox("nope") is None + + +@pytest.mark.asyncio +async def test_destroy_missing_returns_false(): + """destroy_sandbox should return False when the sandbox is gone.""" + provider = _provider(FakeClient()) + assert await provider.destroy_sandbox("gone") is False + + +@pytest.mark.asyncio +async def test_authentication_error(): + """An SDK UnauthorizedError should surface as SandboxAuthenticationError.""" + provider = _provider(FakeClient(get_error=UnauthorizedError("bad key"))) + with pytest.raises(SandboxAuthenticationError): + await provider.execute_command("sbx-1", "echo hi") + + +@pytest.mark.asyncio +async def test_generic_error_raises_sandbox_error(): + """An unexpected SDK error should surface as SandboxError.""" + sb = FakeSandbox(_info("sbx-1"), shell_error=RuntimeError("boom")) + provider = _provider(FakeClient(sandboxes={"sbx-1": sb})) + with pytest.raises(SandboxError, match="boom"): + await provider.execute_command("sbx-1", "echo hi") + + +@pytest.mark.asyncio +async def test_execute_command_timeout(): + """A command timeout is reported as a timed-out result, not an exception.""" + sb = FakeSandbox(_info("sbx-1"), shell_error=CommandTimeoutError("deadline")) + provider = _provider(FakeClient(sandboxes={"sbx-1": sb})) + result = await provider.execute_command("sbx-1", "sleep 999", timeout=1) + assert result.timed_out is True + assert result.exit_code == 124 + assert result.success is False + + +@pytest.mark.asyncio +async def test_find_and_get_or_create(): + """find_sandbox matches labels on metadata; get_or_create reuses a match.""" + running = FakeSandbox(_info("sbx-a", state="RUNNING", metadata={"task": "x"})) + stopped = FakeSandbox(_info("sbx-b", state="PAUSED", metadata={"task": "y"})) + created = FakeSandbox(_info("sbx-new", metadata={"task": "z"})) + client = FakeClient( + create_result=created, + list_result=[running, stopped], + sandboxes={"sbx-a": running, "sbx-b": stopped}, + ) + provider = _provider(client) + + found = await provider.find_sandbox({"task": "x"}) + assert found is not None and found.id == "sbx-a" + + # Paused sandbox is not a running reuse candidate. + assert await provider.find_sandbox({"task": "y"}) is None + + reused = await provider.get_or_create_sandbox(SandboxConfig(labels={"task": "x"})) + assert reused.id == "sbx-a" + assert client.create_calls == [] + + made = await provider.get_or_create_sandbox(SandboxConfig(labels={"task": "z"})) + assert made.id == "sbx-new" + assert len(client.create_calls) == 1 + + +@pytest.mark.asyncio +async def test_file_upload_and_download(tmp_path): + """upload_file / download_file delegate to the SDK fs API.""" + sb = FakeSandbox(_info("sbx-1")) + provider = _provider(FakeClient(sandboxes={"sbx-1": sb})) + + local = tmp_path / "in.txt" + local.write_text("hello tenki") + assert await provider.upload_file("sbx-1", str(local), "/work/in.txt") is True + assert sb.fs.uploads == [(str(local), "/work/in.txt")] + + dest = tmp_path / "out.txt" + assert await provider.download_file("sbx-1", "/work/out.txt", str(dest)) is True + assert sb.fs.downloads == [("/work/out.txt", str(dest))] + assert dest.read_bytes() == b"downloaded" + + +@pytest.mark.asyncio +async def test_stream_execution(): + """stream_execution yields stdout chunks then a trailing stderr block.""" + proc = FakeProcess([b"chunk-1 ", b"chunk-2"], stderr=b"warn") + sb = FakeSandbox(_info("sbx-1"), process=proc) + provider = _provider(FakeClient(sandboxes={"sbx-1": sb})) + + chunks = [c async for c in provider.stream_execution("sbx-1", "echo test")] + joined = "".join(chunks) + assert "chunk-1 chunk-2" in joined + assert "[stderr]: warn" in joined + + +@pytest.mark.asyncio +async def test_stream_execution_kills_process_when_abandoned(): + """Abandoning the generator mid-stream kills the remote process (no leak).""" + proc = FakeProcess([b"chunk-1 ", b"chunk-2", b"chunk-3"]) + sb = FakeSandbox(_info("sbx-1"), process=proc) + provider = _provider(FakeClient(sandboxes={"sbx-1": sb})) + + agen = provider.stream_execution("sbx-1", "echo test") + assert await agen.__anext__() == "chunk-1 " + await agen.aclose() # abandon before consuming the rest + assert proc.killed is True + + +@pytest.mark.asyncio +async def test_stream_execution_maps_wait_error(): + """Errors from proc.wait() are normalized (timeout -> SandboxTimeoutError).""" + proc = FakeProcess([b"partial"], wait_error=CommandTimeoutError("deadline")) + sb = FakeSandbox(_info("sbx-1"), process=proc) + provider = _provider(FakeClient(sandboxes={"sbx-1": sb})) + + with pytest.raises(SandboxTimeoutError): + async for _ in provider.stream_execution("sbx-1", "sleep 999"): + pass + assert proc.killed is True + + +@pytest.mark.asyncio +async def test_stream_execution_wait_timeout(): + """A hung proc.wait() is bounded by timeout and raises SandboxTimeoutError.""" + proc = FakeProcess([b"partial"], wait_hang=True) + sb = FakeSandbox(_info("sbx-1"), process=proc) + provider = _provider(FakeClient(sandboxes={"sbx-1": sb})) + + with pytest.raises(SandboxTimeoutError): + async for _ in provider.stream_execution("sbx-1", "sleep 999", timeout=0.01): + pass + assert proc.killed is True + + +@pytest.mark.asyncio +async def test_resolve_rejects_sandbox_being_destroyed(): + """_resolve refuses to return a handle for an id under destruction.""" + sb = FakeSandbox(_info("sbx-1")) + provider = _provider(FakeClient(sandboxes={"sbx-1": sb})) + provider._destroying.add("sbx-1") + + with pytest.raises(SandboxNotFoundError): + await provider.execute_command("sbx-1", "echo hi") + + +@pytest.mark.asyncio +async def test_health_check(): + """health_check returns True when who_am_i succeeds, else False.""" + assert await _provider(FakeClient()).health_check() is True + assert await _provider(FakeClient(whoami_error=RuntimeError("down"))).health_check() is False + + +@pytest.mark.asyncio +async def test_aclose_closes_client(): + client = FakeClient() + provider = _provider(client) + await provider.health_check() + await provider.aclose() + assert client.closed is True + + +# -------------------------------------------------------------------------- +# Live integration (skipped without a key) +# -------------------------------------------------------------------------- +@pytest.mark.asyncio +@pytest.mark.tenki +async def test_tenki_live_integration(): + """Live smoke test against a real Tenki account (skipped without a key).""" + api_key = os.getenv("TENKI_API_KEY") + if not api_key: + pytest.skip("TENKI_API_KEY not configured") + + provider = TenkiProvider(api_key=api_key) + sandbox = None + try: + sandbox = await provider.create_sandbox(SandboxConfig(labels={"session": "pytest-live"})) + result = await provider.execute_command(sandbox.id, "echo tenki") + assert result.success + assert "tenki" in result.stdout + assert await provider.health_check() is True + finally: + if sandbox is not None: + await provider.destroy_sandbox(sandbox.id) + await provider.aclose()