Files
wehub-resource-sync 91e75e620b
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Has been cancelled
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Has been cancelled
CD: Docs MCP Server / build (linux/amd64) (push) Has been cancelled
CD: Docs MCP Server / build (linux/arm64) (push) Has been cancelled
CD: Docs MCP Server / merge (push) Has been cancelled
CI: cua-driver distro-compat matrix / Resolve release version (push) Has been cancelled
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Has been cancelled
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Has been cancelled
CI: cua-driver distro-compat matrix / Distro compat summary (push) Has been cancelled
CI: Rust Linux unit / Rust Linux unit and compile (push) Has been cancelled
CI: Rust Windows unit / Rust Windows unit and compile (push) Has been cancelled
CI: Nix Linux Rust source / Nix / compositor build (push) Has been cancelled
CI: Nix Linux Rust source / Nix / driver package (push) Has been cancelled
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:19 +08:00

1504 lines
48 KiB
Python

"""Sandbox class — the primary entry point for sandboxed environments.
Exposes .mouse, .keyboard, .screen, .clipboard, .shell, .window, .terminal
as interface objects backed by a Transport.
Usage::
from cua_sandbox import Sandbox, Image
# Provision a new persistent sandbox
sb = await Sandbox.create(Image.desktop("ubuntu"))
await sb.shell.run("uname -a")
await sb.disconnect()
# Connect to an existing sandbox by name (plain await or async with)
sb = await Sandbox.connect("my-sandbox")
await sb.screenshot()
await sb.disconnect()
async with Sandbox.connect("my-sandbox") as sb: # disconnects on exit
await sb.screenshot()
# Ephemeral — auto-destroyed on exit
async with Sandbox.ephemeral(Image.desktop("ubuntu")) as sb:
await sb.shell.run("whoami")
"""
from __future__ import annotations
import logging
import random
import time
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import (
TYPE_CHECKING,
Any,
AsyncIterator,
Callable,
Coroutine,
Optional,
TypeVar,
)
try:
from cua_core.telemetry import is_telemetry_enabled, record_event
_TELEMETRY_AVAILABLE = True
except ImportError:
_TELEMETRY_AVAILABLE = False
def is_telemetry_enabled() -> bool:
return False
def record_event(event_name: str, properties: dict | None = None) -> None:
pass
from cua_sandbox.image import Image
from cua_sandbox.interfaces import (
Apps,
Clipboard,
Files,
Keyboard,
Mobile,
Mouse,
Screen,
Shell,
Terminal,
Tunnel,
Window,
)
from cua_sandbox.transport.base import Transport
from cua_sandbox.transport.cloud import CloudTransport
from cua_sandbox.transport.http import HTTPTransport
from cua_sandbox.transport.websocket import WebSocketTransport
if TYPE_CHECKING:
from cua_sandbox.runtime.base import Runtime, RuntimeInfo
logger = logging.getLogger(__name__)
_T = TypeVar("_T")
@dataclass
class SandboxInfo:
"""Metadata for a local or cloud sandbox."""
name: str
status: str # "running" | "suspended" | "stopped" | "provisioning"
source: str # "cloud" | "lume" | "docker" | "qemu-baremetal" | "qemu-docker"
os_type: Optional[str] = None
host: Optional[str] = None
vnc_url: Optional[str] = None
api_url: Optional[str] = None
created_at: Optional[str] = None
class _ConnectResult:
"""Returned by connect() — supports both ``await`` and ``async with``.
Usage::
# plain await
sb = await Sandbox.connect("name")
# context manager — disconnects on exit (sandbox keeps running)
async with Sandbox.connect("name") as sb:
...
"""
__slots__ = ("_factory", "_instance")
def __init__(self, factory: Callable[[], Coroutine[Any, Any, _T]]) -> None:
self._factory = factory
self._instance: Any = None
def __await__(self) -> Any:
return self._factory().__await__()
async def __aenter__(self) -> Any:
self._instance = await self._factory()
return self._instance
async def __aexit__(self, *exc: Any) -> None:
if self._instance is not None:
await self._instance.disconnect()
def _auto_runtime(image: Image) -> "Runtime":
"""Pick a runtime automatically based on image.os_type and image.kind."""
import platform as _plat
if image.kind is None:
raise ValueError(
"Cannot auto-select runtime: image kind is unresolved. "
"Either use Image.linux()/windows()/macos() which set kind automatically, "
"or pass runtime= explicitly for registry images."
)
if image.kind == "container":
from cua_sandbox.runtime.docker import DockerRuntime
return DockerRuntime(ephemeral=True)
# kind == "vm"
if image.os_type == "macos":
from cua_sandbox.runtime.lume import LumeRuntime
return LumeRuntime()
if image.os_type == "android":
from cua_sandbox.runtime.android_emulator import AndroidEmulatorRuntime
return AndroidEmulatorRuntime()
if image.os_type == "windows" and _plat.system() == "Windows":
from cua_sandbox.runtime.hyperv import _has_hyperv
if _has_hyperv():
from cua_sandbox.runtime.hyperv import HyperVRuntime
return HyperVRuntime()
# If image has a disk path (from_file), use bare-metal QEMU
if image._disk_path:
from cua_sandbox.runtime.qemu import QEMURuntime
return QEMURuntime(mode="bare-metal")
# Linux VM or Windows VM → prefer Docker-wrapped QEMU; fall back to bare-metal
from cua_sandbox.runtime.qemu import QEMURuntime
if image.os_type == "windows":
# Windows bare-metal QEMU works on any host with qemu-system-x86_64
try:
from cua_sandbox.runtime.docker import _has_docker
if not _has_docker():
return QEMURuntime(mode="bare-metal")
except Exception:
pass
return QEMURuntime(mode="docker")
def _record_sandbox_create(
sb: Any,
*,
image: Optional[Any],
local: bool,
ephemeral: bool,
t_start: float,
) -> None:
"""Fire a sandbox_create PostHog event if telemetry is enabled."""
if not sb.telemetry_enabled or not _TELEMETRY_AVAILABLE or not is_telemetry_enabled():
return
props: dict = {
"name": sb.name,
"local": local,
"ephemeral": ephemeral,
"duration_seconds": round(time.monotonic() - t_start, 3),
}
if image is not None:
props["os_type"] = image.os_type
props["image_kind"] = image.kind
if sb._runtime is not None:
props["runtime_type"] = type(sb._runtime).__name__
record_event("sandbox_create", props)
class Sandbox:
"""A sandboxed computer environment.
Provides programmatic control of a VM or container through a unified
interface: ``.mouse``, ``.keyboard``, ``.screen``, ``.clipboard``,
``.shell``, ``.window``, and ``.terminal``.
Sandboxes are always isolated — they never control the host machine
directly. For unsandboxed host control, use :func:`cua_sandbox.localhost`.
There are three ways to obtain a Sandbox:
1. **Persistent** — provision and keep alive after the script exits::
sb = await Sandbox.create(Image.desktop("ubuntu"))
await sb.shell.run("whoami")
await sb.disconnect()
2. **Connect** — attach to an already-running sandbox by name::
sb = await Sandbox.connect("my-sandbox")
await sb.screenshot()
await sb.disconnect()
3. **Ephemeral** — auto-destroyed when the ``async with`` block exits::
async with Sandbox.ephemeral(Image.desktop("ubuntu")) as sb:
await sb.shell.run("whoami")
"""
def __init__(
self,
transport: Transport,
name: Optional[str] = None,
_runtime: Optional[Runtime] = None,
_runtime_info: Optional[RuntimeInfo] = None,
_ephemeral: Optional[bool] = None,
_telemetry_enabled: bool = True,
):
self._transport = transport
self.name = name
self._runtime = _runtime
self._runtime_info = _runtime_info
self._ephemeral = _ephemeral
self._has_snapshots = False
self.telemetry_enabled = _telemetry_enabled
self.screen = Screen(transport)
self.mouse = Mouse(transport)
self.keyboard = Keyboard(transport)
self.clipboard = Clipboard(transport)
self.shell = Shell(transport)
self.files = Files(transport)
self.window = Window(transport)
self.terminal = Terminal(transport)
self.mobile = Mobile(transport)
self.tunnel = Tunnel(transport)
_os = _runtime_info.environment if _runtime_info and _runtime_info.environment else "linux"
self.apps = Apps(transport, os_type=_os)
async def _connect(self) -> None:
await self._transport.connect()
# Update name from transport (e.g. CloudTransport resolves name after creating a VM)
if self.name is None and isinstance(self._transport, CloudTransport):
self.name = self._transport.name
async def disconnect(self) -> None:
"""Drop the transport connection. The sandbox keeps running."""
await self._transport.disconnect()
async def snapshot(self, name: str | None = None, stateful: bool = False) -> "Image":
"""Snapshot this sandbox's current state. Returns an Image.
The returned Image can be passed to Sandbox.create() or Sandbox.ephemeral()
to boot a new sandbox from the snapshot (COW fork — instant on btrfs).
Args:
name: Optional human-readable name for the snapshot.
stateful: Whether to capture memory state (VMs only).
Returns:
An Image with _snapshot_source set, ready to pass to Sandbox.ephemeral().
"""
from cua_sandbox.transport.cloud import CloudTransport
if not isinstance(self._transport, CloudTransport):
raise NotImplementedError("Snapshots are only supported for cloud sandboxes")
image_desc = await self._transport.create_snapshot(name=name, stateful=stateful)
self._has_snapshots = True
from cua_sandbox.image import Image as ImageCls
# Get the original image from the transport for os_type/distro/version
src_image = getattr(self._transport, "_image", None)
# Prefer the original image's os_type/distro/version — image_desc["kind"]
# is the snapshot kind (e.g. "vm"), not the OS type, and would misclassify
# the image for OS-gated builder methods and compat checks.
return ImageCls(
os_type=src_image.os_type if src_image else image_desc.get("os_type", "linux"),
distro=src_image.distro if src_image else image_desc.get("distro", "ubuntu"),
version=src_image.version if src_image else image_desc.get("version", "24.04"),
kind=src_image.kind if src_image else image_desc.get("kind"),
_snapshot_source=image_desc,
)
async def destroy(self) -> None:
"""Disconnect and permanently delete the sandbox (VM/container)."""
if self._has_snapshots:
logger.warning(
"Destroying sandbox %s which has snapshots — "
"forks referencing those snapshots will break. "
"Use Sandbox.ephemeral() which auto-stops instead of deleting "
"when snapshots exist.",
self.name,
)
if self.telemetry_enabled and _TELEMETRY_AVAILABLE and is_telemetry_enabled():
record_event("sandbox_destroy", {"name": self.name, "ephemeral": self._ephemeral})
# Run each cleanup step independently so a failure in one
# (e.g. disconnect timeout) doesn't prevent the VM from being deleted.
try:
await self._transport.disconnect()
except Exception:
logger.warning("Failed to disconnect transport for sandbox %r", self.name)
if isinstance(self._transport, CloudTransport):
try:
await self._transport.delete_vm()
except Exception:
logger.warning("Failed to delete cloud VM %r", self.name)
if self._runtime and self._runtime_info:
vm_name = self._runtime_info.name or self.name or "cua-sandbox"
try:
if self._ephemeral and hasattr(self._runtime, "delete"):
await self._runtime.delete(vm_name)
else:
await self._runtime.stop(vm_name)
except Exception:
logger.warning("Failed to stop/delete runtime for sandbox %r", self.name)
async def screenshot(
self, text: Optional[str] = None, format: str = "png", quality: int = 95
) -> bytes:
_MAGIC: dict[bytes, str] = {b"\x89PNG": "png", b"\xff\xd8\xff": "jpeg"}
data = await self._transport.screenshot(format=format, quality=quality)
got_format = next(
(fmt for magic, fmt in _MAGIC.items() if data.startswith(magic)), "unknown"
)
expected = "jpeg" if format.lower() in ("jpeg", "jpg") else format.lower()
if got_format != expected:
raise ValueError(
f"requested {format!r} but got {got_format!r} (magic bytes: {data[:4].hex()})"
)
return data
async def screenshot_base64(
self, text: Optional[str] = None, format: str = "png", quality: int = 95
) -> str:
return await self.screen.screenshot_base64(format=format, quality=quality)
async def get_environment(self) -> str:
return await self._transport.get_environment()
async def get_display_url(self, *, share: bool = False) -> str:
"""Return a URL to view this sandbox's display.
Args:
share: If True, return a public link with embedded credentials
(cloud only). If False, return a direct connection URL.
"""
return await self._transport.get_display_url(share=share)
async def get_dimensions(self) -> tuple[int, int]:
return await self.screen.size()
# ── Async context manager ────────────────────────────────────────────
async def __aenter__(self) -> Sandbox:
await self._connect()
return self
async def __aexit__(self, *exc: Any) -> None:
await self.disconnect()
# ── Public factory methods ───────────────────────────────────────────
@classmethod
async def create(
cls,
image: Image,
*,
name: Optional[str] = None,
api_key: Optional[str] = None,
local: bool = False,
runtime: Optional["Runtime"] = None,
cpu: Optional[int] = None,
memory_mb: Optional[int] = None,
disk_gb: Optional[int] = None,
region: str = "us-east-1",
time_to_start: Optional[float] = None,
request_timeout: Optional[float] = None,
telemetry_enabled: bool = True,
) -> "Sandbox":
"""Provision a new persistent sandbox and return it connected.
The sandbox is kept alive after your script exits — call ``close()``
when you are done, or use :meth:`ephemeral` if you want it destroyed
automatically.
Args:
image: Image to run (e.g. ``Image.desktop("ubuntu")``).
name: Optional name to assign to the sandbox.
api_key: CUA API key for cloud sandboxes.
local: Use a local runtime instead of cloud.
runtime: Explicit runtime backend (DockerRuntime, QEMURuntime, etc.).
cpu: Number of CPUs for the cloud sandbox.
memory_mb: Memory in MB for the cloud sandbox.
disk_gb: Disk size in GB for the cloud sandbox.
region: Cloud region (default ``"us-east-1"``).
time_to_start: Max seconds to wait for the VM to become reachable
(default 600). Only applies to cloud sandboxes.
request_timeout: Default HTTP request timeout in seconds for
commands sent to the computer-server (default 30, cloud only).
Individual commands with a server-side timeout automatically
extend the client timeout to match.
telemetry_enabled: Set to False to disable telemetry for this instance.
Example::
sb = await Sandbox.create(Image.desktop("ubuntu"))
await sb.shell.run("uname -a")
print(sb.name) # save to reconnect later
await sb.disconnect()
"""
return await cls._create(
image=image,
name=name,
ephemeral=False,
api_key=api_key,
local=local,
runtime=runtime,
cpu=cpu,
memory_mb=memory_mb,
disk_gb=disk_gb,
region=region,
time_to_start=time_to_start,
request_timeout=request_timeout,
telemetry_enabled=telemetry_enabled,
)
@classmethod
def connect(
cls,
name: str,
*,
api_key: Optional[str] = None,
local: bool = False,
ws_url: Optional[str] = None,
http_url: Optional[str] = None,
container_name: Optional[str] = None,
cpu: Optional[int] = None,
memory_mb: Optional[int] = None,
disk_gb: Optional[int] = None,
region: str = "us-east-1",
telemetry_enabled: bool = True,
) -> "_ConnectResult":
"""Connect to an existing sandbox by name.
Supports both ``await`` and ``async with``. When used as a context
manager, ``disconnect()`` is called on exit — the sandbox keeps running.
Args:
name: Name of the existing sandbox.
api_key: CUA API key for cloud sandboxes.
ws_url: WebSocket URL for a remote computer-server.
http_url: HTTP base URL for a remote computer-server.
container_name: Container name for cloud auth (HTTP transport).
region: Cloud region (default ``"us-east-1"``).
Examples::
# plain await
sb = await Sandbox.connect("my-sandbox")
await sb.screenshot()
await sb.disconnect()
# context manager — disconnects on exit, sandbox keeps running
async with Sandbox.connect("my-sandbox") as sb:
await sb.screenshot()
"""
async def _factory() -> "Sandbox":
return await cls._create(
name=name,
ephemeral=False,
local=local,
api_key=api_key,
ws_url=ws_url,
http_url=http_url,
container_name=container_name,
cpu=cpu,
memory_mb=memory_mb,
disk_gb=disk_gb,
region=region,
telemetry_enabled=telemetry_enabled,
)
return _ConnectResult(_factory)
@classmethod
@asynccontextmanager
async def ephemeral(
cls,
image: Image,
*,
name: Optional[str] = None,
api_key: Optional[str] = None,
local: bool = False,
runtime: Optional["Runtime"] = None,
cpu: Optional[int] = None,
memory_mb: Optional[int] = None,
disk_gb: Optional[int] = None,
region: str = "us-east-1",
time_to_start: Optional[float] = None,
request_timeout: Optional[float] = None,
telemetry_enabled: bool = True,
) -> AsyncIterator["Sandbox"]:
"""Create an ephemeral sandbox that is automatically destroyed on exit.
Args:
image: Image to run (e.g. ``Image.desktop("ubuntu")``).
name: Optional name to assign to the sandbox.
api_key: CUA API key for cloud sandboxes.
local: Use a local runtime instead of cloud.
runtime: Explicit runtime backend (DockerRuntime, QEMURuntime, etc.).
cpu: Number of CPUs for the cloud sandbox.
memory_mb: Memory in MB for the cloud sandbox.
disk_gb: Disk size in GB for the cloud sandbox.
region: Cloud region (default ``"us-east-1"``).
time_to_start: Max seconds to wait for the VM to become reachable
(default 600). Only applies to cloud sandboxes.
request_timeout: Default HTTP request timeout in seconds for
commands sent to the computer-server (default 30, cloud only).
Individual commands with a server-side timeout automatically
extend the client timeout to match.
Example::
async with Sandbox.ephemeral(Image.desktop("ubuntu")) as sb:
await sb.shell.run("whoami")
# sandbox is destroyed here
"""
sb = await cls._create(
image=image,
name=name,
ephemeral=True,
api_key=api_key,
local=local,
runtime=runtime,
cpu=cpu,
memory_mb=memory_mb,
disk_gb=disk_gb,
region=region,
time_to_start=time_to_start,
request_timeout=request_timeout,
telemetry_enabled=telemetry_enabled,
)
try:
yield sb
finally:
if sb._has_snapshots and sb.name:
# Stop instead of delete so forks can reference the snapshots.
await cls.suspend(sb.name, local=local, api_key=api_key)
else:
await sb.destroy()
# ── Lifecycle management ─────────────────────────────────────────────
@classmethod
async def list(
cls,
*,
local: bool = False,
api_key: Optional[str] = None,
) -> "list[SandboxInfo]":
"""List running and suspended sandboxes.
Args:
local: If True, list local sandboxes (Lume, Docker, QEMU).
If False, list cloud sandboxes.
api_key: CUA API key for cloud sandboxes.
"""
if local:
return await cls._list_local()
return await cls._list_cloud(api_key=api_key)
@classmethod
async def _list_local(cls) -> "list[SandboxInfo]":
import asyncio
from cua_sandbox.runtime.android_emulator import AndroidEmulatorRuntime
from cua_sandbox.runtime.docker import DockerRuntime
from cua_sandbox.runtime.lume import LumeRuntime
from cua_sandbox.runtime.qemu import QEMUBaremetalRuntime
async def _list_baremetal():
return await QEMUBaremetalRuntime().list()
async def _list_docker():
try:
return await DockerRuntime().list()
except Exception:
return []
async def _list_lume():
try:
return await LumeRuntime().list()
except Exception:
return []
async def _list_android():
try:
return await AndroidEmulatorRuntime().list()
except Exception:
return []
baremetal_vms, docker_vms, lume_vms, android_vms = await asyncio.gather(
_list_baremetal(), _list_docker(), _list_lume(), _list_android()
)
results: list[SandboxInfo] = []
for vm in baremetal_vms:
results.append(
SandboxInfo(
name=vm["name"],
status=vm["status"],
source="qemu-baremetal",
os_type=vm.get("os_type"),
host=vm.get("host"),
api_url=(
f"http://{vm['host']}:{vm['api_port']}"
if vm.get("host") and vm.get("api_port")
else None
),
)
)
for vm in docker_vms:
results.append(
SandboxInfo(
name=vm["name"],
status=vm["status"],
source=vm.get("runtime_type", "docker"),
host="localhost",
)
)
for vm in lume_vms:
results.append(
SandboxInfo(
name=vm["name"],
status=vm["status"],
source="lume",
os_type=vm.get("os_type"),
host=vm.get("ip_address"),
)
)
for vm in android_vms:
results.append(
SandboxInfo(
name=vm["name"],
status=vm["status"],
source="androidemulator",
os_type=vm.get("os_type"),
host=vm.get("host"),
api_url=(
f"http://{vm['host']}:{vm['api_port']}"
if vm.get("host") and vm.get("api_port")
else None
),
)
)
return results
@classmethod
async def _list_cloud(cls, *, api_key: Optional[str] = None) -> "list[SandboxInfo]":
from cua_sandbox.transport.cloud import cloud_list_vms
vms = await cloud_list_vms(api_key=api_key)
results = []
for vm in vms:
raw_status = vm.get("status", "unknown")
results.append(
SandboxInfo(
name=vm.get("name", ""),
status=raw_status,
source="cloud",
os_type=vm.get("os_type") or vm.get("os"),
created_at=vm.get("created_at"),
)
)
return results
@classmethod
async def get_info(
cls,
name: str,
*,
local: bool = False,
api_key: Optional[str] = None,
) -> "SandboxInfo":
"""Get metadata for a specific sandbox.
Args:
name: Sandbox name.
local: If True, look up in local runtimes.
api_key: CUA API key for cloud.
"""
if local:
sandboxes = await cls._list_local()
match = next((s for s in sandboxes if s.name == name), None)
if match:
return match
# Fall back to state file
from cua_sandbox import sandbox_state
state = sandbox_state.load(name)
if state:
return SandboxInfo(
name=name,
status=state.get("status", "unknown"),
source=state.get("runtime_type", "unknown"),
os_type=state.get("os_type"),
host=state.get("host"),
api_url=(
f"http://{state['host']}:{state['api_port']}"
if state.get("host") and state.get("api_port")
else None
),
)
raise ValueError(f"Local sandbox '{name}' not found.")
from cua_sandbox.transport.cloud import cloud_get_vm
vm = await cloud_get_vm(name, api_key=api_key)
return SandboxInfo(
name=vm.get("name", name),
status=vm.get("status", "unknown"),
source="cloud",
os_type=vm.get("os_type") or vm.get("os"),
created_at=vm.get("created_at"),
)
@classmethod
async def suspend(
cls,
name: str,
*,
local: bool = False,
api_key: Optional[str] = None,
) -> None:
"""Suspend a running sandbox (save state).
For local QEMU bare-metal: saves a QMP snapshot then quits the process.
For local Docker/QEMU-docker: pauses the container.
For local Lume: stops the Lume VM (Lume persists state).
For cloud: calls POST /v1/vms/{name}/stop.
Args:
name: Sandbox name.
local: If True, operate on a local sandbox.
api_key: CUA API key for cloud.
"""
if local:
await cls._suspend_local(name)
return
from cua_sandbox.transport.cloud import cloud_vm_action
await cloud_vm_action(name, "stop", api_key=api_key)
@classmethod
async def _suspend_local(cls, name: str) -> None:
from cua_sandbox import sandbox_state
from cua_sandbox.runtime.lume import LumeRuntime
state = sandbox_state.load(name)
runtime_type = state.get("runtime_type") if state else None
if runtime_type == "lume":
await LumeRuntime().suspend(name)
elif runtime_type == "qemu-baremetal":
from cua_sandbox.runtime.qemu import QEMUBaremetalRuntime
rt = QEMUBaremetalRuntime()
if state:
rt.qmp_port = state.get("qmp_port", rt.qmp_port)
await rt.suspend(name)
elif runtime_type in ("docker", "qemu-docker"):
import subprocess
subprocess.run(["docker", "pause", name], capture_output=True)
sandbox_state.update(name, status="suspended")
else:
# Try docker pause as fallback
import subprocess
subprocess.run(["docker", "pause", name], capture_output=True)
@classmethod
async def resume(
cls,
name: str,
*,
local: bool = False,
api_key: Optional[str] = None,
) -> "Sandbox":
"""Resume a suspended sandbox and return a connected Sandbox.
Args:
name: Sandbox name.
local: If True, resume a local sandbox.
api_key: CUA API key for cloud.
Returns:
A connected Sandbox ready to use.
"""
if local:
return await cls._resume_local(name)
from cua_sandbox.transport.cloud import cloud_vm_action
await cloud_vm_action(name, "run", api_key=api_key)
# Connect to the now-running cloud sandbox
sb = await cls._create(name=name, ephemeral=False, api_key=api_key)
return sb
@classmethod
async def _resume_local(cls, name: str) -> "Sandbox":
from cua_sandbox import sandbox_state
from cua_sandbox.transport.http import HTTPTransport
state = sandbox_state.load(name)
if state is None:
raise ValueError(f"No local sandbox named '{name}' found in state files.")
runtime_type = state.get("runtime_type")
if runtime_type == "lume":
from cua_sandbox.image import Image
from cua_sandbox.runtime.lume import LumeRuntime
image = Image.from_dict(state["image"])
rt = LumeRuntime()
rt_info = await rt.resume(image, name)
elif runtime_type == "qemu-baremetal":
from cua_sandbox.image import Image
from cua_sandbox.runtime.qemu import QEMUBaremetalRuntime
image = Image.from_dict(state["image"])
rt = QEMUBaremetalRuntime(
api_port=state.get("api_port", 8000),
vnc_display=state.get("vnc_display", 0),
memory_mb=state.get("memory_mb", 4096),
cpu_count=state.get("cpu_count", 2),
arch=state.get("arch", "x86_64"),
qmp_port=state.get("qmp_port", 4444),
)
rt_info = await rt.resume(image, name)
elif runtime_type in ("docker", "qemu-docker"):
import subprocess
subprocess.run(["docker", "unpause", name], capture_output=True)
api_port = state.get("api_port", 8000)
sandbox_state.update(name, status="running")
rt_info = None
transport = HTTPTransport(f"http://localhost:{api_port}")
sb = cls(transport, name=name, _ephemeral=False)
await sb._connect()
return sb
else:
raise ValueError(
f"Cannot resume sandbox '{name}': unknown runtime_type '{runtime_type}'"
)
transport = HTTPTransport(f"http://{rt_info.host}:{rt_info.api_port}")
sb = cls(transport, name=name, _ephemeral=False)
await sb._connect()
return sb
@classmethod
async def restart(
cls,
name: str,
*,
local: bool = False,
api_key: Optional[str] = None,
) -> "Sandbox":
"""Restart a sandbox (suspend then resume) and return a connected Sandbox.
Args:
name: Sandbox name.
local: If True, restart a local sandbox.
api_key: CUA API key for cloud.
Returns:
A connected Sandbox ready to use.
"""
if local:
await cls._suspend_local(name)
return await cls._resume_local(name)
from cua_sandbox.transport.cloud import cloud_vm_action
await cloud_vm_action(name, "restart", api_key=api_key)
sb = await cls._create(name=name, ephemeral=False, api_key=api_key)
return sb
@classmethod
async def delete(
cls,
name: str,
*,
local: bool = False,
api_key: Optional[str] = None,
) -> None:
"""Permanently delete a sandbox.
For local sandboxes, stops the VM and removes the state file.
For cloud sandboxes, calls DELETE /v1/vms/{name}.
Args:
name: Sandbox name.
local: If True, delete a local sandbox.
api_key: CUA API key for cloud.
"""
if local:
await cls._delete_local(name)
return
from cua_sandbox.transport.cloud import cloud_vm_action
await cloud_vm_action(name, "delete", api_key=api_key)
@classmethod
async def _delete_local(cls, name: str) -> None:
from cua_sandbox import sandbox_state
state = sandbox_state.load(name)
runtime_type = state.get("runtime_type") if state else None
if runtime_type == "lume":
from cua_sandbox.runtime.lume import LumeRuntime
await LumeRuntime().delete(name)
elif runtime_type == "qemu-baremetal":
from cua_sandbox.runtime.qemu import QEMUBaremetalRuntime
await QEMUBaremetalRuntime().stop(name) # stop() already deletes state file
return
elif runtime_type == "androidemulator":
from cua_sandbox.runtime.android_emulator import AndroidEmulatorRuntime
await AndroidEmulatorRuntime().stop(name)
elif runtime_type in ("docker", "qemu-docker"):
import subprocess
subprocess.run(["docker", "stop", name], capture_output=True)
subprocess.run(["docker", "rm", name], capture_output=True)
sandbox_state.delete(name)
# ── Internal factory ─────────────────────────────────────────────────
@classmethod
async def _create(
cls,
*,
local: bool = False,
ws_url: Optional[str] = None,
http_url: Optional[str] = None,
api_key: Optional[str] = None,
container_name: Optional[str] = None,
image: Optional[Image] = None,
runtime: Optional["Runtime"] = None,
name: Optional[str] = None,
ephemeral: Optional[bool] = None,
cpu: Optional[int] = None,
memory_mb: Optional[int] = None,
disk_gb: Optional[int] = None,
region: str = "us-east-1",
time_to_start: Optional[float] = None,
request_timeout: Optional[float] = None,
telemetry_enabled: bool = True,
) -> "Sandbox":
"""Internal workhorse — all public factories delegate here."""
_t_start = time.monotonic()
if ephemeral is None:
ephemeral = bool(image)
rt_info = None
if image and image.kind is None and image._registry:
from cua_sandbox.registry.resolve import resolve_image_kind
image = resolve_image_kind(image)
# Local connect by name — read state file
if name and not image and local and not ws_url and not http_url:
from cua_sandbox import sandbox_state
state = sandbox_state.load(name)
if state is None:
raise ValueError(
f"No local sandbox named '{name}' found. "
f"Check ~/.cua/sandboxes/ or create it with Sandbox.create()."
)
if state.get("os_type") == "android":
grpc_port = state.get("grpc_port")
adb_serial = state.get("adb_serial") or f"emulator-{state['api_port'] - 1}"
sdk_root = state.get("sdk_root")
if grpc_port:
from cua_sandbox.transport.grpc_emulator import (
GRPCEmulatorTransport,
)
from google.protobuf import empty_pb2 # noqa: F401
transport = GRPCEmulatorTransport(
host=state["host"],
grpc_port=grpc_port,
serial=adb_serial,
sdk_root=sdk_root,
)
else:
from cua_sandbox.transport.adb import ADBTransport
transport = ADBTransport(serial=adb_serial, sdk_root=sdk_root)
else:
api_url = f"http://{state['host']}:{state['api_port']}"
transport = HTTPTransport(api_url)
sb = cls(transport, name=name, _ephemeral=False, _telemetry_enabled=telemetry_enabled)
await sb._connect()
_record_sandbox_create(sb, image=None, local=local, ephemeral=False, t_start=_t_start)
return sb
if image and not runtime and local:
# local=True with no runtime → auto-select based on image type
runtime = _auto_runtime(image)
if image and not runtime and not local:
# image without runtime and not local → cloud creation
if not any([ws_url, http_url]):
transport = CloudTransport(
name=name,
api_key=api_key,
image=image,
cpu=cpu,
memory_mb=memory_mb,
disk_gb=disk_gb,
region=region,
time_to_start=time_to_start,
request_timeout=request_timeout,
)
sb = cls(
transport, name=name, _ephemeral=ephemeral, _telemetry_enabled=telemetry_enabled
)
try:
await sb._connect()
except BaseException:
# _connect() calls CloudTransport.connect() which may have
# already created a VM before failing (e.g. timeout while
# polling for "running" status). Delete the orphan so it
# doesn't leak.
vm_name = transport._name
if vm_name:
try:
await transport.delete_vm()
except Exception:
logger.warning(
"Failed to clean up cloud VM %r after connect failure",
vm_name,
)
raise
_record_sandbox_create(
sb, image=image, local=False, ephemeral=bool(ephemeral), t_start=_t_start
)
return sb
runtime = _auto_runtime(image)
if image and runtime:
sb_name = name or _random_name()
rt_info = await runtime.start(image, sb_name)
if rt_info.environment == "android" and not rt_info.qmp_port:
if rt_info.grpc_port:
from cua_sandbox.transport.grpc_emulator import (
GRPCEmulatorTransport,
)
adb_serial = f"emulator-{rt_info.api_port - 1}"
sdk_root = None
if hasattr(runtime, "_sdk") and runtime._sdk:
sdk_root = str(runtime._sdk)
transport = GRPCEmulatorTransport(
host=rt_info.host,
grpc_port=rt_info.grpc_port,
serial=adb_serial,
sdk_root=sdk_root,
)
else:
from cua_sandbox.transport.adb import ADBTransport
adb_serial = f"emulator-{rt_info.api_port - 1}"
sdk_root = None
if hasattr(runtime, "_sdk") and runtime._sdk:
sdk_root = str(runtime._sdk)
transport = ADBTransport(serial=adb_serial, sdk_root=sdk_root)
elif rt_info.agent_type == "osworld":
from cua_sandbox.transport.osworld import OSWorldTransport
transport = OSWorldTransport(
f"http://{rt_info.host}:{rt_info.api_port}",
)
elif rt_info.vnc_port and rt_info.ssh_port:
from cua_sandbox.transport.vncssh import VNCSSHTransport
await runtime.is_ready(rt_info)
transport = VNCSSHTransport(
ssh_host=rt_info.host,
ssh_port=rt_info.ssh_port,
ssh_username=rt_info.ssh_username or "admin",
ssh_password=rt_info.ssh_password or "admin",
vnc_host=rt_info.vnc_host or rt_info.host,
vnc_port=rt_info.vnc_port,
vnc_password=rt_info.vnc_password,
environment=rt_info.environment or image.os_type,
)
elif rt_info.vnc_port and not rt_info.qmp_port and not rt_info.api_port:
# VNC-only transport: QEMU VMs without a computer-server HTTP API.
# When api_port is also set (e.g. Docker containers, Lume VMs), prefer HTTP.
from cua_sandbox.transport.vnc import VNCTransport
transport = VNCTransport(
host=rt_info.host,
port=rt_info.vnc_port,
environment=rt_info.environment or image.os_type,
)
elif rt_info.qmp_port:
from cua_sandbox.transport.qmp import QMPTransport
transport = QMPTransport(
qmp_host=rt_info.host,
qmp_port=rt_info.qmp_port,
environment=rt_info.environment or image.os_type,
)
else:
transport = HTTPTransport(
f"http://{rt_info.host}:{rt_info.api_port}",
api_key=api_key,
container_name=container_name,
)
else:
transport = _make_transport(
ws_url=ws_url,
http_url=http_url,
api_key=api_key,
container_name=container_name,
name=name,
cpu=cpu,
memory_mb=memory_mb,
disk_gb=disk_gb,
region=region,
)
# Write persistent state for local (non-ephemeral) sandboxes
if not ephemeral and rt_info and local:
from cua_sandbox import sandbox_state
runtime_type = type(runtime).__name__.lower().replace("runtime", "")
# Normalize to known types
_rt_map = {
"lume": "lume",
"docker": "docker",
"qemudocker": "qemu-docker",
"qemubaremetal": "qemu-baremetal",
"qemuwsl2": "qemu-wsl2",
}
rt_key = _rt_map.get(runtime_type, runtime_type)
_adb_serial = None
_sdk_root = None
if image.os_type == "android":
_adb_serial = f"emulator-{rt_info.api_port - 1}"
if hasattr(runtime, "_sdk") and runtime._sdk:
_sdk_root = str(runtime._sdk)
sandbox_state.save(
sb_name,
runtime_type=rt_key,
image=image.to_dict(),
host=rt_info.host,
api_port=rt_info.api_port,
vnc_port=rt_info.vnc_port,
qmp_port=rt_info.qmp_port,
grpc_port=rt_info.grpc_port if hasattr(rt_info, "grpc_port") else None,
adb_serial=_adb_serial,
sdk_root=_sdk_root,
os_type=image.os_type,
status="running",
)
resolved_name = (rt_info.name if rt_info else None) or name
sb = cls(
transport,
name=resolved_name,
_runtime=runtime,
_runtime_info=rt_info,
_ephemeral=ephemeral,
_telemetry_enabled=telemetry_enabled,
)
await sb._connect()
_record_sandbox_create(
sb, image=image, local=local, ephemeral=bool(ephemeral), t_start=_t_start
)
return sb
def __repr__(self) -> str:
tname = type(self._transport).__name__
return f"Sandbox(name={self.name!r}, transport={tname})"
_ADJECTIVES = [
"amber",
"bold",
"calm",
"deft",
"eager",
"fast",
"glad",
"hazy",
"idle",
"jade",
"keen",
"lazy",
"mild",
"neat",
"odd",
"pale",
"quiet",
"rapid",
"soft",
"tidy",
"vast",
"warm",
"zany",
"agile",
"brave",
"crisp",
"dusty",
"elfin",
"fizzy",
"grim",
"hardy",
"icy",
"jolly",
"kinky",
"lofty",
"misty",
"noble",
"oaken",
"prim",
"quirky",
"rosy",
"stark",
"trim",
"umber",
"vivid",
"witty",
"xenial",
"young",
"zippy",
"arcane",
"brisk",
"chilly",
"dim",
"eerie",
"fleet",
"gnarly",
"hushed",
"inky",
"jumpy",
"knotty",
"lithe",
"murky",
"nifty",
"ornate",
"plush",
"quaint",
"ruddy",
"spry",
"tacit",
"ultra",
"vague",
"wily",
"exact",
"yare",
"zesty",
"arid",
"blunt",
"cobalt",
"dense",
"ember",
"faint",
"gaunt",
"hollow",
"irked",
"jaded",
"lunar",
"muted",
"nimble",
"opaque",
"prime",
"quiet",
"ringed",
"sable",
"tawny",
"upset",
"vexed",
"wooly",
"xenon",
"yonder",
"zingy",
]
_NOUNS = [
"bear",
"crane",
"deer",
"eagle",
"finch",
"gecko",
"hawk",
"ibis",
"jay",
"kite",
"lark",
"mink",
"newt",
"orca",
"puma",
"quail",
"raven",
"seal",
"toad",
"vole",
"wren",
"yak",
"zebra",
"ant",
"bison",
"carp",
"dingo",
"elk",
"fox",
"gull",
"heron",
"iguana",
"jackal",
"kudu",
"lemur",
"moose",
"narwhal",
"ocelot",
"parrot",
"quokka",
"rhino",
"swan",
"tapir",
"urial",
"viper",
"walrus",
"xerus",
"yabby",
"zorilla",
"alpaca",
"beetle",
"cobra",
"dugong",
"emu",
"ferret",
"gibbon",
"hyena",
"impala",
"junco",
"kakapo",
"lynx",
"marmot",
"numbat",
"osprey",
"possum",
"quetzal",
"rabbit",
"skunk",
"thrush",
"urubu",
"vulture",
"wombat",
"xenops",
"yaffle",
"zonkey",
"addax",
"booby",
"condor",
"dhole",
"egret",
"fossa",
"gannet",
"hoopoe",
"indri",
"jabiru",
"kookaburra",
"loris",
"magpie",
"nene",
"olm",
"pipit",
"quagga",
"roller",
"shrew",
"teal",
"uakari",
"vervet",
"weevil",
"xeme",
"yellowjacket",
"zorach",
]
def _random_name() -> str:
return f"{random.choice(_ADJECTIVES)}-{random.choice(_NOUNS)}"
def _make_transport(
*,
ws_url: Optional[str] = None,
http_url: Optional[str] = None,
api_key: Optional[str] = None,
container_name: Optional[str] = None,
name: Optional[str] = None,
cpu: Optional[int] = None,
memory_mb: Optional[int] = None,
disk_gb: Optional[int] = None,
region: str = "us-east-1",
) -> Transport:
if ws_url:
return WebSocketTransport(ws_url, api_key=api_key)
if http_url:
return HTTPTransport(http_url, api_key=api_key, container_name=container_name)
return CloudTransport(
name=name,
api_key=api_key,
cpu=cpu,
memory_mb=memory_mb,
disk_gb=disk_gb,
region=region,
)
@asynccontextmanager
async def sandbox(
*,
local: bool = False,
ws_url: Optional[str] = None,
http_url: Optional[str] = None,
api_key: Optional[str] = None,
container_name: Optional[str] = None,
image: Optional[Image] = None,
runtime: Optional["Runtime"] = None,
name: Optional[str] = None,
ephemeral: Optional[bool] = None,
cpu: Optional[int] = None,
memory_mb: Optional[int] = None,
disk_gb: Optional[int] = None,
region: str = "us-east-1",
) -> AsyncIterator[Sandbox]:
"""Async context manager for a sandboxed environment.
.. deprecated::
Prefer ``Sandbox.create()``, ``Sandbox.connect()``, or
``Sandbox.ephemeral()`` instead.
"""
sb = await Sandbox._create(
local=local,
ws_url=ws_url,
http_url=http_url,
api_key=api_key,
container_name=container_name,
image=image,
runtime=runtime,
name=name,
ephemeral=ephemeral,
cpu=cpu,
memory_mb=memory_mb,
disk_gb=disk_gb,
region=region,
)
try:
yield sb
finally:
if sb._ephemeral:
await sb.destroy()
else:
await sb.disconnect()