chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:03:19 +08:00
commit 91e75e620b
3227 changed files with 1307078 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
[bumpversion]
current_version = 0.1.17
commit = True
tag = True
tag_name = sandbox-v{new_version}
message = Bump cua-sandbox to v{new_version}
[bumpversion:file:pyproject.toml]
search = version = "{current_version}"
replace = version = "{new_version}"
+79
View File
@@ -0,0 +1,79 @@
# cua-sandbox
Sandboxed VM environments with a unified Python API. Cloud by default.
```bash
pip install cua-sandbox
```
## Ephemeral sandbox
Created on enter, destroyed on exit.
```python
from cua_sandbox import Sandbox, Image
async with Sandbox.ephemeral(Image.linux()) as sb:
await sb.shell.run("uname -a")
await sb.screenshot()
```
## Persistent sandbox
Provision a new sandbox that stays alive after your script exits.
```python
from cua_sandbox import Sandbox, Image
sb = await Sandbox.create(Image.linux())
await sb.shell.run("uname -a")
print(sb.name) # save this to reconnect later
await sb.disconnect()
```
## Connect to existing sandbox
Attach to a sandbox that's already running. Works as a plain await or context manager.
```python
from cua_sandbox import Sandbox
# plain await
sb = await Sandbox.connect("my-sandbox")
await sb.shell.run("whoami")
await sb.disconnect()
# context manager — disconnects on exit, sandbox keeps running
async with Sandbox.connect("my-sandbox") as sb:
await sb.shell.run("whoami")
```
## Destroy a sandbox
```python
await sb.destroy() # disconnect + permanently delete
```
## Local VM
Spins up a local VM using QEMU or Lume, destroyed on exit.
```python
from cua_sandbox import Sandbox, Image
from cua_sandbox.runtime import QEMURuntime
async with Sandbox.ephemeral(Image.linux(), local=True, runtime=QEMURuntime()) as sb:
await sb.shell.run("uname -a")
```
## Localhost (unsandboxed)
Direct host control — **not sandboxed**, use with caution.
```python
from cua_sandbox import Localhost
async with Localhost.connect() as host:
await host.shell.run("echo hello")
await host.screenshot()
```
@@ -0,0 +1,47 @@
"""cua-sandbox — ephemeral and persistent sandboxed computer environments.
Usage::
import cua_sandbox as cua
# Configure API access
cua.configure(api_key="sk-...")
# Local sandbox
async with cua.sandbox(local=True) as sb:
await sb.screenshot()
# Localhost (no sandbox, direct host control)
async with cua.localhost() as host:
await host.mouse.click(100, 200)
"""
__version__ = "0.1.0"
from cua_sandbox._auth import login, whoami
from cua_sandbox._config import configure
from cua_sandbox.image import Image
from cua_sandbox.localhost import Localhost, localhost
from cua_sandbox.runtime.compat import (
RuntimeSupport,
check_local_support,
skip_if_unsupported,
)
from cua_sandbox.sandbox import Sandbox, SandboxInfo, sandbox
from cua_sandbox.transport.cloud import CloudTransport
__all__ = [
"configure",
"login",
"whoami",
"Image",
"Sandbox",
"SandboxInfo",
"sandbox",
"Localhost",
"localhost",
"CloudTransport",
"RuntimeSupport",
"check_local_support",
"skip_if_unsupported",
]
@@ -0,0 +1,67 @@
"""Authentication — login(), whoami(), credential storage.
login() opens a Clerk browser redirect and stores the resulting token
in ~/.cua/credentials. OAuth device flow deferred to a later stage.
"""
from __future__ import annotations
import webbrowser
from pathlib import Path
from typing import Any, Dict, Optional
import httpx
from cua_sandbox._config import get_api_key, get_base_url
_CUA_DIR = Path.home() / ".cua"
_CREDENTIALS_FILE = _CUA_DIR / "credentials"
def login(*, base_url: Optional[str] = None) -> None:
"""Open the CUA login page in a browser and store credentials.
This initiates a Clerk-based browser authentication flow.
The user completes login in their browser, and the resulting
API key is stored in ~/.cua/credentials.
"""
url = base_url or get_base_url()
login_url = f"{url}/auth/login"
print(f"Opening {login_url} in your browser...")
webbrowser.open(login_url)
print("Complete the login in your browser.")
print("Then paste the API key you receive below.")
api_key = input("API key: ").strip()
if not api_key:
print("No API key provided. Aborting.")
return
_save_credentials(api_key=api_key)
print("Credentials saved to ~/.cua/credentials")
def whoami(*, api_key: Optional[str] = None) -> Dict[str, Any]:
"""Return info about the authenticated user.
Returns:
Dict with user info (id, email, etc.) from the CUA API.
"""
key = get_api_key(api_key)
if not key:
raise RuntimeError("Not authenticated. Run cua_sandbox.login() or set CUA_API_KEY.")
resp = httpx.get(
f"{get_base_url()}/v1/whoami",
headers={"Authorization": f"Bearer {key}"},
timeout=10,
)
resp.raise_for_status()
return resp.json()
def _save_credentials(*, api_key: str) -> None:
"""Write credentials to ~/.cua/credentials."""
_CUA_DIR.mkdir(parents=True, exist_ok=True)
_CREDENTIALS_FILE.write_text(f"api_key={api_key}\n")
# Restrict permissions on Unix
try:
_CREDENTIALS_FILE.chmod(0o600)
except OSError:
pass
@@ -0,0 +1,69 @@
"""Global configuration — configure(api_key, base_url).
Auth priority: per-call > configure() > ~/.cua/credentials > CUA_API_KEY env var.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Optional
@dataclass
class _Config:
api_key: Optional[str] = None
base_url: str = "https://api.cua.ai"
_global_config = _Config()
def configure(
*,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
) -> None:
"""Set global configuration for the CUA SDK.
Args:
api_key: API key for cloud sandboxes.
base_url: Base URL for the CUA cloud API.
"""
if api_key is not None:
_global_config.api_key = api_key
if base_url is not None:
_global_config.base_url = base_url
def get_api_key(override: Optional[str] = None) -> Optional[str]:
"""Resolve API key with priority: override > configure() > credentials file > env."""
if override:
return override
if _global_config.api_key:
return _global_config.api_key
# Try credentials file
cred = _read_credentials_key()
if cred:
return cred
return os.environ.get("CUA_API_KEY")
def get_base_url() -> str:
return os.environ.get("CUA_BASE_URL") or _global_config.base_url
def _read_credentials_key() -> Optional[str]:
"""Read API key from ~/.cua/credentials if it exists."""
cred_path = os.path.join(os.path.expanduser("~"), ".cua", "credentials")
try:
with open(cred_path) as f:
for line in f:
line = line.strip()
if line.startswith("api_key="):
return line[len("api_key=") :]
if line.startswith("api_key ="):
return line[len("api_key =") :].strip()
except FileNotFoundError:
pass
return None
@@ -0,0 +1,98 @@
"""Agent integration — adapters that make Sandbox and Localhost work as
AsyncComputerHandler for the cua-agent ComputerAgent.
Usage::
from cua_sandbox import Image, Sandbox
from cua_sandbox.agent import SandboxHandler, LocalhostHandler
async with Sandbox.ephemeral(Image.linux(), local=True) as sb:
handler = SandboxHandler(sb)
agent = ComputerAgent(model="...", tools=[handler])
"""
from __future__ import annotations
import asyncio
import base64
from typing import Any, Dict, List, Literal, Optional, Union
from cua_sandbox.localhost import Localhost
from cua_sandbox.sandbox import Sandbox
class SandboxHandler:
"""Adapts a Sandbox instance to the AsyncComputerHandler protocol."""
def __init__(self, sandbox: Sandbox):
self._sb = sandbox
async def get_environment(self) -> Literal["windows", "mac", "linux", "browser"]:
env = await self._sb.get_environment()
return env # type: ignore[return-value]
async def get_dimensions(self) -> tuple[int, int]:
return await self._sb.get_dimensions()
async def screenshot(self, text: Optional[str] = None) -> str:
raw = await self._sb.screenshot()
return base64.b64encode(raw).decode("utf-8")
async def click(self, x: int, y: int, button: str = "left") -> None:
await self._sb.mouse.click(x, y, button)
async def double_click(self, x: int, y: int) -> None:
await self._sb.mouse.double_click(x, y)
async def scroll(self, x: int, y: int, scroll_x: int, scroll_y: int) -> None:
await self._sb.mouse.scroll(x, y, scroll_x, scroll_y)
async def type(self, text: str) -> None:
await self._sb.keyboard.type(text)
async def wait(self, ms: int = 1000) -> None:
await asyncio.sleep(ms / 1000.0)
async def move(self, x: int, y: int) -> None:
await self._sb.mouse.move(x, y)
async def keypress(self, keys: Union[List[str], str]) -> None:
await self._sb.keyboard.keypress(keys)
async def drag(self, path: List[Dict[str, int]]) -> None:
if not path:
return
start = path[0]
await self._sb.mouse.mouse_down(start["x"], start["y"])
for point in path[1:]:
await self._sb.mouse.move(point["x"], point["y"])
end = path[-1]
await self._sb.mouse.mouse_up(end["x"], end["y"])
async def get_current_url(self) -> str:
return ""
async def left_mouse_down(self, x: Optional[int] = None, y: Optional[int] = None) -> None:
if x is not None and y is not None:
await self._sb.mouse.mouse_down(x, y)
async def left_mouse_up(self, x: Optional[int] = None, y: Optional[int] = None) -> None:
if x is not None and y is not None:
await self._sb.mouse.mouse_up(x, y)
class LocalhostHandler(SandboxHandler):
"""Adapts a Localhost instance to the AsyncComputerHandler protocol.
Localhost exposes the same interface as Sandbox, so we just reuse
the SandboxHandler with a Localhost in place of a Sandbox.
"""
def __init__(self, host: Localhost):
# Localhost has the same interface shape as Sandbox
self._sb = host # type: ignore[assignment]
def is_sandbox(obj: Any) -> bool:
"""Check if an object is a Sandbox or Localhost instance."""
return isinstance(obj, (Sandbox, Localhost))
@@ -0,0 +1,6 @@
"""Image builder — build QEMU VM images from Image layer specs."""
from cua_sandbox.builder.executor import LayerExecutor
from cua_sandbox.builder.overlay import IMAGES_DIR, create_overlay
__all__ = ["LayerExecutor", "create_overlay", "IMAGES_DIR"]
@@ -0,0 +1,94 @@
"""CLI for building QEMU VM images.
Usage:
python -m cua_sandbox.builder build-base --os windows --version 11
python -m cua_sandbox.builder build-base --os windows --version 11 --iso-path /path/to/win11.iso
python -m cua_sandbox.builder build --spec '{"os_type":"windows","version":"11","layers":[{"type":"winget_install","packages":["Git.Git"]}]}'
python -m cua_sandbox.builder info
"""
from __future__ import annotations
import argparse
import asyncio
import json
import logging
from pathlib import Path
from cua_sandbox.builder.overlay import IMAGES_DIR
def main():
logging.basicConfig(level=logging.INFO, format="%(message)s")
parser = argparse.ArgumentParser(description="CUA Sandbox image builder")
sub = parser.add_subparsers(dest="command")
# build-base
bb = sub.add_parser("build-base", help="Build a base image (OS + computer-server)")
bb.add_argument("--os", default="windows", help="OS type (windows, linux)")
bb.add_argument("--version", default="11", help="OS version")
bb.add_argument("--iso-path", help="Path to OS ISO (skip download)")
bb.add_argument("--product-key", help="Windows product key")
bb.add_argument("--force", action="store_true", help="Rebuild even if cached")
# build (from Image spec)
b = sub.add_parser("build", help="Build a user image from an Image spec JSON")
b.add_argument("--spec", required=True, help="Image spec as JSON string or @file.json")
b.add_argument("--force", action="store_true")
# info
sub.add_parser("info", help="Show cached images")
args = parser.parse_args()
if args.command == "build-base":
from cua_sandbox.builder.build import ensure_base_image
disk = asyncio.run(
ensure_base_image(
args.os,
args.version,
windows_iso=args.iso_path,
product_key=args.product_key,
force=args.force,
)
)
print(f"Base image: {disk}")
elif args.command == "build":
spec_str = args.spec
if spec_str.startswith("@"):
spec_str = Path(spec_str[1:]).read_text()
spec = json.loads(spec_str)
from cua_sandbox.builder.build import build_user_image, ensure_base_image
from cua_sandbox.image import Image
image = Image.from_dict(spec)
base = asyncio.run(ensure_base_image(image.os_type, image.version))
user_disk = asyncio.run(build_user_image(image, base, force=args.force))
print(f"User image: {user_disk}")
elif args.command == "info":
if not IMAGES_DIR.exists():
print(f"No images directory: {IMAGES_DIR}")
return
print(f"Images directory: {IMAGES_DIR}\n")
for d in sorted(IMAGES_DIR.iterdir()):
if d.is_dir():
disk = d / "disk.qcow2"
meta = d / "disk.json"
size = f"{disk.stat().st_size / 1024 / 1024:.0f} MB" if disk.exists() else "no disk"
layers = ""
if meta.exists():
m = json.loads(meta.read_text())
layers = f" ({len(m.get('layers', []))} layers)"
print(f" {d.name}: {size}{layers}")
else:
parser.print_help()
if __name__ == "__main__":
main()
@@ -0,0 +1,363 @@
"""Build orchestrator — resolve or build base images, apply user layers, create session overlays.
Implements the 3-layer qcow2 chain:
base (OS + computer-server) → user overlay (layers) → session overlay (ephemeral)
Usage::
from cua_sandbox.builder.build import resolve_image_disk
# Returns the disk path to boot — handles base building, layer caching, overlays
disk_path = await resolve_image_disk(image, name="my-sandbox")
"""
from __future__ import annotations
import asyncio
import json
import logging
from pathlib import Path
from typing import Optional
from cua_sandbox.builder.overlay import (
base_image_path,
create_overlay,
layers_hash,
session_overlay_path,
user_image_path,
)
from cua_sandbox.image import Image
logger = logging.getLogger(__name__)
# computer-server setup script — ported from setup-cua-server.ps1
# Runs inside the VM after first boot to install computer-server + scheduled task
SETUP_COMPUTER_SERVER_PS1 = r'''
$ErrorActionPreference = 'Continue'
# Install UV
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
$uvPath = Join-Path $env:USERPROFILE ".local\bin"
$env:Path = "$uvPath;$env:Path"
# Create UV project
$ProjectDir = Join-Path $env:USERPROFILE "cua-server"
if (!(Test-Path (Join-Path $ProjectDir "pyproject.toml"))) {
New-Item -ItemType Directory -Force -Path $ProjectDir | Out-Null
& uv init --vcs none --no-readme --no-workspace --no-pin-python $ProjectDir
}
# Install cua-computer-server (no agent — VM only needs automation server)
& uv add --directory $ProjectDir cua-computer-server
# Install playwright + firefox
& uv add --directory $ProjectDir playwright
& uv run --directory $ProjectDir playwright install firefox
# Firewall rule for port 8000
netsh advfirewall firewall add rule name="CUA Computer Server" dir=in action=allow protocol=TCP localport=8000
# Create start script
$StartScript = Join-Path $ProjectDir "start-server.ps1"
@"
`$env:PYTHONUNBUFFERED = '1'
`$uvPath = Join-Path `$env:USERPROFILE '.local\bin'
`$env:Path = "`$uvPath;`$env:Path"
while (`$true) {
& uv run --directory '$ProjectDir' python -m computer_server --port 8000
Start-Sleep -Seconds 5
}
"@ | Set-Content -Path $StartScript -Encoding UTF8
# VBScript wrapper for hidden execution
$VbsWrapper = Join-Path $ProjectDir "start-server-hidden.vbs"
@"
Set objShell = CreateObject("WScript.Shell")
objShell.Run "powershell.exe -NoProfile -ExecutionPolicy Bypass -File ""$StartScript""", 0, False
"@ | Set-Content -Path $VbsWrapper -Encoding ASCII
# Scheduled task at logon
$TaskName = "Cua-Computer-Server"
$Username = $env:USERNAME
$existing = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
if ($existing) { Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false }
$Action = New-ScheduledTaskAction -Execute "wscript.exe" -Argument "`"$VbsWrapper`""
$UserId = "$env:COMPUTERNAME\$Username"
$Trigger = New-ScheduledTaskTrigger -AtLogOn -User $UserId
$Principal = New-ScheduledTaskPrincipal -UserId $UserId -LogonType Interactive -RunLevel Highest
$Settings = New-ScheduledTaskSettingsSet `
-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable `
-RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) `
-ExecutionTimeLimit (New-TimeSpan -Days 365) -Hidden
Register-ScheduledTask -TaskName $TaskName -Action $Action -Trigger $Trigger `
-Principal $Principal -Settings $Settings -Force | Out-Null
# Start the server now too
Start-Process wscript.exe -ArgumentList "`"$VbsWrapper`""
Write-Host "CUA Computer Server setup complete"
'''
# Linux equivalent
SETUP_COMPUTER_SERVER_SH = r"""#!/bin/bash
set -e
# Install uv
curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
# Create uv project
mkdir -p ~/cua-server
cd ~/cua-server
[ -f pyproject.toml ] || uv init --vcs none --no-readme --no-workspace --no-pin-python .
# Install cua-computer-server
uv add cua-computer-server "cua-agent[all]"
# Install playwright + firefox
uv add playwright
uv run playwright install firefox
# Create systemd service
sudo tee /etc/systemd/system/cua-computer-server.service > /dev/null <<UNIT
[Unit]
Description=CUA Computer Server
After=network.target
[Service]
Type=simple
User=$USER
WorkingDirectory=$HOME/cua-server
ExecStart=$HOME/.local/bin/uv run python -m computer_server --port 8000
Restart=always
RestartSec=5
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
UNIT
sudo systemctl daemon-reload
sudo systemctl enable --now cua-computer-server
echo "CUA Computer Server setup complete"
"""
async def ensure_base_image(
os_type: str,
version: str,
*,
windows_iso: Optional[str] = None,
product_key: Optional[str] = None,
force: bool = False,
) -> Path:
"""Ensure the base image (OS + computer-server) exists. Build if needed.
Returns the path to the base qcow2.
"""
base_path = base_image_path(os_type, version)
_MIN_BASE_SIZE = {
"windows": 1 * 1024 * 1024 * 1024, # 1 GB — incomplete install guard
"linux": 100 * 1024 * 1024, # 100 MB
}
if base_path.exists() and not force:
min_size = _MIN_BASE_SIZE.get(os_type, 0)
if base_path.stat().st_size >= min_size:
logger.info(f"Using cached base image: {base_path}")
return base_path
logger.warning(
f"Cached base image {base_path} is too small "
f"({base_path.stat().st_size} bytes < {min_size}), rebuilding."
)
base_path.unlink()
logger.info(f"Building base image for {os_type} {version}...")
if os_type == "windows":
return await _build_windows_base(version, base_path, windows_iso, product_key)
elif os_type == "linux":
return await _build_linux_base(version, base_path)
else:
raise ValueError(f"Cannot build base image for os_type={os_type}")
async def _build_windows_base(
version: str,
base_path: Path,
windows_iso: Optional[str],
product_key: Optional[str],
) -> Path:
"""Build Windows base: unattend install + computer-server."""
from cua_sandbox.registry.qemu_builder import (
QEMUImageConfig,
build_image,
)
config = QEMUImageConfig(guest_os="windows", version=version)
work_dir = base_path.parent / "build"
# Phase 1: Unattended Windows install
raw_disk = build_image(
config, windows_iso=windows_iso, work_dir=work_dir, product_key=product_key
)
# Phase 2: Boot and install computer-server
logger.info("Installing computer-server into base image...")
# Boot the raw disk to install computer-server
# First we need to boot without expecting computer-server (it's not installed yet)
# We use a temporary overlay so we can retry if needed
import shutil
temp_disk = work_dir / "temp-boot.qcow2"
shutil.copy2(raw_disk, temp_disk)
# Boot and wait for Windows to be accessible (but not computer-server — it's not installed)
# We need to wait for Windows to boot then run the setup script
# This is tricky because we don't have computer-server yet...
# Use QMP or VNC to inject the setup script, or use the virtio-serial approach
#
# For now: the Autounattend.xml should include a FirstLogonCommand that
# downloads and runs the setup script. Let's add that to the builder.
logger.info(
"Base image built at %s. Computer-server must be installed via "
"Autounattend FirstLogonCommand or manual VNC session.",
raw_disk,
)
# Move the built disk to the base path
base_path.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(raw_disk), str(base_path))
return base_path
async def _build_linux_base(version: str, base_path: Path) -> Path:
"""Build Linux base: install from cloud image + computer-server."""
raise NotImplementedError("Linux base image building not yet implemented")
async def build_user_image(
image: Image,
base_path: Path,
*,
force: bool = False,
) -> Path:
"""Build a user image by applying Image layers on top of the base.
Creates a qcow2 overlay backed by base_path, boots it, runs all layers
via computer-server, then shuts down. The overlay is the user image.
Returns the path to the user image qcow2.
"""
if not image._layers:
# No user layers — just use the base
return base_path
lhash = layers_hash(list(image._layers))
user_path = user_image_path(image.os_type, image.version, lhash)
if user_path.exists() and not force:
logger.info(f"Using cached user image: {user_path} (hash={lhash})")
return user_path
logger.info(f"Building user image (hash={lhash}, {len(image._layers)} layers)...")
# Create overlay on top of base
create_overlay(base_path, user_path)
# Boot the overlay and execute layers
from cua_sandbox.runtime.qemu import QEMUBaremetalRuntime
runtime = QEMUBaremetalRuntime(api_port=18098, memory_mb=8192, cpu_count=4)
build_image = Image.from_file(str(user_path), os_type=image.os_type)
try:
info = await runtime.start(build_image, f"cua-build-{lhash}")
# Execute layers via computer-server
from cua_sandbox.builder.executor import LayerExecutor
executor = LayerExecutor(f"http://{info.host}:{info.api_port}")
await executor.execute_layers(list(image._layers))
# Shut down cleanly
logger.info("Layers applied, shutting down build VM...")
try:
await executor.run_command(
"shutdown /s /t 5" if image.os_type == "windows" else "sudo shutdown -h now"
)
except Exception:
pass
await asyncio.sleep(10)
finally:
await runtime.stop(f"cua-build-{lhash}")
logger.info(f"User image built: {user_path}")
# Save layer metadata
meta_path = user_path.with_suffix(".json")
meta_path.write_text(
json.dumps(
{
"os_type": image.os_type,
"version": image.version,
"layers": list(image._layers),
"base": str(base_path),
"hash": lhash,
},
indent=2,
)
)
return user_path
async def create_session_disk(
image: Image,
name: str,
*,
base_disk: Optional[Path] = None,
) -> Path:
"""Create a session overlay for a sandbox run.
If the image has layers and a cached user image exists, overlay on that.
Otherwise overlay on the base image. If no base exists, returns the
image's _disk_path directly (no overlay).
Returns the disk path to boot.
"""
# If image has a direct disk path and no layers, use it directly
if image._disk_path and not image._layers:
return Path(image._disk_path)
# Determine the backing disk
if base_disk:
backing = base_disk
elif image._disk_path:
backing = Path(image._disk_path)
else:
# Auto-build base if it doesn't exist
backing = await ensure_base_image(image.os_type, image.version)
# If there are user layers, check for cached user image
if image._layers:
lhash = layers_hash(list(image._layers))
user_disk = user_image_path(image.os_type, image.version, lhash)
if user_disk.exists():
backing = user_disk
else:
# Need to build the user image first
backing = await build_user_image(image, backing)
# Create ephemeral session overlay
session_disk = session_overlay_path(name)
if session_disk.exists():
session_disk.unlink()
create_overlay(backing, session_disk)
return session_disk
@@ -0,0 +1,322 @@
"""Layer executor — runs Image layers via computer-server API.
Given a running computer-server at some URL, translates each Image layer dict
into shell commands and executes them sequentially.
"""
from __future__ import annotations
import base64
import json
import logging
import os
from typing import Any
import httpx
logger = logging.getLogger(__name__)
_OS_EXT = {"linux": "sh", "macos": "sh", "android": "sh", "windows": "ps1"}
def _find_app_install_script(app_id: str, os_type: str) -> str | None:
"""Locate the install script for *app_id* from cua-sandbox-apps.
Returns the script text, or None if not found.
"""
ext = _OS_EXT.get(os_type, "sh")
# Try cua_sandbox_apps package path first
try:
from pathlib import Path
import cua_sandbox_apps
apps_dir = Path(cua_sandbox_apps.__file__).parent / "apps"
script_path = apps_dir / app_id / os_type / f"install.{ext}"
if script_path.exists():
return script_path.read_text(encoding="utf-8")
except (ImportError, Exception):
pass
return None
def _find_app_launch_script(app_id: str, os_type: str) -> str | None:
"""Locate the launch script for *app_id* from cua-sandbox-apps."""
ext = _OS_EXT.get(os_type, "sh")
try:
from pathlib import Path
import cua_sandbox_apps
apps_dir = Path(cua_sandbox_apps.__file__).parent / "apps"
script_path = apps_dir / app_id / os_type / f"launch.{ext}"
if script_path.exists():
return script_path.read_text(encoding="utf-8")
except (ImportError, Exception):
pass
return None
class LayerExecutor:
"""Execute Image layer specs against a running computer-server."""
def __init__(self, base_url: str, timeout: float = 600, os_type: str = "linux"):
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.os_type = os_type # "linux", "macos", "windows", "android"
async def run_command(self, command: str, timeout: float | None = None) -> dict:
"""Run a shell command via computer-server and return the result."""
t = timeout or self.timeout
async with httpx.AsyncClient(timeout=t) as client:
# computer-server uses SSE on /cmd
resp = await client.post(
f"{self.base_url}/cmd",
json={"command": "run_command", "params": {"command": command}},
timeout=t,
)
resp.raise_for_status()
# Parse SSE response — collect all data lines
result: dict[str, Any] = {}
for line in resp.text.splitlines():
if line.startswith("data: "):
try:
data = json.loads(line[6:])
if isinstance(data, dict):
result.update(data)
except (json.JSONDecodeError, ValueError):
pass
return result
async def write_file(self, path: str, content_b64: str, timeout: float | None = None) -> dict:
"""Write a file via computer-server write_bytes command."""
t = timeout or self.timeout
async with httpx.AsyncClient(timeout=t) as client:
resp = await client.post(
f"{self.base_url}/cmd",
json={
"command": "write_bytes",
"params": {"path": path, "content_b64": content_b64},
},
timeout=t,
)
resp.raise_for_status()
result: dict[str, Any] = {}
for line in resp.text.splitlines():
if line.startswith("data: "):
try:
data = json.loads(line[6:])
if isinstance(data, dict):
result.update(data)
except (json.JSONDecodeError, ValueError):
pass
return result
async def execute_layer(self, layer: dict) -> dict:
"""Execute a single Image layer and return the result."""
lt = layer["type"]
handler = getattr(self, f"_exec_{lt}", None)
if handler is None:
raise ValueError(f"Unknown layer type: {lt}")
return await handler(layer)
async def execute_layers(self, layers: list[dict]) -> list[dict]:
"""Execute all layers sequentially. Raises on first failure."""
results = []
for i, layer in enumerate(layers):
lt = layer["type"]
logger.info(f"Executing layer {i + 1}/{len(layers)}: {lt}")
result = await self.execute_layer(layer)
rc = result.get("return_code", result.get("returncode", -1))
success = result.get("success", rc == 0)
if not success or rc not in (0, None):
logger.error(f"Layer {lt} failed (rc={rc}): {result.get('stderr', '')}")
raise RuntimeError(
f"Layer {i + 1} ({lt}) failed with exit code {rc}: "
f"{result.get('stderr', '')}"
)
logger.info(f"Layer {lt} completed successfully")
results.append(result)
return results
# ── Per-layer-type handlers ──────────────────────────────────────────
def _is_windows(self) -> bool:
return self.os_type == "windows"
async def _exec_run(self, layer: dict) -> dict:
cmd = layer["command"]
if self._is_windows():
pass
elif self.os_type == "linux":
# Linux containers run as a non-root user; use sudo for root access
cmd = f"sudo bash -c '. /etc/profile.d/cua-env.sh 2>/dev/null; {_bash_escape(cmd)}'"
elif self.os_type == "macos":
# macOS VMs: default password is "lume"; pipe it to sudo -S for root access
cmd = (
f"echo lume | sudo -S bash -c "
f"'. /etc/profile.d/cua-env.sh 2>/dev/null; {_bash_escape(cmd)}'"
)
else:
# Android: stock Android uses mksh/sh, not bash; env file is pushed by android_emulator
cmd = f"sh -c '. /data/local/tmp/.cua_env 2>/dev/null; {_bash_escape(cmd)}'"
return await self.run_command(cmd)
async def _exec_apt_install(self, layer: dict) -> dict:
pkgs = " ".join(layer["packages"])
return await self.run_command(
f"sudo DEBIAN_FRONTEND=noninteractive apt-get update -qq && "
f"sudo DEBIAN_FRONTEND=noninteractive apt-get install -y {pkgs}"
)
async def _exec_brew_install(self, layer: dict) -> dict:
pkgs = " ".join(layer["packages"])
return await self.run_command(f"brew install {pkgs}", timeout=900)
async def _exec_choco_install(self, layer: dict) -> dict:
pkgs = " ".join(layer["packages"])
return await self.run_command(f"choco install -y {pkgs}", timeout=900)
async def _exec_winget_install(self, layer: dict) -> dict:
cmds = []
for pkg in layer["packages"]:
cmds.append(
f"winget install --accept-source-agreements "
f"--accept-package-agreements -e --id {pkg}"
)
combined = " && ".join(cmds)
return await self.run_command(combined, timeout=900)
async def _exec_uv_install(self, layer: dict) -> dict:
pkgs = " ".join(layer["packages"])
if self._is_windows():
return await self.run_command(
f"uv add --directory %USERPROFILE%\\cua-server {pkgs}",
timeout=600,
)
# Linux/macOS: install uv if missing, then install packages system-wide
return await self.run_command(
f"command -v uv >/dev/null 2>&1 || "
f"(curl -LsSf https://astral.sh/uv/install.sh | sh && "
f'export PATH="$HOME/.cargo/bin:$HOME/.local/bin:$PATH") && '
f"uv pip install --system {pkgs}",
timeout=600,
)
async def _exec_pip_install(self, layer: dict) -> dict:
pkgs = " ".join(layer["packages"])
if self._is_windows():
return await self.run_command(f"pip install {pkgs}", timeout=600)
return await self.run_command(f"pip3 install --break-system-packages {pkgs}", timeout=600)
async def _exec_env(self, layer: dict) -> dict:
import re as _re
variables = layer.get("variables", {})
if not variables:
return {"success": True, "return_code": 0}
if self._is_windows():
cmds = [f'setx {k} "{v}"' for k, v in variables.items()]
return await self.run_command(" && ".join(cmds))
# Validate keys before composing any shell commands
for k in variables:
if not _re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", k):
raise ValueError(f"Unsafe env var name: {k!r}")
# Linux/macOS: append to /etc/environment for persistence.
# Write each line as a separate printf call so values are treated as
# literals — no heredoc expansion, no quote stripping.
sudo = "echo lume | sudo -S" if self.os_type == "macos" else "sudo"
result: dict = {"success": True, "return_code": 0}
for k, v in variables.items():
safe_v = v.replace("'", "'\\''")
result = await self.run_command(
f"printf '%s=%s\\n' '{k}' '{safe_v}' | {sudo} tee -a /etc/environment > /dev/null"
)
if not result.get("success"):
return result
return result
async def _exec_copy(self, layer: dict) -> dict:
src = layer["src"]
dst = layer["dst"]
if not os.path.exists(src):
return {"success": False, "return_code": 1, "error": f"Source not found: {src}"}
with open(src, "rb") as f:
content_b64 = base64.b64encode(f.read()).decode()
if self._is_windows():
dst_dir = dst.rsplit("\\", 1)[0] if "\\" in dst else dst.rsplit("/", 1)[0]
if dst_dir:
await self.run_command(f'mkdir "{dst_dir}"')
result = await self.write_file(dst, content_b64)
if not result.get("success", False):
return {"success": False, "return_code": 1, "error": result.get("error", "")}
return {"success": True, "return_code": 0}
# Linux/macOS: write to temp then sudo mv to handle root-owned destinations
import posixpath
basename = posixpath.basename(dst)
tmp_path = f"/tmp/_cua_copy_{basename}"
result = await self.write_file(tmp_path, content_b64)
if not result.get("success", False):
return {"success": False, "return_code": 1, "error": result.get("error", "")}
sudo = "echo lume | sudo -S" if self.os_type == "macos" else "sudo"
dst_dir = posixpath.dirname(dst)
if dst_dir and dst_dir != "/":
await self.run_command(f"{sudo} mkdir -p {_sh_quote(dst_dir)}")
r = await self.run_command(f"{sudo} mv {_sh_quote(tmp_path)} {_sh_quote(dst)}")
rc = r.get("return_code", r.get("returncode", -1))
return {"success": rc == 0, "return_code": rc, "stderr": r.get("stderr", "")}
async def _exec_app_install(self, layer: dict) -> dict:
"""Install an app from cua-sandbox-apps. Reads its install.sh and runs it."""
app_id = layer["app_id"]
# Locate the install script from the apps catalog
script = _find_app_install_script(app_id, self.os_type)
if script is None:
return {
"success": False,
"return_code": 1,
"stderr": f"No install script found for app '{app_id}' on {self.os_type}. "
f"Install cua-sandbox-apps or run 'cua-sandbox-apps generate' first.",
}
return await self.run_command(f"bash -c {_sh_quote(script)}", timeout=900)
async def _exec_expose(self, layer: dict) -> dict:
# Expose is a no-op at layer execution time — ports are mapped by the runtime
return {"success": True, "return_code": 0}
async def _exec_apk_install(self, layer: dict) -> dict:
# APK install: transfer the APK file to the device and install via adb
apk_paths = layer.get("packages", [])
results = []
for apk_path in apk_paths:
if not os.path.exists(apk_path):
return {
"success": False,
"return_code": 1,
"error": f"APK not found: {apk_path}",
}
remote_path = f"/data/local/tmp/{os.path.basename(apk_path)}"
with open(apk_path, "rb") as f:
content_b64 = base64.b64encode(f.read()).decode()
await self.write_file(remote_path, content_b64)
r = await self.run_command(f"pm install -r {remote_path}", timeout=120)
results.append(r)
return results[-1] if results else {"success": True, "return_code": 0}
async def _exec_pwa_install(self, layer: dict) -> dict:
# PWA install — just a placeholder; actual install happens via the sandbox
return {"success": True, "return_code": 0}
def _bash_escape(s: str) -> str:
"""Escape a command string for embedding inside single-quoted bash -c '...'."""
# Replace single quotes: end the single-quote, add escaped single-quote, restart
return s.replace("'", "'\\''")
def _sh_quote(s: str) -> str:
"""Wrap string in single quotes, escaping any existing single quotes."""
return "'" + _bash_escape(s) + "'"
@@ -0,0 +1,91 @@
"""QEMU qcow2 overlay (backing file) management.
Provides the 3-layer chain:
Layer 0 (base): windows-11-base.qcow2 — unattend + computer-server
Layer 1 (user): {hash}.qcow2 — user's .winget_install()/.run() etc
Layer 2 (session): session-{uuid}.qcow2 — ephemeral sandbox runtime
"""
from __future__ import annotations
import hashlib
import json
import logging
import subprocess
from pathlib import Path
logger = logging.getLogger(__name__)
IMAGES_DIR = Path.home() / ".cua" / "cua-sandbox" / "images"
def _qemu_img() -> str:
"""Resolve qemu-img binary."""
import shutil
found = shutil.which("qemu-img")
if found:
return found
from cua_sandbox.runtime.qemu_installer import qemu_bin
parent = Path(qemu_bin("x86_64")).parent
for name in ["qemu-img", "qemu-img.exe"]:
p = parent / name
if p.exists():
return str(p)
raise RuntimeError("qemu-img not found")
def create_overlay(backing: Path, overlay: Path) -> Path:
"""Create a qcow2 overlay with the given backing file."""
overlay.parent.mkdir(parents=True, exist_ok=True)
cmd = [
_qemu_img(),
"create",
"-q",
"-f",
"qcow2",
"-b",
str(backing),
"-F",
"qcow2",
str(overlay),
]
subprocess.run(cmd, check=True, capture_output=True)
logger.info(f"Created overlay: {overlay} (backing: {backing})")
return overlay
def commit_overlay(overlay: Path) -> None:
"""Commit an overlay's changes into its backing file (flattens one level)."""
cmd = [_qemu_img(), "commit", "-q", str(overlay)]
subprocess.run(cmd, check=True, capture_output=True)
logger.info(f"Committed overlay: {overlay}")
def rebase_standalone(disk: Path) -> None:
"""Remove backing file reference, making the disk standalone."""
cmd = [_qemu_img(), "rebase", "-u", "-b", "", str(disk)]
subprocess.run(cmd, check=True, capture_output=True)
logger.info(f"Rebased standalone: {disk}")
def layers_hash(layers: list[dict]) -> str:
"""Compute a stable hash for a list of Image layers."""
raw = json.dumps(layers, sort_keys=True).encode()
return hashlib.sha256(raw).hexdigest()[:16]
def base_image_path(os_type: str, version: str) -> Path:
"""Path to the cached base image (OS + computer-server installed)."""
return IMAGES_DIR / f"{os_type}-{version}-base" / "disk.qcow2"
def user_image_path(os_type: str, version: str, layer_hash: str) -> Path:
"""Path to a cached user layer image."""
return IMAGES_DIR / f"{os_type}-{version}-{layer_hash}" / "disk.qcow2"
def session_overlay_path(name: str) -> Path:
"""Path to an ephemeral session overlay."""
return IMAGES_DIR / "sessions" / f"{name}.qcow2"
@@ -0,0 +1,506 @@
"""Shared Windows unattended install helpers.
Used by both the QEMU builder and Hyper-V runtime to create Windows images
from scratch via Autounattend.xml + CUA server setup script.
"""
from __future__ import annotations
import logging
import shutil
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
# ── Download helpers ────────────────────────────────────────────────────────
def download_file(url: str, dest: Path, description: str = "") -> Path:
"""Download a file with progress, skipping if it already exists."""
if dest.exists():
logger.info(f"Already downloaded: {dest}")
return dest
dest.parent.mkdir(parents=True, exist_ok=True)
logger.info(f"Downloading {description or url}{dest}")
import urllib.request
urllib.request.urlretrieve(url, str(dest))
logger.info(f"Downloaded {dest.stat().st_size / 1024 / 1024:.0f} MB")
return dest
def _resolve_server_eval_url(
server_version: str, culture: str = "en-us", country: str = "US"
) -> str:
"""Resolve Windows Server evaluation ISO download URL from Microsoft's eval center.
Adapted from quickemu/quickget's download_windows_server() which is itself
adapted from the Mido project (https://github.com/ElliotKillick/Mido).
Args:
server_version: e.g. "windows-server-2022", "windows-server-2025"
culture: Language culture code, e.g. "en-us"
country: Country code, e.g. "US"
Returns:
Direct download URL for the ISO.
"""
import re
import urllib.request
eval_url = f"https://www.microsoft.com/en-us/evalcenter/download-{server_version}"
logger.info(f"Parsing Microsoft Eval Center: {eval_url}")
headers = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
}
req = urllib.request.Request(eval_url, headers=headers)
with urllib.request.urlopen(req, timeout=30) as resp:
html = resp.read().decode("utf-8", errors="replace")
# Extract go.microsoft.com/fwlink links with the matching culture
pattern = rf"https://go\.microsoft\.com/fwlink/p/\?LinkID=\d+&clcid=0x[0-9a-f]+&culture={re.escape(culture)}&country={re.escape(country)}"
links = re.findall(pattern, html)
if not links:
# Fallback: try any English link
pattern_fallback = r"https://go\.microsoft\.com/fwlink/p/\?LinkID=\d+&clcid=0x[0-9a-f]+&culture=en-us&country=US"
links = re.findall(pattern_fallback, html)
if not links:
raise RuntimeError(
f"Could not find download link on {eval_url}. "
f"Microsoft may have changed the page layout."
)
# First link is typically the x64 ISO
download_link = links[0]
logger.info(f"Resolved download link: {download_link}")
# Follow redirect to get the actual ISO URL
req2 = urllib.request.Request(download_link, method="HEAD", headers=headers)
with urllib.request.urlopen(req2, timeout=30) as resp2:
final_url = resp2.url
logger.info(f"Final ISO URL: {final_url}")
return final_url
def download_windows_iso(
version: str = "11",
dest: Optional[Path] = None,
iso_path: Optional[str] = None,
) -> Path:
"""Get a Windows ISO. If iso_path is given, use it directly."""
if iso_path:
p = Path(iso_path)
if not p.exists():
raise FileNotFoundError(f"Windows ISO not found: {iso_path}")
return p
dest_dir = dest or Path.home() / ".cua" / "cua-sandbox" / "iso"
dest_dir.mkdir(parents=True, exist_ok=True)
iso_file = dest_dir / f"windows-{version}.iso"
if iso_file.exists():
logger.info(f"Using cached ISO: {iso_file}")
return iso_file
if version == "11":
url = "https://go.microsoft.com/fwlink/?linkid=2334167&clcid=0x409"
elif version == "10":
url = "https://go.microsoft.com/fwlink/?LinkId=691209"
elif version in ("server-2022", "2022"):
version = "server-2022"
iso_file = dest_dir / f"windows-{version}.iso"
if iso_file.exists():
logger.info(f"Using cached ISO: {iso_file}")
return iso_file
url = _resolve_server_eval_url("windows-server-2022")
elif version in ("server-2025", "2025"):
version = "server-2025"
iso_file = dest_dir / f"windows-{version}.iso"
if iso_file.exists():
logger.info(f"Using cached ISO: {iso_file}")
return iso_file
url = _resolve_server_eval_url("windows-server-2025")
elif version in ("server-2019", "2019"):
version = "server-2019"
iso_file = dest_dir / f"windows-{version}.iso"
if iso_file.exists():
logger.info(f"Using cached ISO: {iso_file}")
return iso_file
url = _resolve_server_eval_url("windows-server-2019")
else:
raise ValueError(
f"Cannot auto-download Windows {version}. "
f"Supported: 10, 11, server-2022, server-2025, server-2019. "
f"Or provide --iso-path to use your own ISO."
)
logger.info(f"Downloading Windows {version} Enterprise Evaluation ISO...")
logger.info("This is ~6GB and may take a while.")
return download_file(url, iso_file, f"Windows {version} ISO")
# ── Autounattend.xml generation ─────────────────────────────────────────────
def generate_autounattend_xml(
product_key: Optional[str] = None,
*,
include_virtio_drivers: bool = True,
version: str = "11",
) -> str:
"""Generate a Windows Autounattend.xml for unattended installation.
Args:
product_key: Windows product key. If None, uses generic key for edition.
include_virtio_drivers: Include VirtIO driver paths in WinPE pass.
Set False for Hyper-V (has enlightened drivers built-in).
version: Windows version - "11", "10", "server-2022", "server-2025", etc.
"""
is_server = version.startswith("server-")
# KMS Generic Volume License Keys (GVLKs) for Server editions
# https://learn.microsoft.com/en-us/windows-server/get-started/kms-client-activation-keys
_server_gvlks = {
"server-2025": "TVRH6-WHNXV-R9WG3-9XRFY-MY832", # Standard
"server-2022": "VDYBN-27WPP-V4HQT-9VMD4-VMK7H", # Standard
"server-2019": "N69G4-B89J2-4G8F4-WWYCC-J464C", # Standard
}
key_section = ""
if product_key:
key_section = f"""<ProductKey>
<Key>{product_key}</Key>
</ProductKey>"""
elif is_server:
# Server eval ISOs: omit product key entirely. The eval ISO has its own
# built-in eval key. GVLKs are for volume license media only.
# Users with SPLA can inject their GVLK via the product_key parameter.
pass
else:
key_section = """<ProductKey>
<Key>VK7JG-NPHTM-C97JM-9MPGT-3V66T</Key>
</ProductKey>"""
# Image selection: use /IMAGE/NAME for reliability (index varies by ISO).
# For server eval ISOs, standard pattern is:
# "Windows Server 2022 Standard Evaluation (Desktop Experience)"
# For desktop: /IMAGE/INDEX = 1 (single-edition ISOs)
_server_image_names = {
"server-2022": "Windows Server 2022 Standard Evaluation (Desktop Experience)",
"server-2025": "Windows Server 2025 Standard Evaluation (Desktop Experience)",
"server-2019": "Windows Server 2019 SERVERDATACENTEREVAL",
}
if is_server:
image_name = _server_image_names.get(version)
# Use /IMAGE/NAME for server (more reliable than index)
image_select_key = "/IMAGE/NAME"
image_select_value = (
image_name
or f"Windows Server {version.split('-')[1]} Standard Evaluation (Desktop Experience)"
)
else:
image_select_key = "/IMAGE/INDEX"
image_select_value = "1"
virtio_section = ""
if include_virtio_drivers:
virtio_section = """
<component name="Microsoft-Windows-PnpCustomizationsWinPE" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<DriverPaths>
<PathAndCredentials wcm:action="add" wcm:keyValue="1">
<Path>D:\\viostor\\w11\\amd64</Path>
</PathAndCredentials>
<PathAndCredentials wcm:action="add" wcm:keyValue="2">
<Path>E:\\viostor\\w11\\amd64</Path>
</PathAndCredentials>
<PathAndCredentials wcm:action="add" wcm:keyValue="3">
<Path>F:\\viostor\\w11\\amd64</Path>
</PathAndCredentials>
<PathAndCredentials wcm:action="add" wcm:keyValue="4">
<Path>D:\\NetKVM\\w11\\amd64</Path>
</PathAndCredentials>
<PathAndCredentials wcm:action="add" wcm:keyValue="5">
<Path>E:\\NetKVM\\w11\\amd64</Path>
</PathAndCredentials>
<PathAndCredentials wcm:action="add" wcm:keyValue="6">
<Path>F:\\NetKVM\\w11\\amd64</Path>
</PathAndCredentials>
</DriverPaths>
</component>"""
return f"""<?xml version="1.0" encoding="utf-8"?>
<unattend xmlns="urn:schemas-microsoft-com:unattend" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<settings pass="windowsPE">
<component name="Microsoft-Windows-International-Core-WinPE" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<SetupUILanguage>
<UILanguage>en-US</UILanguage>
</SetupUILanguage>
<InputLocale>0409:00000409</InputLocale>
<SystemLocale>en-US</SystemLocale>
<UILanguage>en-US</UILanguage>
<UserLocale>en-US</UserLocale>
</component>{virtio_section}
<component name="Microsoft-Windows-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<DiskConfiguration>
<Disk wcm:action="add">
<DiskID>0</DiskID>
<WillWipeDisk>true</WillWipeDisk>
<CreatePartitions>
<CreatePartition wcm:action="add">
<Order>1</Order>
<Type>EFI</Type>
<Size>128</Size>
</CreatePartition>
<CreatePartition wcm:action="add">
<Order>2</Order>
<Type>MSR</Type>
<Size>128</Size>
</CreatePartition>
<CreatePartition wcm:action="add">
<Order>3</Order>
<Type>Primary</Type>
<Extend>true</Extend>
</CreatePartition>
</CreatePartitions>
<ModifyPartitions>
<ModifyPartition wcm:action="add">
<Order>1</Order>
<PartitionID>1</PartitionID>
<Label>System</Label>
<Format>FAT32</Format>
</ModifyPartition>
<ModifyPartition wcm:action="add">
<Order>2</Order>
<PartitionID>2</PartitionID>
</ModifyPartition>
<ModifyPartition wcm:action="add">
<Order>3</Order>
<PartitionID>3</PartitionID>
<Label>Windows</Label>
<Letter>C</Letter>
<Format>NTFS</Format>
</ModifyPartition>
</ModifyPartitions>
</Disk>
</DiskConfiguration>
<ImageInstall>
<OSImage>
<InstallTo>
<DiskID>0</DiskID>
<PartitionID>3</PartitionID>
</InstallTo>
<InstallFrom>
<MetaData wcm:action="add">
<Key>{image_select_key}</Key>
<Value>{image_select_value}</Value>
</MetaData>
</InstallFrom>
<InstallToAvailablePartition>false</InstallToAvailablePartition>
</OSImage>
</ImageInstall>
<UserData>
<AcceptEula>true</AcceptEula>
<FullName>User</FullName>
<Organization>CUA</Organization>
{key_section}
</UserData>
<EnableFirewall>false</EnableFirewall>
<RunSynchronous>
<RunSynchronousCommand wcm:action="add">
<Order>1</Order>
<Path>reg.exe add "HKLM\\SYSTEM\\Setup\\LabConfig" /v BypassTPMCheck /t REG_DWORD /d 1 /f</Path>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>2</Order>
<Path>reg.exe add "HKLM\\SYSTEM\\Setup\\LabConfig" /v BypassSecureBootCheck /t REG_DWORD /d 1 /f</Path>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>3</Order>
<Path>reg.exe add "HKLM\\SYSTEM\\Setup\\LabConfig" /v BypassRAMCheck /t REG_DWORD /d 1 /f</Path>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>4</Order>
<Path>reg.exe add "HKLM\\SYSTEM\\Setup\\MoSetup" /v AllowUpgradesWithUnsupportedTPMOrCPU /t REG_DWORD /d 1 /f</Path>
</RunSynchronousCommand>
</RunSynchronous>
</component>
</settings>
<settings pass="offlineServicing">
<component name="Microsoft-Windows-LUA-Settings" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<EnableLUA>false</EnableLUA>
</component>
</settings>
<settings pass="specialize">
<component name="Microsoft-Windows-Deployment" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<RunSynchronous>
<RunSynchronousCommand wcm:action="add">
<Order>1</Order>
<Path>reg.exe add "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\OOBE" /v BypassNRO /t REG_DWORD /d 1 /f</Path>
</RunSynchronousCommand>
</RunSynchronous>
</component>
<component name="Microsoft-Windows-TerminalServices-LocalSessionManager" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<fDenyTSConnections>false</fDenyTSConnections>
</component>
<component name="Microsoft-Windows-TerminalServices-RDP-WinStationExtensions" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<UserAuthentication>0</UserAuthentication>
</component>
<component name="Networking-MPSSVC-Svc" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<FirewallGroups>
<FirewallGroup wcm:action="add" wcm:keyValue="RemoteDesktop">
<Active>true</Active>
<Profile>all</Profile>
<Group>@FirewallAPI.dll,-28752</Group>
</FirewallGroup>
</FirewallGroups>
</component>
</settings>
<settings pass="oobeSystem">
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<UserAccounts>
<LocalAccounts>
<LocalAccount wcm:action="add">
<Name>User</Name>
<Group>Administrators</Group>
<Password>
<Value />
<PlainText>true</PlainText>
</Password>
</LocalAccount>
</LocalAccounts>
</UserAccounts>
<AutoLogon>
<Username>User</Username>
<Enabled>true</Enabled>
<LogonCount>65432</LogonCount>
<Password>
<Value />
<PlainText>true</PlainText>
</Password>
</AutoLogon>
<Display>
<ColorDepth>32</ColorDepth>
<HorizontalResolution>1280</HorizontalResolution>
<VerticalResolution>720</VerticalResolution>
</Display>
<OOBE>
<HideEULAPage>true</HideEULAPage>
<HideLocalAccountScreen>true</HideLocalAccountScreen>
<HideOEMRegistrationScreen>true</HideOEMRegistrationScreen>
<HideOnlineAccountScreens>true</HideOnlineAccountScreens>
<HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
<NetworkLocation>Home</NetworkLocation>
<ProtectYourPC>3</ProtectYourPC>
<SkipUserOOBE>true</SkipUserOOBE>
<SkipMachineOOBE>true</SkipMachineOOBE>
</OOBE>
<FirstLogonCommands>
<SynchronousCommand wcm:action="add">
<Order>1</Order>
<CommandLine>cmd /C POWERCFG -H OFF</CommandLine>
<Description>Disable Hibernation</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>2</Order>
<CommandLine>cmd /C POWERCFG -X -monitor-timeout-ac 0</CommandLine>
<Description>Disable monitor blanking</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>3</Order>
<CommandLine>cmd /C POWERCFG -X -standby-timeout-ac 0</CommandLine>
<Description>Disable Sleep</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>4</Order>
<CommandLine>reg.exe add "HKLM\\SOFTWARE\\Policies\\Microsoft\\Windows\\WindowsUpdate\\AU" /v "NoAutoUpdate" /t REG_DWORD /d 1 /f</CommandLine>
<Description>Disable Windows Update</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>5</Order>
<CommandLine>powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "foreach ($d in [System.IO.DriveInfo]::GetDrives()) {{ $f = Join-Path $d.Name 'setup-cua-server.ps1'; if (Test-Path $f) {{ &amp; $f; break }} }}"</CommandLine>
<Description>Install CUA Computer Server</Description>
</SynchronousCommand>
</FirstLogonCommands>
</component>
</settings>
</unattend>"""
def create_unattend_iso(
work_dir: Path,
product_key: Optional[str] = None,
*,
include_virtio_drivers: bool = True,
include_startup_nsh: bool = True,
version: str = "11",
) -> Path:
"""Create a small data-only ISO containing Autounattend.xml + setup script.
Args:
include_virtio_drivers: Include VirtIO driver paths (QEMU needs them, Hyper-V doesn't).
include_startup_nsh: Include UEFI shell startup.nsh (QEMU/OVMF needs it, Hyper-V doesn't).
version: Windows version for autounattend generation.
"""
unattend_iso = work_dir / "unattend.iso"
if unattend_iso.exists():
logger.info(f"Using cached unattend ISO: {unattend_iso}")
return unattend_iso
xml = generate_autounattend_xml(
product_key, include_virtio_drivers=include_virtio_drivers, version=version
)
xml_path = work_dir / "Autounattend.xml"
xml_path.write_text(xml, encoding="utf-8")
from cua_sandbox.builder.build import SETUP_COMPUTER_SERVER_PS1
setup_path = work_dir / "setup-cua-server.ps1"
setup_path.write_text(SETUP_COMPUTER_SERVER_PS1, encoding="utf-8")
iso_src = work_dir / "unattend-iso"
iso_src.mkdir(parents=True, exist_ok=True)
shutil.copy2(xml_path, iso_src / "Autounattend.xml")
shutil.copy2(setup_path, iso_src / "setup-cua-server.ps1")
if include_startup_nsh:
startup_nsh = work_dir / "startup.nsh"
startup_nsh.write_text("FS0:\\EFI\\BOOT\\BOOTX64.EFI\n", encoding="utf-8")
shutil.copy2(startup_nsh, iso_src / "startup.nsh")
_create_data_iso(iso_src, unattend_iso, "OEMDRV")
logger.info(f"Created unattend ISO: {unattend_iso}")
return unattend_iso
def _create_data_iso(src_dir: Path, dest_iso: Path, label: str = "OEMDRV") -> None:
"""Create a data-only ISO using pycdlib (pure Python, cross-platform)."""
import pycdlib
iso = pycdlib.PyCdlib()
iso.new(joliet=3, vol_ident=label)
for f in src_dir.iterdir():
if not f.is_file():
continue
iso_name = f.name.upper().replace("-", "_")
if "." in iso_name:
base, ext = iso_name.rsplit(".", 1)
iso9660_name = f"/{base[:8]}.{ext[:3]};1"
else:
iso9660_name = f"/{iso_name[:8]}.;1"
iso.add_file(
str(f),
iso_path=iso9660_name,
joliet_path=f"/{f.name}",
)
iso.write(str(dest_iso))
iso.close()
@@ -0,0 +1,479 @@
"""Image builder — pure-data immutable chained builder for sandbox images.
Supports Linux, macOS, and Windows constructors. Serializes to a spec dict
for cloud API or cloud-init consumption.
Usage::
from cua_sandbox import Image
img = (
Image.linux("ubuntu", "24.04")
.apt_install("curl", "git", "build-essential")
.pip_install("numpy", "pandas")
.env(MY_VAR="hello")
.run("echo 'setup complete'")
.expose(8080)
)
spec = img.to_dict()
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
logger = logging.getLogger(__name__)
_IMAGE_CACHE = Path.home() / ".cua" / "cua-sandbox" / "image-cache"
def _download_image(url: str) -> str:
"""Download an image URL to the local cache, extract if zipped.
Returns the path to the final disk image (qcow2, img, etc.).
Skips download if the file already exists in the cache.
"""
import hashlib
import urllib.request
_IMAGE_CACHE.mkdir(parents=True, exist_ok=True)
# Determine filename from URL
url_filename = url.rsplit("/", 1)[-1].split("?")[0]
# Use hash prefix to avoid collisions
url_hash = hashlib.sha256(url.encode()).hexdigest()[:12]
download_path = _IMAGE_CACHE / f"{url_hash}_{url_filename}"
# Check if we already have the extracted result
if download_path.suffix.lower() == ".zip":
# Look for an already-extracted disk image
extracted = _find_disk_image(_IMAGE_CACHE / url_hash)
if extracted:
logger.info(f"Using cached image: {extracted}")
return str(extracted)
if not download_path.exists():
logger.info(f"Downloading {url}{download_path}")
urllib.request.urlretrieve(url, str(download_path))
logger.info(f"Download complete: {download_path}")
# Extract zip files
if download_path.suffix.lower() == ".zip":
import zipfile
extract_dir = _IMAGE_CACHE / url_hash
extract_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"Extracting {download_path}{extract_dir}")
with zipfile.ZipFile(download_path) as zf:
zf.extractall(extract_dir)
# Find the disk image inside
disk = _find_disk_image(extract_dir)
if not disk:
raise FileNotFoundError(
f"No disk image found in {download_path}. "
f"Contents: {[f.name for f in extract_dir.rglob('*') if f.is_file()]}"
)
logger.info(f"Extracted disk image: {disk}")
return str(disk)
return str(download_path)
def _find_disk_image(directory: Path) -> Optional[Path]:
"""Find a disk image file in a directory."""
for ext in (".qcow2", ".img", ".raw", ".vhdx", ".vmdk"):
for f in directory.rglob(f"*{ext}"):
return f
return None
_INSTALL_OS_MAP: Dict[str, Tuple[str, ...]] = {
"apt_install": ("linux",),
"brew_install": ("macos",),
"choco_install": ("windows",),
"winget_install": ("windows",),
"apk_install": ("android",),
"pwa_install": ("android",),
}
@dataclass(frozen=True)
class Image:
"""Immutable, chainable image specification.
Each mutation method returns a new Image instance so that builders
can be forked at any point.
"""
os_type: str # "linux" | "macos" | "windows" | "android"
distro: str # e.g. "ubuntu", "macos", "windows"
version: str # e.g. "24.04", "15", "11"
kind: Optional[str] = None # "container" | "vm" | None (resolved after registry pull)
_layers: Tuple[Dict[str, Any], ...] = ()
_env: Tuple[Tuple[str, str], ...] = ()
_ports: Tuple[int, ...] = ()
_files: Tuple[Tuple[str, str], ...] = () # (src, dst)
_registry: Optional[str] = None # OCI registry reference
_disk_path: Optional[str] = None # local disk file path (qcow2, vhdx, raw)
_agent_type: Optional[str] = None # e.g. "osworld" for OSWorld Flask server
_snapshot_source: Optional[Dict[str, Any]] = None # set by Sandbox.snapshot()
# ── Constructors ─────────────────────────────────────────────────────
@classmethod
def linux(cls, distro: str = "ubuntu", version: str = "24.04", kind: str = "vm") -> Image:
"""Linux image. Defaults to 'vm' (QEMU). Use kind='container' for Docker/XFCE."""
return cls(os_type="linux", distro=distro, version=version, kind=kind)
@classmethod
def macos(cls, version: str = "26", kind: str = "vm") -> Image:
"""macOS image. Always a VM (Apple Virtualization / Lume).
Supported versions: ``"15"`` / ``"sequoia"``, ``"26"`` / ``"tahoe"``.
"""
from cua_sandbox.runtime.images import MACOS_VERSION_IMAGES
if version not in MACOS_VERSION_IMAGES:
supported = ", ".join(f'"{v}"' for v in MACOS_VERSION_IMAGES)
raise ValueError(f"Unsupported macOS version {version!r}. Supported: {supported}")
return cls(os_type="macos", distro="macos", version=version, kind=kind)
@classmethod
def windows(cls, version: str = "11", kind: str = "vm") -> Image:
"""Windows image. Always a VM (QEMU or Hyper-V)."""
return cls(os_type="windows", distro="windows", version=version, kind=kind)
@classmethod
def android(cls, version: str = "14", kind: str = "vm") -> Image:
"""Android image. Always a VM (QEMU emulator)."""
return cls(os_type="android", distro="android", version=version, kind=kind)
@classmethod
def from_registry(cls, ref: str) -> Image:
"""Create an image from a registry reference. kind is resolved after pull."""
return cls(os_type="linux", distro="registry", version="latest", kind=None, _registry=ref)
@classmethod
def from_file(
cls,
path: str,
*,
os_type: str = "windows",
kind: str = "vm",
agent_type: Optional[str] = None,
) -> Image:
"""Create an image from a local disk, ISO file, or URL.
Supported formats: qcow2, vhdx, raw, img, iso.
URLs (http/https) are downloaded automatically. Zip files are extracted.
For ISOs, the runtime will create a qcow2 disk and attach the ISO
as a CD-ROM for installation/boot.
Args:
path: Local file path or URL (http/https).
os_type: OS type hint ("linux", "windows", "macos", "android").
kind: "vm" or "container".
agent_type: Agent type hint (e.g. "osworld" for OSWorld Flask server).
"""
if path.startswith(("http://", "https://")):
path = _download_image(path)
return cls(
os_type=os_type,
distro="local",
version="local",
kind=kind,
_disk_path=path,
_agent_type=agent_type,
)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> Image:
"""Reconstruct an Image from a serialized spec dict."""
img = cls(
os_type=data["os_type"],
distro=data["distro"],
version=data["version"],
kind=data.get("kind"),
_registry=data.get("registry"),
)
# Replay layers
for layer in data.get("layers", []):
img = img._add_layer(layer)
for k, v in data.get("env", {}).items():
img = img.env(**{k: v})
for p in data.get("ports", []):
img = img.expose(p)
for src, dst in data.get("files", []):
img = img.copy(src, dst)
return img
def _check_os(self, layer_type: str) -> None:
"""Raise ValueError if this install method is incompatible with os_type."""
allowed = _INSTALL_OS_MAP.get(layer_type)
if allowed and self.os_type not in allowed:
raise ValueError(
f"{layer_type} is not supported on {self.os_type!r} images. "
f"Supported OS types: {', '.join(allowed)}"
)
# ── Chainable mutations (return new Image) ───────────────────────────
def _add_layer(self, layer: Dict[str, Any]) -> Image:
return Image(
os_type=self.os_type,
distro=self.distro,
version=self.version,
kind=self.kind,
_layers=self._layers + (layer,),
_env=self._env,
_ports=self._ports,
_files=self._files,
_registry=self._registry,
_disk_path=self._disk_path,
_agent_type=self._agent_type,
_snapshot_source=self._snapshot_source,
)
def _with(self, **kwargs) -> Image:
"""Return a new Image with specific fields overridden."""
fields = {
"os_type": self.os_type,
"distro": self.distro,
"version": self.version,
"kind": self.kind,
"_layers": self._layers,
"_env": self._env,
"_ports": self._ports,
"_files": self._files,
"_registry": self._registry,
"_disk_path": self._disk_path,
"_agent_type": self._agent_type,
"_snapshot_source": self._snapshot_source,
}
fields.update(kwargs)
return Image(**fields)
def apt_install(self, *packages: str) -> Image:
"""Install packages via apt (Linux only)."""
self._check_os("apt_install")
return self._add_layer({"type": "apt_install", "packages": list(packages)})
def brew_install(self, *packages: str) -> Image:
"""Install packages via Homebrew (macOS)."""
self._check_os("brew_install")
return self._add_layer({"type": "brew_install", "packages": list(packages)})
def choco_install(self, *packages: str) -> Image:
"""Install packages via Chocolatey (Windows)."""
self._check_os("choco_install")
return self._add_layer({"type": "choco_install", "packages": list(packages)})
def winget_install(self, *packages: str) -> Image:
"""Install packages via winget (Windows)."""
self._check_os("winget_install")
return self._add_layer({"type": "winget_install", "packages": list(packages)})
def apk_install(self, *apk_paths: str) -> Image:
"""Install APK files via adb (Android only)."""
self._check_os("apk_install")
return self._add_layer({"type": "apk_install", "packages": list(apk_paths)})
def pwa_install(
self,
manifest_url: str,
package_name: Optional[str] = None,
keystore: Optional[str] = None,
keystore_alias: str = "android",
keystore_password: str = "android",
builder: str = "pwa2apk",
push_timeout: Optional[float] = None,
) -> "Image":
"""Build an APK from a PWA manifest URL and install it (Android only).
Two builder backends are supported:
* ``"pwa2apk"`` (default) — generates a lightweight WebView-based APK.
No Chrome dependency, no "Running in Chrome" banner, no Digital Asset
Links needed. ~10 KB APK.
* ``"bubblewrap"`` — generates a Chrome Trusted Web Activity (TWA) APK.
Requires Chrome on the device and a matching
``/.well-known/assetlinks.json`` on the server. Shows a mandatory
"Running in Chrome" privacy disclosure on every launch.
Args:
manifest_url: Full URL to the PWA's ``manifest.json`` or
``manifest.webmanifest``.
Example: ``"http://10.0.2.2:3000/manifest.json"``
package_name: Android package ID. Defaults to a reversed-hostname
derivation (e.g. ``"com.example.app"``).
keystore: Path to a ``*.keystore`` / ``*.jks`` file. When
omitted a fresh keystore is generated and cached.
Pass the keystore bundled with your PWA repo so the
fingerprint is deterministic.
keystore_alias: Key alias inside the keystore (default ``"android"``).
keystore_password: Password for both the store and the key
(default ``"android"``).
builder: ``"pwa2apk"`` (default) or ``"bubblewrap"``.
push_timeout: Optional seconds for the ``write_bytes`` ``adb push``
of the built APK. When ``None`` the server default
applies.
"""
if builder not in ("pwa2apk", "bubblewrap"):
raise ValueError(f"builder must be 'pwa2apk' or 'bubblewrap', got {builder!r}")
self._check_os("pwa_install")
layer: dict = {"type": "pwa_install", "manifest_url": manifest_url, "builder": builder}
if package_name:
layer["package_name"] = package_name
if keystore:
layer["keystore"] = keystore
layer["keystore_alias"] = keystore_alias
layer["keystore_password"] = keystore_password
if push_timeout is not None:
layer["push_timeout"] = push_timeout
return self._add_layer(layer)
def uv_install(self, *packages: str) -> Image:
"""Install Python packages via uv add into the cua-server project."""
return self._add_layer({"type": "uv_install", "packages": list(packages)})
def pip_install(self, *packages: str) -> Image:
"""Install Python packages via pip."""
return self._add_layer({"type": "pip_install", "packages": list(packages)})
def app_install(self, app_id: str) -> Image:
"""Install an app from the cua-sandbox-apps catalog.
Requires ``cua-sandbox-apps`` to be installed. The app's install
script for this image's OS is executed as a build layer.
"""
return self._add_layer({"type": "app_install", "app_id": app_id})
def run(self, command: str) -> Image:
"""Run a shell command during image build."""
return self._add_layer({"type": "run", "command": command})
def env(self, **variables: str) -> Image:
"""Set environment variables."""
new_env = self._env + tuple(variables.items())
return self._with(_env=new_env)
def copy(self, src: str, dst: str) -> Image:
"""Copy a file into the image."""
new_files = self._files + ((src, dst),)
return self._with(_files=new_files)
def expose(self, port: int) -> Image:
"""Expose a port."""
new_ports = self._ports + (port,)
return self._with(_ports=new_ports)
# ── Serialization ────────────────────────────────────────────────────
def to_dict(self) -> Dict[str, Any]:
"""Serialize to a plain dict suitable for JSON or cloud API."""
d: Dict[str, Any] = {
"os_type": self.os_type,
"distro": self.distro,
"version": self.version,
"kind": self.kind,
"layers": list(self._layers),
}
if self._env:
d["env"] = dict(self._env)
if self._ports:
d["ports"] = list(self._ports)
if self._files:
d["files"] = [list(f) for f in self._files]
if self._registry:
d["registry"] = self._registry
return d
def to_cloud_init(self) -> str:
"""Generate a cloud-init user-data script from the image layers."""
lines = ["#!/bin/bash", "set -e"]
for k, v in self._env:
lines.append(f"export {k}={v!r}")
for layer in self._layers:
lt = layer["type"]
if lt == "apt_install":
pkgs = " ".join(layer["packages"])
lines.append(f"apt-get update && apt-get install -y {pkgs}")
elif lt == "brew_install":
pkgs = " ".join(layer["packages"])
lines.append(f"brew install {pkgs}")
elif lt == "winget_install":
for pkg in layer["packages"]:
lines.append(
f"winget install --accept-source-agreements --accept-package-agreements -e --id {pkg}"
)
elif lt == "uv_install":
pkgs = " ".join(layer["packages"])
lines.append(f"uv add --directory ~/cua-server {pkgs}")
elif lt == "choco_install":
pkgs = " ".join(layer["packages"])
lines.append(f"choco install -y {pkgs}")
elif lt == "pip_install":
pkgs = " ".join(layer["packages"])
lines.append(f"pip install {pkgs}")
elif lt == "apk_install":
for apk in layer["packages"]:
lines.append(f"adb install {apk}")
elif lt == "pwa_install":
manifest_url = layer["manifest_url"]
builder = layer.get("builder", "pwa2apk")
if builder == "pwa2apk":
lines.append(
f"# pwa2apk: WebView APK (no Chrome dependency)\n"
f"if [ ! -d /tmp/pwa2apk ]; then git clone https://github.com/trycua/pwa2apk.git /tmp/pwa2apk; fi\n"
f"node /tmp/pwa2apk/src/cli.js '{manifest_url}' --output /tmp/pwa.apk\n"
f"adb install /tmp/pwa.apk"
)
else:
lines.append(
f"# bubblewrap: Chrome TWA APK\n"
f"npm install -g @bubblewrap/cli 2>/dev/null || true\n"
f"_BWW_DIR=$(mktemp -d)\n"
f"(cd \"$_BWW_DIR\" && bubblewrap init --manifest '{manifest_url}' --directory . --skipPwaValidation && bubblewrap build --skipSigning)\n"
f'adb install "$_BWW_DIR/app-release-unsigned.apk"'
)
elif lt == "run":
lines.append(layer["command"])
return "\n".join(lines) + "\n"
def local_support(self): # -> RuntimeSupport (lazy import avoids circular dep)
"""Check whether this image can run locally on the current host.
Returns a :class:`~cua_sandbox.runtime.compat.RuntimeSupport` describing:
- ``supported`` — runtime is available or auto-installable on this OS
- ``hw_accel`` — hardware acceleration (HVF / KVM / Hyper-V) is available
- ``runtime_installed`` — runtime binary found right now (no install needed)
- ``auto_installable`` — SDK can install the runtime automatically
- ``reason`` — human-readable explanation
In tests, use the bundled helper instead of checking manually::
from cua_sandbox.runtime.compat import skip_if_unsupported
async def test_something():
skip_if_unsupported(Image.macos())
async with Sandbox.ephemeral(Image.macos(), local=True) as sb:
...
"""
from cua_sandbox.runtime.compat import ( # noqa: F401
RuntimeSupport,
check_local_support,
)
return check_local_support(self)
def __repr__(self) -> str:
reg = f", registry={self._registry!r}" if self._registry else ""
return (
f"Image({self.os_type}/{self.distro}:{self.version}, "
f"kind={self.kind}, {len(self._layers)} layers{reg})"
)
@@ -0,0 +1,27 @@
from cua_sandbox.interfaces.apps import Apps
from cua_sandbox.interfaces.clipboard import Clipboard
from cua_sandbox.interfaces.files import FileEntry, Files
from cua_sandbox.interfaces.keyboard import Keyboard
from cua_sandbox.interfaces.mobile import Mobile
from cua_sandbox.interfaces.mouse import Mouse
from cua_sandbox.interfaces.screen import Screen
from cua_sandbox.interfaces.shell import Shell
from cua_sandbox.interfaces.terminal import Terminal
from cua_sandbox.interfaces.tunnel import Tunnel, TunnelInfo
from cua_sandbox.interfaces.window import Window
__all__ = [
"Apps",
"Clipboard",
"FileEntry",
"Files",
"Keyboard",
"Mobile",
"Mouse",
"Screen",
"Shell",
"Terminal",
"Tunnel",
"TunnelInfo",
"Window",
]
@@ -0,0 +1,60 @@
"""Apps interface — install and launch apps from the cua-sandbox-apps catalog."""
from __future__ import annotations
from cua_sandbox.interfaces.shell import CommandResult
from cua_sandbox.transport.base import Transport
class Apps:
"""Install and launch cataloged applications inside the sandbox."""
def __init__(self, transport: Transport, os_type: str):
self._t = transport
self._os_type = os_type
async def install(self, app_id: str) -> CommandResult:
"""Install an app by its catalog ID (e.g. ``"unity"``, ``"godot-engine"``)."""
from cua_sandbox.builder.executor import _find_app_install_script
script = _find_app_install_script(app_id, self._os_type)
if script is None:
return CommandResult(
stdout="",
returncode=1,
stderr=f"No install script for '{app_id}' on {self._os_type}. "
f"Install cua-sandbox-apps or run 'cua-sandbox-apps generate'.",
)
result = await self._t.send(
"run_command", command=f"bash -c {_sh_quote(script)}", timeout=900
)
if isinstance(result, dict):
return CommandResult(
stdout=result.get("stdout", ""),
stderr=result.get("stderr", ""),
returncode=result.get("return_code", result.get("returncode", -1)),
)
return CommandResult(
stdout=getattr(result, "stdout", ""),
stderr=getattr(result, "stderr", ""),
returncode=getattr(result, "returncode", -1),
)
async def launch(self, app_id: str) -> CommandResult:
"""Launch a previously-installed app by its catalog ID."""
from cua_sandbox.builder.executor import _find_app_launch_script
script = _find_app_launch_script(app_id, self._os_type)
if script is None:
return CommandResult(
stdout="",
returncode=1,
stderr=f"No launch script for '{app_id}' on {self._os_type}.",
)
result = await self._t.pty_create(command=f"bash -c {_sh_quote(script)}")
pid = result.get("pid") if isinstance(result, dict) else None
return CommandResult(stdout=str(pid or ""), stderr="", returncode=0)
def _sh_quote(s: str) -> str:
return "'" + s.replace("'", "'\\''") + "'"
@@ -0,0 +1,23 @@
"""Clipboard interface, backed by a Transport."""
from __future__ import annotations
from cua_sandbox.transport.base import Transport
class Clipboard:
"""Clipboard read/write."""
def __init__(self, transport: Transport):
self._t = transport
async def get(self) -> str:
"""Return the current clipboard text."""
result = await self._t.send("copy_to_clipboard")
if isinstance(result, dict):
return result.get("content", "")
return result
async def set(self, text: str) -> None:
"""Set the clipboard text."""
await self._t.send("set_clipboard", text=text)
@@ -0,0 +1,119 @@
"""Files interface — file operations inside the sandbox, backed by a Transport.
Wraps the computer-server file_handler commands (read_text, write_text,
read_bytes, write_bytes, file_exists, directory_exists, list_dir, create_dir,
delete_file, delete_dir, get_file_size).
Adds two helpers on top of the wire commands:
- ``upload(local_path, remote_path)`` — push a host file into the sandbox.
- ``download(remote_path, local_path)`` — pull a sandbox file to the host.
"""
from __future__ import annotations
import base64
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional, Union
from cua_sandbox.transport.base import Transport
@dataclass
class FileEntry:
name: str
path: str
is_dir: bool
size: Optional[int] = None
def _unwrap(result: Any, key: str, default: Any = None) -> Any:
"""computer-server returns dicts like {'success': True, 'content': ...}."""
if isinstance(result, dict):
return result.get(key, default)
return default
class Files:
"""File and directory operations inside the sandbox.
All paths are inside the sandbox unless otherwise noted.
"""
def __init__(self, transport: Transport):
self._t = transport
# ── existence / metadata ─────────────────────────────────────────────
async def exists(self, path: str) -> bool:
result = await self._t.send("file_exists", path=path)
return bool(_unwrap(result, "exists", False))
async def is_dir(self, path: str) -> bool:
result = await self._t.send("directory_exists", path=path)
return bool(_unwrap(result, "exists", False))
async def size(self, path: str) -> int:
result = await self._t.send("get_file_size", path=path)
return int(_unwrap(result, "size", 0) or 0)
# ── directories ──────────────────────────────────────────────────────
async def list(self, path: str) -> list[FileEntry]:
result = await self._t.send("list_dir", path=path)
raw = _unwrap(result, "files", []) or _unwrap(result, "entries", []) or []
out: list[FileEntry] = []
for item in raw:
if isinstance(item, str):
out.append(FileEntry(name=item, path=f"{path.rstrip('/')}/{item}", is_dir=False))
elif isinstance(item, dict):
out.append(
FileEntry(
name=item.get("name", ""),
path=item.get("path", f"{path.rstrip('/')}/{item.get('name', '')}"),
is_dir=bool(item.get("is_dir", False)),
size=item.get("size"),
)
)
return out
async def make_dir(self, path: str) -> None:
await self._t.send("create_dir", path=path)
async def remove_dir(self, path: str) -> None:
await self._t.send("delete_dir", path=path)
async def remove(self, path: str) -> None:
await self._t.send("delete_file", path=path)
# ── text I/O ─────────────────────────────────────────────────────────
async def read_text(self, path: str) -> str:
result = await self._t.send("read_text", path=path)
return _unwrap(result, "content", "") or ""
async def write_text(self, path: str, content: str) -> None:
await self._t.send("write_text", path=path, content=content)
# ── binary I/O ───────────────────────────────────────────────────────
async def read_bytes(self, path: str, offset: int = 0, length: Optional[int] = None) -> bytes:
kwargs: dict[str, Any] = {"path": path, "offset": offset}
if length is not None:
kwargs["length"] = length
result = await self._t.send("read_bytes", **kwargs)
b64 = _unwrap(result, "content_b64", "") or _unwrap(result, "content", "") or ""
if not b64:
return b""
return base64.b64decode(b64)
async def write_bytes(self, path: str, content: bytes) -> None:
b64 = base64.b64encode(content).decode("ascii")
await self._t.send("write_bytes", path=path, content_b64=b64)
# ── host ↔ sandbox transfer ──────────────────────────────────────────
async def upload(self, local_path: Union[str, Path], remote_path: str) -> None:
"""Push a file from the host into the sandbox."""
data = Path(local_path).read_bytes()
await self.write_bytes(remote_path, data)
async def download(self, remote_path: str, local_path: Union[str, Path]) -> None:
"""Pull a file from the sandbox down to the host."""
data = await self.read_bytes(remote_path)
Path(local_path).write_bytes(data)
@@ -0,0 +1,30 @@
"""Keyboard interface — type text and press keys, backed by a Transport."""
from __future__ import annotations
from typing import List, Union
from cua_sandbox.transport.base import Transport
class Keyboard:
"""Keyboard control."""
def __init__(self, transport: Transport):
self._t = transport
async def type(self, text: str) -> None:
"""Type a string of text."""
await self._t.send("type_text", text=text)
async def keypress(self, keys: Union[List[str], str]) -> None:
"""Press a key combination (e.g. ["ctrl", "c"] or "enter")."""
if isinstance(keys, str):
keys = [keys]
await self._t.send("hotkey", keys=keys)
async def key_down(self, key: str) -> None:
await self._t.send("key_down", key=key)
async def key_up(self, key: str) -> None:
await self._t.send("key_up", key=key)
@@ -0,0 +1,191 @@
"""Mobile interface — touch, gestures, and hardware keys via ADB, backed by a Transport."""
from __future__ import annotations
import asyncio
from cua_sandbox.transport.base import Transport
class Mobile:
"""Mobile (Android) touch and hardware-key control.
All touch coordinates are in screen pixels. Single-touch methods use
``input tap/swipe`` via ``adb shell``. Multi-touch gestures delegate to
the ``multitouch_gesture`` transport action, which uses ``adb root`` +
MT Protocol B ``sendevent`` for reliable injection on both local and cloud
transports.
"""
def __init__(self, transport: Transport):
self._t = transport
async def _shell(self, command: str) -> None:
await self._t.send("shell", command=command)
# ── Touch ─────────────────────────────────────────────────────────────
async def tap(self, x: int, y: int) -> None:
await self._shell(f"input tap {x} {y}")
async def long_press(self, x: int, y: int, duration_ms: int = 1000) -> None:
await self._shell(f"input swipe {x} {y} {x} {y} {duration_ms}")
async def double_tap(self, x: int, y: int, delay: float = 0.1) -> None:
await self._shell(f"input tap {x} {y}")
await asyncio.sleep(delay)
await self._shell(f"input tap {x} {y}")
async def type_text(self, text: str) -> None:
encoded = text.replace(" ", "%s")
await self._shell(f"input text '{encoded}'")
# ── Gestures ──────────────────────────────────────────────────────────
async def swipe(self, x1: int, y1: int, x2: int, y2: int, duration_ms: int = 300) -> None:
await self._shell(f"input swipe {x1} {y1} {x2} {y2} {duration_ms}")
async def scroll_up(self, x: int, y: int, distance: int = 600, duration_ms: int = 400) -> None:
await self._shell(f"input swipe {x} {y + distance} {x} {y} {duration_ms}")
async def scroll_down(
self, x: int, y: int, distance: int = 600, duration_ms: int = 400
) -> None:
await self._shell(f"input swipe {x} {y} {x} {y + distance} {duration_ms}")
async def scroll_left(
self, x: int, y: int, distance: int = 400, duration_ms: int = 300
) -> None:
await self._shell(f"input swipe {x + distance} {y} {x} {y} {duration_ms}")
async def scroll_right(
self, x: int, y: int, distance: int = 400, duration_ms: int = 300
) -> None:
await self._shell(f"input swipe {x} {y} {x + distance} {y} {duration_ms}")
async def fling(self, x1: int, y1: int, x2: int, y2: int) -> None:
await self.swipe(x1, y1, x2, y2, duration_ms=80)
async def _get_screen_size(self) -> tuple[int, int]:
"""Return ``(width, height)`` in screen pixels via ``wm size``."""
result = await self._t.send("shell", command="wm size")
output = result.get("stdout", "").strip()
for line in output.splitlines():
if "size" in line.lower():
dims = line.split(":")[-1].strip()
w, h = dims.split("x")
return int(w), int(h)
return 32768, 32768 # fallback: 1:1 mapping
async def gesture(
self,
*finger_paths: tuple[int, int],
duration_ms: int = 400,
steps: int = 0,
) -> None:
"""Inject an arbitrary N-finger gesture via MT Protocol B ``sendevent``.
Each positional argument is a sequence of ``(x, y)`` waypoints for one
finger. Pass an even number of ``(x, y)`` tuples and they are paired
into start/end positions per finger::
# two-finger pinch-out
await mobile.gesture(
(cx - 20, cy), (cx - 200, cy), # finger 0
(cx + 20, cy), (cx + 200, cy), # finger 1
)
Delegates to the ``multitouch_gesture`` transport action, which uses
``adb root`` + MT Protocol B ``sendevent`` for reliable injection on
both local ADB and cloud HTTP transports.
Args:
*finger_paths: Alternating start/end ``(x, y)`` tuples, two per finger.
Must have an even length >= 4 (at least 2 fingers × 2 points).
duration_ms: Total gesture duration in milliseconds.
steps: Interpolation steps (0 = auto: duration_ms // 20, min 5).
"""
if len(finger_paths) < 4 or len(finger_paths) % 2 != 0:
raise ValueError(
"gesture() requires an even number of (x, y) tuples, two per finger "
f"(start + end). Got {len(finger_paths)} tuples."
)
# Pair waypoints into per-finger (start, end) tuples
fingers_pairs: list[tuple[tuple[int, int], tuple[int, int]]] = []
for i in range(0, len(finger_paths), 2):
fingers_pairs.append((finger_paths[i], finger_paths[i + 1]))
sw, sh = await self._get_screen_size()
n_steps = steps if steps > 0 else max(5, duration_ms // 20)
# Structured params for the server-side multitouch_gesture action
fingers_payload = [{"start": list(start), "end": list(end)} for start, end in fingers_pairs]
await self._t.send(
"multitouch_gesture",
fingers=fingers_payload,
screen_w=sw,
screen_h=sh,
duration_ms=duration_ms,
steps=n_steps,
)
async def pinch_in(self, cx: int, cy: int, spread: int = 300, duration_ms: int = 400) -> None:
"""Pinch-in (zoom out) with two real simultaneous fingers."""
await self.gesture(
(cx - spread, cy),
(cx - 20, cy),
(cx + spread, cy),
(cx + 20, cy),
duration_ms=duration_ms,
)
async def pinch_out(self, cx: int, cy: int, spread: int = 300, duration_ms: int = 400) -> None:
"""Pinch-out (zoom in) with two real simultaneous fingers."""
await self.gesture(
(cx - 20, cy),
(cx - spread, cy),
(cx + 20, cy),
(cx + spread, cy),
duration_ms=duration_ms,
)
# ── Hardware keys ─────────────────────────────────────────────────────
async def key(self, keycode: int) -> None:
await self._shell(f"input keyevent {keycode}")
async def home(self) -> None:
await self.key(3)
async def back(self) -> None:
await self.key(4)
async def recents(self) -> None:
await self.key(187)
async def power(self) -> None:
await self.key(26)
async def volume_up(self) -> None:
await self.key(24)
async def volume_down(self) -> None:
await self.key(25)
async def enter(self) -> None:
await self.key(66)
async def backspace(self) -> None:
await self.key(67)
# ── System ────────────────────────────────────────────────────────────
async def wake(self) -> None:
await self.key(224)
async def notifications(self) -> None:
await self._shell("service call statusbar 1")
async def close_notifications(self) -> None:
await self._shell("service call statusbar 2")
@@ -0,0 +1,42 @@
"""Mouse interface — click, move, drag, scroll, backed by a Transport."""
from __future__ import annotations
from cua_sandbox.transport.base import Transport
class Mouse:
"""Mouse control."""
def __init__(self, transport: Transport):
self._t = transport
async def click(self, x: int, y: int, button: str = "left") -> None:
await self._t.send("left_click", x=x, y=y, button=button)
async def right_click(self, x: int, y: int) -> None:
await self._t.send("right_click", x=x, y=y)
async def double_click(self, x: int, y: int) -> None:
await self._t.send("double_click", x=x, y=y)
async def move(self, x: int, y: int) -> None:
await self._t.send("move_cursor", x=x, y=y)
async def scroll(self, x: int, y: int, scroll_x: int = 0, scroll_y: int = 3) -> None:
await self._t.send("scroll", x=x, y=y, scroll_x=scroll_x, scroll_y=scroll_y)
async def mouse_down(self, x: int, y: int, button: str = "left") -> None:
await self._t.send("mouse_down", x=x, y=y, button=button)
async def mouse_up(self, x: int, y: int, button: str = "left") -> None:
await self._t.send("mouse_up", x=x, y=y, button=button)
async def drag(
self, start_x: int, start_y: int, end_x: int, end_y: int, button: str = "left"
) -> None:
await self._t.send(
"drag",
path=[[start_x, start_y], [end_x, end_y]],
button=button,
)
@@ -0,0 +1,34 @@
"""Screen interface — screenshots and screen info, backed by a Transport."""
from __future__ import annotations
import base64
from typing import Tuple
from cua_sandbox.transport.base import Transport
class Screen:
"""Screen capture and info."""
def __init__(self, transport: Transport):
self._t = transport
async def screenshot(self, format: str = "png", quality: int = 95) -> bytes:
"""Capture a screenshot and return raw image bytes.
Args:
format: "png" (lossless, default) or "jpeg" (lossy, ~5-10x smaller).
quality: JPEG quality 1-95, ignored for PNG.
"""
return await self._t.screenshot(format=format, quality=quality)
async def screenshot_base64(self, format: str = "png", quality: int = 95) -> str:
"""Capture a screenshot and return as a base64-encoded string."""
raw = await self._t.screenshot(format=format, quality=quality)
return base64.b64encode(raw).decode("ascii")
async def size(self) -> Tuple[int, int]:
"""Return (width, height) of the screen."""
d = await self._t.get_screen_size()
return d["width"], d["height"]
@@ -0,0 +1,57 @@
"""Shell interface — run commands, backed by a Transport."""
from __future__ import annotations
from dataclasses import dataclass
from cua_sandbox.transport.base import Transport
@dataclass
class CommandResult:
stdout: str
stderr: str
returncode: int
@property
def success(self) -> bool:
return self.returncode == 0
class Shell:
"""Shell command execution."""
def __init__(self, transport: Transport):
self._t = transport
async def run(
self,
command: str,
timeout: int = 30,
background: bool = False,
) -> CommandResult:
"""Run a shell command and return the result.
When ``background=True``, returns immediately with ``stdout=str(pid)``
and ``returncode=0``; poll for completion via a sentinel file or by
inspecting the process list.
"""
if background:
session = await self._t.pty_create(command=command)
pid = session.get("pid") if isinstance(session, dict) else None
return CommandResult(stdout=str(pid or ""), stderr="", returncode=0)
result = await self._t.send("run_command", command=command, timeout=timeout)
if isinstance(result, dict):
rc = result.get("returncode", result.get("return_code", -1))
return CommandResult(
stdout=result.get("stdout", ""),
stderr=result.get("stderr", ""),
returncode=rc if rc is not None else 0,
)
# LocalTransport returns cua_auto.shell.CommandResult directly
return CommandResult(
stdout=getattr(result, "stdout", ""),
stderr=getattr(result, "stderr", ""),
returncode=getattr(result, "returncode", -1),
)
@@ -0,0 +1,30 @@
"""Terminal (PTY) interface, backed by a Transport."""
from __future__ import annotations
from typing import Optional
from cua_sandbox.transport.base import Transport
class Terminal:
"""PTY terminal sessions."""
def __init__(self, transport: Transport):
self._t = transport
async def create(self, command: Optional[str] = None, cols: int = 80, rows: int = 24) -> dict:
"""Create a new PTY session. Returns {"pid": int, "cols": int, "rows": int}."""
return await self._t.pty_create(command=command, cols=cols, rows=rows)
async def send_input(self, pid: int, data: str) -> None:
"""Send input to a PTY session."""
await self._t.pty_send(pid, data)
async def info(self, pid: int) -> Optional[dict]:
"""Return session info or None if gone."""
return await self._t.pty_info(pid)
async def close(self, pid: int) -> bool:
"""Kill a PTY session."""
return await self._t.pty_kill(pid)
@@ -0,0 +1,115 @@
"""Tunnel interface — forward a port from the sandbox to the host.
Usage::
# Async context manager (port released on exit)
async with sb.tunnel.forward(9222) as t:
print(t.url) # "http://localhost:49823"
print(t.host, t.port) # "localhost", 49823
# Plain await (caller must call .close())
t = await sb.tunnel.forward(9222)
await t.close()
# Multiple ports — returns dict[sandbox_port, TunnelInfo]
async with sb.tunnel.forward(9222, 8080) as tunnels:
devtools = tunnels[9222].url
app = tunnels[8080].url
"""
from __future__ import annotations
import asyncio
from typing import Dict, List, Optional, Union
from cua_sandbox.transport.base import Transport
class TunnelInfo:
"""A single forwarded port."""
def __init__(self, host: str, port: int, sandbox_port: int) -> None:
self.host = host
self.port = port # host-side port
self.sandbox_port = sandbox_port # original port inside sandbox
self._closer: Optional[object] = None # Callable[[TunnelInfo], Coroutine]
@property
def url(self) -> str:
return f"http://{self.host}:{self.port}"
async def close(self) -> None:
"""Close this tunnel (no-op if already closed or inside a context manager)."""
if self._closer is not None:
await self._closer(self) # type: ignore[operator]
self._closer = None
def __repr__(self) -> str:
return f"TunnelInfo(host={self.host!r}, port={self.port}, sandbox_port={self.sandbox_port})"
class _TunnelContext:
"""Returned by Tunnel.forward() — supports both await and async with."""
def __init__(self, transport: Transport, ports: tuple[int | str, ...]):
self._t = transport
self._ports = ports
self._infos: List[TunnelInfo] = []
# ── awaitable ─────────────────────────────────────────────────────────────
def __await__(self):
return self._open().__await__()
async def _open(self) -> Union[TunnelInfo, Dict[int | str, TunnelInfo]]:
for p in self._ports:
info = await self._t.forward_tunnel(p)
info._closer = self._close_one
self._infos.append(info)
return self._result()
def _result(self) -> Union[TunnelInfo, Dict[int | str, TunnelInfo]]:
if len(self._infos) == 1:
return self._infos[0]
return {i.sandbox_port: i for i in self._infos}
# ── async context manager ─────────────────────────────────────────────────
async def __aenter__(self) -> Union[TunnelInfo, Dict[int, TunnelInfo]]:
return await self._open()
async def __aexit__(self, *_) -> None:
await asyncio.gather(*(self._t.close_tunnel(i) for i in self._infos))
self._infos.clear()
async def _close_one(self, info: TunnelInfo) -> None:
await self._t.close_tunnel(info)
self._infos = [i for i in self._infos if i is not info]
class Tunnel:
"""Port-forwarding interface — exposes sandbox ports on the host."""
def __init__(self, transport: Transport):
self._t = transport
def forward(self, *ports: int | str) -> _TunnelContext:
"""Forward one or more sandbox ports (or abstract sockets) to the host.
*ports* may be:
- ``int`` — TCP port inside the sandbox (e.g. ``8080``)
- ``str`` — Android abstract socket name (e.g. ``"chrome_devtools_remote"``)
Returns a context manager (or awaitable) that yields:
- a single :class:`TunnelInfo` when one target is given
- a ``dict[sandbox_port, TunnelInfo]`` when multiple targets are given
Example — Chrome DevTools over ADB::
async with sb.tunnel.forward("chrome_devtools_remote") as t:
# http://localhost:<random>/json lists CDP targets
print(t.url)
"""
if not ports:
raise ValueError("forward() requires at least one port or socket name")
return _TunnelContext(self._t, ports)
@@ -0,0 +1,16 @@
"""Window interface, backed by a Transport."""
from __future__ import annotations
from cua_sandbox.transport.base import Transport
class Window:
"""Window management."""
def __init__(self, transport: Transport):
self._t = transport
async def get_active_title(self) -> str:
"""Return the title of the currently focused window."""
return await self._t.send("get_active_window_title")
@@ -0,0 +1,100 @@
"""Localhost — wraps cua_auto directly. No sandbox, no computer-server.
All cua_auto calls are sync; async wrappers use asyncio.to_thread().
Usage::
from cua_sandbox import localhost
async with localhost() as host:
await host.mouse.click(100, 200)
img = await host.screen.screenshot()
"""
from __future__ import annotations
from contextlib import asynccontextmanager
from typing import AsyncIterator, Optional
from cua_sandbox.interfaces import (
Clipboard,
Keyboard,
Mouse,
Screen,
Shell,
Terminal,
Window,
)
from cua_sandbox.sandbox import _ConnectResult
from cua_sandbox.transport.local import LocalTransport
class Localhost:
"""Direct host control via cua_auto — no sandboxing."""
def __init__(self) -> None:
self._transport = LocalTransport()
self.screen = Screen(self._transport)
self.mouse = Mouse(self._transport)
self.keyboard = Keyboard(self._transport)
self.clipboard = Clipboard(self._transport)
self.shell = Shell(self._transport)
self.window = Window(self._transport)
self.terminal = Terminal(self._transport)
async def _connect(self) -> None:
await self._transport.connect()
async def disconnect(self) -> None:
await self._transport.disconnect()
async def screenshot(self, text: Optional[str] = None) -> bytes:
return await self._transport.screenshot()
async def screenshot_base64(self, text: Optional[str] = None) -> str:
return await self.screen.screenshot_base64()
async def get_environment(self) -> str:
return await self._transport.get_environment()
async def get_dimensions(self) -> tuple[int, int]:
return await self.screen.size()
@classmethod
def connect(cls) -> "_ConnectResult":
"""Connect to the local machine.
Supports both ``await`` and ``async with``.
Examples::
# plain await
host = await Localhost.connect()
await host.shell.run("echo hello")
await host.disconnect()
# context manager
async with Localhost.connect() as host:
await host.shell.run("echo hello")
"""
async def _factory() -> "Localhost":
host = cls()
await host._connect()
return host
return _ConnectResult(_factory)
def __repr__(self) -> str:
return "Localhost()"
@asynccontextmanager
async def localhost() -> AsyncIterator[Localhost]:
"""Async context manager yielding a Localhost instance.
.. deprecated::
Prefer ``Localhost.connect()`` instead.
"""
async with Localhost.connect() as host:
yield host
@@ -0,0 +1,28 @@
from cua_sandbox.registry.cache import ImageCache
from cua_sandbox.registry.manifest import (
ImageFormat,
detect_format,
detect_kind,
detect_os_from_config,
get_manifest,
)
from cua_sandbox.registry.qemu_builder import QEMUImageConfig
from cua_sandbox.registry.qemu_builder import build_image as build_qemu_image
from cua_sandbox.registry.qemu_builder import pull_qemu_image
from cua_sandbox.registry.qemu_builder import push_image as push_qemu_image
from cua_sandbox.registry.resolve import pull_image, resolve_image_kind
__all__ = [
"get_manifest",
"detect_kind",
"detect_format",
"detect_os_from_config",
"ImageFormat",
"resolve_image_kind",
"pull_image",
"ImageCache",
"QEMUImageConfig",
"push_qemu_image",
"pull_qemu_image",
"build_qemu_image",
]
@@ -0,0 +1,63 @@
"""Local image cache at ~/.cua/cua-sandbox/images/."""
from __future__ import annotations
import json
import shutil
from pathlib import Path
from typing import Optional
CACHE_ROOT = Path.home() / ".cua" / "cua-sandbox" / "images"
class ImageCache:
"""Manages locally cached VM/container images."""
def __init__(self, root: Optional[Path] = None):
self.root = root or CACHE_ROOT
self.root.mkdir(parents=True, exist_ok=True)
def image_dir(self, registry: str, org: str, name: str, tag: str) -> Path:
return self.root / registry / org / name / tag
def is_cached(self, registry: str, org: str, name: str, tag: str) -> bool:
return (self.image_dir(registry, org, name, tag) / "manifest.json").exists()
def save_manifest(self, registry: str, org: str, name: str, tag: str, manifest: dict) -> Path:
d = self.image_dir(registry, org, name, tag)
d.mkdir(parents=True, exist_ok=True)
p = d / "manifest.json"
p.write_text(json.dumps(manifest, indent=2))
return d
def get_manifest(self, registry: str, org: str, name: str, tag: str) -> Optional[dict]:
p = self.image_dir(registry, org, name, tag) / "manifest.json"
if p.exists():
return json.loads(p.read_text())
return None
def list_images(self) -> list[dict]:
images = []
for p in self.root.rglob("manifest.json"):
try:
parts = p.relative_to(self.root).parts
if len(parts) >= 4:
images.append(
{
"registry": parts[0],
"org": parts[1],
"name": parts[2],
"tag": parts[3],
"path": str(p.parent),
}
)
except Exception:
pass
return images
def remove(self, registry: str, org: str, name: str, tag: str) -> bool:
d = self.image_dir(registry, org, name, tag)
if d.exists():
shutil.rmtree(d)
return True
return False
@@ -0,0 +1,270 @@
"""Fetch and classify OCI manifests using oras-py.
This is the single entry point for inspecting any OCI image — VM or container.
All runtime/pull logic delegates here first to figure out what the image is.
"""
from __future__ import annotations
from enum import Enum
from typing import Optional
import oras.provider
from cua_sandbox.registry.media_types import (
CONTAINER_CONFIG_TYPES,
CONTAINER_LAYER_TYPES,
LEGACY_DISK_CHUNK,
OCI_VM_CONFIG,
OCI_VM_CONFIG_LEGACY,
OCI_VM_DISK,
OCI_VM_DISK_LEGACY,
QEMU_CONFIG,
QEMU_DISK,
QEMU_DISK_GZIP,
TART_CONFIG,
TART_DISK,
VM_MEDIA_TYPES,
)
from cua_sandbox.registry.ref import parse_ref
class ImageFormat(Enum):
"""Format of a VM image in the registry."""
OCI_LAYERED = "oci-layered" # agoda media types, gzip chunks with part annotations
LEGACY_LZ4 = "legacy-lz4" # trycua LZ4-chunked
CHUNKED_PARTS = "chunked-parts" # standard OCI layer type with ;part.number= in media type
TART = "tart" # Cirrus Labs Tart VM disk chunks
QEMU = "qemu" # trycua QEMU qcow2 disk + config
CONTAINER = "container" # standard Docker/OCI container
UNKNOWN = "unknown"
def get_manifest(ref: str, platform: Optional[str] = None) -> dict:
"""Fetch the OCI manifest for an image reference.
If the manifest is a multi-arch index, resolves to the best platform match.
Args:
ref: Full or short image reference, e.g.
'ghcr.io/trycua/macos-sequoia-cua-sparse:latest-oci-layered'
'trycua/cua-xfce:latest'
platform: Platform filter e.g. "linux/amd64". Auto-detected if None.
"""
import platform as _plat
r = oras.provider.Registry()
registry, org, name, tag = parse_ref(ref)
full = f"{registry}/{org}/{name}:{tag}"
manifest = r.get_manifest(full)
# If it's a manifest index, resolve to a specific platform manifest
if manifest.get("manifests"):
arch_map = {"x86_64": "amd64", "AMD64": "amd64", "aarch64": "arm64", "ARM64": "arm64"}
if platform:
want_os, want_arch = platform.split("/", 1)
else:
want_os = "linux"
machine = _plat.machine()
want_arch = arch_map.get(machine, machine.lower())
# Find best match
for m in manifest["manifests"]:
p = m.get("platform", {})
if p.get("os") == want_os and p.get("architecture") == want_arch:
# Skip attestation manifests
annot = m.get("annotations", {})
if "attestation" in annot.get("vnd.docker.reference.type", ""):
continue
digest = m["digest"]
full_repo = f"{registry}/{org}/{name}"
return r.get_manifest(f"{full_repo}@{digest}")
# Fallback: first non-attestation manifest
for m in manifest["manifests"]:
annot = m.get("annotations", {})
if "attestation" not in annot.get("vnd.docker.reference.type", ""):
digest = m["digest"]
full_repo = f"{registry}/{org}/{name}"
return r.get_manifest(f"{full_repo}@{digest}")
return manifest
def detect_format(manifest: dict) -> ImageFormat:
"""Determine the image format from a manifest dict."""
layers = manifest.get("layers", [])
config = manifest.get("config", {})
config_mt = config.get("mediaType", "")
# OCI-layered (lume/agoda): config or disk layers use trycua.lume or legacy agoda types
if config_mt in (OCI_VM_CONFIG, OCI_VM_CONFIG_LEGACY):
return ImageFormat.OCI_LAYERED
if any(layer.get("mediaType") in (OCI_VM_DISK, OCI_VM_DISK_LEGACY) for layer in layers):
return ImageFormat.OCI_LAYERED
# Legacy LZ4
if config_mt in (LEGACY_DISK_CHUNK,):
return ImageFormat.LEGACY_LZ4
if any(layer.get("mediaType") == LEGACY_DISK_CHUNK for layer in layers):
return ImageFormat.LEGACY_LZ4
# Chunked-parts: standard OCI layer type but with ;part.number= suffix
for layer in layers:
mt = layer.get("mediaType", "")
if "part.number=" in mt:
return ImageFormat.CHUNKED_PARTS
# QEMU (cua): trycua.qemu config or disk layers
if config_mt == QEMU_CONFIG:
return ImageFormat.QEMU
if any(layer.get("mediaType") in (QEMU_DISK, QEMU_DISK_GZIP) for layer in layers):
return ImageFormat.QEMU
# Tart (Cirrus Labs): OCI config but cirruslabs disk layers
if any(layer.get("mediaType") in (TART_DISK, TART_CONFIG) for layer in layers):
return ImageFormat.TART
# Standard container
if config_mt in CONTAINER_CONFIG_TYPES:
return ImageFormat.CONTAINER
if any(layer.get("mediaType") in CONTAINER_LAYER_TYPES for layer in layers):
return ImageFormat.CONTAINER
return ImageFormat.UNKNOWN
def detect_kind(manifest: dict) -> str:
"""Classify manifest as 'vm' or 'container'."""
fmt = detect_format(manifest)
if fmt in (
ImageFormat.OCI_LAYERED,
ImageFormat.LEGACY_LZ4,
ImageFormat.CHUNKED_PARTS,
ImageFormat.TART,
ImageFormat.QEMU,
):
return "vm"
if fmt == ImageFormat.CONTAINER:
return "container"
# Fallback: check if any layer has a VM media type
layers = manifest.get("layers", [])
config_mt = manifest.get("config", {}).get("mediaType", "")
if config_mt in VM_MEDIA_TYPES or any(
layer.get("mediaType") in VM_MEDIA_TYPES for layer in layers
):
return "vm"
return "container"
def detect_os(manifest: dict) -> Optional[str]:
"""Try to infer OS from manifest annotations or media types."""
# Check top-level annotations
annot = manifest.get("annotations", {})
os_val = annot.get("org.trycua.lume.os", "").lower()
if os_val:
if "macos" in os_val or "mac" in os_val:
return "macos"
if "windows" in os_val:
return "windows"
if "linux" in os_val:
return "linux"
# lume/agoda media types → macOS
config_mt = manifest.get("config", {}).get("mediaType", "")
if config_mt in (OCI_VM_CONFIG, OCI_VM_CONFIG_LEGACY):
return "macos"
if any(
layer.get("mediaType") in (OCI_VM_DISK, OCI_VM_DISK_LEGACY)
for layer in manifest.get("layers", [])
):
return "macos"
return None
def detect_os_from_config(ref: str, manifest: dict) -> Optional[str]:
"""Fetch the OCI config blob and read the 'os' field.
This handles images (like Tart) where OS info is in the config blob
rather than manifest annotations. Falls back to detect_os() first.
"""
os_type = detect_os(manifest)
if os_type:
return os_type
config = manifest.get("config", {})
digest = config.get("digest")
if not digest:
return None
r = oras.provider.Registry()
registry, org, name, _tag = parse_ref(ref)
full_repo = f"{registry}/{org}/{name}"
try:
resp = r.get_blob(full_repo, digest)
data = resp.json() if hasattr(resp, "json") else {}
# Standard OCI: "os" field; QEMU: "guest_os" field
os_val = (data.get("os") or data.get("guest_os") or "").lower()
if os_val in ("linux", "windows"):
return os_val
if os_val in ("darwin", "macos"):
return "macos"
except Exception:
pass
# Also check tart config layer
for layer in manifest.get("layers", []):
if layer.get("mediaType") == TART_CONFIG:
try:
resp = r.get_blob(full_repo, layer["digest"])
data = resp.json() if hasattr(resp, "json") else {}
os_val = (data.get("os") or "").lower()
if os_val in ("linux", "windows"):
return os_val
if os_val in ("darwin", "macos"):
return "macos"
except Exception:
pass
return None
def get_layer_info(manifest: dict) -> list[dict]:
"""Extract structured layer info from a manifest.
Returns a list of dicts with keys: mediaType, digest, size, title,
part_number, part_total, uncompressed_size.
"""
result = []
for layer in manifest.get("layers", []):
annot = layer.get("annotations", {})
mt = layer.get("mediaType", "")
# Parse part info from either annotations or media type string
part_number = annot.get("org.trycua.lume.part.number")
part_total = annot.get("org.trycua.lume.part.total")
if part_number is None and "part.number=" in mt:
# Parse from media type: "...;part.number=1;part.total=164"
for segment in mt.split(";"):
if segment.startswith("part.number="):
part_number = segment.split("=", 1)[1]
elif segment.startswith("part.total="):
part_total = segment.split("=", 1)[1]
result.append(
{
"mediaType": mt,
"digest": layer.get("digest", ""),
"size": layer.get("size", 0),
"title": annot.get("org.opencontainers.image.title", ""),
"part_number": int(part_number) if part_number is not None else None,
"part_total": int(part_total) if part_total is not None else None,
"uncompressed_size": int(
annot.get("com.agoda.macosvz.content.uncompressed-size", 0)
)
or None,
}
)
return result
@@ -0,0 +1,89 @@
"""OCI media type constants for VM and container image detection.
VM image formats in the wild:
OCI-layered (agoda): gzip-compressed disk chunks, agoda media types,
annotations with part.number/offset/total
Legacy Lume (LZ4): LZ4-chunked disk, trycua media types
Chunked-parts: standard OCI layer type with ;part.number= in media type string
Tart (Cirrus Labs): compressed disk chunks, cirruslabs media types,
OCI config with os/architecture fields
QEMU (cua): qcow2 disk chunks, trycua.qemu media types,
config with guest_os/cpu/ram/tpm
Standard container images use OCI/Docker layer media types.
"""
# ── QEMU VM (cua) ────────────────────────────────────────────────────────────
QEMU_CONFIG = "application/vnd.trycua.qemu.config.v1+json"
QEMU_DISK = "application/vnd.trycua.qemu.disk.v1"
QEMU_DISK_GZIP = "application/vnd.trycua.qemu.disk.v1+gzip"
# ── Tart (Cirrus Labs) ───────────────────────────────────────────────────────
TART_CONFIG = "application/vnd.cirruslabs.tart.config.v1"
TART_DISK = "application/vnd.cirruslabs.tart.disk.v2"
TART_NVRAM = "application/vnd.cirruslabs.tart.nvram.v1"
# ── OCI-compliant VM (agoda / kubelet) ──────────────────────────────────────
OCI_VM_CONFIG = "application/vnd.agoda.macosvz.config.v1+json"
OCI_VM_DISK = "application/vnd.agoda.macosvz.disk.image.v1"
OCI_VM_AUX = "application/vnd.agoda.macosvz.aux.image.v1"
# Backward-compat: existing images on ghcr.io still use the agoda media types
OCI_VM_CONFIG_LEGACY = "application/vnd.agoda.macosvz.config.v1+json"
OCI_VM_DISK_LEGACY = "application/vnd.agoda.macosvz.disk.image.v1"
OCI_VM_AUX_LEGACY = "application/vnd.agoda.macosvz.aux.image.v1"
# ── Legacy Lume (LZ4-chunked) ──────────────────────────────────────────────
LEGACY_DISK_CHUNK = "application/vnd.trycua.lume.disk.chunk.lz4"
LEGACY_AUX = "application/vnd.trycua.lume.aux.image.v1"
LEGACY_CONFIG = "application/vnd.trycua.lume.config.v1+json"
# ── Standard OCI / Docker container ────────────────────────────────────────
OCI_IMAGE_LAYER = "application/vnd.oci.image.layer.v1.tar+gzip"
OCI_IMAGE_LAYER_NONDIST = "application/vnd.oci.image.layer.nondistributable.v1.tar+gzip"
DOCKER_IMAGE_LAYER = "application/vnd.docker.image.rootfs.diff.tar.gzip"
OCI_IMAGE_CONFIG = "application/vnd.oci.image.config.v1+json"
DOCKER_IMAGE_CONFIG = "application/vnd.docker.container.image.v1+json"
# ── Sets ────────────────────────────────────────────────────────────────────
VM_MEDIA_TYPES = frozenset(
{
QEMU_CONFIG,
QEMU_DISK,
QEMU_DISK_GZIP,
OCI_VM_CONFIG,
OCI_VM_DISK,
OCI_VM_AUX,
OCI_VM_CONFIG_LEGACY,
OCI_VM_DISK_LEGACY,
OCI_VM_AUX_LEGACY,
LEGACY_DISK_CHUNK,
LEGACY_AUX,
LEGACY_CONFIG,
TART_CONFIG,
TART_DISK,
TART_NVRAM,
}
)
CONTAINER_LAYER_TYPES = frozenset(
{
OCI_IMAGE_LAYER,
OCI_IMAGE_LAYER_NONDIST,
DOCKER_IMAGE_LAYER,
}
)
CONTAINER_CONFIG_TYPES = frozenset(
{
OCI_IMAGE_CONFIG,
DOCKER_IMAGE_CONFIG,
}
)
@@ -0,0 +1,886 @@
"""Build QEMU Windows VM images and push/pull as OCI artifacts.
Workflow:
1. Download Windows ISO from Microsoft (or use a provided path)
2. Download virtio-win.iso from Fedora
3. Create qcow2 disk + unattended install ISO
4. Run unattended Windows install via QEMU
5. Package the resulting qcow2 as an OCI image and push to registry
Usage:
python -m cua_sandbox.registry.qemu_builder build \\
--ref ghcr.io/trycua/cua-qemu-windows-vm:latest \\
--windows-version 11 \\
--disk-size 64
python -m cua_sandbox.registry.qemu_builder push \\
--ref ghcr.io/trycua/cua-qemu-windows-vm:latest \\
--disk /path/to/disk.qcow2
"""
from __future__ import annotations
import gzip
import hashlib
import json
import logging
import shutil
import subprocess
import tempfile
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Optional
from cua_sandbox.builder.windows_unattend import (
create_unattend_iso,
download_file,
download_windows_iso,
)
logger = logging.getLogger(__name__)
WORK_DIR = Path.home() / ".cua" / "cua-sandbox" / "qemu-builder"
VIRTIO_ISO_URL = (
"https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/virtio-win.iso"
)
CHUNK_SIZE = 500 * 1024 * 1024 # 500 MB chunks for OCI layers
@dataclass
class QEMUImageConfig:
"""VM configuration stored as the OCI config blob."""
guest_os: str = "windows"
version: str = "11" # "11", "10", "server-2022", "server-2025"
cpu: int = 4
ram_mb: int = 8192
disk_size_gb: int = 64
disk_format: str = "qcow2"
tpm: bool = True
architecture: str = "x86_64"
qemu_bin: Optional[str] = (
None # Custom QEMU binary path (e.g. /opt/incus/bin/qemu-system-x86_64)
)
display: dict = field(default_factory=lambda: {"width": 1920, "height": 1080})
# ── Download helpers ────────────────────────────────────────────────────────
def download_virtio_iso(work_dir: Optional[Path] = None) -> Path:
"""Download virtio-win.iso from Fedora."""
dest = (work_dir or WORK_DIR) / "virtio-win.iso"
return download_file(VIRTIO_ISO_URL, dest, "virtio-win.iso")
# ── Disk creation ───────────────────────────────────────────────────────────
def create_qcow2(path: Path, size_gb: int) -> Path:
"""Create a qcow2 disk image."""
path.parent.mkdir(parents=True, exist_ok=True)
cmd = [
"qemu-img",
"create",
"-q",
"-f",
"qcow2",
"-o",
"lazy_refcounts=on,preallocation=off",
str(path),
f"{size_gb}G",
]
# Try system qemu-img first, then portable
try:
subprocess.run(cmd, check=True, capture_output=True)
except FileNotFoundError:
from cua_sandbox.runtime.qemu_installer import qemu_bin
cmd[0] = str(Path(qemu_bin("x86_64")).parent / "qemu-img")
if not Path(cmd[0]).exists():
cmd[0] = str(Path(qemu_bin("x86_64")).parent / "qemu-img.exe")
subprocess.run(cmd, check=True, capture_output=True)
logger.info(f"Created {size_gb}G qcow2 disk: {path}")
return path
# ── WSL2 build helper ──────────────────────────────────────────────────────
def _win_to_wsl(p: Path | str) -> str:
"""Convert a Windows path to WSL /mnt/... path."""
s = str(p).replace("\\", "/")
if len(s) >= 2 and s[1] == ":":
return f"/mnt/{s[0].lower()}{s[2:]}"
return s
def _build_image_wsl2(
config: QEMUImageConfig,
cmd: list[str],
disk_path: Path,
work_dir: Path,
proc_port: int = 18000,
) -> Path:
"""Run QEMU build inside WSL2 with KVM acceleration.
Converts all Windows paths in the QEMU command to WSL /mnt/... paths,
uses the WSL2 qemu-system-x86_64 with -enable-kvm and -cpu host.
"""
import socket
import threading
import time
import urllib.request
def _convert_arg(arg: str) -> str:
"""Convert Windows paths in a QEMU argument to WSL paths."""
if len(arg) >= 3 and arg[1] == ":" and arg[2] in ("/", "\\"):
return _win_to_wsl(arg)
if "=" in arg and (":\\" in arg or ":/" in arg):
parts = arg.split(",")
converted = []
for part in parts:
if "=" in part:
k, v = part.split("=", 1)
if len(v) >= 3 and v[1] == ":" and v[2] in ("/", "\\"):
v = _win_to_wsl(v)
converted.append(f"{k}={v}")
else:
converted.append(part)
return ",".join(converted)
return arg
# Build WSL2 command: replace binary, convert paths, use native OVMF
wsl_cmd = ["qemu-system-x86_64"]
skip_next = False
for i, arg in enumerate(cmd[1:], 1):
if skip_next:
skip_next = False
continue
# Replace -cpu with host passthrough
if arg == "-cpu":
wsl_cmd += ["-cpu", "host"]
skip_next = True
continue
# Replace OVMF pflash with WSL2 native paths
if (
arg == "-drive" and i + 1 <= len(cmd) and "if=pflash" in cmd[i]
if i < len(cmd)
else False
):
pass # handled below
if "if=pflash" in arg:
if "readonly=on" in arg:
# OVMF code — use WSL2 native
wsl_cmd.append(
"if=pflash,format=raw,readonly=on,file=/usr/share/OVMF/OVMF_CODE_4M.fd"
)
else:
# EFI vars — create from WSL2 native template (must match OVMF_CODE_4M size)
wsl_efivars = _win_to_wsl(work_dir / "efivars-wsl.fd")
wsl_cmd.append(f"if=pflash,format=raw,file={wsl_efivars}")
continue
wsl_cmd.append(_convert_arg(arg))
wsl_cmd.append("-enable-kvm")
# Bind QMP and network to 0.0.0.0 so Windows host can reach them
for i, a in enumerate(wsl_cmd):
if "tcp:127.0.0.1:4444" in a:
wsl_cmd[i] = a.replace("127.0.0.1", "0.0.0.0")
if "hostfwd=tcp::" in a:
wsl_cmd[i] = a.replace("hostfwd=tcp::", "hostfwd=tcp:0.0.0.0:")
# Create WSL2-compatible EFI vars from WSL native template
wsl_efivars_win = work_dir / "efivars-wsl.fd"
if not wsl_efivars_win.exists():
subprocess.run(
[
"wsl",
"-e",
"bash",
"-c",
f"cp /usr/share/OVMF/OVMF_VARS_4M.fd '{_win_to_wsl(wsl_efivars_win)}'",
],
check=True,
capture_output=True,
)
shell_cmd = " ".join(wsl_cmd)
logger.info(f"WSL2 QEMU command: {shell_cmd}")
# Launch QEMU in WSL2
proc = subprocess.Popen(
["wsl", "-e", "bash", "-c", shell_cmd],
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
)
logger.info(f"QEMU WSL2 install running as PID {proc.pid}. Monitor via VNC on port 5900.")
def _qmp_cmd(cmd_json: bytes):
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(3)
s.connect(("127.0.0.1", 4444))
s.recv(4096)
s.sendall(b'{"execute":"qmp_capabilities"}\n')
s.recv(4096)
s.sendall(cmd_json + b"\n")
s.recv(4096)
s.close()
return True
except Exception:
return False
def _boot_and_monitor():
# Phase 1: boot keypresses (fewer needed with KVM — faster boot)
for i in range(15):
time.sleep(2)
if _qmp_cmd(
b'{"execute":"send-key","arguments":{"keys":[{"type":"qcode","data":"ret"}]}}'
):
logger.info(f"Sent boot keypress ({i+1}/15)")
# Phase 2: wait for CUA server (KVM is much faster, ~15-30 min)
logger.info(f"Waiting for CUA computer-server on port {proc_port}...")
for i in range(180): # 180 * 10s = 30 min
time.sleep(10)
try:
r = urllib.request.urlopen(f"http://127.0.0.1:{proc_port}/", timeout=5)
if r.status == 200:
logger.info("CUA computer-server is running! Sending ACPI shutdown...")
break
except Exception:
if i % 6 == 0:
logger.info(f"Still waiting... ({i * 10 // 60} min elapsed)")
else:
logger.warning("Timed out waiting for CUA server. Shutting down anyway.")
# Phase 3: ACPI shutdown
time.sleep(5)
_qmp_cmd(b'{"execute":"system_powerdown"}')
for _ in range(12):
time.sleep(10)
if proc.poll() is not None:
return
_qmp_cmd(b'{"execute":"system_powerdown"}')
logger.warning("ACPI shutdown did not work, terminating.")
proc.terminate()
t = threading.Thread(target=_boot_and_monitor, daemon=True)
t.start()
logger.info("Waiting for QEMU WSL2 to exit (15-30 min with KVM)...")
proc.wait()
if proc.returncode != 0:
stderr = proc.stderr.read().decode() if proc.stderr else ""
raise RuntimeError(f"QEMU WSL2 install failed (exit {proc.returncode}): {stderr}")
logger.info(f"Windows install complete. Disk image: {disk_path}")
config_path = work_dir / "config.json"
config_path.write_text(json.dumps(asdict(config), indent=2))
return disk_path
# ── Full build ──────────────────────────────────────────────────────────────
def build_image(
config: Optional[QEMUImageConfig] = None,
*,
windows_iso: Optional[str] = None,
work_dir: Optional[Path] = None,
product_key: Optional[str] = None,
use_wsl2: bool = False,
) -> Path:
"""Build a Windows qcow2 image via unattended QEMU install.
Returns the path to the completed qcow2 disk.
This is a long-running operation (30-60 minutes for Windows install).
"""
config = config or QEMUImageConfig()
work_dir = work_dir or WORK_DIR / f"windows-{config.version}"
work_dir.mkdir(parents=True, exist_ok=True)
disk_path = work_dir / "disk.qcow2"
if disk_path.exists():
logger.info(f"Disk already exists: {disk_path}")
return disk_path
# Step 1: Get ISOs
win_iso = download_windows_iso(config.version, work_dir, windows_iso)
virtio_iso = download_virtio_iso(work_dir)
# Create a small data-only ISO with Autounattend.xml + setup script.
# Windows Setup searches all drives for Autounattend.xml automatically,
# so we keep the original Windows ISO unchanged (it's UEFI-bootable).
unattend_iso = create_unattend_iso(work_dir, product_key, version=config.version)
# Step 2: Create disk
create_qcow2(disk_path, config.disk_size_gb)
# Step 3: Run QEMU install
logger.info("Starting unattended Windows install via QEMU...")
logger.info("This will take 30-60 minutes. Monitor via VNC on port 5900.")
import platform as _plat
if config.qemu_bin:
qemu = config.qemu_bin
else:
from cua_sandbox.runtime.qemu_installer import qemu_bin
qemu = qemu_bin(config.architecture)
qemu_dir = Path(qemu).parent
# Locate OVMF UEFI firmware (required for Windows 10/11)
ovmf_code = None
for candidate in [
qemu_dir / "share" / "edk2-x86_64-code.fd",
qemu_dir.parent / "share" / "qemu" / "edk2-x86_64-code.fd", # Homebrew: bin/../share/qemu/
Path("/usr/share/OVMF/OVMF_CODE.fd"),
Path("/usr/share/ovmf/OVMF_CODE.fd"),
Path("/usr/share/qemu/edk2-x86_64-code.fd"),
]:
if candidate.exists():
ovmf_code = candidate
break
if not ovmf_code:
raise RuntimeError(
"OVMF UEFI firmware not found. Windows 11 requires UEFI boot. "
"Expected edk2-x86_64-code.fd in QEMU share directory."
)
# Create EFI vars file (writable copy for this install)
efivars = work_dir / "efivars.fd"
if not efivars.exists():
vars_template = qemu_dir / "share" / "edk2-i386-vars.fd"
if not vars_template.exists():
vars_template = qemu_dir.parent / "share" / "qemu" / "edk2-i386-vars.fd"
if vars_template.exists():
shutil.copy2(vars_template, efivars)
else:
efivars.write_bytes(b"\x00" * (256 * 1024))
cmd = [
qemu,
"-name",
"windows-install",
"-machine",
"q35,smm=off",
"-m",
str(config.ram_mb),
"-smp",
str(config.cpu),
"-cpu",
"qemu64,+ssse3,+sse4.1,+sse4.2,+popcnt",
# UEFI firmware
"-drive",
f"if=pflash,format=raw,readonly=on,file={ovmf_code}",
"-drive",
f"if=pflash,format=raw,file={efivars}",
# Main disk — virtio-blk (bootindex=0 so after reboot it boots from disk)
"-drive",
f"file={disk_path},id=disk0,format=qcow2,if=none",
"-device",
"virtio-blk-pci,drive=disk0,bootindex=0",
# AHCI controller for all CDROMs (Q35 built-in IDE only supports 1 unit)
"-device",
"ich9-ahci,id=sata",
# Windows ISO — bootindex=0 so OVMF boots from it first
"-drive",
f"file={win_iso},if=none,media=cdrom,readonly=on,id=cd0",
"-device",
"ide-cd,drive=cd0,bus=sata.0,bootindex=1",
# Unattend ISO (data-only: Autounattend.xml + setup script + startup.nsh)
"-drive",
f"file={unattend_iso},if=none,media=cdrom,readonly=on,id=cd1",
"-device",
"ide-cd,drive=cd1,bus=sata.1",
# VirtIO drivers ISO
"-drive",
f"file={virtio_iso},if=none,media=cdrom,readonly=on,id=cd2",
"-device",
"ide-cd,drive=cd2,bus=sata.2",
"-vnc",
":0",
# QMP monitor for sending keypresses (bypass "press any key to boot from CD")
"-qmp",
"tcp:127.0.0.1:4444,server,nowait",
# Network (localhost only, no firewall prompt)
"-netdev",
"user,id=net0,hostfwd=tcp::18000-:8000",
"-device",
"virtio-net-pci,netdev=net0,mac=52:55:00:d1:55:01",
# Disable S3/S4 (avoids ACPI sleep issues during install) — same as dockur
"-global",
"ICH9-LPC.disable_s3=1",
"-global",
"ICH9-LPC.disable_s4=1",
# RTC base localtime for Windows
"-rtc",
"base=localtime",
]
# TPM (requires swtpm — not available on Windows)
if config.tpm and shutil.which("swtpm") and _plat.system() != "Windows":
tpm_dir = work_dir / "tpm"
tpm_dir.mkdir(exist_ok=True)
cmd += [
"-chardev",
f"socket,id=chrtpm,path={tpm_dir}/swtpm-sock",
"-tpmdev",
"emulator,id=tpm0,chardev=chrtpm",
"-device",
"tpm-tis,tpmdev=tpm0",
]
subprocess.Popen(
[
"swtpm",
"socket",
"--tpmstate",
f"dir={tpm_dir}",
"--ctrl",
f"type=unixio,path={tpm_dir}/swtpm-sock",
"--tpm2",
"--log",
f"file={tpm_dir}/swtpm.log",
]
)
# Hardware acceleration
if use_wsl2:
# WSL2 path: rewrite entire command for WSL2 with KVM
return _build_image_wsl2(
config,
cmd,
disk_path,
work_dir,
proc_port=18000,
)
# WHPX on Windows has MMIO emulation bugs with OVMF pflash drives,
# so we use TCG (software emulation) on Windows for UEFI builds.
if _plat.system() == "Windows":
cmd += ["-accel", "tcg"]
elif shutil.which("kvm-ok"):
r = subprocess.run(["kvm-ok"], capture_output=True)
if r.returncode == 0:
cmd.append("-enable-kvm")
elif Path("/dev/kvm").exists():
cmd.append("-enable-kvm")
# Run QEMU install
logger.info(f"QEMU command: {' '.join(cmd)}")
if _plat.system() == "Windows":
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
logger.info(f"QEMU install running as PID {proc.pid}. Monitor via VNC on port 5900.")
# Background thread: send boot keypresses, then health-check server, then shutdown
import socket
import threading
import time
import urllib.request
def _qmp_cmd(cmd_json: bytes):
"""Send a QMP command and return."""
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(3)
s.connect(("127.0.0.1", 4444))
s.recv(4096) # QMP greeting
s.sendall(b'{"execute":"qmp_capabilities"}\n')
s.recv(4096)
s.sendall(cmd_json + b"\n")
s.recv(4096)
s.close()
return True
except Exception:
return False
def _boot_and_monitor():
"""Phase 1: send Enter keys for 30s to bypass boot prompts.
Phase 2: poll CUA server on port 18000 until it responds.
Phase 3: send ACPI shutdown via QMP."""
# Phase 1: boot keypresses
for i in range(15):
time.sleep(2)
if _qmp_cmd(
b'{"execute":"send-key","arguments":{"keys":[{"type":"qcode","data":"ret"}]}}'
):
logger.info(f"Sent boot keypress ({i+1}/15)")
# Phase 2: wait for CUA server (up to 90 min for TCG installs)
logger.info("Waiting for CUA computer-server to become available on port 18000...")
for i in range(540): # 540 * 10s = 90 min
time.sleep(10)
try:
r = urllib.request.urlopen("http://127.0.0.1:18000/", timeout=5)
if r.status == 200:
logger.info("CUA computer-server is running! Sending ACPI shutdown...")
break
except Exception:
if i % 6 == 0:
logger.info(f"Still waiting for CUA server... ({i * 10 // 60} min elapsed)")
else:
logger.warning("Timed out waiting for CUA server (90 min). Shutting down anyway.")
# Phase 3: ACPI shutdown
time.sleep(5)
_qmp_cmd(b'{"execute":"system_powerdown"}')
# Wait and retry if needed
for _ in range(12):
time.sleep(10)
if proc.poll() is not None:
return
_qmp_cmd(b'{"execute":"system_powerdown"}')
logger.warning("ACPI shutdown did not work, terminating QEMU.")
proc.terminate()
t = threading.Thread(target=_boot_and_monitor, daemon=True)
t.start()
logger.info("Waiting for QEMU to exit (this takes 30-60 minutes)...")
proc.wait()
if proc.returncode != 0:
stderr = proc.stderr.read().decode() if proc.stderr else ""
raise RuntimeError(f"QEMU install failed (exit {proc.returncode}): {stderr}")
else:
result = subprocess.run(cmd)
if result.returncode != 0:
raise RuntimeError(f"QEMU install failed with exit code {result.returncode}")
logger.info(f"Windows install complete. Disk image: {disk_path}")
# Save config alongside disk
config_path = work_dir / "config.json"
config_path.write_text(json.dumps(asdict(config), indent=2))
return disk_path
# ── OCI push/pull ───────────────────────────────────────────────────────────
def _sha256_file(path: Path) -> str:
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(8 * 1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def push_image(
disk_path: str | Path,
ref: str,
config: Optional[QEMUImageConfig] = None,
) -> None:
"""Push a qcow2 disk image to an OCI registry.
Chunks the disk into gzip-compressed ~500MB layers with part annotations.
"""
import oras.provider
from cua_sandbox.registry.media_types import QEMU_CONFIG, QEMU_DISK_GZIP
from cua_sandbox.registry.ref import parse_ref
disk_path = Path(disk_path)
if not disk_path.exists():
raise FileNotFoundError(f"Disk image not found: {disk_path}")
config = config or QEMUImageConfig()
registry, org, name, tag = parse_ref(ref)
full_repo = f"{registry}/{org}/{name}"
r = oras.provider.Registry()
with tempfile.TemporaryDirectory() as tmp:
tmp_dir = Path(tmp)
# Create config blob
config_data = json.dumps(asdict(config)).encode()
config_digest = f"sha256:{hashlib.sha256(config_data).hexdigest()}"
config_path = tmp_dir / "config.json"
config_path.write_bytes(config_data)
# Chunk the disk into gzip-compressed parts
disk_size = disk_path.stat().st_size
part_total = (disk_size + CHUNK_SIZE - 1) // CHUNK_SIZE
layers = []
logger.info(f"Chunking {disk_size / 1024 / 1024:.0f} MB disk into {part_total} parts...")
with open(disk_path, "rb") as f:
for part_num in range(1, part_total + 1):
chunk_data = f.read(CHUNK_SIZE)
if not chunk_data:
break
# Gzip compress
compressed = gzip.compress(chunk_data, compresslevel=6)
chunk_path = tmp_dir / f"disk.part.{part_num:04d}.gz"
chunk_path.write_bytes(compressed)
chunk_digest = f"sha256:{hashlib.sha256(compressed).hexdigest()}"
layers.append(
{
"mediaType": QEMU_DISK_GZIP,
"digest": chunk_digest,
"size": len(compressed),
"annotations": {
"org.trycua.qemu.part.number": str(part_num),
"org.trycua.qemu.part.total": str(part_total),
"org.trycua.qemu.uncompressed-size": str(len(chunk_data)),
"org.opencontainers.image.title": f"disk.qcow2.part.{part_num:04d}",
},
}
)
logger.info(
f" part {part_num}/{part_total}: {len(compressed) / 1024 / 1024:.1f} MB compressed"
)
# Push blob
r.push_blob(full_repo, chunk_path, chunk_digest)
# Push config blob
r.push_blob(full_repo, config_path, config_digest)
# Create and push manifest
manifest = {
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": {
"mediaType": QEMU_CONFIG,
"digest": config_digest,
"size": len(config_data),
},
"layers": layers,
"annotations": {
"org.trycua.qemu.guest-os": config.guest_os,
"org.trycua.qemu.version": config.version,
"org.trycua.qemu.disk-format": config.disk_format,
"org.trycua.qemu.uncompressed-disk-size": str(disk_size),
},
}
r.put_manifest(f"{full_repo}:{tag}", json.dumps(manifest))
logger.info(f"Pushed {ref} ({part_total} layers, {disk_size / 1024 / 1024:.0f} MB)")
def pull_qemu_image(ref: str, dest_dir: Optional[Path] = None) -> tuple[QEMUImageConfig, Path]:
"""Pull a QEMU VM image from an OCI registry.
Downloads gzip-compressed disk chunks, decompresses, and reassembles
into a qcow2 disk image.
Returns (config, disk_path).
"""
import oras.provider
from cua_sandbox.registry.cache import ImageCache
from cua_sandbox.registry.manifest import get_manifest
from cua_sandbox.registry.media_types import QEMU_DISK, QEMU_DISK_GZIP
from cua_sandbox.registry.ref import parse_ref
registry, org, name, tag = parse_ref(ref)
full_repo = f"{registry}/{org}/{name}"
cache = ImageCache()
if dest_dir is None:
dest_dir = cache.image_dir(registry, org, name, tag)
dest_dir.mkdir(parents=True, exist_ok=True)
disk_path = dest_dir / "disk.qcow2"
config_path = dest_dir / "config.json"
# Check cache
if disk_path.exists() and config_path.exists():
logger.info(f"Using cached QEMU image: {dest_dir}")
data = json.loads(config_path.read_text())
return (
QEMUImageConfig(
**{k: v for k, v in data.items() if k in QEMUImageConfig.__dataclass_fields__}
),
disk_path,
)
manifest = get_manifest(ref)
r = oras.provider.Registry()
# Download config
config_blob = manifest.get("config", {})
if config_blob.get("digest"):
resp = r.get_blob(full_repo, config_blob["digest"])
config_path.write_bytes(resp.content)
config_data = resp.json() if hasattr(resp, "json") else {}
else:
config_data = {}
config = QEMUImageConfig(
**{k: v for k, v in config_data.items() if k in QEMUImageConfig.__dataclass_fields__}
)
# Download and reassemble disk parts
layers = manifest.get("layers", [])
disk_parts: list[tuple[int, dict]] = []
for layer in layers:
mt = layer.get("mediaType", "")
annot = layer.get("annotations", {})
if mt in (QEMU_DISK, QEMU_DISK_GZIP):
part_num = int(annot.get("org.trycua.qemu.part.number", 1))
disk_parts.append((part_num, layer))
if not disk_parts:
raise RuntimeError(f"No QEMU disk layers found in manifest for {ref}")
disk_parts.sort(key=lambda x: x[0])
total = len(disk_parts)
logger.info(f"Downloading {total} disk parts for {ref}...")
with open(disk_path, "wb") as f:
for part_num, layer in disk_parts:
mt = layer.get("mediaType", "")
digest = layer["digest"]
size = layer.get("size", 0)
logger.info(f" part {part_num}/{total}: {size / 1024 / 1024:.1f} MB")
resp = r.get_blob(full_repo, digest)
data = resp.content
# Decompress gzip if needed
if mt == QEMU_DISK_GZIP or mt.endswith("+gzip"):
data = gzip.decompress(data)
f.write(data)
logger.info(f"Assembled disk: {disk_path} ({disk_path.stat().st_size / 1024 / 1024:.0f} MB)")
# Save manifest
cache.save_manifest(registry, org, name, tag, manifest)
return config, disk_path
# ── CLI entry point ─────────────────────────────────────────────────────────
def main():
import argparse
logging.basicConfig(level=logging.INFO, format="%(message)s")
parser = argparse.ArgumentParser(description="Build and push QEMU VM images as OCI artifacts")
sub = parser.add_subparsers(dest="command")
# build
build_p = sub.add_parser("build", help="Build a Windows VM image")
build_p.add_argument(
"--windows-version", default="11", help="Windows version (10, 11, server-2022, server-2025)"
)
build_p.add_argument("--iso-path", help="Path to Windows ISO (skip download)")
build_p.add_argument("--disk-size", type=int, default=64, help="Disk size in GB")
build_p.add_argument("--ram", type=int, default=8192, help="RAM in MB")
build_p.add_argument("--cpu", type=int, default=4, help="CPU cores")
build_p.add_argument("--no-tpm", action="store_true", help="Disable TPM")
build_p.add_argument("--work-dir", type=Path, help="Working directory")
build_p.add_argument("--product-key", help="Windows product key")
build_p.add_argument(
"--wsl2", action="store_true", help="Run QEMU inside WSL2 with KVM acceleration"
)
build_p.add_argument(
"--qemu-bin", help="Custom QEMU binary path (e.g. /opt/incus/bin/qemu-system-x86_64)"
)
# push
push_p = sub.add_parser("push", help="Push a qcow2 disk as OCI image")
push_p.add_argument(
"--ref", required=True, help="OCI reference (e.g. ghcr.io/trycua/win11:latest)"
)
push_p.add_argument("--disk", required=True, help="Path to qcow2 disk")
push_p.add_argument("--guest-os", default="windows")
push_p.add_argument("--version", default="11")
push_p.add_argument("--disk-size", type=int, default=64)
push_p.add_argument("--ram", type=int, default=8192)
push_p.add_argument("--cpu", type=int, default=4)
push_p.add_argument("--no-tpm", action="store_true")
# pull
pull_p = sub.add_parser("pull", help="Pull a QEMU VM image from registry")
pull_p.add_argument("--ref", required=True, help="OCI reference")
pull_p.add_argument("--dest", type=Path, help="Destination directory")
# build-and-push
bp_p = sub.add_parser("build-and-push", help="Build then push")
bp_p.add_argument("--ref", required=True)
bp_p.add_argument("--windows-version", default="11")
bp_p.add_argument("--iso-path", help="Path to Windows ISO")
bp_p.add_argument("--disk-size", type=int, default=64)
bp_p.add_argument("--ram", type=int, default=8192)
bp_p.add_argument("--cpu", type=int, default=4)
bp_p.add_argument("--no-tpm", action="store_true")
bp_p.add_argument("--product-key")
bp_p.add_argument("--wsl2", action="store_true")
args = parser.parse_args()
if args.command == "build":
cfg = QEMUImageConfig(
guest_os="windows",
version=args.windows_version,
cpu=args.cpu,
ram_mb=args.ram,
disk_size_gb=args.disk_size,
tpm=not args.no_tpm,
qemu_bin=getattr(args, "qemu_bin", None),
)
disk = build_image(
cfg,
windows_iso=args.iso_path,
work_dir=args.work_dir,
product_key=args.product_key,
use_wsl2=args.wsl2,
)
print(f"Built: {disk}")
elif args.command == "push":
cfg = QEMUImageConfig(
guest_os=args.guest_os,
version=args.version,
cpu=args.cpu,
ram_mb=args.ram,
disk_size_gb=args.disk_size,
tpm=not args.no_tpm,
)
push_image(args.disk, args.ref, cfg)
elif args.command == "pull":
cfg, disk = pull_qemu_image(args.ref, args.dest)
print(f"Pulled: {disk}")
print(f"Config: {json.dumps(asdict(cfg), indent=2)}")
elif args.command == "build-and-push":
cfg = QEMUImageConfig(
guest_os="windows",
version=args.windows_version,
cpu=args.cpu,
ram_mb=args.ram,
disk_size_gb=args.disk_size,
tpm=not args.no_tpm,
)
disk = build_image(
cfg, windows_iso=args.iso_path, product_key=args.product_key, use_wsl2=args.wsl2
)
push_image(disk, args.ref, cfg)
else:
parser.print_help()
if __name__ == "__main__":
main()
@@ -0,0 +1,39 @@
"""Parse OCI image references into (registry, org, name, tag)."""
from __future__ import annotations
def parse_ref(ref: str) -> tuple[str, str, str, str]:
"""Parse a full or short image reference.
Handles:
ghcr.io/trycua/macos-sequoia-cua:latest → (ghcr.io, trycua, macos-sequoia-cua, latest)
trycua/cua-xfce:latest → (ghcr.io, trycua, cua-xfce, latest)
cua-xfce:latest → (ghcr.io, trycua, cua-xfce, latest)
cua-xfce → (ghcr.io, trycua, cua-xfce, latest)
"""
if ":" in ref:
ref_no_tag, tag = ref.rsplit(":", 1)
else:
ref_no_tag, tag = ref, "latest"
parts = ref_no_tag.split("/")
if len(parts) >= 3:
registry = parts[0]
org = parts[1]
name = "/".join(parts[2:])
elif len(parts) == 2:
registry = "ghcr.io"
org = parts[0]
name = parts[1]
else:
registry = "ghcr.io"
org = "trycua"
name = parts[0]
# Docker Hub: docker.io → registry-1.docker.io (the actual API host)
if registry == "docker.io":
registry = "registry-1.docker.io"
return registry, org, name, tag
@@ -0,0 +1,196 @@
"""Resolve image kind from registry manifest and coordinate pulls.
Uses oras-py for manifest fetching. Delegates actual image pull to:
- docker pull (for containers)
- lume pull (for macOS VMs on macOS hosts)
- self-managed download (for VM disk images on other hosts)
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Optional
from cua_sandbox.image import Image
from cua_sandbox.registry.cache import ImageCache
from cua_sandbox.registry.manifest import (
ImageFormat,
detect_format,
detect_kind,
detect_os_from_config,
get_manifest,
)
from cua_sandbox.registry.ref import parse_ref
logger = logging.getLogger(__name__)
def resolve_image_kind(image: Image) -> Image:
"""Fetch manifest for a registry image and return a new Image with kind/os resolved.
If kind is already set, returns unchanged.
"""
if image.kind is not None:
return image
if not image._registry:
raise ValueError("Cannot resolve kind: image has no registry reference")
manifest = get_manifest(image._registry)
kind = detect_kind(manifest)
os_type = detect_os_from_config(image._registry, manifest) or image.os_type
return Image(
os_type=os_type,
distro=image.distro,
version=image.version,
kind=kind,
_layers=image._layers,
_env=image._env,
_ports=image._ports,
_files=image._files,
_registry=image._registry,
_disk_path=image._disk_path,
)
def pull_image(
image: Image,
*,
cache: Optional[ImageCache] = None,
force: bool = False,
) -> tuple[Image, Path]:
"""Pull an image and return (resolved_image, local_path).
Resolves kind from manifest, then delegates pull to the appropriate backend:
- container → docker pull (returns the image ref, caller uses DockerRuntime)
- vm + macOS host → lume pull
- vm + other host → download blobs via oras
"""
if not image._registry:
raise ValueError("Cannot pull: image has no registry reference")
cache = cache or ImageCache()
registry, org, name, tag = parse_ref(image._registry)
# Fetch manifest (always via oras — lightweight)
manifest = get_manifest(image._registry)
kind = detect_kind(manifest)
os_type = detect_os_from_config(image._registry, manifest) or image.os_type
fmt = detect_format(manifest)
resolved = Image(
os_type=os_type,
distro=image.distro,
version=image.version,
kind=kind,
_layers=image._layers,
_env=image._env,
_ports=image._ports,
_files=image._files,
_registry=image._registry,
_disk_path=image._disk_path,
)
# Cache the manifest
dest_dir = cache.save_manifest(registry, org, name, tag, manifest)
if kind == "container":
# For containers, docker pull handles everything — just return the ref
logger.info(f"Container image {image._registry} — use docker pull")
return resolved, dest_dir
# QEMU format — use dedicated pull
if fmt == ImageFormat.QEMU:
from cua_sandbox.registry.qemu_builder import pull_qemu_image
disk_path = dest_dir / "disk.qcow2"
if not force and disk_path.exists():
logger.info(f"Using cached QEMU image at {dest_dir}")
return resolved, dest_dir
logger.info(f"Pulling QEMU image {image._registry}")
pull_qemu_image(image._registry, dest_dir)
return resolved, dest_dir
# Other VM formats — check cache
disk_path = dest_dir / "disk.img"
if not force and disk_path.exists():
logger.info(f"Using cached VM image at {dest_dir}")
return resolved, dest_dir
# Pull VM image blobs
logger.info(f"Pulling VM image {image._registry} ({fmt.value}, {kind})")
_pull_vm_blobs(image._registry, manifest, dest_dir)
return resolved, dest_dir
def _pull_vm_blobs(ref: str, manifest: dict, dest_dir: Path) -> None:
"""Download VM disk blobs using oras and reassemble."""
import oras.provider
registry, org, name, tag = parse_ref(ref)
full_repo = f"{registry}/{org}/{name}"
r = oras.provider.Registry()
layers = manifest.get("layers", [])
# Separate disk parts from other layers (aux, etc.)
disk_parts: list[tuple[int, dict]] = []
other_layers: list[dict] = []
for layer in layers:
mt = layer.get("mediaType", "")
annot = layer.get("annotations", {})
# Detect part number from annotations or media type
part_num = annot.get("org.trycua.lume.part.number")
if part_num is None and "part.number=" in mt:
for seg in mt.split(";"):
if seg.startswith("part.number="):
part_num = seg.split("=", 1)[1]
if part_num is not None:
disk_parts.append((int(part_num), layer))
else:
other_layers.append(layer)
# Download and reassemble disk parts
if disk_parts:
disk_parts.sort(key=lambda x: x[0])
disk_path = dest_dir / "disk.img"
logger.info(f" downloading {len(disk_parts)} disk parts...")
with open(disk_path, "wb") as f:
for part_num, layer in disk_parts:
digest = layer["digest"]
size = layer.get("size", 0)
logger.info(f" part {part_num}/{disk_parts[-1][0]}: {size} bytes")
resp = r.get_blob(full_repo, digest)
f.write(resp.content)
logger.info(f" disk image: {disk_path} ({disk_path.stat().st_size} bytes)")
# Download other layers (aux image, etc.)
for layer in other_layers:
mt = layer.get("mediaType", "")
digest = layer["digest"]
title = layer.get("annotations", {}).get("org.opencontainers.image.title", "")
if "aux" in mt.lower() or "aux" in title.lower():
out = dest_dir / "aux.img"
elif title:
out = dest_dir / title
else:
out = dest_dir / f"blob_{digest[:16]}"
logger.info(f" downloading {title or mt} ({layer.get('size', '?')} bytes)")
resp = r.get_blob(full_repo, digest)
out.write_bytes(resp.content)
# Download config
config = manifest.get("config", {})
if config.get("digest"):
resp = r.get_blob(full_repo, config["digest"])
(dest_dir / "config.json").write_bytes(resp.content)
@@ -0,0 +1,26 @@
from cua_sandbox.runtime.android_emulator import AndroidEmulatorRuntime
from cua_sandbox.runtime.base import Runtime, RuntimeInfo
from cua_sandbox.runtime.docker import DockerRuntime
from cua_sandbox.runtime.hyperv import HyperVRuntime
from cua_sandbox.runtime.lume import LumeRuntime
from cua_sandbox.runtime.qemu import (
QEMUBaremetalRuntime,
QEMUDockerRuntime,
QEMURuntime,
QEMUWSL2Runtime,
)
from cua_sandbox.runtime.tart import TartRuntime
__all__ = [
"Runtime",
"RuntimeInfo",
"DockerRuntime",
"QEMURuntime",
"QEMUDockerRuntime",
"QEMUBaremetalRuntime",
"QEMUWSL2Runtime",
"LumeRuntime",
"HyperVRuntime",
"AndroidEmulatorRuntime",
"TartRuntime",
]
@@ -0,0 +1,114 @@
#!/usr/bin/env node
/**
* Non-interactive bubblewrap project initialiser.
*
* Usage:
* node _bw_init.js <manifest_url> <output_dir> <package_id> \
* [keystore_path [keystore_alias [keystore_password [fetch_url]]]]
*
* manifest_url: The TWA origin URL (used in twa-manifest.json host field).
* fetch_url: Optional URL to fetch the Web App Manifest from (defaults to
* manifest_url). Use this when manifest_url is an
* emulator-internal address like http://10.0.2.2:3000 that is
* not reachable from the host — pass the host-accessible URL
* (e.g. http://127.0.0.1:3000) as fetch_url while keeping
* manifest_url as the in-emulator origin for assetlinks.json.
*/
const path = require('path');
const fs = require('fs');
const { execSync } = require('child_process');
const npmPrefix = execSync('npm root -g').toString().trim();
const BW_CLI = path.join(npmPrefix, '@bubblewrap', 'cli');
const corePath = path.join(BW_CLI, 'node_modules', '@bubblewrap', 'core');
const { TwaManifest } = require(path.join(corePath, 'dist/lib/TwaManifest'));
const [
,
,
manifestUrl,
outputDir,
packageId,
keystorePath,
keystoreAlias,
keystorePassword,
fetchUrl,
] = process.argv;
if (!manifestUrl || !outputDir || !packageId) {
console.error(
'Usage: node _bw_init.js <manifest_url> <output_dir> <package_id> [keystore_path [alias [password [fetch_url]]]]'
);
process.exit(1);
}
const KEYSTORE = keystorePath || path.join(outputDir, 'android.keystore');
const KEYSTORE_ALIAS = keystoreAlias || 'android';
const KEYSTORE_PASS = keystorePassword || 'android';
// If the manifest URL uses an emulator-internal host (10.0.2.2), replace it
// with 127.0.0.1 for fetching from the host machine.
const resolvedFetchUrl =
fetchUrl || manifestUrl.replace('10.0.2.2', '127.0.0.1').replace('10.0.3.2', '127.0.0.1');
(async () => {
try {
console.log(`Fetching web manifest from: ${resolvedFetchUrl}`);
const twa = await TwaManifest.fromWebManifest(resolvedFetchUrl);
// Override host with the in-emulator origin so assetlinks.json is verified
// against the address Chrome will actually use inside the emulator.
const originUrl = new URL(manifestUrl);
twa.packageId = packageId;
twa.host = originUrl.host; // e.g. "cuaai--todo-gym-web.modal.run"
// startUrl comes from Web App Manifest start_url (already parsed by fromWebManifest).
// Do NOT override it with the manifest file path — that would open manifest.json.
twa.manifestUrl = manifestUrl;
twa.signingKey = { path: KEYSTORE, alias: KEYSTORE_ALIAS };
// If the maskable icon is a PNG that 404s but an SVG variant exists,
// fall back to the SVG so bubblewrap update doesn't fail on missing icons.
if (twa.maskableIconUrl && twa.maskableIconUrl.endsWith('.png')) {
try {
const { execFileSync } = require('child_process');
const status = execFileSync(
'curl', ['-s', '-o', '/dev/null', '-w', '%{http_code}', '--', twa.maskableIconUrl],
{ timeout: 10000 }
)
.toString()
.trim();
if (status === '404') {
const svgUrl = twa.maskableIconUrl.replace(/\.png$/, '.svg');
const svgStatus = execFileSync(
'curl', ['-s', '-o', '/dev/null', '-w', '%{http_code}', '--', svgUrl],
{ timeout: 10000 }
)
.toString()
.trim();
if (svgStatus === '200') {
console.log(`Maskable icon PNG 404d, falling back to SVG: ${svgUrl}`);
twa.maskableIconUrl = svgUrl;
}
}
} catch (_) {
// best-effort — ignore curl failures
}
}
const outFile = path.join(outputDir, 'twa-manifest.json');
const json = twa.toJson();
json.signingKey = {
path: KEYSTORE,
alias: KEYSTORE_ALIAS,
keypassword: KEYSTORE_PASS,
password: KEYSTORE_PASS,
};
fs.writeFileSync(outFile, JSON.stringify(json, null, 2));
console.log(`twa-manifest.json written to ${outFile}`);
console.log(` host: ${json.host} package: ${json.packageId}`);
} catch (e) {
console.error('Error:', e.message || e);
process.exit(1);
}
})();
@@ -0,0 +1,904 @@
"""Android Emulator runtime — uses the official Android SDK emulator.
Follows the docker-android pattern:
1. Auto-installs Android command-line tools + SDK if needed
2. Creates an AVD (Android Virtual Device)
3. Launches the emulator with `-no-boot-anim -no-window -no-snapshot`
4. Waits for `sys.boot_completed == 1` via ADB
5. Supports APK installation via ADB
On Apple Silicon Macs, uses ARM64 system images with HVF acceleration.
On x86_64 hosts, uses x86_64 system images with KVM/HAXM.
"""
from __future__ import annotations
import asyncio
import logging
import os
import platform as _plat
import shutil
import socket
import subprocess
from pathlib import Path
from typing import Optional
from cua_sandbox.image import Image
from cua_sandbox.runtime.base import Runtime, RuntimeInfo
logger = logging.getLogger(__name__)
# ── SDK paths ────────────────────────────────────────────────────────────────
_SDK_ROOT = Path.home() / ".cua" / "android-sdk"
_AVD_HOME = Path.home() / ".cua" / "android-avd"
_CMDLINE_TOOLS_VERSION = "11076708" # Latest as of 2024
def _java_env() -> dict:
"""Return env dict with JAVA_HOME set if needed."""
env = os.environ.copy()
if env.get("JAVA_HOME"):
return env
# Try Homebrew OpenJDK (macOS)
brew_jdk = Path("/opt/homebrew/opt/openjdk/libexec/openjdk.jdk/Contents/Home")
if not brew_jdk.exists():
# Intel Mac
brew_jdk = Path("/usr/local/opt/openjdk/libexec/openjdk.jdk/Contents/Home")
if brew_jdk.exists():
env["JAVA_HOME"] = str(brew_jdk)
env["PATH"] = f"{brew_jdk / 'bin'}:{env.get('PATH', '')}"
return env
# Check if java works without JAVA_HOME
try:
result = subprocess.run(["java", "-version"], capture_output=True, timeout=5)
if result.returncode == 0:
return env
except (subprocess.SubprocessError, FileNotFoundError):
pass
raise RuntimeError("Java not found. Install via: brew install openjdk")
def _sdk_path() -> Path:
"""Return the Android SDK root, preferring existing installations."""
for env in ("ANDROID_HOME", "ANDROID_SDK_ROOT"):
val = os.environ.get(env)
if val and Path(val).exists():
return Path(val)
# Check common install locations
common = [
Path.home() / "Library" / "Android" / "sdk", # Android Studio (macOS)
Path("/opt/android"), # docker-android
Path.home() / "Android" / "Sdk", # Linux
Path(os.environ.get("LOCALAPPDATA", "")) / "Android" / "Sdk", # Android Studio (Windows)
Path.home() / "AppData" / "Local" / "Android" / "Sdk", # Windows fallback
]
for p in common:
if (p / "emulator").exists():
return p
return _SDK_ROOT
def _ensure_sdk() -> Path:
"""Ensure Android SDK command-line tools, emulator, and platform-tools are installed."""
sdk = _sdk_path()
_win = _plat.system().lower() == "windows"
_ext = ".exe" if _win else ""
_bat = ".bat" if _win else ""
emulator_bin = sdk / "emulator" / f"emulator{_ext}"
sdkmanager_bin = sdk / "cmdline-tools" / "latest" / "bin" / f"sdkmanager{_bat}"
adb_bin = sdk / "platform-tools" / f"adb{_ext}"
if emulator_bin.exists() and adb_bin.exists():
return sdk
logger.info(f"Android SDK not found. Installing to {sdk} ...")
sdk.mkdir(parents=True, exist_ok=True)
# Download command-line tools
if not sdkmanager_bin.exists():
system = _plat.system().lower()
if system == "darwin":
tools_url = f"https://dl.google.com/android/repository/commandlinetools-mac-{_CMDLINE_TOOLS_VERSION}_latest.zip"
elif system == "linux":
tools_url = f"https://dl.google.com/android/repository/commandlinetools-linux-{_CMDLINE_TOOLS_VERSION}_latest.zip"
elif system == "windows":
tools_url = f"https://dl.google.com/android/repository/commandlinetools-win-{_CMDLINE_TOOLS_VERSION}_latest.zip"
else:
raise RuntimeError(f"Android SDK auto-install not supported on {system}.")
import urllib.request
import zipfile
zip_path = sdk / "cmdline-tools.zip"
logger.info("Downloading Android command-line tools ...")
urllib.request.urlretrieve(tools_url, str(zip_path))
with zipfile.ZipFile(zip_path) as zf:
zf.extractall(sdk / "cmdline-tools")
# Move to expected path
extracted = sdk / "cmdline-tools" / "cmdline-tools"
dest = sdk / "cmdline-tools" / "latest"
if extracted.exists() and not dest.exists():
extracted.rename(dest)
zip_path.unlink()
# Ensure scripts are executable
for f in (sdk / "cmdline-tools" / "latest" / "bin").iterdir():
f.chmod(0o755)
sdkmanager_bin = sdk / "cmdline-tools" / "latest" / "bin" / f"sdkmanager{_bat}"
if not sdkmanager_bin.exists():
raise RuntimeError(f"sdkmanager not found at {sdkmanager_bin}")
env = _java_env()
# Accept licenses
logger.info("Accepting Android SDK licenses ...")
subprocess.run(
[str(sdkmanager_bin), f"--sdk_root={sdk}", "--licenses"],
input=b"y\ny\ny\ny\ny\ny\ny\ny\n",
capture_output=True,
timeout=120,
env=env,
)
# Install emulator, platform-tools, and system image
arch = "arm64-v8a" if _plat.machine() in ("arm64", "aarch64") else "x86_64"
api_level = "34"
img_type = "google_apis"
package = f"system-images;android-{api_level};{img_type};{arch}"
platform = f"platforms;android-{api_level}"
logger.info(f"Installing Android SDK packages: {package} ...")
result = subprocess.run(
[
str(sdkmanager_bin),
f"--sdk_root={sdk}",
"--install",
package,
platform,
"platform-tools",
"emulator",
],
capture_output=True,
text=True,
timeout=600,
env=env,
)
if result.returncode != 0:
raise RuntimeError(f"sdkmanager install failed: {result.stderr}")
logger.info("Android SDK installation complete.")
return sdk
def _find_free_emulator_port() -> int:
"""Return a free odd adb_port (console_port = adb_port - 1, must be even).
Scans even console ports 55545682; returns the first pair where both
console_port and adb_port are not in use (TCP bind check).
"""
for console_port in range(5554, 5684, 2): # 5554, 5556, ..., 5682
adb_port = console_port + 1
free = True
for port in (console_port, adb_port):
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", port))
except OSError:
free = False
break
if free:
return adb_port
raise RuntimeError("No free Android emulator port pair found in range 55545682")
def _find_bin(sdk: Path, name: str) -> str:
"""Find a binary in the SDK, handling .exe/.bat on Windows."""
exts = ["", ".exe", ".bat"] if _plat.system().lower() == "windows" else [""]
dirs = [
sdk / "emulator",
sdk / "platform-tools",
sdk / "cmdline-tools" / "latest" / "bin",
]
for d in dirs:
for ext in exts:
c = d / f"{name}{ext}"
if c.exists():
return str(c)
# Fall back to PATH
found = shutil.which(name)
if found:
return found
raise FileNotFoundError(f"{name} not found in SDK at {sdk}")
class AndroidEmulatorRuntime(Runtime):
"""Android emulator runtime using the official Android SDK.
Creates an AVD and launches the Android emulator, following the
docker-android pattern. Uses ADB for boot detection, APK install,
and shell commands.
"""
def __init__(
self,
*,
api_level: int = 34,
img_type: str = "google_apis",
device_id: str = "pixel",
memory_mb: int = 4096,
cpu_count: int = 4,
adb_port: Optional[int] = None,
no_boot_anim: bool = True,
gpu_mode: str = "swiftshader_indirect",
headless: bool = True,
grpc_port: Optional[int] = None,
):
self.api_level = api_level
self.img_type = img_type
self.device_id = device_id
self.memory_mb = memory_mb
self.cpu_count = cpu_count
self.adb_port = adb_port
self.no_boot_anim = no_boot_anim
self.gpu_mode = gpu_mode
self.headless = headless
self.grpc_port = grpc_port
self._proc: Optional[subprocess.Popen] = None
self._avd_name: Optional[str] = None
self._sdk: Optional[Path] = None
async def start(self, image: Image, name: str, **opts) -> RuntimeInfo:
self._sdk = _ensure_sdk()
self._avd_name = name
# Resolve port lazily so callers don't need to pick one
if self.adb_port is None:
self.adb_port = _find_free_emulator_port()
logger.info(
f"Auto-selected emulator port: console={self.adb_port - 1}, adb={self.adb_port}"
)
arch = "arm64-v8a" if _plat.machine() in ("arm64", "aarch64") else "x86_64"
package = f"system-images;android-{self.api_level};{self.img_type};{arch}"
abi = f"{self.img_type}/{arch}"
avdmanager = _find_bin(self._sdk, "avdmanager")
emulator = _find_bin(self._sdk, "emulator")
adb = _find_bin(self._sdk, "adb")
# Set AVD home
_AVD_HOME.mkdir(parents=True, exist_ok=True)
env = _java_env()
env["ANDROID_SDK_ROOT"] = str(self._sdk)
env["ANDROID_AVD_HOME"] = str(_AVD_HOME)
env["ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL"] = "10"
# Create AVD if it doesn't exist
avd_dir = _AVD_HOME / f"{name}.avd"
if not avd_dir.exists():
logger.info(f"Creating AVD '{name}' with {package} ...")
result = subprocess.run(
[
avdmanager,
"create",
"avd",
"--force",
"--name",
name,
"--abi",
abi,
"--package",
package,
"--device",
self.device_id,
],
input="no\n",
capture_output=True,
text=True,
env=env,
timeout=60,
)
if result.returncode != 0:
raise RuntimeError(f"AVD creation failed: {result.stderr}\n{result.stdout}")
logger.info(f"AVD '{name}' created.")
# Start ADB server
subprocess.run([adb, "start-server"], capture_output=True, env=env)
# Launch emulator
cmd = [
emulator,
"-avd",
name,
"-gpu",
self.gpu_mode,
"-memory",
str(self.memory_mb),
"-cores",
str(self.cpu_count),
*(["-no-window"] if self.headless else []),
"-no-snapshot",
"-skip-adb-auth",
"-port",
str(self.adb_port - 1), # console port = adb_port - 1
]
if self.no_boot_anim:
cmd.append("-no-boot-anim")
# gRPC service: defaults to console_port+3000 if not specified
resolved_grpc_port = self.grpc_port or (self.adb_port - 1 + 3000)
cmd += ["-grpc", str(resolved_grpc_port)]
logger.info(f"Starting Android emulator: {' '.join(cmd)}")
self._proc = subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
env=env,
)
# Brief check that it didn't crash immediately
await asyncio.sleep(3)
if self._proc.poll() is not None:
stderr = self._proc.stderr.read().decode() if self._proc.stderr else ""
raise RuntimeError(f"Emulator crashed on launch: {stderr}")
info = RuntimeInfo(
host="localhost",
api_port=self.adb_port,
name=name,
environment="android",
grpc_port=resolved_grpc_port,
)
# Install APKs from image layers
await self.is_ready(info)
await self._apply_layers(image, adb, env)
return info
async def _apply_layers(self, image: Image, adb: str, env: dict) -> None:
"""Apply image layers post-boot (APK installs, shell commands, etc.)."""
serial = f"emulator-{self.adb_port - 1}"
# Write image env vars to a persistent file so every subsequent
# adb shell command (including those from the transport) can source it.
if image._env:
import re
import shlex
import tempfile
lines = []
for k, v in image._env:
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", k):
raise ValueError(f"Invalid environment variable name: {k!r}")
lines.append(f"export {k}={shlex.quote(v)}")
exports = "\n".join(lines) + "\n"
with tempfile.NamedTemporaryFile(mode="w", suffix=".sh", delete=False) as f:
f.write(exports)
tmp_path = f.name
subprocess.run(
[adb, "-s", serial, "push", tmp_path, "/data/local/tmp/.cua_env"],
capture_output=True,
env=env,
timeout=10,
)
Path(tmp_path).unlink(missing_ok=True)
for layer in image._layers:
lt = layer["type"]
if lt == "apk_install":
for apk in layer["packages"]:
# Download URL-based APKs
local_apk = await self._resolve_apk(apk)
logger.info(f"Installing APK: {local_apk}")
result = subprocess.run(
[adb, "-s", serial, "install", "-r", str(local_apk)],
capture_output=True,
text=True,
env=env,
timeout=120,
)
if result.returncode != 0:
logger.warning(f"APK install failed: {result.stderr}")
else:
logger.info(f"APK installed: {result.stdout.strip()}")
elif lt == "pwa_install":
manifest_url = layer["manifest_url"]
pkg = layer.get("package_name")
ks = Path(layer["keystore"]) if layer.get("keystore") else None
ks_alias = layer.get("keystore_alias", "android")
ks_pass = layer.get("keystore_password", "android")
builder = layer.get("builder", "pwa2apk")
if builder == "pwa2apk":
# Import cloud transport's pwa2apk builder (works without a sandbox)
from cua_sandbox.transport.cloud import CloudTransport
apk_path, fingerprint = await CloudTransport._build_pwa2apk(
manifest_url, pkg, ks, ks_alias, ks_pass
)
else:
apk_path, fingerprint = await self._build_pwa_apk(
manifest_url, pkg, ks, ks_alias, ks_pass
)
logger.info(
f"PWA APK built ({builder}): {apk_path}\n"
f" SHA-256 fingerprint: {fingerprint}"
)
logger.info(f"Installing PWA APK: {apk_path}")
result = subprocess.run(
[adb, "-s", serial, "install", "-r", str(apk_path)],
capture_output=True,
text=True,
env=env,
timeout=120,
)
if result.returncode != 0:
logger.warning(f"PWA APK install failed: {result.stderr}")
else:
logger.info(f"PWA APK installed: {result.stdout.strip()}")
# For bubblewrap TWA, set Chrome flags. pwa2apk doesn't need this.
if builder == "bubblewrap":
from urllib.parse import urlparse
origin = f"{urlparse(manifest_url).scheme}://{urlparse(manifest_url).netloc}"
for cmd in [
"am set-debug-app --persistent com.android.chrome",
"mkdir -p /data/local/tmp && "
"echo 'chrome --no-first-run --disable-fre "
"--no-default-browser-check "
f'--disable-digital-asset-link-verification-for-url="{origin}"\' '
"> /data/local/tmp/chrome-command-line",
]:
subprocess.run(
[adb, "-s", serial, "shell", cmd],
capture_output=True,
env=env,
timeout=10,
)
elif lt == "run":
cmd = layer["command"]
logger.info(f"Running: {cmd}")
subprocess.run(
[adb, "-s", serial, "shell", cmd],
capture_output=True,
text=True,
env=env,
timeout=60,
)
elif lt == "pip_install":
# pip install via termux or similar — skip for now
logger.warning("pip_install not supported on Android emulator, skipping")
@staticmethod
async def _build_pwa_apk(
manifest_url: str,
package_name: Optional[str] = None,
keystore_path: Optional[Path] = None,
keystore_alias: str = "android",
keystore_password: str = "android",
) -> tuple[Path, str]:
"""Build a signed APK from a PWA manifest URL using Bubblewrap.
Auto-installs bubblewrap if not on PATH and auto-configures
``~/.bubblewrap/config.json`` from the already-known JDK and SDK paths,
so no manual interactive setup is required.
Returns ``(apk_path, sha256_fingerprint)`` where ``sha256_fingerprint``
is the colon-separated hex digest of the signing certificate — ready to
embed in the server's ``/.well-known/assetlinks.json``.
"""
import hashlib
import json as _json
from urllib.parse import urlparse
env = _java_env()
java_home = env.get("JAVA_HOME", "")
# Prefer Java 21 for Gradle compatibility (bubblewrap uses Gradle 8.x)
for _j21 in (
Path("/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home"),
Path("/usr/local/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home"),
):
if _j21.exists():
java_home = str(_j21)
env["JAVA_HOME"] = java_home
env["PATH"] = f"{_j21 / 'bin'}:{env.get('PATH', '')}"
break
jdk_bin = Path(java_home) / "bin" if java_home else Path(".")
keytool = str(jdk_bin / "keytool") if (jdk_bin / "keytool").exists() else "keytool"
# ── 1. Ensure bubblewrap CLI is on PATH ──────────────────────────────
# Augment PATH with common Homebrew node locations so shutil.which finds them
# even when the process was launched without a login shell.
extra_node_paths = ["/opt/homebrew/bin", "/usr/local/bin"]
augmented_path = os.pathsep.join(extra_node_paths) + os.pathsep + env.get("PATH", "")
env["PATH"] = augmented_path
bw = shutil.which("bubblewrap", path=augmented_path)
if not bw:
node = shutil.which("node", path=augmented_path)
npm = shutil.which("npm", path=augmented_path)
if not node or not npm:
raise RuntimeError(
"node/npm not found on PATH; required for pwa_install.\n"
"Install Node.js: https://nodejs.org/"
)
logger.info("Installing @bubblewrap/cli …")
subprocess.run(
[npm, "install", "-g", "@bubblewrap/cli"],
check=True,
capture_output=True,
timeout=300,
)
bw = shutil.which("bubblewrap")
if not bw:
raise RuntimeError(
"bubblewrap still not found after npm install -g @bubblewrap/cli.\n"
"Ensure npm global bin is on PATH."
)
# ── 2. Auto-configure ~/.bubblewrap/config.json ──────────────────────
# bubblewrap expects jdkPath to be the .jdk bundle root on macOS
# (it appends Contents/Home internally). Gradle also requires Java ≤ 21.
def _bubblewrap_jdk_path() -> str:
"""Return the .jdk bundle path suitable for bubblewrap's jdkPath.
Prefers openjdk@21 (Gradle-compatible) over newer versions.
On macOS returns the .jdk bundle root; elsewhere the JDK home.
"""
candidates = [
# Prefer Java 21 — Gradle 8.x supports up to Java 21
Path("/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk"),
Path("/usr/local/opt/openjdk@21/libexec/openjdk.jdk"),
Path("/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk"),
# Fall back to whatever _java_env() found
Path(java_home).parent.parent if java_home.endswith("Contents/Home") else None,
Path(java_home) if java_home else None,
]
for c in candidates:
if c and c.exists():
return str(c)
return java_home # last resort
bw_config = Path.home() / ".bubblewrap" / "config.json"
bw_config.parent.mkdir(parents=True, exist_ok=True)
if not bw_config.exists() or not _json.loads(bw_config.read_text()).get("jdkPath"):
sdk = _sdk_path()
cfg = {
"jdkPath": _bubblewrap_jdk_path(),
"androidSdkPath": str(sdk),
}
bw_config.write_text(_json.dumps(cfg, indent=2))
logger.info(f"Wrote ~/.bubblewrap/config.json: jdkPath={cfg['jdkPath']}")
else:
cfg = _json.loads(bw_config.read_text())
# Patch androidSdkPath if it no longer exists
if not Path(cfg.get("androidSdkPath", "")).exists():
cfg["androidSdkPath"] = str(_sdk_path())
bw_config.write_text(_json.dumps(cfg, indent=2))
# ── 2b. Ensure build-tools + tools stub (both required by bubblewrap) ─
sdk = Path(cfg["androidSdkPath"])
sdkmanager = sdk / "cmdline-tools" / "latest" / "bin" / "sdkmanager"
if sdkmanager.exists() and not any(
(sdk / "build-tools").iterdir() if (sdk / "build-tools").exists() else []
):
logger.info("Installing Android build-tools (required by bubblewrap) …")
subprocess.run(
[str(sdkmanager), f"--sdk_root={sdk}", "build-tools;34.0.0"],
input=b"y\n",
capture_output=True,
timeout=300,
env=env,
)
# bubblewrap validates SDK by checking for tools/ or bin/ at sdk root
(sdk / "tools").mkdir(exist_ok=True)
# ── 3. Determine package ID and cache directory ──────────────────────
if not package_name:
import re
host = urlparse(manifest_url).hostname or ""
parts = [p for p in reversed(host.split(".")) if p]
# Sanitize each part: replace non-alphanumeric/underscore chars with _,
# prefix with _ if starts with a digit (invalid Java identifier start).
sanitized = []
for part in parts:
part = re.sub(r"[^a-zA-Z0-9_]", "_", part)
if part and part[0].isdigit():
part = "_" + part
if part:
sanitized.append(part)
package_name = ".".join(sanitized) if sanitized else "com.cua.pwa"
cache_key = hashlib.sha256(f"{manifest_url}|{package_name}".encode()).hexdigest()[:12]
cache_dir = Path.home() / ".cua" / "cua-sandbox" / "pwa-cache" / cache_key
fingerprint_file = cache_dir / "sha256.fingerprint"
signed_apk = cache_dir / "app-release-signed.apk"
if signed_apk.exists() and fingerprint_file.exists():
fingerprint = fingerprint_file.read_text().strip()
logger.info(f"Using cached PWA APK: {signed_apk} fingerprint: {fingerprint}")
return signed_apk, fingerprint
cache_dir.mkdir(parents=True, exist_ok=True)
# ── 4. Resolve keystore ──────────────────────────────────────────────
if keystore_path:
keystore = Path(keystore_path)
if not keystore.exists():
raise RuntimeError(f"Keystore not found: {keystore}")
else:
keystore = cache_dir / "android.keystore"
if not keystore.exists():
logger.info("Generating signing keystore …")
subprocess.run(
[
keytool,
"-genkeypair",
"-v",
"-keystore",
str(keystore),
"-alias",
keystore_alias,
"-keyalg",
"RSA",
"-keysize",
"2048",
"-validity",
"10000",
"-storepass",
keystore_password,
"-keypass",
keystore_password,
"-dname",
"CN=CUA PWA, OU=Dev, O=CUA, L=SF, S=CA, C=US",
],
check=True,
capture_output=True,
timeout=30,
)
# ── 5. Extract SHA-256 fingerprint from the keystore ─────────────────
fp_result = subprocess.run(
[
keytool,
"-list",
"-v",
"-keystore",
str(keystore),
"-alias",
keystore_alias,
"-storepass",
keystore_password,
],
capture_output=True,
text=True,
timeout=15,
)
fingerprint = ""
for line in fp_result.stdout.splitlines():
if "SHA256:" in line:
fingerprint = line.split("SHA256:", 1)[1].strip()
break
if not fingerprint:
raise RuntimeError(
f"Could not extract SHA-256 fingerprint from keystore.\nkeytool output:\n{fp_result.stdout}"
)
fingerprint_file.write_text(fingerprint)
logger.info(f"Keystore SHA-256 fingerprint: {fingerprint}")
# ── 6. Generate twa-manifest.json via _bw_init.js ────────────────────
bw_init_js = Path(__file__).parent / "_bw_init.js"
node = shutil.which("node", path=augmented_path)
if not node:
raise RuntimeError("node not found on PATH; required for pwa_install")
logger.info(f"Generating twa-manifest.json for {manifest_url}")
init_result = subprocess.run(
[
node,
str(bw_init_js),
manifest_url,
str(cache_dir),
package_name,
str(keystore),
keystore_alias,
keystore_password,
],
capture_output=True,
text=True,
env=env,
timeout=60,
)
if init_result.returncode != 0:
raise RuntimeError(f"_bw_init.js failed for {manifest_url}:\n{init_result.stderr}")
# ── 7. bubblewrap update → Android project ───────────────────────────
logger.info("Running bubblewrap update (generates Android project) …")
update_result = subprocess.run(
[bw, "update", "--skipPwaValidation"],
capture_output=True,
text=True,
cwd=str(cache_dir),
env=env,
timeout=300,
)
if update_result.returncode != 0:
raise RuntimeError(f"bubblewrap update failed:\n{update_result.stderr}")
# ── 8. bubblewrap build → signed APK ─────────────────────────────────
# JAVA_HOME for gradle must point to Contents/Home, not the .jdk bundle.
jdk_bundle = Path(cfg.get("jdkPath", java_home))
contents_home = jdk_bundle / "Contents" / "Home"
gradle_java_home = str(contents_home) if contents_home.exists() else str(jdk_bundle)
bw_env = {
**env,
"JAVA_HOME": gradle_java_home,
"BUBBLEWRAP_KEYSTORE_PASSWORD": keystore_password,
"BUBBLEWRAP_KEY_PASSWORD": keystore_password,
}
logger.info("Running bubblewrap build …")
build_result = subprocess.run(
[bw, "build"],
capture_output=True,
text=True,
cwd=str(cache_dir),
env=bw_env,
timeout=600,
)
if build_result.returncode != 0:
raise RuntimeError(f"bubblewrap build failed:\n{build_result.stderr}")
apks = list(cache_dir.rglob("*.apk"))
if not apks:
raise RuntimeError(
f"bubblewrap build succeeded but no .apk found in {cache_dir}\n"
f"stdout: {build_result.stdout}"
)
logger.info(f"PWA APK built: {apks[0]}")
return apks[0], fingerprint
@staticmethod
async def _resolve_apk(apk_path: str) -> Path:
"""If apk_path is a URL, download it. Otherwise return as-is."""
if apk_path.startswith(("http://", "https://")):
import hashlib
import urllib.request
cache = Path.home() / ".cua" / "cua-sandbox" / "apk-cache"
cache.mkdir(parents=True, exist_ok=True)
filename = apk_path.rsplit("/", 1)[-1].split("?")[0]
url_hash = hashlib.sha256(apk_path.encode()).hexdigest()[:8]
local = cache / f"{url_hash}_{filename}"
if not local.exists():
logger.info(f"Downloading APK: {apk_path}")
urllib.request.urlretrieve(apk_path, str(local))
return local
return Path(apk_path)
async def is_ready(self, info: RuntimeInfo, timeout: float = 300) -> bool:
"""Wait for `sys.boot_completed == 1` via ADB, like docker-android."""
sdk = self._sdk or _sdk_path()
adb = _find_bin(sdk, "adb")
serial = f"emulator-{self.adb_port - 1}"
env = os.environ.copy()
env["ANDROID_SDK_ROOT"] = str(sdk)
logger.info(f"Waiting for Android boot (timeout={timeout}s) ...")
# First wait for ADB device
deadline = asyncio.get_event_loop().time() + timeout
while asyncio.get_event_loop().time() < deadline:
result = subprocess.run(
[adb, "-s", serial, "shell", "getprop", "sys.boot_completed"],
capture_output=True,
text=True,
env=env,
timeout=10,
)
value = result.stdout.strip()
if value == "1":
logger.info(f"Android emulator {info.name} boot completed.")
# Disable animations for faster interaction (docker-android pattern)
for prop in [
"settings put global window_animation_scale 0.0",
"settings put global transition_animation_scale 0.0",
"settings put global animator_duration_scale 0.0",
]:
subprocess.run(
[adb, "-s", serial, "shell", prop],
capture_output=True,
env=env,
)
return True
await asyncio.sleep(5)
raise TimeoutError(f"Android emulator {info.name} did not boot within {timeout}s")
async def list(self) -> list[dict]:
"""List known Android emulator sandboxes from state files, checking if alive."""
import subprocess
from cua_sandbox import sandbox_state
states = [s for s in sandbox_state.list_all() if s.get("runtime_type") == "androidemulator"]
result = []
for s in states:
name = s["name"]
status = s.get("status", "unknown")
if status == "running":
try:
alive = (
subprocess.run(
["pgrep", "-f", f"qemu.*-avd {name}"],
capture_output=True,
).returncode
== 0
)
if not alive:
status = "stopped"
sandbox_state.update(name, status="stopped")
except FileNotFoundError:
pass
result.append(
{
"name": name,
"status": status,
"runtime_type": "androidemulator",
"host": s.get("host"),
"api_port": s.get("api_port"),
"os_type": s.get("os_type"),
}
)
return result
async def stop(self, name: str) -> None:
if self._proc and self._proc.poll() is None:
self._proc.terminate()
try:
self._proc.wait(timeout=15)
except subprocess.TimeoutExpired:
self._proc.kill()
self._proc = None
# Also try ADB kill
try:
sdk = self._sdk or _sdk_path()
adb = _find_bin(sdk, "adb")
subprocess.run(
[adb, "-s", f"emulator-{self.adb_port - 1}", "emu", "kill"],
capture_output=True,
timeout=10,
)
except Exception:
pass
async def take_screenshot(self) -> bytes:
"""Take a screenshot via ADB screencap."""
sdk = self._sdk or _sdk_path()
adb = _find_bin(sdk, "adb")
serial = f"emulator-{self.adb_port - 1}"
# screencap to file, then pull
subprocess.run(
[adb, "-s", serial, "shell", "screencap", "-p", "/sdcard/screenshot.png"],
capture_output=True,
timeout=15,
)
result = subprocess.run(
[adb, "-s", serial, "exec-out", "cat", "/sdcard/screenshot.png"],
capture_output=True,
timeout=15,
)
if result.returncode == 0 and result.stdout[:4] == b"\x89PNG":
return result.stdout
raise RuntimeError("Failed to take screenshot via ADB")
@@ -0,0 +1,103 @@
"""Abstract runtime — starts a VM or container from an Image spec and returns connection info."""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Optional
from cua_sandbox.image import Image
if TYPE_CHECKING:
pass
@dataclass
class CheckpointInfo:
"""Metadata for a saved checkpoint / snapshot of a sandbox."""
name: str
runtime_type: str
created_at: float = 0.0
metadata: dict = field(default_factory=dict)
@dataclass
class RuntimeInfo:
"""Connection info returned after a runtime spins up."""
host: str
api_port: int
vnc_port: Optional[int] = None
api_key: Optional[str] = None
container_id: Optional[str] = None
name: Optional[str] = None
qmp_port: Optional[int] = None # Set when QMP transport should be used
environment: Optional[str] = None # OS type hint for QMP transport
agent_type: Optional[str] = None # e.g. "osworld" for OSWorld Flask server
guest_server_port: int = 8000 # Port the guest server listens on
ssh_port: Optional[int] = None # SSH port on the guest
ssh_username: Optional[str] = None
ssh_password: Optional[str] = None
ssh_key_filename: Optional[str] = None
vnc_host: Optional[str] = None # VNC host (if different from host, e.g. localhost for Tart)
vnc_password: Optional[str] = None
grpc_port: Optional[int] = (
None # Set when gRPC transport should be used (emulator gRPC service)
)
class Runtime(ABC):
"""Base class for local runtimes that create VMs/containers from an Image."""
@abstractmethod
async def start(self, image: Image, name: str, **opts) -> RuntimeInfo:
"""Start a VM/container and return connection info."""
@abstractmethod
async def stop(self, name: str) -> None:
"""Stop and clean up a VM/container."""
@abstractmethod
async def is_ready(self, info: RuntimeInfo, timeout: float = 120) -> bool:
"""Wait until the computer-server inside is reachable."""
async def suspend(self, name: str) -> None:
"""Suspend (pause/save state of) a running VM."""
raise NotImplementedError(f"{type(self).__name__} does not support suspend()")
async def resume(self, image: "Image", name: str, **opts) -> RuntimeInfo:
"""Resume a suspended VM and return its RuntimeInfo."""
raise NotImplementedError(f"{type(self).__name__} does not support resume()")
async def list(self) -> list[dict]:
"""List known VMs managed by this runtime.
Returns a list of dicts with at minimum: name, status.
"""
raise NotImplementedError(f"{type(self).__name__} does not support list()")
# ── Checkpoint / fork primitives ─────────────────────────────────────────
async def ensure_base(self, image: "Image", base_name: str) -> CheckpointInfo:
"""Pull/build image into a stopped base sandbox if not already present.
Idempotent. First call does the full pull; subsequent calls return
immediately. The base sandbox is never started — it only serves as a
fork source for ephemeral VMs.
"""
raise NotImplementedError(f"{type(self).__name__} does not support ensure_base()")
async def fork(self, base_name: str, new_name: str, **opts) -> None:
"""Create a new sandbox from a base/checkpoint using CoW where possible."""
raise NotImplementedError(f"{type(self).__name__} does not support fork()")
async def checkpoint(self, name: str, checkpoint_name: str, **opts) -> CheckpointInfo:
"""Capture the current state of a running sandbox as a named checkpoint."""
raise NotImplementedError(f"{type(self).__name__} does not support checkpoint()")
async def list_checkpoints(self) -> list[CheckpointInfo]:
raise NotImplementedError(f"{type(self).__name__} does not support list_checkpoints()")
async def delete_checkpoint(self, checkpoint_name: str) -> None:
raise NotImplementedError(f"{type(self).__name__} does not support delete_checkpoint()")
@@ -0,0 +1,486 @@
"""Runtime compatibility checks — host/guest OS + arch + hardware acceleration.
Answers two questions for any Image before you try to boot it locally:
1. Is the required runtime installed (or auto-installable) on this host?
2. Does the host OS/arch support hardware acceleration for the guest OS/arch?
Usage::
from cua_sandbox import Image
from cua_sandbox.runtime.compat import check_local_support
support = check_local_support(Image.linux())
print(support.supported) # True if Docker is present
print(support.hw_accel) # True for containers (native), True on Linux VMs with KVM
print(support.reason) # human-readable summary
# Or via the Image method:
support = Image.macos().local_support()
In pytest, use the bundled helper::
from cua_sandbox.runtime.compat import skip_if_unsupported
async def test_something():
skip_if_unsupported(Image.macos())
async with Sandbox.ephemeral(Image.macos(), local=True) as sb:
...
"""
from __future__ import annotations
import platform
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from cua_sandbox.image import Image
# ---------------------------------------------------------------------------
# Result type
# ---------------------------------------------------------------------------
@dataclass
class RuntimeSupport:
"""Result of a local runtime compatibility check for a given Image.
Attributes:
supported: Image can run locally at all (runtime available or
auto-installable, and host/guest OS is compatible).
hw_accel: Hardware acceleration is available for the guest on
this host (HVF on Apple Silicon, KVM on Linux, etc.).
Always True for containers — they run natively.
runtime_installed: Required runtime binary/daemon is found right now
without any installation step.
auto_installable: Runtime can be downloaded and installed automatically
by the SDK without user intervention (e.g. Android SDK).
runtime_name: Name of the runtime that would be used
("docker", "lume", "qemu", "android_emulator", "hyperv").
host_os: Detected host OS ("darwin", "linux", "windows").
host_arch: Detected host CPU architecture ("arm64", "x86_64").
guest_os: Guest OS from the image ("linux", "macos", "windows", "android").
reason: Human-readable explanation — always set, describes
the limiting factor when supported=False or hw_accel=False.
"""
supported: bool
hw_accel: bool
runtime_installed: bool
auto_installable: bool
runtime_name: str
host_os: str
host_arch: str
guest_os: str
reason: str
def __str__(self) -> str:
accel = "hw-accel" if self.hw_accel else "software-only"
status = "supported" if self.supported else "unsupported"
installed = (
"installed"
if self.runtime_installed
else ("auto-installable" if self.auto_installable else "not-installed")
)
return (
f"RuntimeSupport({self.guest_os} on {self.host_os}/{self.host_arch}: "
f"{status}, {accel}, runtime={self.runtime_name} [{installed}])"
)
# ---------------------------------------------------------------------------
# Internal host probes
# ---------------------------------------------------------------------------
def _host_os() -> str:
s = platform.system().lower()
if s == "darwin":
return "darwin"
if s == "windows":
return "windows"
return "linux"
def _host_arch() -> str:
m = platform.machine().lower()
if m in ("arm64", "aarch64"):
return "arm64"
return "x86_64"
def _has_docker() -> bool:
from cua_sandbox.runtime.docker import _has_docker as _docker_check
return _docker_check()
def _has_kvm() -> bool:
from pathlib import Path
return _host_os() == "linux" and Path("/dev/kvm").exists()
def _has_hvf_for_arm64_guest() -> bool:
"""HVF for ARM64 guests (macOS VMs, ARM Android) — Apple Silicon only."""
return _host_os() == "darwin" and _host_arch() == "arm64"
def _has_hvf_for_x86_guest() -> bool:
"""HVF for x86_64 guests (Windows, Linux VMs) — Intel Macs only.
On Apple Silicon, QEMU must use TCG software emulation for x86_64 guests
because HVF does not support cross-architecture virtualisation.
On Intel Macs, QEMU can use HVF (-accel hvf) for x86_64 guests.
"""
return _host_os() == "darwin" and _host_arch() == "x86_64"
def _x86_guest_hw_accel() -> tuple[bool, str]:
"""Return (hw_accel, reason) for an x86_64 guest on the current host."""
os_ = _host_os()
arch = _host_arch()
if os_ == "linux" and _has_kvm():
return True, "KVM hardware acceleration."
if os_ == "darwin" and arch == "x86_64":
return True, "Apple Hypervisor.framework (HVF) via QEMU -accel hvf on Intel Mac."
if os_ == "windows" and _has_hyperv():
return True, "Hyper-V hardware acceleration."
if os_ == "darwin" and arch == "arm64":
return False, (
"QEMU cannot use HVF for x86_64 guests on Apple Silicon — TCG software emulation only. "
"Expect slow performance."
)
return False, (
f"No hardware acceleration for x86_64 guest on {os_}/{arch}. "
"Enable KVM (Linux) or Hyper-V (Windows) for acceleration."
)
def _has_lume() -> bool:
from cua_sandbox.runtime.lume import _has_lume as _lume_check
return _lume_check()
def _has_android_sdk() -> bool:
"""Return True if the Android emulator binary is present.
Delegates to android_emulator._sdk_path() — the exact same logic the
runtime uses at boot — then checks that emulator/emulator exists there.
"""
from cua_sandbox.runtime.android_emulator import _sdk_path
sdk = _sdk_path()
return (sdk / "emulator" / "emulator").exists() or (
sdk / "emulator" / "emulator.exe"
).exists() # Windows
def _has_java() -> bool:
"""Return True if a usable JRE is found.
Calls android_emulator._java_env() — the exact same probe the runtime uses.
Returns False if _java_env() raises (Java not found).
"""
from cua_sandbox.runtime.android_emulator import _java_env
try:
_java_env()
return True
except RuntimeError:
return False
def _has_hyperv() -> bool:
from cua_sandbox.runtime.hyperv import _has_hyperv as _hyperv_check
return _hyperv_check()
def _has_qemu() -> bool:
"""Return True if a QEMU binary is available.
Delegates to qemu_installer.qemu_bin() which checks PATH, Homebrew,
MacPorts, and the cached portable install — the same resolution the
runtime uses at boot time.
"""
arch = _host_arch()
from cua_sandbox.runtime.qemu_installer import qemu_bin
try:
qemu_bin(arch)
return True
except RuntimeError:
# Also try the other arch — a cross-arch QEMU is still useful
other = "x86_64" if arch == "arm64" else "arm64"
try:
qemu_bin(other)
return True
except RuntimeError:
return False
# ---------------------------------------------------------------------------
# Main check
# ---------------------------------------------------------------------------
def check_local_support(image: "Image") -> RuntimeSupport:
"""Return a RuntimeSupport describing whether *image* can run locally.
Args:
image: The Image to check. Use Image.linux(), Image.macos(), etc.
Returns:
RuntimeSupport with .supported, .hw_accel, .reason, and more.
"""
os_ = _host_os()
arch = _host_arch()
guest = image.os_type
kind = image.kind or "vm"
# ── Linux container ────────────────────────────────────────────────────
if guest == "linux" and kind == "container":
installed = _has_docker()
return RuntimeSupport(
supported=installed,
hw_accel=True, # containers run natively — no emulation overhead
runtime_installed=installed,
auto_installable=False,
runtime_name="docker",
host_os=os_,
host_arch=arch,
guest_os=guest,
reason=(
"Docker is running."
if installed
else "Docker is not running or not installed. Install from https://docker.com"
),
)
# ── Linux VM ──────────────────────────────────────────────────────────
if guest == "linux" and kind == "vm":
docker_ok = _has_docker()
qemu_ok = _has_qemu()
installed = docker_ok or qemu_ok
hw, hw_reason = _x86_guest_hw_accel()
runtime = "docker+qemu" if docker_ok else ("qemu" if qemu_ok else "qemu")
if not installed:
reason = (
"No runtime for Linux VMs found. "
"Install Docker (preferred) or qemu-system-x86_64."
)
else:
reason = f"Linux VM via QEMU. {hw_reason}"
return RuntimeSupport(
supported=installed,
hw_accel=hw,
runtime_installed=installed,
auto_installable=False,
runtime_name=runtime,
host_os=os_,
host_arch=arch,
guest_os=guest,
reason=reason,
)
# ── macOS VM ──────────────────────────────────────────────────────────
if guest == "macos":
# macOS VMs require macOS host + Apple Silicon (HVF via Virtualization.framework)
if os_ != "darwin":
return RuntimeSupport(
supported=False,
hw_accel=False,
runtime_installed=False,
auto_installable=False,
runtime_name="lume",
host_os=os_,
host_arch=arch,
guest_os=guest,
reason=f"macOS VMs require a macOS host. Current host OS: {os_}.",
)
if arch != "arm64":
return RuntimeSupport(
supported=False,
hw_accel=False,
runtime_installed=False,
auto_installable=False,
runtime_name="lume",
host_os=os_,
host_arch=arch,
guest_os=guest,
reason=(
"macOS VMs require Apple Silicon (ARM64) for Apple Hypervisor.framework. "
f"Current host arch: {arch} (Intel Macs cannot run macOS VMs via Lume)."
),
)
# Apple Silicon macOS — HVF always available
installed = _has_lume()
return RuntimeSupport(
supported=True, # Lume can be auto-installed via `lume install`
hw_accel=True, # HVF always present on Apple Silicon
runtime_installed=installed,
auto_installable=True, # Lume installs via a single curl/brew command
runtime_name="lume",
host_os=os_,
host_arch=arch,
guest_os=guest,
reason=(
"macOS VM with Apple Hypervisor.framework (HVF) acceleration."
if installed
else (
"Lume CLI not installed but can be auto-installed. "
"Run: brew install trycua/tap/lume or curl -fsSL https://lume.sh | sh"
)
),
)
# ── Windows VM ────────────────────────────────────────────────────────
if guest == "windows":
if os_ == "windows" and _has_hyperv():
return RuntimeSupport(
supported=True,
hw_accel=True,
runtime_installed=True,
auto_installable=False,
runtime_name="hyperv",
host_os=os_,
host_arch=arch,
guest_os=guest,
reason="Windows VM with Hyper-V hardware acceleration.",
)
docker_ok = _has_docker()
qemu_ok = _has_qemu()
installed = docker_ok or qemu_ok
hw, hw_reason = _x86_guest_hw_accel()
runtime = "docker+qemu" if docker_ok else ("qemu" if qemu_ok else "qemu")
if not installed:
reason = (
"No runtime for Windows VMs found. "
"Install Docker (preferred) or qemu-system-x86_64."
)
else:
reason = f"Windows VM via QEMU. {hw_reason}"
return RuntimeSupport(
supported=installed,
hw_accel=hw,
runtime_installed=installed,
auto_installable=False,
runtime_name=runtime,
host_os=os_,
host_arch=arch,
guest_os=guest,
reason=reason,
)
# ── Android VM ────────────────────────────────────────────────────────
if guest == "android":
sdk_ok = _has_android_sdk()
java_ok = _has_java()
# HW accel matrix for Android:
# Apple Silicon (arm64): HVF for ARM64 Android system images
# Intel Mac (x86_64): HVF for x86_64 Android system images
# Linux x86_64 w/ KVM: KVM for x86_64 Android system images
if os_ == "darwin" and arch == "arm64":
hw = True
hw_reason = "Apple Hypervisor.framework (HVF) with ARM64 Android system image."
elif os_ == "darwin" and arch == "x86_64":
hw = True
hw_reason = (
"Apple Hypervisor.framework (HVF) with x86_64 Android system image on Intel Mac."
)
elif os_ == "linux" and _has_kvm():
hw = True
hw_reason = "KVM with x86_64 Android system image."
elif os_ == "windows":
# WHPX (Windows Hypervisor Platform) is used by the Android emulator on Windows
hw = _has_hyperv() # WHPX requires Hyper-V / Windows Hypervisor Platform
hw_reason = (
"Windows Hypervisor Platform (WHPX) acceleration."
if hw
else "WHPX not available. Emulator will use software rendering (slow)."
)
else:
hw = False
hw_reason = (
f"No hardware acceleration for Android on {os_}/{arch}. "
"Emulator will use software rendering (slow)."
)
installed = sdk_ok and java_ok
auto = not installed # SDK auto-installs on all platforms if Java is present
if not java_ok:
reason = (
"Java not found. Install via: brew install openjdk (macOS), "
"apt install default-jdk (Linux), or "
"winget install EclipseAdoptium.Temurin.21.JDK (Windows). " + hw_reason
)
elif not sdk_ok:
reason = "Android SDK not found but will be auto-installed on first boot. " + hw_reason
else:
reason = "Android SDK installed. " + hw_reason
return RuntimeSupport(
supported=java_ok, # Java is a hard prerequisite; SDK auto-installs if Java is present
hw_accel=hw,
runtime_installed=installed,
auto_installable=auto and java_ok,
runtime_name="android_emulator",
host_os=os_,
host_arch=arch,
guest_os=guest,
reason=reason,
)
# ── Unknown / registry image with unresolved kind ─────────────────────
return RuntimeSupport(
supported=False,
hw_accel=False,
runtime_installed=False,
auto_installable=False,
runtime_name="unknown",
host_os=os_,
host_arch=arch,
guest_os=guest,
reason=(
f"Cannot determine runtime for os_type={guest!r}, kind={kind!r}. "
"Use Image.linux() / .macos() / .windows() / .android() "
"or pass runtime= explicitly."
),
)
# ---------------------------------------------------------------------------
# Pytest helper
# ---------------------------------------------------------------------------
def skip_if_unsupported(image: "Image", *, require_hw_accel: bool = False) -> None:
"""Call at the top of a pytest test/fixture to skip when the host can't run *image*.
Args:
image: The Image the test intends to boot with local=True.
require_hw_accel: If True, also skip when hardware acceleration is unavailable
(e.g. skip Android tests that would run in software emulation).
Example::
async def test_macos_brew_install():
skip_if_unsupported(Image.macos())
async with Sandbox.ephemeral(Image.macos().brew_install("wget"), local=True) as sb:
assert (await sb.shell.run("wget --version")).success
"""
import pytest # imported here so compat.py has no hard pytest dep at import time
support = check_local_support(image)
if not support.supported:
pytest.skip(f"[{support.runtime_name}] {support.reason}")
if require_hw_accel and not support.hw_accel:
pytest.skip(
f"[{support.runtime_name}] hardware acceleration not available: {support.reason}"
)
@@ -0,0 +1,339 @@
"""Docker runtime — spins up containers with computer-server."""
from __future__ import annotations
import asyncio
import logging
import subprocess
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from cua_sandbox.image import Image
import httpx
from cua_sandbox.image import Image
from cua_sandbox.runtime.base import Runtime, RuntimeInfo
from cua_sandbox.runtime.images import (
DEFAULT_API_PORT,
DEFAULT_VNC_PORT,
internal_ports,
resolve_image,
)
logger = logging.getLogger(__name__)
def _find_free_port(start: int = 8000, end: int = 9000) -> int:
"""Return a free TCP port. Uses OS assignment (port 0) to avoid races."""
import socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("", 0))
return s.getsockname()[1]
def _docker_bin() -> str:
"""Return the resolved path to the docker CLI binary.
Probes common install locations in addition to PATH — SSH sessions often
have a stripped PATH that omits /usr/local/bin (e.g. OrbStack on macOS).
Raises RuntimeError if docker is not found.
"""
import os
import shutil
candidates = [
"docker",
"/usr/local/bin/docker",
"/opt/homebrew/bin/docker",
"/usr/bin/docker",
os.path.expanduser("~/.docker/bin/docker"),
]
for candidate in candidates:
resolved = shutil.which(candidate) or candidate
try:
subprocess.run([resolved, "info"], capture_output=True, check=True, timeout=10)
return resolved
except (subprocess.SubprocessError, FileNotFoundError, OSError):
continue
raise RuntimeError(
"Docker not found. Install from https://docker.com or ensure the docker CLI is on PATH."
)
def _has_docker() -> bool:
"""Return True if a Docker daemon is reachable."""
try:
_docker_bin()
return True
except RuntimeError:
return False
def _has_kvm() -> bool:
"""Check if /dev/kvm is available (Linux/WSL2 only)."""
import platform
if platform.system() != "Linux":
return False
from pathlib import Path
return Path("/dev/kvm").exists()
class DockerRuntime(Runtime):
"""Runs containers via ``docker run``."""
def __init__(
self,
*,
api_port: int = DEFAULT_API_PORT,
vnc_port: int = DEFAULT_VNC_PORT,
ephemeral: bool = True,
volumes: Optional[list[str]] = None,
environment: Optional[dict[str, str]] = None,
devices: Optional[list[str]] = None,
platform: Optional[str] = None,
privileged: bool = False,
stop_timeout: int = 120,
):
self.api_port = api_port
self.vnc_port = vnc_port
self.ephemeral = ephemeral
self.volumes = volumes or []
self.environment = environment or {}
self.devices = devices or []
self.platform = platform
self.privileged = privileged
self.stop_timeout = stop_timeout
async def start(self, image: Image, name: str, **opts) -> RuntimeInfo:
if not _has_docker():
raise RuntimeError("Docker is not installed or not running")
docker_image = resolve_image(image.os_type, image._registry)
internal_api, internal_vnc = internal_ports(docker_image)
extra_flags: list[str] = []
if "qemu" in docker_image:
extra_flags += ["--cap-add", "NET_ADMIN"]
# Volume mounts
for v in self.volumes:
extra_flags += ["-v", v]
# Environment variables (from DockerRuntime constructor)
for k, val in self.environment.items():
extra_flags += ["-e", f"{k}={val}"]
# Environment variables from image.env() calls
for k, val in getattr(image, "_env", ()):
extra_flags += ["-e", f"{k}={val}"]
# Devices (e.g. /dev/kvm)
for d in self.devices:
extra_flags += ["--device", d]
# Platform (e.g. linux/amd64)
if self.platform:
extra_flags += ["--platform", self.platform]
# Privileged mode
if self.privileged:
extra_flags.append("--privileged")
# Stop timeout
extra_flags += ["--stop-timeout", str(self.stop_timeout)]
# Expose additional ports from image._ports
for port in getattr(image, "_ports", ()):
host_p = _find_free_port(port, port + 1000)
extra_flags += ["-p", f"{host_p}:{port}"]
# Remove existing container with same name
docker = _docker_bin()
subprocess.run([docker, "rm", "-f", name], capture_output=True)
api_port = _find_free_port(self.api_port)
vnc_port = _find_free_port(api_port + 1)
cmd = [
docker,
"run",
"-d",
"--name",
name,
"--label",
"cua.sandbox=true",
"--label",
f"cua.sandbox.name={name}",
"-p",
f"{api_port}:{internal_api}",
"-p",
f"{vnc_port}:{internal_vnc}",
*extra_flags,
docker_image,
]
logger.info(f"Starting container: {' '.join(cmd)}")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"docker run failed: {result.stderr}")
container_id = result.stdout.strip()[:12]
info = RuntimeInfo(
host="localhost",
api_port=api_port,
vnc_port=vnc_port,
container_id=container_id,
name=name,
)
await self.is_ready(info)
# Apply image layers and files via computer-server.
# If any provisioning step fails, stop and remove the half-baked container
# so callers don't get a broken sandbox silently left running.
env_items = getattr(image, "_env", ())
file_items = getattr(image, "_files", ())
has_work = image._layers or file_items
if has_work or env_items:
try:
from cua_sandbox.builder.executor import LayerExecutor
executor = LayerExecutor(
f"http://{info.host}:{info.api_port}", os_type=image.os_type
)
# Write env vars to a sourceable profile script so run layers can access them
if env_items and image.os_type != "windows":
import re as _re
import shlex as _shlex
# Validate all keys up front
for k, _ in env_items:
if not _re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", k):
raise ValueError(f"Unsafe env var name: {k!r}")
await executor.run_command(
"printf '#!/bin/sh\\n' | sudo tee /etc/profile.d/cua-env.sh > /dev/null"
)
for k, v in env_items:
quoted_v = _shlex.quote(v)
await executor.run_command(
f"printf 'export {k}=%s\\n' {quoted_v} "
f"| sudo tee -a /etc/profile.d/cua-env.sh > /dev/null"
)
# Apply files before layers so later run layers can reference copied files
for src, dst in file_items:
await executor.execute_layers([{"type": "copy", "src": src, "dst": dst}])
if image._layers:
await executor.execute_layers(list(image._layers))
except Exception:
# Tear down the container so it doesn't linger in a broken state
try:
docker = _docker_bin()
subprocess.run([docker, "rm", "-f", name], capture_output=True)
except Exception:
pass
raise
return info
async def stop(self, name: str) -> None:
docker = _docker_bin()
subprocess.run([docker, "stop", name], capture_output=True)
if self.ephemeral:
subprocess.run([docker, "rm", name], capture_output=True)
async def suspend(self, name: str) -> None:
"""Pause a running Docker container."""
subprocess.run([_docker_bin(), "pause", name], capture_output=True)
async def resume(self, image: "Image", name: str, **opts) -> RuntimeInfo:
"""Unpause a paused Docker container and return its RuntimeInfo."""
docker = _docker_bin()
subprocess.run([docker, "unpause", name], capture_output=True)
# Inspect to get mapped ports
result = subprocess.run(
[
docker,
"inspect",
"--format",
'{{(index (index .NetworkSettings.Ports "8000/tcp") 0).HostPort}}',
name,
],
capture_output=True,
text=True,
)
api_port = int(result.stdout.strip()) if result.stdout.strip().isdigit() else self.api_port
result2 = subprocess.run(
[
docker,
"inspect",
"--format",
'{{(index (index .NetworkSettings.Ports "5900/tcp") 0).HostPort}}',
name,
],
capture_output=True,
text=True,
)
vnc_port = (
int(result2.stdout.strip()) if result2.stdout.strip().isdigit() else self.vnc_port
)
info = RuntimeInfo(host="localhost", api_port=api_port, vnc_port=vnc_port, name=name)
await self.is_ready(info)
return info
async def list(self) -> list[dict]:
"""List Docker containers with the cua.sandbox=true label."""
result = subprocess.run(
[
_docker_bin(),
"ps",
"-a",
"--filter",
"label=cua.sandbox=true",
"--format",
"{{.Names}}\t{{.Status}}",
],
capture_output=True,
text=True,
)
rows = []
for line in result.stdout.strip().splitlines():
if not line:
continue
parts = line.split("\t", 1)
name_col = parts[0].strip()
raw_status = parts[1].strip() if len(parts) > 1 else ""
if raw_status.startswith("Up"):
status = "running"
elif raw_status.startswith("Paused") or "(Paused)" in raw_status:
status = "suspended"
elif raw_status.startswith("Exited"):
status = "stopped"
else:
status = raw_status.lower()
rows.append({"name": name_col, "status": status, "runtime_type": "docker"})
return rows
async def is_ready(self, info: RuntimeInfo, timeout: float = 120) -> bool:
url = f"http://{info.host}:{info.api_port}/status"
deadline = asyncio.get_event_loop().time() + timeout
async with httpx.AsyncClient(timeout=5) as client:
while asyncio.get_event_loop().time() < deadline:
try:
resp = await client.get(url)
if resp.status_code == 200:
logger.info(f"Container {info.name} is ready")
return True
except (
httpx.ConnectError,
httpx.ReadTimeout,
httpx.RemoteProtocolError,
httpx.ConnectTimeout,
httpx.ReadError,
):
pass
await asyncio.sleep(2)
raise TimeoutError(f"Container {info.name} not ready after {timeout}s")
@@ -0,0 +1,370 @@
"""Hyper-V runtime — Windows VMs built from ISO via unattended install.
Builds a Windows image from scratch using Hyper-V Gen2 VMs (hardware-accelerated),
with the same Autounattend.xml + CUA server install as the QEMU builder.
Layer chain (all VHDX, cached):
Layer 0 (base): base-windows-{ver}.vhdx — Windows + CUA server (built from ISO)
Layer 1 (user): user-{hash}.vhdx — user's .winget_install()/.run() etc
Layer 2 (session): session-{name}.vhdx — ephemeral per-sandbox run
Requires Windows Pro/Enterprise/Education with Hyper-V enabled.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import subprocess
from pathlib import Path
from typing import Optional
import httpx
from cua_sandbox.builder.windows_unattend import (
create_unattend_iso,
download_windows_iso,
)
from cua_sandbox.image import Image
from cua_sandbox.runtime.base import Runtime, RuntimeInfo
from cua_sandbox.runtime.images import DEFAULT_API_PORT
logger = logging.getLogger(__name__)
CACHE_DIR = Path.home() / ".cua" / "cua-sandbox" / "hyperv"
def _has_hyperv() -> bool:
try:
result = subprocess.run(
["powershell", "-Command", "Get-Command New-VM -ErrorAction Stop"],
capture_output=True,
text=True,
)
return result.returncode == 0
except (subprocess.SubprocessError, FileNotFoundError):
return False
def _ps(cmd: str) -> str:
"""Run a PowerShell command and return stdout. Raises on failure."""
result = subprocess.run(
["powershell", "-NoProfile", "-Command", cmd],
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(f"PowerShell error: {result.stderr.strip()}")
return result.stdout.strip()
def _create_diff_vhdx(parent: Path, child: Path) -> Path:
"""Create a differencing VHDX with the given parent."""
child.parent.mkdir(parents=True, exist_ok=True)
_ps(f"New-VHD -ParentPath '{parent}' -Path '{child}' -Differencing")
logger.info(f"Created differencing VHDX: {child} (parent: {parent})")
return child
class HyperVRuntime(Runtime):
"""Runs Windows VMs via Hyper-V with layered VHDX caching.
On first use, builds a base Windows image from ISO using unattended
install (same Autounattend.xml as the QEMU builder). The base image
includes CUA computer-server pre-installed. This is cached and reused.
Each sandbox session gets its own ephemeral differencing disk on top.
"""
def __init__(
self,
*,
api_port: int = DEFAULT_API_PORT,
switch_name: str = "Default Switch",
memory_mb: int = 4096,
cpu_count: int = 4,
disk_size_gb: int = 64,
cache_dir: Optional[Path] = None,
windows_iso: Optional[str] = None,
product_key: Optional[str] = None,
):
self.api_port = api_port
self.switch_name = switch_name
self.memory_mb = memory_mb
self.cpu_count = cpu_count
self.disk_size_gb = disk_size_gb
self.cache_dir = cache_dir or CACHE_DIR
self.windows_iso = windows_iso
self.product_key = product_key
async def start(self, image: Image, name: str, **opts) -> RuntimeInfo:
if not _has_hyperv():
raise RuntimeError(
"Hyper-V is not available. Enable it via: "
"Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V -All"
)
memory = opts.get("memory_mb", self.memory_mb)
cpus = opts.get("cpu_count", self.cpu_count)
switch = opts.get("switch_name", self.switch_name)
# Build the layered VHDX chain
session_disk = await self._resolve_session_disk(image, name)
# Clean up any existing VM with this name
self._cleanup_vm(name)
logger.info(f"Creating Hyper-V VM {name} from {session_disk}")
_ps(
f"New-VM -Name '{name}' "
f"-MemoryStartupBytes {memory * 1024 * 1024} "
f"-VHDPath '{session_disk}' "
f"-SwitchName '{switch}' "
f"-Generation 2"
)
_ps(f"Set-VM -Name '{name}' -ProcessorCount {cpus}")
_ps(f"Set-VMFirmware -VMName '{name}' -EnableSecureBoot Off")
_ps(f"Start-VM -Name '{name}'")
ip = await self._wait_for_ip(name)
info = RuntimeInfo(host=ip, api_port=self.api_port, name=name)
await self.is_ready(info)
return info
async def _resolve_session_disk(self, image: Image, name: str) -> Path:
"""Build the VHDX layer chain and return the session disk path.
Layer 0: base-windows-{ver}.vhdx (Windows + CUA server, cached)
Layer 1: user-{hash}.vhdx (user layers applied, cached) — only if image has layers
Layer 2: session-{name}.vhdx (ephemeral)
"""
self.cache_dir.mkdir(parents=True, exist_ok=True)
version = image.version or "11"
# Layer 0: Base image (Windows + CUA server installed from ISO)
base = await self._ensure_base_image(version)
# Layer 1: User layers (cached, if any)
if image._layers:
layer_hash = hashlib.sha256(
json.dumps(list(image._layers), sort_keys=True).encode()
).hexdigest()[:16]
user_layer = self.cache_dir / f"user-{layer_hash}.vhdx"
if not user_layer.exists():
user_layer = await self._build_user_layer(image, base, user_layer)
backing = user_layer
else:
backing = base
# Layer 2: Ephemeral session disk
sessions_dir = self.cache_dir / "sessions"
session_disk = sessions_dir / f"{name}.vhdx"
if session_disk.exists():
session_disk.unlink()
_create_diff_vhdx(backing, session_disk)
return session_disk
async def _ensure_base_image(self, version: str = "11") -> Path:
"""Build or return cached base Windows image with CUA server installed."""
base_vhdx = self.cache_dir / f"base-windows-{version}.vhdx"
if base_vhdx.exists():
logger.info(f"Using cached base image: {base_vhdx}")
return base_vhdx
logger.info(
"Building base Windows image (first-time setup, ~15-30 min with Hyper-V acceleration)..."
)
work_dir = self.cache_dir / "build"
work_dir.mkdir(parents=True, exist_ok=True)
# Get Windows ISO
win_iso = download_windows_iso(version, work_dir, self.windows_iso)
# Create unattend ISO (no virtio drivers needed, no startup.nsh needed)
unattend_iso = create_unattend_iso(
work_dir,
self.product_key,
include_virtio_drivers=False,
include_startup_nsh=False,
)
# Create VHDX disk
build_vhdx = work_dir / "build-disk.vhdx"
if build_vhdx.exists():
build_vhdx.unlink()
_ps(f"New-VHD -Path '{build_vhdx}' -SizeBytes {self.disk_size_gb}GB -Dynamic")
vm_name = "cua-hyperv-build"
self._cleanup_vm(vm_name)
try:
# Create Gen2 VM
_ps(
f"New-VM -Name '{vm_name}' "
f"-MemoryStartupBytes {self.memory_mb * 1024 * 1024} "
f"-VHDPath '{build_vhdx}' "
f"-SwitchName '{self.switch_name}' "
f"-Generation 2"
)
_ps(f"Set-VM -Name '{vm_name}' -ProcessorCount {self.cpu_count}")
_ps(f"Set-VMFirmware -VMName '{vm_name}' -EnableSecureBoot Off")
# Attach Windows ISO as DVD
_ps(f"Add-VMDvdDrive -VMName '{vm_name}' -Path '{win_iso}'")
# Attach unattend ISO as second DVD
_ps(f"Add-VMDvdDrive -VMName '{vm_name}' -Path '{unattend_iso}'")
# Set boot order: DVD first (Windows installer), then hard drive
_ps(
f"$dvd = Get-VMDvdDrive -VMName '{vm_name}' | Where-Object {{ $_.Path -eq '{win_iso}' }}; "
f"Set-VMFirmware -VMName '{vm_name}' -FirstBootDevice $dvd"
)
# Start the VM — Windows Setup finds Autounattend.xml automatically
logger.info("Starting Windows unattended install via Hyper-V...")
_ps(f"Start-VM -Name '{vm_name}'")
# Wait for CUA server to come up (Windows install + first login + setup script)
ip = await self._wait_for_ip(vm_name, timeout=600)
logger.info(f"Build VM got IP: {ip}")
info = RuntimeInfo(host=ip, api_port=self.api_port, name=vm_name)
await self.is_ready(info, timeout=2700) # 45 min max
logger.info("CUA computer-server installed and verified!")
# Shut down cleanly
_ps(f"Stop-VM -Name '{vm_name}' -Force")
await asyncio.sleep(5)
finally:
# Remove DVD drives and VM definition (keep the disk)
try:
_ps(f"Get-VMDvdDrive -VMName '{vm_name}' | Remove-VMDvdDrive")
except RuntimeError:
pass
try:
_ps(f"Remove-VM -Name '{vm_name}' -Force")
except RuntimeError:
pass
# Move build disk to final cached location
build_vhdx.rename(base_vhdx)
logger.info(f"Base image cached: {base_vhdx}")
return base_vhdx
async def _build_user_layer(self, image: Image, parent: Path, user_layer: Path) -> Path:
"""Apply user Image layers on top of the base image."""
logger.info(f"Building user layer ({len(image._layers)} layers)...")
build_disk = user_layer.with_suffix(".build.vhdx")
if build_disk.exists():
build_disk.unlink()
_create_diff_vhdx(parent, build_disk)
vm_name = "cua-hyperv-build-user"
self._cleanup_vm(vm_name)
_ps(
f"New-VM -Name '{vm_name}' "
f"-MemoryStartupBytes {self.memory_mb * 1024 * 1024} "
f"-VHDPath '{build_disk}' "
f"-SwitchName '{self.switch_name}' "
f"-Generation 2"
)
_ps(f"Set-VM -Name '{vm_name}' -ProcessorCount {self.cpu_count}")
_ps(f"Set-VMFirmware -VMName '{vm_name}' -EnableSecureBoot Off")
_ps(f"Start-VM -Name '{vm_name}'")
try:
ip = await self._wait_for_ip(vm_name, timeout=300)
info = RuntimeInfo(host=ip, api_port=self.api_port, name=vm_name)
await self.is_ready(info, timeout=300)
from cua_sandbox.builder.executor import LayerExecutor
executor = LayerExecutor(f"http://{ip}:{self.api_port}")
await executor.execute_layers(list(image._layers))
logger.info("User layers applied, shutting down build VM...")
_ps(f"Stop-VM -Name '{vm_name}' -Force")
await asyncio.sleep(5)
finally:
self._cleanup_vm(vm_name)
build_disk.rename(user_layer)
meta = user_layer.with_suffix(".json")
meta.write_text(
json.dumps(
{
"os_type": image.os_type,
"layers": list(image._layers),
"parent": str(parent),
},
indent=2,
)
)
logger.info(f"User layer cached: {user_layer}")
return user_layer
async def _wait_for_ip(self, name: str, timeout: float = 180) -> str:
deadline = asyncio.get_event_loop().time() + timeout
while asyncio.get_event_loop().time() < deadline:
try:
ip = _ps(
f"(Get-VM -Name '{name}' | Get-VMNetworkAdapter).IPAddresses "
f"| Where-Object {{ $_ -match '\\d+\\.\\d+\\.\\d+\\.\\d+' }} "
f"| Select-Object -First 1"
)
if ip and not ip.startswith("0.0.0.0"):
return ip
except RuntimeError:
pass
await asyncio.sleep(3)
raise TimeoutError(f"Hyper-V VM {name} did not get an IP within {timeout}s")
def _cleanup_vm(self, name: str) -> None:
"""Stop and remove a VM if it exists."""
try:
exists = _ps(
f"Get-VM -Name '{name}' -ErrorAction SilentlyContinue "
f"| Select-Object -ExpandProperty Name"
)
if exists == name:
_ps(f"Stop-VM -Name '{name}' -Force -ErrorAction SilentlyContinue")
_ps(f"Remove-VM -Name '{name}' -Force")
except RuntimeError:
pass
async def stop(self, name: str) -> None:
"""Stop the VM, remove it, and delete the ephemeral session disk."""
self._cleanup_vm(name)
session_disk = self.cache_dir / "sessions" / f"{name}.vhdx"
if session_disk.exists():
session_disk.unlink()
logger.info(f"Deleted session disk: {session_disk}")
async def is_ready(self, info: RuntimeInfo, timeout: float = 120) -> bool:
url = f"http://{info.host}:{info.api_port}/status"
deadline = asyncio.get_event_loop().time() + timeout
async with httpx.AsyncClient(timeout=5) as client:
while asyncio.get_event_loop().time() < deadline:
try:
resp = await client.get(url)
if resp.status_code == 200:
logger.info(f"Hyper-V VM {info.name} computer-server is ready")
return True
except (
httpx.ConnectError,
httpx.ReadTimeout,
httpx.RemoteProtocolError,
httpx.ConnectTimeout,
httpx.ReadError,
):
pass
await asyncio.sleep(3)
raise TimeoutError(f"Hyper-V VM {info.name} computer-server not ready after {timeout}s")
@@ -0,0 +1,60 @@
"""Docker image tags, port mappings, and runtime constants."""
# ── Docker image tags ────────────────────────────────────────────────────────
UBUNTU_XFCE = "trycua/cua-xfce:latest"
QEMU_LINUX = "trycua/cua-qemu-linux:latest"
QEMU_WINDOWS = "trycua/cua-qemu-windows:latest"
QEMU_ANDROID = "trycua/cua-qemu-android:latest"
MACOS_SEQUOIA = "trycua/macos-sequoia:latest"
# ── macOS version → OCI image ref ────────────────────────────────────────────
# Maps macOS version strings (and codename aliases) to ghcr.io image refs.
# Codenames: Sequoia = 15, Tahoe = 26
MACOS_VERSION_IMAGES: dict[str, str] = {
"15": "ghcr.io/trycua/macos-sequoia-cua:latest",
"sequoia": "ghcr.io/trycua/macos-sequoia-cua:latest",
"26": "ghcr.io/trycua/macos-tahoe-cua:latest",
"tahoe": "ghcr.io/trycua/macos-tahoe-cua:latest",
}
# ── Internal ports (inside the container) ────────────────────────────────────
XFCE_API_PORT = 8000
XFCE_VNC_PORT = 6901
QEMU_API_PORT = 5000
QEMU_VNC_PORT = 8006
ANDROID_API_PORT = 8000
ANDROID_VNC_PORT = 6080
LUME_API_PORT = 8443
LUME_PROVIDER_PORT = 7777
# ── Default host-side ports ──────────────────────────────────────────────────
DEFAULT_API_PORT = 8000
DEFAULT_VNC_PORT = 6901
def resolve_image(os_type: str, registry: str | None = None) -> str:
"""Map an os_type to a default Docker image tag."""
if registry:
return registry
return {
"linux": UBUNTU_XFCE,
"windows": QEMU_WINDOWS,
"macos": MACOS_SEQUOIA,
"android": QEMU_ANDROID,
}.get(os_type, UBUNTU_XFCE)
def internal_ports(docker_image: str) -> tuple[int, int]:
"""Return (api_port, vnc_port) for the given Docker image."""
img = docker_image.lower()
if "qemu-android" in img:
return ANDROID_API_PORT, ANDROID_VNC_PORT
if "qemu" in img:
return QEMU_API_PORT, QEMU_VNC_PORT
return XFCE_API_PORT, XFCE_VNC_PORT
@@ -0,0 +1,623 @@
"""Lume runtime — macOS VMs via Apple Virtualization.framework (macOS hosts only).
Requires the Lume CLI running on the host (default port 7777).
"""
from __future__ import annotations
import asyncio
import logging
import os
import sys
from typing import TYPE_CHECKING
import httpx
from cua_sandbox.image import Image
if TYPE_CHECKING:
pass
from cua_sandbox.runtime.base import Runtime, RuntimeInfo
from cua_sandbox.runtime.images import (
LUME_API_PORT,
LUME_PROVIDER_PORT,
MACOS_SEQUOIA,
MACOS_VERSION_IMAGES,
)
logger = logging.getLogger(__name__)
def _lume_path() -> str | None:
"""Return the path to the lume binary, or None if not found."""
# Check PATH first
import shutil
if shutil.which("lume"):
return "lume"
# Common install location not always in PATH (e.g. ~/.local/bin)
local_bin = os.path.expanduser("~/.local/bin/lume")
if os.path.isfile(local_bin) and os.access(local_bin, os.X_OK):
return local_bin
return None
def _base_vm_name(oci_ref: str) -> str:
"""Return a stable base-VM name for an OCI image ref (12-char sha256 prefix)."""
import hashlib
digest = hashlib.sha256(oci_ref.encode()).hexdigest()[:12]
return f"cua-base-{digest}"
def _resp_detail(resp: "httpx.Response") -> str:
try:
return str(resp.json())
except Exception:
return resp.text
def _has_lume() -> bool:
return _lume_path() is not None
class LumeRuntime(Runtime):
"""Runs macOS VMs via the Lume CLI / API."""
def __init__(
self,
*,
lume_host: str = "localhost",
lume_port: int = LUME_PROVIDER_PORT,
api_port: int = LUME_API_PORT,
):
self.lume_host = lume_host
self.lume_port = lume_port
self.api_port = api_port
async def start(self, image: Image, name: str, **opts) -> RuntimeInfo:
if not _has_lume():
raise RuntimeError(
"Lume CLI is not installed. "
"Install from https://github.com/trycua/cua/tree/main/libs/lume"
)
lume_url = f"http://{self.lume_host}:{self.lume_port}"
# Fast path — VM already exists
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.get(f"{lume_url}/lume/vms/{name}")
vm = resp.json() if resp.status_code == 200 else {}
existing_status = vm.get("status")
if existing_status == "running":
logger.info(f"Lume VM {name} already running")
ip = await self._wait_for_ip(name, lume_url)
await self._deliver_vnc_config(name, lume_url)
info = RuntimeInfo(host=ip, api_port=self.api_port, name=name)
await self.is_ready(info)
await self._apply_image_layers(image, info)
return info
if existing_status in ("stopped", "suspended"):
# Resume the existing stopped VM rather than colliding with a clone
logger.info(f"Lume VM {name} exists (stopped) — resuming")
return await self.resume(image, name, **opts)
oci_ref = image._registry or MACOS_VERSION_IMAGES.get(image.version or "") or MACOS_SEQUOIA
# Pull-once + clone: ensure a stopped base VM exists, then clone it
# (APFS clonefile — instant). First call takes ~155s; subsequent calls
# return in <1s regardless of image size.
base_name = _base_vm_name(oci_ref)
await self.ensure_base(image, base_name)
await self.fork(base_name, name)
# Run the cloned VM
await self._run_vm(name, lume_url, opts)
ip = await self._wait_for_ip(name, lume_url)
await self._deliver_vnc_config(name, lume_url)
info = RuntimeInfo(host=ip, api_port=self.api_port, name=name)
await self.is_ready(info)
await self._apply_image_layers(image, info)
return info
async def _run_vm(self, name: str, lume_url: str, opts: dict) -> None:
"""POST /lume/vms/{name}/run and raise on failure."""
async with httpx.AsyncClient(timeout=120) as client:
run_resp = await client.post(
f"{lume_url}/lume/vms/{name}/run",
json={
"noDisplay": opts.get("no_display", True),
"sharedDirectories": opts.get("shared_directories", []),
},
timeout=120,
)
if run_resp.status_code >= 400:
try:
detail = run_resp.json()
except Exception:
detail = run_resp.text
raise RuntimeError(f"Lume failed to run VM '{name}': {detail}")
# ── Checkpoint / fork primitives ─────────────────────────────────────────
async def ensure_base(self, image: Image, base_name: str): # -> CheckpointInfo
"""Pull an OCI image into a stopped golden base VM, if it doesn't exist yet.
Idempotent — if *base_name* already exists and is stopped, returns
immediately. Raises if the base VM is running, because cloning a live VM
can produce dirty or inconsistent clones (same constraint as cloud snapshots).
The base VM is never started; it only serves as a fork source.
"""
import time
from cua_sandbox.runtime.base import CheckpointInfo
lume_url = f"http://{self.lume_host}:{self.lume_port}"
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.get(f"{lume_url}/lume/vms/{base_name}")
if resp.status_code == 200:
vm = resp.json()
if vm.get("status") == "running":
raise RuntimeError(
f"Base VM '{base_name}' is running — stop it before using as a clone source"
)
if vm.get("status") in ("stopped", "suspended"):
logger.info(f"Base VM '{base_name}' already exists (stopped) — skipping pull")
return CheckpointInfo(name=base_name, runtime_type="lume", created_at=time.time())
oci_ref = image._registry or MACOS_VERSION_IMAGES.get(image.version or "") or MACOS_SEQUOIA
logger.info(f"Pulling base image '{oci_ref}''{base_name}' (first time only)...")
pull_payload: dict = {"image": oci_ref, "name": base_name}
parts = oci_ref.split("/")
if len(parts) >= 3 and "." in parts[0]:
pull_payload = {
"image": "/".join(parts[2:]),
"name": base_name,
"registry": parts[0],
"organization": parts[1],
}
await self._pull_vm(base_name, lume_url, pull_payload)
return CheckpointInfo(name=base_name, runtime_type="lume", created_at=time.time())
async def fork(self, base_name: str, new_name: str, **opts) -> None:
"""Clone a stopped VM *base_name* → *new_name* via APFS clonefile (instant).
The source VM must be stopped before calling this — cloning a running VM
risks dirty state or clone failures (mirrors cloud snapshot behaviour).
"""
lume_url = f"http://{self.lume_host}:{self.lume_port}"
logger.info(f"Cloning base VM '{base_name}''{new_name}'")
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(
f"{lume_url}/lume/vms/clone",
json={"name": base_name, "newName": new_name},
timeout=60,
)
if resp.status_code >= 400:
try:
detail = resp.json()
except Exception:
detail = resp.text
raise RuntimeError(f"Lume clone '{base_name}''{new_name}' failed: {detail}")
logger.info(f"Cloned '{base_name}''{new_name}' successfully")
# Prefix used to distinguish checkpoint VMs from ordinary sandboxes
_CHECKPOINT_PREFIX = "cua-ckpt-"
async def checkpoint(self, name: str, checkpoint_name: str, **opts): # -> CheckpointInfo
"""Stop *name*, clone it into a checkpoint, then restart *name*.
Mirrors the cloud behaviour (stop → snapshot → restart) so clones are
always taken from a clean stopped state.
"""
import time
from cua_sandbox.runtime.base import CheckpointInfo
lume_url = f"http://{self.lume_host}:{self.lume_port}"
# Enforce the naming prefix so list/delete_checkpoint stay scoped
if not checkpoint_name.startswith(
self._CHECKPOINT_PREFIX
) and not checkpoint_name.startswith("cua-base-"):
checkpoint_name = f"{self._CHECKPOINT_PREFIX}{checkpoint_name}"
# Determine whether the source VM is running so we can restart it after
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.get(f"{lume_url}/lume/vms/{name}")
vm = resp.json() if resp.status_code == 200 else {}
was_running = vm.get("status") == "running"
if was_running:
logger.info(f"Stopping '{name}' before checkpoint clone")
await self.stop(name)
try:
await self.fork(name, checkpoint_name)
finally:
if was_running:
logger.info(f"Restarting '{name}' after checkpoint clone")
await self._run_vm(name, lume_url, opts)
return CheckpointInfo(
name=checkpoint_name,
runtime_type="lume",
created_at=time.time(),
metadata={"source": name},
)
async def list_checkpoints(self): # -> list[CheckpointInfo]
"""Return VMs that are checkpoints (cua-base-* or cua-ckpt-* prefix)."""
import time
from cua_sandbox.runtime.base import CheckpointInfo
vms = await self.list()
return [
CheckpointInfo(name=v["name"], runtime_type="lume", created_at=time.time())
for v in vms
if v["name"].startswith("cua-base-") or v["name"].startswith(self._CHECKPOINT_PREFIX)
]
async def delete_checkpoint(self, checkpoint_name: str) -> None:
"""Delete a checkpoint VM. Only operates on cua-base-* / cua-ckpt-* names."""
if not checkpoint_name.startswith("cua-base-") and not checkpoint_name.startswith(
self._CHECKPOINT_PREFIX
):
raise ValueError(
f"'{checkpoint_name}' does not look like a checkpoint "
f"(expected 'cua-base-' or '{self._CHECKPOINT_PREFIX}' prefix)"
)
await self.delete(checkpoint_name)
# ── Internal pull helper ──────────────────────────────────────────────────
async def _pull_vm(self, name: str, lume_url: str, pull_payload: dict) -> None:
"""Pull an OCI image into a stopped VM named *name*.
Tries /lume/pull/start (async with progress polling) first, falls back
to the synchronous /lume/pull for older lume versions.
"""
async with httpx.AsyncClient(timeout=30) as client:
try:
start_resp = await self._pull_start_with_retry(lume_url, pull_payload)
except (httpx.ReadError, httpx.RemoteProtocolError):
# lume v0.3.x — sync pull
logger.info("Lume /pull/start unavailable, falling back to synchronous pull...")
print("\rPulling macOS image...", end="", flush=True, file=sys.stderr)
try:
sync_resp = await client.post(
f"{lume_url}/lume/pull", json=pull_payload, timeout=1800
)
if sync_resp.status_code >= 400:
raise RuntimeError(
f"Lume pull failed for '{name}': {_resp_detail(sync_resp)}"
)
except (httpx.ReadError, httpx.RemoteProtocolError):
check = await client.get(f"{lume_url}/lume/vms/{name}", timeout=10)
if check.status_code != 200 or check.json().get("status") in (
"",
None,
"error",
):
raise RuntimeError(
f"Lume pull failed for '{name}': connection dropped and VM not found"
" (check GITHUB_TOKEN is set in lume's LaunchAgent plist)"
)
print(file=sys.stderr)
return
if start_resp.status_code == 404:
# Older lume without /pull/start
try:
pull_resp = await client.post(
f"{lume_url}/lume/pull", json=pull_payload, timeout=1800
)
except (httpx.ReadError, httpx.RemoteProtocolError):
check = await client.get(f"{lume_url}/lume/vms/{name}", timeout=10)
if check.status_code != 200 or check.json().get("status") in (
"",
None,
"error",
):
raise RuntimeError(
f"Lume pull failed for '{name}': connection dropped and VM not found"
)
return
if pull_resp.status_code >= 400:
raise RuntimeError(f"Lume pull failed for '{name}': {_resp_detail(pull_resp)}")
return
if start_resp.status_code >= 400:
raise RuntimeError(
f"Lume pull/start failed for '{name}': {_resp_detail(start_resp)}"
)
# Poll until pull completes
logger.info(f"Pulling image for '{name}'...")
pull_deadline = asyncio.get_event_loop().time() + 1800
last_progress = -1.0
while asyncio.get_event_loop().time() < pull_deadline:
try:
poll = await client.get(f"{lume_url}/lume/vms/{name}", timeout=10)
except (httpx.ReadError, httpx.RemoteProtocolError, httpx.ConnectError):
await asyncio.sleep(3)
continue
if poll.status_code == 200:
data = poll.json()
status = data.get("status", "")
progress = data.get("downloadProgress")
if progress is not None and progress != last_progress:
print(
f"\rPulling macOS image: {progress:.0f}%",
end="",
flush=True,
file=sys.stderr,
)
last_progress = progress
if status == "pulling":
await asyncio.sleep(3)
continue
if "error" in status.lower():
raise RuntimeError(
f"Lume pull failed for '{name}': {data.get('message', status)}"
)
break
elif poll.status_code >= 400:
data = {}
try:
data = poll.json()
except Exception:
pass
raise RuntimeError(
f"Lume pull failed for '{name}': {data.get('message', poll.text)}"
)
await asyncio.sleep(3)
else:
raise TimeoutError(f"Lume pull for '{name}' did not complete within 1800s")
if last_progress >= 0:
print(file=sys.stderr)
async def _pull_start_with_retry(
self, lume_url: str, payload: dict, retries: int = 3
) -> httpx.Response:
"""POST /lume/pull/start with retry for spurious 'Invalid request body' 400s.
Lume's custom HTTP server reads with minimumIncompleteLength=1, so on a
fresh TCP connection the body can arrive after the first receive() returns,
causing a spurious 400. A brief pause + retry recovers reliably.
"""
last_resp: httpx.Response | None = None
for attempt in range(retries):
if attempt > 0:
await asyncio.sleep(1)
async with httpx.AsyncClient(timeout=30) as pull_client:
resp = await pull_client.post(
f"{lume_url}/lume/pull/start",
json=payload,
timeout=30,
)
if resp.status_code != 400 or "Invalid request body" not in resp.text:
return resp # success or a real error
logger.warning(
"pull/start got 'Invalid request body' (attempt %d/%d), retrying...",
attempt + 1,
retries,
)
last_resp = resp
return last_resp # type: ignore[return-value]
async def _apply_image_layers(self, image: "Image", info: RuntimeInfo) -> None:
"""Apply image layers (run, brew_install, env, copy, etc.) via computer-server."""
env_items = getattr(image, "_env", ())
file_items = getattr(image, "_files", ())
has_work = image._layers or file_items
if not has_work and not env_items:
return
from cua_sandbox.builder.executor import LayerExecutor
executor = LayerExecutor(f"http://{info.host}:{info.api_port}", os_type=image.os_type)
if env_items and image.os_type != "windows":
if image.os_type == "macos":
# macOS: computer-server runs under launchd with an explicit
# EnvironmentVariables dict in its plist. launchctl setenv is
# ignored by such services, so we must add vars to the plist
# directly via PlistBuddy, then unload/load the service.
import re as _re
plist = "~/Library/LaunchAgents/com.trycua.computer_server.plist"
for k, v in env_items:
# Validate key: must be a safe identifier (alphanumeric + _)
if not _re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", k):
raise ValueError(f"Unsafe env var name: {k!r}")
# Escape value for PlistBuddy: single-quote the shell arg,
# and escape any single quotes within the value itself.
safe_v = v.replace("'", "'\\''")
# Use Set if key exists, Add otherwise
await executor.run_command(
f"/usr/libexec/PlistBuddy -c 'Set :EnvironmentVariables:{k} {safe_v}' {plist} 2>/dev/null || "
f"/usr/libexec/PlistBuddy -c 'Add :EnvironmentVariables:{k} string {safe_v}' {plist}"
)
# Reload via lume ssh from the host — launchctl unload would kill
# computer-server mid-execution if run from inside via /cmd.
lume_bin = _lume_path() or "lume"
reload_cmd = (
"launchctl bootout gui/$(id -u)/com.trycua.computer_server 2>/dev/null; "
f"launchctl bootstrap gui/$(id -u) {plist}"
)
proc = await asyncio.create_subprocess_exec(
lume_bin,
"ssh",
info.name,
"--",
reload_cmd,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await asyncio.wait_for(proc.wait(), timeout=30)
await self.is_ready(info) # wait for computer-server to come back
else:
import re as _re
import shlex as _shlex
sudo = "sudo"
# Validate all keys up front
for k, _ in env_items:
if not _re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", k):
raise ValueError(f"Unsafe env var name: {k!r}")
await executor.run_command(
f"printf '#!/bin/sh\\n' | {sudo} tee /etc/profile.d/cua-env.sh > /dev/null"
)
for k, v in env_items:
quoted_v = _shlex.quote(v)
await executor.run_command(
f"printf 'export {k}=%s\\n' {quoted_v} "
f"| {sudo} tee -a /etc/profile.d/cua-env.sh > /dev/null"
)
for src, dst in file_items:
await executor.execute_layers([{"type": "copy", "src": src, "dst": dst}])
if image._layers:
await executor.execute_layers(list(image._layers))
async def _deliver_vnc_config(self, name: str, lume_url: str) -> None:
"""Write the current VNC port/password into ~/.vnc.env inside the VM.
Lume v0.3.x doesn't auto-deliver VNC config via VirtioFS, so the
computer-server inside the VM may use a stale cached port from a
previous run. We push the live values via `lume ssh` so the
computer-server always connects to the correct VNC endpoint.
"""
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(f"{lume_url}/lume/vms/{name}")
data = resp.json()
vnc_url = data.get("vncUrl") or data.get("vnc_url")
if not vnc_url:
return # no VNC info — nothing to deliver
# Parse vnc://:PASSWORD@HOST:PORT
import re
m = re.match(r"vnc://:([^@]*)@[^:]+:(\d+)", vnc_url)
if not m:
return
password, port = m.group(1), m.group(2)
vnc_env = f"VNC_PORT={port}\\nVNC_PASSWORD={password}"
# Write vnc.env and kill the computer-server process so launchd
# revives it with the new config. launchctl kickstart -k fails
# silently from a non-GUI SSH session, so pkill is more reliable.
proc = await asyncio.create_subprocess_exec(
_lume_path() or "lume",
"ssh",
name,
"--",
f"printf '{vnc_env}' > ~/.vnc.env && "
"pkill -f 'python.*computer_server' 2>/dev/null || true",
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await asyncio.wait_for(proc.wait(), timeout=30)
logger.info(f"Delivered VNC config to '{name}' (port {port})")
except Exception as exc:
logger.debug(f"VNC config delivery skipped for '{name}': {exc}")
async def _wait_for_ip(self, name: str, lume_url: str, timeout: float = 300) -> str:
deadline = asyncio.get_event_loop().time() + timeout
async with httpx.AsyncClient(timeout=10) as client:
while asyncio.get_event_loop().time() < deadline:
resp = await client.get(f"{lume_url}/lume/vms/{name}")
data = resp.json()
status = data.get("status", "")
if status == "stopped":
raise RuntimeError(f"Lume VM '{name}' is stopped — failed to start")
ip = data.get("ip_address") or data.get("ipAddress")
if ip and ip != "unknown" and not ip.startswith("0.0.0.0"):
return ip
await asyncio.sleep(3)
raise TimeoutError(f"Lume VM {name} did not get an IP within {timeout}s")
async def suspend(self, name: str) -> None:
"""Stop (save state of) a Lume VM."""
lume_url = f"http://{self.lume_host}:{self.lume_port}"
async with httpx.AsyncClient(timeout=30) as client:
await client.post(f"{lume_url}/lume/vms/{name}/stop")
async def resume(self, image: "Image", name: str, **opts) -> RuntimeInfo:
"""Start a stopped Lume VM and return its RuntimeInfo."""
lume_url = f"http://{self.lume_host}:{self.lume_port}"
async with httpx.AsyncClient(timeout=120) as client:
await client.post(
f"{lume_url}/lume/vms/{name}/run",
json={
"noDisplay": opts.get("no_display", True),
"sharedDirectories": opts.get("shared_directories", []),
},
timeout=120,
)
ip = await self._wait_for_ip(name, lume_url)
await self._deliver_vnc_config(name, lume_url)
info = RuntimeInfo(host=ip, api_port=self.api_port, name=name)
await self.is_ready(info)
return info
async def list(self) -> list[dict]:
"""List all Lume VMs."""
lume_url = f"http://{self.lume_host}:{self.lume_port}"
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(f"{lume_url}/lume/vms")
vms = resp.json()
if isinstance(vms, dict):
vms = vms.get("vms", [])
except Exception:
return []
result = []
for vm in vms:
raw = vm.get("status", "").lower()
if raw == "running":
status = "running"
elif raw in ("stopped", "stop"):
status = "suspended"
else:
status = raw
result.append(
{
"name": vm.get("name", ""),
"status": status,
"runtime_type": "lume",
"os_type": "macos",
"ip_address": vm.get("ip_address") or vm.get("ipAddress"),
}
)
return result
async def stop(self, name: str) -> None:
lume_url = f"http://{self.lume_host}:{self.lume_port}"
async with httpx.AsyncClient(timeout=30) as client:
await client.post(f"{lume_url}/lume/vms/{name}/stop")
async def delete(self, name: str) -> None:
"""Stop and permanently delete a Lume VM."""
lume_url = f"http://{self.lume_host}:{self.lume_port}"
async with httpx.AsyncClient(timeout=60) as client:
# Stop first (ignore errors — VM may already be stopped)
await client.post(f"{lume_url}/lume/vms/{name}/stop")
await client.delete(f"{lume_url}/lume/vms/{name}")
async def is_ready(self, info: RuntimeInfo, timeout: float = 120) -> bool:
url = f"http://{info.host}:{info.api_port}/status"
deadline = asyncio.get_event_loop().time() + timeout
async with httpx.AsyncClient(timeout=5) as client:
while asyncio.get_event_loop().time() < deadline:
try:
resp = await client.get(url)
if resp.status_code == 200:
logger.info(f"Lume VM {info.name} computer-server is ready")
return True
except (
httpx.ConnectError,
httpx.ReadTimeout,
httpx.ConnectTimeout,
httpx.ReadError,
):
pass
await asyncio.sleep(3)
raise TimeoutError(f"Lume VM {info.name} computer-server not ready after {timeout}s")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,159 @@
"""Auto-download and manage QEMU portable + wimlib for Windows."""
from __future__ import annotations
import logging
import os
import platform
import shutil
import tempfile
import zipfile
from pathlib import Path
import httpx
logger = logging.getLogger(__name__)
QEMU_PORTABLE_URL = (
"https://github.com/ganarcasas/qemu-portable/releases/download/20241220/"
"qemu-portable-20241220.zip"
)
QEMU_DIR = Path.home() / ".cua" / "cua-sandbox" / "qemu"
QEMU_INNER_DIR = "qemu-portable-20241220"
WIMLIB_URL = "https://wimlib.net/downloads/wimlib-1.14.5-windows-x86_64-bin.zip"
WIMLIB_DIR = Path.home() / ".cua" / "cua-sandbox" / "wimlib"
def qemu_bin(arch: str = "x86_64") -> str:
"""Return the path to qemu-system-{arch}, installing if needed on Windows.
Resolution order:
1. Already on PATH → use it
2. Common macOS Homebrew/MacPorts install locations (may not be on PATH)
3. Already downloaded to ~/.cua/cua-sandbox/qemu/ → use it
4. Windows only: download portable zip → extract → use it
5. Raise RuntimeError with install instructions
"""
binary = f"qemu-system-{arch}"
# 1. On PATH
found = shutil.which(binary)
if found:
return found
# 2. Common macOS install locations not always on PATH in subprocess envs
if platform.system() == "Darwin":
for prefix in (
"/opt/homebrew/bin", # Homebrew on Apple Silicon
"/usr/local/bin", # Homebrew on Intel
"/opt/local/bin", # MacPorts
):
candidate = os.path.join(prefix, binary)
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
return candidate
# 3. Portable install
exe_name = f"{binary}.exe" if platform.system() == "Windows" else binary
local_bin = QEMU_DIR / QEMU_INNER_DIR / exe_name
if local_bin.exists():
return str(local_bin)
# 4. Auto-download (Windows only)
if platform.system() == "Windows":
_download_portable()
if local_bin.exists():
return str(local_bin)
if platform.system() == "Darwin":
raise RuntimeError(
f"{binary} not found. Install QEMU via Homebrew or MacPorts:\n\n"
f" brew install qemu\n"
f" — or —\n"
f" sudo port install qemu\n"
)
raise RuntimeError(
f"{binary} not found. Install QEMU or, on Windows, let cua-sandbox "
f"download it automatically (failed to find {local_bin})."
)
def wimlib_imagex() -> str:
"""Return path to wimlib-imagex, installing if needed on Windows.
Resolution order:
1. Already on PATH → use it
2. Already downloaded to ~/.cua/cua-sandbox/wimlib/ → use it
3. Windows only: download zip → extract → use it
4. Raise RuntimeError
"""
# 1. On PATH
found = shutil.which("wimlib-imagex")
if found:
return found
# 2. Local install
local_bin = WIMLIB_DIR / "wimlib-imagex.exe"
if local_bin.exists():
return str(local_bin)
# 3. Auto-download (Windows only)
if platform.system() == "Windows":
_download_wimlib()
if local_bin.exists():
return str(local_bin)
raise RuntimeError(
"wimlib-imagex not found. Install wimlib or, on Windows, let cua-sandbox "
"download it automatically."
)
def _download_zip(url: str, dest_dir: Path, description: str) -> None:
"""Download and extract a zip to dest_dir."""
dest_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"Downloading {description} from {url} ...")
tmp = tempfile.mktemp(suffix=".zip")
try:
with httpx.Client(follow_redirects=True, timeout=300) as client:
with client.stream("GET", url) as resp:
resp.raise_for_status()
total = int(resp.headers.get("content-length", 0))
downloaded = 0
with open(tmp, "wb") as f:
for chunk in resp.iter_bytes(chunk_size=1024 * 256):
f.write(chunk)
downloaded += len(chunk)
if total:
pct = downloaded * 100 // total
print(f"\r downloading {description}{pct}%", end="", flush=True)
print()
logger.info(f"Extracting to {dest_dir} ...")
with zipfile.ZipFile(tmp) as zf:
zf.extractall(dest_dir)
logger.info(f"{description} installed successfully")
finally:
try:
os.unlink(tmp)
except OSError:
pass
def _download_portable() -> None:
"""Download and extract QEMU portable to ~/.cua/cua-sandbox/qemu/."""
marker = QEMU_DIR / QEMU_INNER_DIR / "qemu-system-x86_64.exe"
if marker.exists():
return
_download_zip(QEMU_PORTABLE_URL, QEMU_DIR, "QEMU portable")
def _download_wimlib() -> None:
"""Download and extract wimlib to ~/.cua/cua-sandbox/wimlib/."""
marker = WIMLIB_DIR / "wimlib-imagex.exe"
if marker.exists():
return
_download_zip(WIMLIB_URL, WIMLIB_DIR, "wimlib")
@@ -0,0 +1,271 @@
"""Tart runtime — uses Cirrus Labs' Tart CLI for Apple VZ framework VMs.
Tart manages macOS and Linux VMs using Apple's Virtualization.framework.
It supports pulling OCI images from registries (ghcr.io, etc.), running
headless VMs with VNC on the host side, and SSH into the guest.
Default SSH credentials for Tart VMs: admin/admin.
VNC is exposed on a random localhost port with a random password (parsed from stderr).
Usage::
from cua_sandbox.runtime import TartRuntime
async with sandbox(
local=True,
image=Image.from_registry("ghcr.io/cirruslabs/macos-tahoe-base:latest"),
runtime=TartRuntime(),
name="my-macos-vm",
) as sb:
await sb.shell.run("uname -a")
screenshot = await sb.screenshot()
"""
from __future__ import annotations
import asyncio
import logging
import re
import shutil
from typing import Optional
from cua_sandbox.image import Image
from cua_sandbox.runtime.base import Runtime, RuntimeInfo
logger = logging.getLogger(__name__)
TART_DEFAULT_USERNAME = "admin"
TART_DEFAULT_PASSWORD = "admin"
def _has_tart() -> bool:
return shutil.which("tart") is not None
class TartRuntime(Runtime):
"""Apple Virtualization.framework runtime via Tart CLI.
Uses VNC (host-side, random port) for screenshots and SSH for shell commands.
"""
def __init__(
self,
*,
ephemeral: bool = True,
cpu_count: Optional[int] = None,
memory_mb: Optional[int] = None,
disk_gb: Optional[int] = None,
display: str = "1024x768",
ssh_username: str = TART_DEFAULT_USERNAME,
ssh_password: str = TART_DEFAULT_PASSWORD,
):
self.ephemeral = ephemeral
self.cpu_count = cpu_count
self.memory_mb = memory_mb
self.disk_gb = disk_gb
self.display = display
self.ssh_username = ssh_username
self.ssh_password = ssh_password
self._proc: Optional[asyncio.subprocess.Process] = None
self._vm_name: Optional[str] = None
self.vnc_host: Optional[str] = None
self.vnc_port: Optional[int] = None
self.vnc_password: Optional[str] = None
async def start(self, image: Image, name: str, **opts) -> RuntimeInfo:
if not _has_tart():
raise RuntimeError("Tart CLI not found. Install via: brew install cirruslabs/cli/tart")
registry_ref = image._registry
if not registry_ref:
raise ValueError(
"TartRuntime requires an OCI registry image. "
"Use Image.from_registry('ghcr.io/...') to specify one."
)
# Pull the image
logger.info(f"Pulling {registry_ref} ...")
pull = await asyncio.create_subprocess_exec(
"tart",
"pull",
registry_ref,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await pull.communicate()
if pull.returncode != 0:
raise RuntimeError(f"tart pull failed: {stderr.decode()}")
# Clone for ephemeral use
self._vm_name = name
if self.ephemeral:
del_proc = await asyncio.create_subprocess_exec(
"tart",
"delete",
name,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await del_proc.communicate()
clone = await asyncio.create_subprocess_exec(
"tart",
"clone",
registry_ref,
name,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await clone.communicate()
if clone.returncode != 0:
raise RuntimeError(f"tart clone failed: {stderr.decode()}")
# Configure VM resources
set_args = []
if self.cpu_count:
set_args += ["--cpu", str(self.cpu_count)]
if self.memory_mb:
set_args += ["--memory", str(self.memory_mb)]
if self.disk_gb:
set_args += ["--disk-size", str(self.disk_gb)]
if self.display:
set_args += ["--display", self.display]
if set_args:
set_proc = await asyncio.create_subprocess_exec(
"tart",
"set",
name,
*set_args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await set_proc.communicate()
# Start VM headless with VNC
run_cmd = ["tart", "run", name, "--no-graphics", "--vnc-experimental"]
logger.info(f"Starting VM: {' '.join(run_cmd)}")
self._proc = await asyncio.create_subprocess_exec(
*run_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
# Parse VNC URL from stdout (e.g. "vnc://:password@127.0.0.1:61777")
vnc_line = await self._read_vnc_line(timeout=10)
if vnc_line:
m = re.search(r"vnc://:([^@]+)@([^:]+):(\d+)", vnc_line)
if m:
self.vnc_password = m.group(1)
self.vnc_host = m.group(2)
self.vnc_port = int(m.group(3))
logger.info(f"VNC available at {self.vnc_host}:{self.vnc_port}")
if not self.vnc_port:
raise RuntimeError("Could not parse VNC port from tart output")
# Brief check that it didn't crash
if self._proc.returncode is not None:
err = await self._proc.stderr.read() if self._proc.stderr else b""
raise RuntimeError(f"Tart VM crashed on launch: {err.decode()}")
# Get VM IP for SSH
ip = await self._get_ip(name, timeout=120)
if not ip:
raise RuntimeError(f"Could not get IP for Tart VM {name}")
env = "mac" if "macos" in registry_ref.lower() or "mac" in registry_ref.lower() else "linux"
return RuntimeInfo(
host=ip,
api_port=22,
vnc_port=self.vnc_port,
vnc_host=self.vnc_host or "127.0.0.1",
vnc_password=self.vnc_password,
ssh_port=22,
ssh_username=self.ssh_username,
ssh_password=self.ssh_password,
name=name,
environment=env,
)
async def _read_vnc_line(self, timeout: float = 10) -> Optional[str]:
"""Read stdout lines until we find the VNC URL."""
if not self._proc or not self._proc.stdout:
return None
try:
deadline = asyncio.get_event_loop().time() + timeout
while asyncio.get_event_loop().time() < deadline:
line = await asyncio.wait_for(
self._proc.stdout.readline(),
timeout=max(0.1, deadline - asyncio.get_event_loop().time()),
)
text = line.decode().strip()
if "vnc://" in text.lower():
return text
except (asyncio.TimeoutError, Exception):
pass
return None
async def _get_ip(self, name: str, timeout: int = 120) -> Optional[str]:
try:
proc = await asyncio.create_subprocess_exec(
"tart",
"ip",
name,
"--wait",
str(timeout),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout + 10)
ip = stdout.decode().strip()
if ip:
logger.info(f"VM {name} IP: {ip}")
return ip
except (asyncio.TimeoutError, Exception) as e:
logger.warning(f"Could not get VM IP: {e}")
return None
async def is_ready(self, info: RuntimeInfo, timeout: float = 120) -> bool:
"""Wait until SSH is reachable."""
import socket
deadline = asyncio.get_event_loop().time() + timeout
while asyncio.get_event_loop().time() < deadline:
try:
sock = socket.create_connection((info.host, 22), timeout=5)
sock.close()
logger.info(f"Tart VM {info.name} SSH is reachable")
return True
except (ConnectionRefusedError, OSError, socket.timeout):
pass
await asyncio.sleep(3)
raise TimeoutError(f"Tart VM {info.name} SSH not ready within {timeout}s")
async def stop(self, name: str) -> None:
stop = await asyncio.create_subprocess_exec(
"tart",
"stop",
name,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await stop.communicate()
if self._proc and self._proc.returncode is None:
self._proc.terminate()
try:
await asyncio.wait_for(self._proc.communicate(), timeout=10)
except asyncio.TimeoutError:
self._proc.kill()
self._proc = None
if self.ephemeral:
await asyncio.create_subprocess_exec(
"tart",
"delete",
name,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
logger.info(f"Deleted ephemeral VM: {name}")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,105 @@
"""Persistent state tracking for local sandboxes.
Each running local sandbox writes a JSON file at ~/.cua/sandboxes/{name}.json.
This allows Sandbox.connect(name, local=True), Sandbox.list(local=True), etc.
to work across Python process restarts.
State files are written by Sandbox.create() for local runtimes and deleted by
Sandbox.delete(). Ephemeral sandboxes never write state files.
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
SANDBOX_STATE_DIR = Path.home() / ".cua" / "sandboxes"
def _state_path(name: str) -> Path:
return SANDBOX_STATE_DIR / f"{name}.json"
def save(
name: str,
*,
runtime_type: str,
image: dict,
host: str,
api_port: int,
vnc_port: Optional[int] = None,
qmp_port: Optional[int] = None,
grpc_port: Optional[int] = None,
adb_serial: Optional[str] = None,
sdk_root: Optional[str] = None,
disk_path: Optional[str] = None,
os_type: Optional[str] = None,
vnc_display: Optional[int] = None,
memory_mb: Optional[int] = None,
cpu_count: Optional[int] = None,
arch: Optional[str] = None,
status: str = "running",
) -> None:
"""Write or overwrite the state file for a local sandbox."""
SANDBOX_STATE_DIR.mkdir(parents=True, exist_ok=True)
data: dict[str, Any] = {
"name": name,
"runtime_type": runtime_type,
"image": image,
"host": host,
"api_port": api_port,
"vnc_port": vnc_port,
"qmp_port": qmp_port,
"grpc_port": grpc_port,
"adb_serial": adb_serial,
"sdk_root": sdk_root,
"disk_path": disk_path,
"os_type": os_type,
"vnc_display": vnc_display,
"memory_mb": memory_mb,
"cpu_count": cpu_count,
"arch": arch,
"status": status,
"created_at": datetime.now(timezone.utc).isoformat(),
}
_state_path(name).write_text(json.dumps(data, indent=2))
def load(name: str) -> Optional[dict]:
"""Load state for a named sandbox, or None if not found."""
p = _state_path(name)
if not p.exists():
return None
try:
return json.loads(p.read_text())
except (json.JSONDecodeError, OSError):
return None
def update(name: str, **fields: Any) -> None:
"""Update specific fields in an existing state file."""
data = load(name)
if data is None:
return
data.update(fields)
_state_path(name).write_text(json.dumps(data, indent=2))
def delete(name: str) -> None:
"""Remove the state file for a sandbox."""
_state_path(name).unlink(missing_ok=True)
def list_all() -> list[dict]:
"""Return all state file contents."""
if not SANDBOX_STATE_DIR.exists():
return []
result = []
for p in SANDBOX_STATE_DIR.glob("*.json"):
try:
result.append(json.loads(p.read_text()))
except (json.JSONDecodeError, OSError):
pass
return result
@@ -0,0 +1,102 @@
"""Synchronous API wrappers for scripts and notebooks.
Usage::
from cua_sandbox.sync import sandbox, localhost, Image
# Blocking sandbox
with sandbox(local=True) as sb:
sb.mouse.click(100, 200)
img = sb.screenshot()
# Blocking localhost
with localhost() as host:
host.mouse.click(100, 200)
"""
from __future__ import annotations
import asyncio
from contextlib import contextmanager
from typing import Any, Iterator, Optional
from cua_sandbox.image import Image
from cua_sandbox.localhost import Localhost as _AsyncLocalhost
from cua_sandbox.sandbox import Sandbox as _AsyncSandbox
def _get_or_create_loop() -> asyncio.AbstractEventLoop:
"""Get the running event loop, or create a new one."""
try:
loop = asyncio.get_running_loop()
return loop
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
return loop
def _run(coro: Any) -> Any:
"""Run a coroutine synchronously."""
try:
asyncio.get_running_loop()
# We're inside an existing event loop (e.g. Jupyter) — use nest_asyncio pattern
import nest_asyncio
nest_asyncio.apply()
return asyncio.get_event_loop().run_until_complete(coro)
except RuntimeError:
return asyncio.run(coro)
class _SyncProxy:
"""Wraps an async object and makes attribute access synchronous."""
def __init__(self, async_obj: Any):
self._async_obj = async_obj
def __getattr__(self, name: str) -> Any:
attr = getattr(self._async_obj, name)
if asyncio.iscoroutinefunction(attr):
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
return _run(attr(*args, **kwargs))
return sync_wrapper
# If the attribute is an interface object, wrap it too
if hasattr(attr, "_t"): # Interface objects have _t (transport)
return _SyncProxy(attr)
return attr
def __repr__(self) -> str:
return f"Sync({self._async_obj!r})"
@contextmanager
def sandbox(
*,
local: bool = False,
ws_url: Optional[str] = None,
api_key: Optional[str] = None,
image: Optional[Image] = None,
name: Optional[str] = None,
) -> Iterator[_SyncProxy]:
"""Synchronous context manager yielding a sync-wrapped Sandbox."""
sb = _run(_AsyncSandbox._create(local=local, ws_url=ws_url, api_key=api_key, name=name))
proxy = _SyncProxy(sb)
try:
yield proxy
finally:
_run(sb.disconnect())
@contextmanager
def localhost() -> Iterator[_SyncProxy]:
"""Synchronous context manager yielding a sync-wrapped Localhost."""
host = _AsyncLocalhost()
_run(host._connect())
proxy = _SyncProxy(host)
try:
yield proxy
finally:
_run(host.disconnect())
@@ -0,0 +1,25 @@
from cua_sandbox.transport.adb import ADBTransport
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.local import LocalTransport
from cua_sandbox.transport.osworld import OSWorldTransport
from cua_sandbox.transport.qmp import QMPTransport
from cua_sandbox.transport.ssh import SSHTransport
from cua_sandbox.transport.vnc import VNCTransport
from cua_sandbox.transport.vncssh import VNCSSHTransport
from cua_sandbox.transport.websocket import WebSocketTransport
__all__ = [
"Transport",
"LocalTransport",
"WebSocketTransport",
"HTTPTransport",
"CloudTransport",
"QMPTransport",
"OSWorldTransport",
"ADBTransport",
"VNCTransport",
"VNCSSHTransport",
"SSHTransport",
]
File diff suppressed because one or more lines are too long
@@ -0,0 +1,230 @@
"""ADBTransport — communicates with Android emulator via ADB.
Screenshots via `adb exec-out screencap -p`, shell via `adb shell`,
screen size via `adb shell wm size`.
"""
from __future__ import annotations
import os
import shutil
import subprocess
from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict, Optional
if TYPE_CHECKING:
from cua_sandbox.interfaces.tunnel import TunnelInfo
from cua_sandbox.transport.base import Transport
class ADBTransport(Transport):
"""Transport backed by ADB (Android Debug Bridge)."""
def __init__(self, serial: str = "emulator-5554", sdk_root: Optional[str] = None):
self._serial = serial
self._sdk_root = sdk_root
self._adb: Optional[str] = None
def _find_adb(self) -> str:
if self._sdk_root:
candidate = Path(self._sdk_root) / "platform-tools" / "adb"
if candidate.exists():
return str(candidate)
found = shutil.which("adb")
if found:
return found
raise FileNotFoundError("adb not found. Install the Android SDK or set ANDROID_HOME.")
async def connect(self) -> None:
self._adb = self._find_adb()
env = self._env()
subprocess.run([self._adb, "start-server"], capture_output=True, env=env)
async def disconnect(self) -> None:
pass
def _env(self) -> dict:
env = os.environ.copy()
if self._sdk_root:
env["ANDROID_SDK_ROOT"] = self._sdk_root
return env
def _adb_cmd(self, *args: str, timeout: float = 15) -> subprocess.CompletedProcess:
assert self._adb is not None, "Transport not connected"
return subprocess.run(
[self._adb, "-s", self._serial, *args],
capture_output=True,
env=self._env(),
timeout=timeout,
)
async def _adb_cmd_async(self, *args: str, timeout: float = 15) -> subprocess.CompletedProcess:
"""Run _adb_cmd in a thread executor so the event loop stays responsive."""
import asyncio
import functools
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None, functools.partial(self._adb_cmd, *args, timeout=timeout)
)
async def send(self, action: str, **params: Any) -> Any:
if action in ("shell", "execute", "run_command"):
cmd = params.get("command", "")
cmd = f"[ -f /data/local/tmp/.cua_env ] && . /data/local/tmp/.cua_env; {cmd}"
result = await self._adb_cmd_async("shell", cmd)
return {
"stdout": result.stdout.decode(errors="replace"),
"stderr": result.stderr.decode(errors="replace"),
"returncode": result.returncode,
}
if action == "multitouch_gesture":
return self._multitouch_gesture(**params)
raise ValueError(f"Unknown action: {action}")
def _multitouch_gesture(
self,
fingers: list,
screen_w: int,
screen_h: int,
duration_ms: int = 400,
steps: int = 0,
) -> Any:
"""Inject multi-touch via MT Protocol B sendevent (local ADB).
Calls ``adb root`` to restart adbd as root so plain ``sendevent``
works without ``su`` inside the Android shell.
"""
import re
import time
# Restart adbd as root
self._adb_cmd("root", timeout=10)
time.sleep(1.0) # wait for adbd to restart
# Find touch device
dev_result = self._adb_cmd(
"shell",
"for f in /dev/input/event*; do "
" getevent -p \"$f\" 2>/dev/null | grep -q '0035' "
' && echo "$f" && break; '
"done",
)
dev = dev_result.stdout.decode(errors="replace").strip() or "/dev/input/event1"
# Detect axis max
axis_max = 32767
gp_result = self._adb_cmd("shell", f"getevent -p {dev} 2>/dev/null | grep '0035'")
gp_out = gp_result.stdout.decode(errors="replace")
m = re.search(r"max\s+(\d+)", gp_out)
if m:
axis_max = int(m.group(1))
n_steps = steps if steps > 0 else max(5, duration_ms // 20)
step_delay = (duration_ms / 1000) / n_steps
EV_SYN, EV_KEY, EV_ABS = 0, 1, 3
SYN_REPORT, BTN_TOUCH = 0, 330
SLOT, TID, MTX, MTY, PRESS = 47, 57, 53, 54, 58
TID_NONE = 4294967295
def px_to_raw(px: int, dim: int) -> int:
return max(0, min(axis_max, int(px * axis_max / dim)))
def se(t: int, c: int, v: int) -> str:
return f"sendevent {dev} {t} {c} {v}"
cmds: list[str] = []
for idx, finger in enumerate(fingers):
x1, y1 = finger["start"]
cmds += [
se(EV_ABS, SLOT, idx),
se(EV_ABS, TID, idx),
se(EV_ABS, MTX, px_to_raw(x1, screen_w)),
se(EV_ABS, MTY, px_to_raw(y1, screen_h)),
se(EV_ABS, PRESS, 64),
]
cmds += [se(EV_KEY, BTN_TOUCH, 1), se(EV_SYN, SYN_REPORT, 0)]
for i in range(1, n_steps + 1):
t = i / n_steps
for idx, finger in enumerate(fingers):
x1, y1 = finger["start"]
x2, y2 = finger["end"]
cmds += [
se(EV_ABS, SLOT, idx),
se(EV_ABS, MTX, px_to_raw(int(x1 + (x2 - x1) * t), screen_w)),
se(EV_ABS, MTY, px_to_raw(int(y1 + (y2 - y1) * t), screen_h)),
]
cmds += [se(EV_SYN, SYN_REPORT, 0), f"sleep {step_delay:.3f}"]
for idx in range(len(fingers)):
cmds += [se(EV_ABS, SLOT, idx), se(EV_ABS, TID, TID_NONE)]
cmds += [se(EV_KEY, BTN_TOUCH, 0), se(EV_SYN, SYN_REPORT, 0)]
result = self._adb_cmd("shell", " && ".join(cmds), timeout=60)
return {
"stdout": result.stdout.decode(errors="replace"),
"returncode": result.returncode,
}
async def screenshot(self, format: str = "png", quality: int = 95) -> bytes:
assert self._adb is not None, "Transport not connected"
# exec-out screencap -p returns PNG; PNG compresses on the emulator side
# so less data is transferred over ADB than raw RGBA.
result = await self._adb_cmd_async("exec-out", "screencap", "-p", timeout=15)
if not (result.returncode == 0 and result.stdout[:4] == b"\x89PNG"):
raise RuntimeError(f"ADB screenshot failed: {result.stderr.decode(errors='replace')}")
if format.lower() in ("jpeg", "jpg"):
from io import BytesIO
from PIL import Image as PILImage
img = PILImage.open(BytesIO(result.stdout)).convert("RGB")
buf = BytesIO()
img.save(buf, format="JPEG", quality=quality, optimize=True)
return buf.getvalue()
return result.stdout
async def get_screen_size(self) -> Dict[str, int]:
result = await self._adb_cmd_async("shell", "wm", "size")
# Output: "Physical size: 1080x1920"
output = result.stdout.decode().strip()
for line in output.splitlines():
if "size" in line.lower():
parts = line.split(":")[-1].strip()
w, h = parts.split("x")
return {"width": int(w), "height": int(h)}
raise RuntimeError(f"Could not parse screen size: {output}")
async def get_environment(self) -> str:
return "android"
# ── Tunnel ────────────────────────────────────────────────────────────────
async def forward_tunnel(self, sandbox_port: int | str) -> "TunnelInfo":
"""Forward a sandbox TCP port or abstract socket to a free host port.
- ``int`` → ``adb forward tcp:0 tcp:<sandbox_port>``
- ``str`` → ``adb forward tcp:0 localabstract:<sandbox_port>``
(e.g. ``"chrome_devtools_remote"`` for Chrome DevTools)
"""
from cua_sandbox.interfaces.tunnel import TunnelInfo
target = (
f"localabstract:{sandbox_port}"
if isinstance(sandbox_port, str)
else f"tcp:{sandbox_port}"
)
result = await self._adb_cmd_async("forward", "tcp:0", target)
if result.returncode != 0:
raise RuntimeError(f"adb forward failed: {result.stderr.decode(errors='replace')}")
host_port = int(result.stdout.decode().strip())
return TunnelInfo(host="localhost", port=host_port, sandbox_port=sandbox_port)
async def close_tunnel(self, info: "TunnelInfo") -> None:
"""Remove the adb forward rule for *info.sandbox_port*."""
await self._adb_cmd_async("forward", "--remove", f"tcp:{info.port}")
@@ -0,0 +1,121 @@
"""Abstract transport protocol.
A Transport moves commands and data between the sandbox client and the
underlying computer (local host, WebSocket to computer-server, or cloud API).
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any, Dict, Optional
if TYPE_CHECKING:
from cua_sandbox.interfaces.tunnel import TunnelInfo
def convert_screenshot(png_bytes: bytes, format: str, quality: int) -> bytes:
"""Convert raw PNG bytes to the requested format.
Args:
png_bytes: Raw PNG image bytes.
format: "png", "jpeg", or "jpg".
quality: JPEG quality (1-95), ignored for PNG.
"""
fmt = format.lower()
if fmt in ("jpeg", "jpg"):
from io import BytesIO
from PIL import Image as PILImage
img = PILImage.open(BytesIO(png_bytes)).convert("RGB")
buf = BytesIO()
img.save(buf, format="JPEG", quality=quality)
return buf.getvalue()
return png_bytes
class Transport(ABC):
"""Base class for all transports."""
@abstractmethod
async def connect(self) -> None:
"""Establish the transport connection."""
@abstractmethod
async def disconnect(self) -> None:
"""Tear down the transport connection."""
@abstractmethod
async def send(self, action: str, **params: Any) -> Any:
"""Send a command and return the result."""
@abstractmethod
async def screenshot(self, format: str = "png", quality: int = 95) -> bytes:
"""Capture a screenshot and return raw image bytes.
Args:
format: "png" (lossless, default) or "jpeg" (lossy, ~5-10x smaller).
quality: JPEG quality 1-95, ignored for PNG.
"""
@abstractmethod
async def get_screen_size(self) -> Dict[str, int]:
"""Return {"width": ..., "height": ...}."""
@abstractmethod
async def get_environment(self) -> str:
"""Return 'windows', 'mac', 'linux', or 'browser'."""
async def forward_tunnel(self, sandbox_port: int | str) -> "TunnelInfo":
"""Forward *sandbox_port* to an available host port and return info.
Subclasses that support tunnelling must override this method.
The returned :class:`~cua_sandbox.interfaces.tunnel.TunnelInfo` must
have ``host`` and ``port`` set to the host-side address.
"""
raise NotImplementedError(
f"{type(self).__name__} does not support port forwarding. "
"Supported transports: ADBTransport, GRPCEmulatorTransport, SSHTransport."
)
async def close_tunnel(self, info: "TunnelInfo") -> None:
"""Release a previously forwarded port or socket. No-op by default."""
# ── PTY sessions ────────────────────────────────────────────────────
# Transports that support a pseudo-terminal override these. The default
# implementations raise NotImplementedError.
async def pty_create(
self,
command: Optional[str] = None,
cols: int = 120,
rows: int = 40,
cwd: Optional[str] = None,
envs: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
"""Spawn a PTY session. Returns {"pid": int, "cols": int, "rows": int}."""
raise NotImplementedError(f"{type(self).__name__} does not support PTY.")
async def pty_send(self, pid: int, data: str) -> None:
"""Write *data* to the PTY session's stdin."""
raise NotImplementedError(f"{type(self).__name__} does not support PTY.")
async def pty_kill(self, pid: int) -> bool:
"""Terminate a PTY session."""
raise NotImplementedError(f"{type(self).__name__} does not support PTY.")
async def pty_info(self, pid: int) -> Optional[Dict[str, Any]]:
"""Return session info if still running, None otherwise."""
raise NotImplementedError(f"{type(self).__name__} does not support PTY.")
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
(localhost VNC for local runtimes; auth-gated URL for cloud).
Raises NotImplementedError for transports that don't expose a display
(e.g. HTTP, ADB).
"""
raise NotImplementedError(f"{type(self).__name__} does not support get_display_url().")
@@ -0,0 +1,819 @@
"""CloudTransport — connects to a CUA cloud VM via the platform API.
Resolves VM connection info from the API, optionally creates a new VM,
then delegates all computer control to an inner HTTPTransport pointed at
the VM's computer-server endpoint.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any, Dict, Optional
import httpx
from cua_sandbox._config import get_api_key, get_base_url
from cua_sandbox.transport.base import Transport
from cua_sandbox.transport.http import HTTPTransport
logger = logging.getLogger(__name__)
_POLL_INTERVAL = 0.5 # seconds between status polls
_POLL_TIMEOUT = 600.0 # max seconds to wait for VM to be running
_CREATE_MAX_RETRIES = 5 # retry POST /v1/vms on 503 (no capacity)
_CREATE_RETRY_BASE_S = 2.0 # exponential backoff base (2, 4, 8, 16, 32s)
class CloudTransport(Transport):
"""Transport that provisions / connects to a CUA cloud VM."""
_DEFAULT_CPU = 1
_DEFAULT_MEMORY_MB = 4096
_DEFAULT_DISK_GB = 64
def __init__(
self,
name: Optional[str] = None,
*,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
# Creation params (used only when creating a new VM)
image: Optional[Any] = 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,
):
self._name = name
self._api_key_override = api_key
self._base_url = base_url or get_base_url()
self._image = image
self._cpu = cpu
self._memory_mb = memory_mb
self._disk_gb = disk_gb
self._region = region
self._time_to_start = time_to_start if time_to_start is not None else _POLL_TIMEOUT
self._request_timeout = request_timeout if request_timeout is not None else 30.0
self._inner: Optional[HTTPTransport] = None
self._api_client: Optional[httpx.AsyncClient] = None
# ── Connection lifecycle ────────────────────────────────────────────
async def connect(self) -> None:
api_key = get_api_key(self._api_key_override)
if not api_key:
raise ValueError(
"No CUA API key found. Cloud sandboxes are the default — to use one, provide an API key via:\n"
" 1. cua.configure(api_key='sk-...')\n"
" 2. Set the CUA_API_KEY environment variable\n"
" 3. Run cua.login() to authenticate via browser\n"
" 4. Pass api_key='sk-...' directly to sandbox()\n"
"\n"
"For local-only usage (no cloud), use sandbox(local=True) instead."
)
self._api_client = httpx.AsyncClient(
base_url=self._base_url,
headers={"Authorization": f"Bearer {api_key}"},
timeout=30.0,
)
if self._name:
logger.debug("[cloud] getting VM info for %r", self._name)
vm_info = await self._get_vm(self._name)
logger.debug("[cloud] VM info: status=%r", vm_info.get("status"))
else:
logger.debug("[cloud] creating new VM")
vm_info = await self._create_vm()
self._name = vm_info["name"]
logger.debug("[cloud] created VM %r", self._name)
_is_local_dev = not self._base_url.rstrip("/").endswith("cua.sh") and (
"localhost" in self._base_url
or "127.0.0.1" in self._base_url
or "0.0.0.0" in self._base_url
)
# ── Parallel path: resolve endpoint + probe server while waiting for "running" ──
# The VM is booting; we can start probing its CUA server as soon as the
# API gives us an IP-based endpoint, without waiting for the full CRD
# status to transition to "running".
async def _poll_until_running_and_resolved() -> tuple[dict, str]:
"""Poll VM status AND resolve a usable endpoint URL in one loop."""
nonlocal vm_info
elapsed = 0.0
cs_url = ""
is_running = vm_info.get("status") in ("running", "ready")
while elapsed < self._time_to_start:
# Try to extract a direct-IP endpoint from current vm_info
try:
url = self._resolve_endpoint(vm_info)
if not (_is_local_dev and (".cua.sh" in url)):
cs_url = url
except (ValueError, KeyError):
pass
if is_running and cs_url:
return vm_info, cs_url
await asyncio.sleep(_POLL_INTERVAL)
elapsed += _POLL_INTERVAL
vm_info = await self._get_vm(self._name)
is_running = vm_info.get("status") in ("running", "ready")
if not is_running:
raise TimeoutError(
f"VM {self._name!r} did not become running within {self._time_to_start}s "
f"(last status: {vm_info.get('status')})"
)
if not cs_url:
cs_url = self._resolve_endpoint(vm_info)
return vm_info, cs_url
async def _connect_and_wait_ready(cs_url: str, api_key: str) -> None:
"""Create inner HTTPTransport and wait for server readiness."""
# Forks inherit the source container's credentials from the
# snapshot, so auth must use the source instance name.
snap_source = getattr(self._image, "_snapshot_source", None) if self._image else None
auth_name = snap_source["instance"] if snap_source else self._name
self._inner = HTTPTransport(
cs_url, api_key=api_key, container_name=auth_name, timeout=self._request_timeout
)
await self._inner.connect()
await self._wait_for_server_ready()
# Run VM status polling, endpoint resolution, and server probe in
# parallel. As soon as the API returns a direct-IP endpoint (even
# while CRD status is still "creating"), start TCP-probing the CUA
# server. This overlaps the kopf handler's credential injection
# and DNS setup with the CUA server boot.
probe_task: Optional[asyncio.Task] = None
resolved_url: Optional[str] = None
async def _poll_and_probe() -> tuple[dict, str]:
"""Poll VM status; start TCP/HTTP probe as soon as we have a direct IP."""
nonlocal vm_info, probe_task, resolved_url
elapsed = 0.0
is_running = vm_info.get("status") in ("running", "ready")
while elapsed < self._time_to_start:
# Try to extract a direct-IP endpoint
try:
url = self._resolve_endpoint(vm_info)
if not (_is_local_dev and ".cua.sh" in url):
# Got a direct IP — start probing if not already
if probe_task is None:
resolved_url = url
logger.debug(
"[cloud] early endpoint: %s at %.1fs — starting probe", url, elapsed
)
probe_task = asyncio.create_task(_connect_and_wait_ready(url, api_key))
except (ValueError, KeyError):
pass
if is_running and resolved_url:
return vm_info, resolved_url
await asyncio.sleep(_POLL_INTERVAL)
elapsed += _POLL_INTERVAL
vm_info = await self._get_vm(self._name)
is_running = vm_info.get("status") in ("running", "ready")
if not is_running:
raise TimeoutError(
f"VM {self._name!r} did not become running within {self._time_to_start}s "
f"(last status: {vm_info.get('status')})"
)
url = resolved_url or self._resolve_endpoint(vm_info)
return vm_info, url
vm_info, cs_url = await _poll_and_probe()
logger.debug("[cloud] resolved endpoint: %s", cs_url)
# If probe was started early, wait for it; otherwise start now
if probe_task is not None:
await probe_task
else:
await _connect_and_wait_ready(cs_url, api_key)
logger.debug("[cloud] computer-server ready")
# Apply env vars and image layers (e.g. APK installs) after server is ready
if self._image and (self._image._layers or self._image._env):
logger.debug("[cloud] applying image layers")
await self._apply_image_layers()
async def disconnect(self) -> None:
if self._inner:
await self._inner.disconnect()
self._inner = None
if self._api_client:
await self._api_client.aclose()
self._api_client = None
async def create_snapshot(self, name: str | None = None, stateful: bool = False) -> dict:
"""Create a snapshot of this VM. Returns an image descriptor dict."""
assert self._api_client and self._name
resp = await self._api_client.post(
f"/v1/vms/{self._name}/snapshot",
json={"name": name or "", "stateful": stateful},
timeout=600.0, # snapshot can take minutes on dir storage
)
resp.raise_for_status()
data = resp.json()
# Poll until snapshot is ready
image_desc = data.get("image", data)
snap_name = image_desc.get("snapshot", "")
if snap_name:
await self._wait_for_snapshot_ready(snap_name)
return image_desc
async def _wait_for_snapshot_ready(self, snapshot_name: str, timeout: float = 120) -> None:
"""Wait for snapshot to be ready.
The API's snapshot endpoint is now synchronous — it blocks until the
Kopf operator finishes the snapshot. This method is kept as a no-op
for compatibility.
"""
return
async def delete_vm(self) -> None:
"""Delete the cloud VM via the platform API."""
api_key = get_api_key(self._api_key_override)
if not api_key or not self._name:
return
async with httpx.AsyncClient(
base_url=self._base_url,
headers={"Authorization": f"Bearer {api_key}"},
timeout=30.0,
) as client:
await client.delete(f"/v1/vms/{self._name}")
async def suspend_vm(self) -> None:
"""Stop (suspend) the cloud VM."""
if not self._name:
return
assert self._api_client
await self._api_client.post(f"/v1/vms/{self._name}/stop")
async def resume_vm(self) -> None:
"""Start (resume) the cloud VM."""
if not self._name:
return
assert self._api_client
await self._api_client.post(f"/v1/vms/{self._name}/run")
async def restart_vm(self) -> None:
"""Restart the cloud VM."""
if not self._name:
return
assert self._api_client
await self._api_client.post(f"/v1/vms/{self._name}/restart")
# ── Delegated methods ───────────────────────────────────────────────
async def send(self, action: str, **params: Any) -> Any:
assert self._inner, "Transport not connected"
# Source .cua_env before run_command on Android (same as ADB/gRPC transports)
if (
action == "run_command"
and "command" in params
and self._image
and self._image.os_type == "android"
):
params = dict(params)
params["command"] = (
"[ -f /data/local/tmp/.cua_env ] && . /data/local/tmp/.cua_env; "
+ params["command"]
)
return await self._inner.send(action, **params)
async def screenshot(self, format: str = "png", quality: int = 95) -> bytes:
assert self._inner, "Transport not connected"
return await self._inner.screenshot(format=format, quality=quality)
async def get_screen_size(self) -> Dict[str, int]:
assert self._inner, "Transport not connected"
return await self._inner.get_screen_size()
async def get_environment(self) -> str:
assert self._inner, "Transport not connected"
return await self._inner.get_environment()
async def get_display_url(self, *, share: bool = False) -> str:
if not self._name:
raise ValueError("Transport not connected — no VM name available")
if not share:
return f"https://cua.ai/connect/{self._name}"
vm_info = await self._get_vm(self._name)
password = vm_info.get("password", "")
for ep in vm_info.get("endpoints", []):
if ep.get("name") == "vnc":
host = ep["host"]
url = f"https://{host}"
if password:
url += f"/?password={password}"
return url
raise ValueError(
f"VM '{self._name}' has no VNC endpoint. "
"Only Android and desktop VMs expose a VNC endpoint."
)
# ── Helpers ─────────────────────────────────────────────────────────
@property
def name(self) -> Optional[str]:
return self._name
async def _get_vm(self, name: str) -> dict:
assert self._api_client
resp = await self._api_client.get(f"/v1/vms/{name}")
resp.raise_for_status()
return resp.json()
async def _create_vm(self) -> dict:
assert self._api_client
if not self._image:
raise ValueError(
"Cannot create a cloud VM without an image. Use:\n"
" Sandbox.create(image=Image.linux()) or Sandbox.create(image=Image.windows()) or Sandbox.create(image=Image.macos())\n"
"Or connect to an existing VM by name: Sandbox.connect(name='my-vm')"
)
# Fork path: image came from sb.snapshot() — create VM from snapshot
snap_source = getattr(self._image, "_snapshot_source", None)
if snap_source:
body = {
"source": "snapshot",
"instance": snap_source["instance"],
"snapshot": snap_source["snapshot"],
"instanceType": snap_source.get("instanceType", "vm"),
}
resp = await self._api_client.post("/v1/vms", json=body)
resp.raise_for_status()
return resp.json()
os_type = getattr(self._image, "os_type", None)
if not os_type:
raise ValueError(
"Image must have an os_type. Use Image.linux(), Image.windows(), or Image.macos()."
)
body: Dict[str, Any] = {
"os": os_type,
"region": self._region,
}
# If any resource spec is provided, send explicit specs (defaulting missing to small).
# Otherwise, send configuration="small" for backwards compat with legacy API.
if any(v is not None for v in (self._cpu, self._memory_mb, self._disk_gb)):
body["cpu"] = self._cpu or self._DEFAULT_CPU
body["memoryMb"] = self._memory_mb or self._DEFAULT_MEMORY_MB
body["diskGb"] = self._disk_gb or self._DEFAULT_DISK_GB
else:
body["configuration"] = "small"
resp = await self._post_with_retry("/v1/vms", body)
resp.raise_for_status()
return resp.json()
async def _post_with_retry(self, path: str, body: dict) -> httpx.Response:
"""POST with exponential backoff retry on 503 (no capacity)."""
assert self._api_client
for attempt in range(_CREATE_MAX_RETRIES):
resp = await self._api_client.post(path, json=body)
if resp.status_code != 503:
return resp
if attempt == _CREATE_MAX_RETRIES - 1:
break
delay = _CREATE_RETRY_BASE_S * (2**attempt)
logger.warning(
"No sandbox capacity (503), retrying in %.1fs (attempt %d/%d)",
delay,
attempt + 1,
_CREATE_MAX_RETRIES,
)
await asyncio.sleep(delay)
return resp # return last 503 response, caller will raise_for_status
async def _wait_for_running(self, vm_info: dict) -> dict:
"""Poll until the VM status is 'running' (or 'ready')."""
elapsed = 0.0
while vm_info.get("status") not in ("running", "ready"):
if elapsed >= self._time_to_start:
raise TimeoutError(
f"VM {self._name!r} did not become running within {self._time_to_start}s "
f"(last status: {vm_info.get('status')})"
)
await asyncio.sleep(_POLL_INTERVAL)
elapsed += _POLL_INTERVAL
vm_info = await self._get_vm(self._name) # type: ignore[arg-type]
logger.debug(
"[cloud] _wait_for_running: elapsed=%.0fs status=%r", elapsed, vm_info.get("status")
)
return vm_info
async def _wait_for_server_ready(self) -> None:
"""Poll the computer-server until it responds.
First, quickly check if the HTTP server is accepting connections (any
response, even 404). Then verify with get_screen_size which needs the
emulator. This two-phase approach lets the SDK proceed as soon as the
server process is up, overlapping with emulator boot.
"""
assert self._inner
elapsed = 0.0
last_err: Optional[Exception] = None
# Phase 1: wait for HTTP port to accept connections (fast — just needs
# the Python process to start, not the emulator).
while elapsed < self._time_to_start:
try:
resp = await self._inner._client.get("/", timeout=2.0)
# Any response means the server is up
logger.debug(
"[cloud] server HTTP up (status=%d) at %.1fs", resp.status_code, elapsed
)
break
except Exception as e:
last_err = e
await asyncio.sleep(_POLL_INTERVAL)
elapsed += _POLL_INTERVAL
# Check if this is a Windows server (no screen_size endpoint)
try:
status_resp = await self._inner._client.get("/status", timeout=5.0)
if status_resp.status_code == 200:
status_data = status_resp.json()
if status_data.get("os_type") == "windows":
logger.debug("[cloud] Windows server detected, skipping screen_size check")
return # Windows servers are ready after Phase 1
except Exception:
pass # Fall through to Phase 2
# Phase 2: wait for get_screen_size (needs emulator/display running)
while elapsed < self._time_to_start:
try:
await self._inner.get_screen_size()
return # Fully ready
except httpx.HTTPStatusError as e:
if e.response.status_code < 500 and e.response.status_code != 404:
raise
last_err = e
logger.debug("[cloud] _wait_for_server_ready: elapsed=%.0fs err=%r", elapsed, e)
await asyncio.sleep(_POLL_INTERVAL)
elapsed += _POLL_INTERVAL
except Exception as e:
last_err = e
logger.debug("[cloud] _wait_for_server_ready: elapsed=%.0fs err=%r", elapsed, e)
await asyncio.sleep(_POLL_INTERVAL)
elapsed += _POLL_INTERVAL
raise TimeoutError(
f"Computer-server for VM {self._name!r} not reachable within {self._time_to_start}s: {last_err}"
)
async def _apply_image_layers(self) -> None:
"""Apply env vars and image layers (APK installs, PWA, shell commands) after the VM is ready."""
import base64
assert self._inner
# Apply environment variables by writing a .cua_env sourced file
if self._image._env:
import shlex
lines = []
for k, v in self._image._env:
lines.append(f"export {k}={shlex.quote(v)}")
env_content = "\n".join(lines) + "\n"
await self._inner.send(
"write_bytes",
path="/data/local/tmp/.cua_env",
content_b64=base64.b64encode(env_content.encode()).decode(),
)
logger.debug("[cloud] wrote %d env vars to .cua_env", len(self._image._env))
for layer in self._image._layers:
lt = layer["type"]
if lt == "apk_install":
for apk in layer["packages"]:
await self._install_apk(apk)
elif lt == "pwa_install":
await self._install_pwa(layer)
elif lt == "run":
await self._inner.send("run_command", command=layer["command"], timeout=60)
async def _install_apk(self, apk: str) -> None:
"""Download (if URL) and install an APK via the computer-server."""
import base64
import hashlib
import urllib.request
from pathlib import Path
dest = "/data/local/tmp/cua_install.apk"
if apk.startswith(("http://", "https://")):
cache_dir = Path.home() / ".cua" / "cua-sandbox" / "apk-cache"
cache_dir.mkdir(parents=True, exist_ok=True)
cache_file = cache_dir / (hashlib.sha256(apk.encode()).hexdigest()[:16] + ".apk")
if not cache_file.exists():
urllib.request.urlretrieve(apk, cache_file)
apk_bytes = cache_file.read_bytes()
else:
apk_bytes = Path(apk).read_bytes()
await self._inner.send(
"write_bytes",
path=dest,
content_b64=base64.b64encode(apk_bytes).decode(),
)
await self._inner.send(
"run_command",
command=(
f"out=$(pm install -r {dest} 2>&1); "
f'if echo "$out" | grep -q INSTALL_FAILED_UPDATE_INCOMPATIBLE; then '
f' pkg=$(echo "$out" | sed -n "s/.*Package \\(\\S*\\) signatures.*/\\1/p"); '
f' pm uninstall "$pkg"; pm install -r {dest}; '
f'else echo "$out"; fi; true'
),
timeout=90,
)
async def _install_pwa(self, layer: dict) -> None:
"""Build PWA APK on the host, then push and install.
Supports two builders:
- "pwa2apk" (default): WebView-based APK, no Chrome dependency or banners.
- "bubblewrap": Chrome TWA APK, requires asset links and shows Chrome disclosure.
"""
import base64
from pathlib import Path
builder = layer.get("builder", "pwa2apk")
manifest_url = layer["manifest_url"]
pkg = layer.get("package_name")
ks = Path(layer["keystore"]) if layer.get("keystore") else None
ks_alias = layer.get("keystore_alias", "android")
ks_pass = layer.get("keystore_password", "android")
if builder == "pwa2apk":
apk_path, fingerprint = await self._build_pwa2apk(
manifest_url, pkg, ks, ks_alias, ks_pass
)
else:
from cua_sandbox.runtime.android_emulator import (
AndroidEmulatorRuntime,
_ensure_sdk,
)
_ensure_sdk()
runtime = AndroidEmulatorRuntime.__new__(AndroidEmulatorRuntime)
apk_path, fingerprint = await runtime._build_pwa_apk(
manifest_url, pkg, ks, ks_alias, ks_pass
)
logger.info(f"[cloud] PWA APK built ({builder}): {apk_path} (fingerprint: {fingerprint})")
apk_bytes = Path(apk_path).read_bytes()
dest = "/data/local/tmp/cua_pwa.apk"
write_kwargs: dict = {
"path": dest,
"content_b64": base64.b64encode(apk_bytes).decode(),
}
if "push_timeout" in layer:
write_kwargs["timeout"] = layer["push_timeout"]
await self._inner.send("write_bytes", **write_kwargs)
logger.debug(f"[cloud] installing APK: pm install -r {dest}")
await self._inner.send(
"run_command",
command=f"pm install -r {dest} 2>&1; true",
timeout=120,
)
logger.debug("[cloud] APK installed")
# For bubblewrap TWA, suppress Chrome first-run and set asset link bypass.
# For pwa2apk WebView, none of this is needed.
if builder == "bubblewrap":
from urllib.parse import urlparse
origin = f"{urlparse(manifest_url).scheme}://{urlparse(manifest_url).netloc}"
for cmd in [
"am set-debug-app --persistent com.android.chrome",
"mkdir -p /data/local/tmp && "
"echo 'chrome --no-first-run --disable-fre --no-default-browser-check "
f'--disable-digital-asset-link-verification-for-url="{origin}"\' '
"> /data/local/tmp/chrome-command-line",
]:
await self._inner.send("run_command", command=cmd, timeout=10)
@staticmethod
async def _build_pwa2apk(
manifest_url: str,
package_name: str | None = None,
keystore_path: str | None = None,
keystore_alias: str = "android",
keystore_password: str = "android",
) -> tuple:
"""Build a WebView-based APK using pwa2apk (no Chrome dependency)."""
import shutil
import subprocess
node = shutil.which("node")
if not node:
raise RuntimeError("node not found on PATH; required for pwa2apk")
# Find pwa2apk — check common locations
pwa2apk_cli = None
for candidate in [
shutil.which("pwa2apk"),
# npm global
*([] if not shutil.which("npm") else []),
]:
if candidate:
pwa2apk_cli = candidate
break
# Fall back to requiring it as a node module
if not pwa2apk_cli:
# Try npx
npx = shutil.which("npx")
if npx:
pwa2apk_cli = npx
# Build via the pwa2apk Node API directly
import hashlib
from pathlib import Path
# Check if pwa2apk is installed globally or locally
pwa2apk_dir = None
for p in [
Path.home() / ".cua" / "pwa2apk",
Path("/tmp/pwa2apk"),
]:
if (p / "src" / "index.js").exists():
pwa2apk_dir = p
break
if not pwa2apk_dir:
# Auto-clone pwa2apk
logger.info("Cloning pwa2apk...")
pwa2apk_dir = Path.home() / ".cua" / "pwa2apk"
pwa2apk_dir.mkdir(parents=True, exist_ok=True)
clone_result = subprocess.run(
["git", "clone", "https://github.com/trycua/pwa2apk.git", str(pwa2apk_dir)],
capture_output=True,
text=True,
timeout=60,
)
if clone_result.returncode != 0:
raise RuntimeError(f"Failed to clone pwa2apk: {clone_result.stderr}")
# Build the args for the CLI
import os
cache_key = hashlib.sha256(f"{manifest_url}|{package_name or ''}".encode()).hexdigest()[:12]
output_apk = Path.home() / ".cua" / "pwa2apk-cache" / f"{cache_key}.apk"
output_apk.parent.mkdir(parents=True, exist_ok=True)
cmd = [
node,
str(pwa2apk_dir / "src" / "cli.js"),
manifest_url,
"--output",
str(output_apk),
]
if package_name:
cmd.extend(["--package", package_name])
if keystore_path:
cmd.extend(["--keystore", str(keystore_path)])
cmd.extend(["--keystore-alias", keystore_alias])
cmd.extend(["--keystore-password", keystore_password])
env = {**os.environ}
if "JAVA_HOME" not in env:
for jdk in [
# Linux
"/usr/lib/jvm/java-17-openjdk-amd64",
"/usr/lib/jvm/java-21-openjdk-amd64",
# macOS (Homebrew ARM) — prefer @17/@21 over unversioned (may be JDK 25+)
"/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home",
"/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home",
"/opt/homebrew/opt/openjdk/libexec/openjdk.jdk/Contents/Home",
# macOS (Homebrew Intel)
"/usr/local/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home",
"/usr/local/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home",
"/usr/local/opt/openjdk/libexec/openjdk.jdk/Contents/Home",
]:
if Path(jdk).exists():
env["JAVA_HOME"] = jdk
break
logger.info(f"Building APK with pwa2apk: {manifest_url}")
result = subprocess.run(
cmd,
capture_output=True,
text=True,
env=env,
timeout=300,
)
if result.returncode != 0:
raise RuntimeError(
f"pwa2apk build failed:\nstdout: {result.stdout}\nstderr: {result.stderr}"
)
# Extract fingerprint from output
fingerprint = ""
for line in result.stdout.splitlines():
if "SHA-256:" in line:
fingerprint = line.split("SHA-256:", 1)[1].strip()
break
if not output_apk.exists():
raise RuntimeError(f"pwa2apk did not produce APK at {output_apk}")
return output_apk, fingerprint
@staticmethod
def _resolve_endpoint(vm_info: dict) -> str:
"""Build the computer-server HTTP URL from VM info."""
# Prefer explicit endpoints array
for ep in vm_info.get("endpoints", []):
if ep.get("name") in ("computer-server", "api"):
host = ep["host"]
# cua.sh hosts are behind a reverse proxy — don't append port
if host.endswith(".cua.sh"):
return f"https://{host}"
return f"http://{host}:{ep['port']}"
# Fallback: legacy host-based URL
host = vm_info.get("host")
if not host:
raise ValueError(f"Cannot resolve computer-server endpoint from VM info: {vm_info}")
return f"http://{host}:8000"
async def cloud_list_vms(
*, api_key: Optional[str] = None, base_url: Optional[str] = None
) -> list[dict]:
"""List all cloud VMs. Returns raw VM dicts from the API."""
from cua_sandbox._config import get_api_key, get_base_url
key = get_api_key(api_key)
if not key:
raise ValueError("No CUA API key. Set CUA_API_KEY or run cua.login().")
url = base_url or get_base_url()
async with httpx.AsyncClient(
base_url=url,
headers={"Authorization": f"Bearer {key}"},
timeout=30.0,
) as client:
resp = await client.get("/v1/vms")
resp.raise_for_status()
data = resp.json()
return data if isinstance(data, list) else data.get("vms", [])
async def cloud_get_vm(
name: str, *, api_key: Optional[str] = None, base_url: Optional[str] = None
) -> dict:
"""Get info for a single cloud VM by name."""
from cua_sandbox._config import get_api_key, get_base_url
key = get_api_key(api_key)
if not key:
raise ValueError("No CUA API key. Set CUA_API_KEY or run cua.login().")
url = base_url or get_base_url()
async with httpx.AsyncClient(
base_url=url,
headers={"Authorization": f"Bearer {key}"},
timeout=30.0,
) as client:
resp = await client.get(f"/v1/vms/{name}")
resp.raise_for_status()
return resp.json()
async def cloud_vm_action(
name: str,
action: str,
*,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
) -> None:
"""POST /v1/vms/{name}/{action}. action is 'stop', 'run', 'restart', or 'delete'."""
from cua_sandbox._config import get_api_key, get_base_url
key = get_api_key(api_key)
if not key:
raise ValueError("No CUA API key. Set CUA_API_KEY or run cua.login().")
url = base_url or get_base_url()
async with httpx.AsyncClient(
base_url=url,
headers={"Authorization": f"Bearer {key}"},
timeout=30.0,
) as client:
if action == "delete":
await client.delete(f"/v1/vms/{name}")
else:
await client.post(f"/v1/vms/{name}/{action}")
@@ -0,0 +1,288 @@
"""GRPCEmulatorTransport — screenshots and input via the Android emulator's gRPC service.
The emulator exposes an EmulatorController gRPC service on console_port+3000
(default 8554 for an emulator on console port 5554). This bypasses ADB entirely,
reducing screenshot latency from ~500ms to ~20ms.
Launch the emulator with -grpc <port> or rely on the default console_port+3000.
Uses the synchronous grpc channel (not grpc.aio) so the stub is safe to call
from any event loop via run_in_executor — avoids the "Future attached to a
different loop" error that grpc.aio channels produce in pytest session fixtures.
"""
from __future__ import annotations
import asyncio
import functools
import platform
import shutil
import subprocess
from io import BytesIO
from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict, List, Optional
import grpc
# google.protobuf must be imported before the emulator pb2 stubs — do not reorder.
from google.protobuf import empty_pb2 # noqa: F401 isort: skip
# isort: split
from cua_sandbox.transport._grpc_emulator import emulator_controller_pb2 as pb2
from cua_sandbox.transport._grpc_emulator import (
emulator_controller_pb2_grpc as pb2_grpc,
)
from cua_sandbox.transport.base import Transport
if TYPE_CHECKING:
from cua_sandbox.interfaces.tunnel import TunnelInfo
def _find_adb(sdk_root: Optional[str] = None) -> str:
if sdk_root:
for ext in (".exe", "") if platform.system().lower() == "windows" else ("",):
candidate = Path(sdk_root) / "platform-tools" / f"adb{ext}"
if candidate.exists():
return str(candidate)
found = shutil.which("adb")
if found:
return found
raise FileNotFoundError("adb not found. Install the Android SDK or set ANDROID_HOME.")
class GRPCEmulatorTransport(Transport):
"""Transport backed by the Android emulator's gRPC EmulatorController service.
Uses a synchronous gRPC channel so it is safe across multiple event loops
(e.g. pytest session fixtures). All blocking RPC calls are offloaded to a
thread executor.
"""
def __init__(
self,
host: str = "localhost",
grpc_port: int = 8554,
serial: str = "emulator-5554",
sdk_root: Optional[str] = None,
):
self._host = host
self._grpc_port = grpc_port
self._serial = serial
self._sdk_root = sdk_root
self._adb: Optional[str] = None
self._channel: Optional[grpc.Channel] = None
self._stub: Optional[pb2_grpc.EmulatorControllerStub] = None
# ── Lifecycle ─────────────────────────────────────────────────────────────
async def connect(self) -> None:
self._adb = _find_adb(self._sdk_root)
self._channel = grpc.insecure_channel(
f"{self._host}:{self._grpc_port}",
options=[
("grpc.max_receive_message_length", 32 * 1024 * 1024),
("grpc.max_send_message_length", 32 * 1024 * 1024),
],
)
self._stub = pb2_grpc.EmulatorControllerStub(self._channel)
async def disconnect(self) -> None:
if self._channel:
self._channel.close()
self._channel = None
self._stub = None
# ── Helpers ───────────────────────────────────────────────────────────────
async def _rpc(self, fn, *args, **kwargs):
"""Run a synchronous gRPC call in the thread executor."""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, functools.partial(fn, *args, **kwargs))
async def _send_touch(self, event: pb2.TouchEvent) -> None:
await self._rpc(self._stub.sendTouch, event)
# ── Transport interface ───────────────────────────────────────────────────
async def screenshot(self, format: str = "png", quality: int = 95) -> bytes:
assert self._stub is not None, "Transport not connected"
fmt = format.lower()
if fmt in ("jpeg", "jpg"):
img_fmt = pb2.ImageFormat(format=pb2.ImageFormat.RGB888)
response = await self._rpc(self._stub.getScreenshot, img_fmt)
w, h = response.format.width, response.format.height
from PIL import Image as PILImage
img = PILImage.frombytes("RGB", (w, h), bytes(response.image))
buf = BytesIO()
img.save(buf, format="JPEG", quality=quality, optimize=True)
return buf.getvalue()
img_fmt = pb2.ImageFormat(format=pb2.ImageFormat.PNG)
response = await self._rpc(self._stub.getScreenshot, img_fmt)
return bytes(response.image)
async def get_screen_size(self) -> Dict[str, int]:
assert self._stub is not None, "Transport not connected"
img_fmt = pb2.ImageFormat(format=pb2.ImageFormat.PNG)
response = await self._rpc(self._stub.getScreenshot, img_fmt)
return {"width": response.format.width, "height": response.format.height}
async def get_environment(self) -> str:
return "android"
async def send(self, action: str, **params: Any) -> Any:
assert self._stub is not None, "Transport not connected"
if action in ("left_click", "right_click", "double_click"):
x, y = int(params["x"]), int(params["y"])
n_taps = 2 if action == "double_click" else 1
for _ in range(n_taps):
await self._send_touch(
pb2.TouchEvent(touches=[pb2.Touch(x=x, y=y, identifier=0, pressure=1)])
)
await self._send_touch(
pb2.TouchEvent(touches=[pb2.Touch(x=x, y=y, identifier=0, pressure=0)])
)
return {}
if action == "mouse_down":
x, y = int(params["x"]), int(params["y"])
await self._send_touch(
pb2.TouchEvent(touches=[pb2.Touch(x=x, y=y, identifier=0, pressure=1)])
)
return {}
if action == "mouse_up":
x, y = int(params["x"]), int(params["y"])
await self._send_touch(
pb2.TouchEvent(touches=[pb2.Touch(x=x, y=y, identifier=0, pressure=0)])
)
return {}
if action == "move_cursor":
return {}
if action in ("shell", "execute", "run_command"):
assert self._adb is not None, "Transport not connected"
cmd = params.get("command", "")
cmd = f"[ -f /data/local/tmp/.cua_env ] && . /data/local/tmp/.cua_env; {cmd}"
timeout = float(params.get("timeout", 15))
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None,
functools.partial(
subprocess.run,
[self._adb, "-s", self._serial, "shell", cmd],
capture_output=True,
timeout=timeout,
check=False,
),
)
return {
"stdout": result.stdout.decode(errors="replace"),
"stderr": result.stderr.decode(errors="replace"),
"returncode": result.returncode,
}
if action == "multitouch_gesture":
# Send all fingers simultaneously per frame via sendTouch.
fingers: List[Dict] = params["fingers"]
steps: int = max(1, int(params.get("steps", 10)))
duration_ms: float = float(params.get("duration_ms", 400))
delay = (duration_ms / 1000.0) / steps
# Press all fingers simultaneously
await self._send_touch(
pb2.TouchEvent(
touches=[
pb2.Touch(
x=int(f["start"][0]),
y=int(f["start"][1]),
identifier=i,
pressure=1,
)
for i, f in enumerate(fingers)
]
)
)
# Interpolate movement frames
for step in range(1, steps + 1):
t = step / steps
await self._send_touch(
pb2.TouchEvent(
touches=[
pb2.Touch(
x=int(f["start"][0] + t * (f["end"][0] - f["start"][0])),
y=int(f["start"][1] + t * (f["end"][1] - f["start"][1])),
identifier=i,
pressure=1,
)
for i, f in enumerate(fingers)
]
)
)
await asyncio.sleep(delay)
# Release all fingers simultaneously
await self._send_touch(
pb2.TouchEvent(
touches=[
pb2.Touch(
x=int(f["end"][0]),
y=int(f["end"][1]),
identifier=i,
pressure=0,
)
for i, f in enumerate(fingers)
]
)
)
return {}
raise NotImplementedError(f"GRPCEmulatorTransport.send({action!r}) not implemented")
# ── Tunnel ────────────────────────────────────────────────────────────────
async def forward_tunnel(self, sandbox_port: int | str) -> "TunnelInfo":
"""Forward a sandbox TCP port or abstract socket to a free host port.
- ``int`` → ``adb forward tcp:0 tcp:<sandbox_port>``
- ``str`` → ``adb forward tcp:0 localabstract:<sandbox_port>``
(e.g. ``"chrome_devtools_remote"`` for Chrome DevTools)
"""
from cua_sandbox.interfaces.tunnel import TunnelInfo
target = (
f"localabstract:{sandbox_port}"
if isinstance(sandbox_port, str)
else f"tcp:{sandbox_port}"
)
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None,
functools.partial(
subprocess.run,
[self._adb, "-s", self._serial, "forward", "tcp:0", target],
capture_output=True,
check=False,
),
)
if result.returncode != 0:
raise RuntimeError(f"adb forward failed: {result.stderr.decode(errors='replace')}")
host_port = int(result.stdout.decode().strip())
return TunnelInfo(host="localhost", port=host_port, sandbox_port=sandbox_port)
async def close_tunnel(self, info: "TunnelInfo") -> None:
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None,
functools.partial(
subprocess.run,
[self._adb, "-s", self._serial, "forward", "--remove", f"tcp:{info.port}"],
capture_output=True,
check=False,
),
)
@@ -0,0 +1,241 @@
"""HTTPTransport — REST fallback for computer-server's POST /cmd endpoint.
The computer-server /cmd endpoint accepts JSON ``{"command": ..., "params": {...}}``
and returns an SSE stream with a single ``data: {...}`` frame containing the result.
"""
from __future__ import annotations
import asyncio
import base64
import json
import logging
from typing import Any, Dict, Optional
import httpx
from cua_sandbox.transport.base import Transport
logger = logging.getLogger(__name__)
# Retry transient 5xx responses on /cmd. The computer-server can briefly
# return 5xx (e.g. when Traefik temporarily drops the pod from its
# endpoint list, or when the emulator's gRPC subsystem hangs during
# fork/exec). 4xx errors are not retried (client error, won't change).
# Read/transport timeouts are not retried either — the command may
# already be running on the server, and most /cmd actions aren't
# idempotent.
_CMD_MAX_RETRIES = 3
_CMD_RETRY_BACKOFF_S = 0.5 # doubled each retry: 0.5s, 1.0s, 2.0s
class HTTPTransport(Transport):
"""Transport that communicates with computer-server over HTTP POST /cmd (SSE)."""
def __init__(
self,
base_url: str,
*,
api_key: Optional[str] = None,
container_name: Optional[str] = None,
timeout: float = 30.0,
):
"""
Args:
base_url: Base URL of the computer-server, e.g. "http://localhost:8000".
api_key: Optional API key (X-API-Key header) for cloud auth.
container_name: Optional container name (X-Container-Name header) for cloud auth.
timeout: HTTP request timeout in seconds.
"""
self._base_url = base_url.rstrip("/")
self._api_key = api_key
self._container_name = container_name
self._timeout = timeout
self._client: Optional[httpx.AsyncClient] = None
async def connect(self) -> None:
headers: Dict[str, str] = {}
if self._api_key:
headers["X-API-Key"] = self._api_key
headers["Authorization"] = f"Bearer {self._api_key}"
if self._container_name:
headers["X-Container-Name"] = self._container_name
self._client = httpx.AsyncClient(
base_url=self._base_url,
headers=headers,
timeout=self._timeout,
)
async def disconnect(self) -> None:
if self._client:
await self._client.aclose()
self._client = None
async def _cmd(self, command: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Send a command to POST /cmd and parse the SSE response.
Retries on transient 5xx responses (server briefly unavailable,
grpc fork hiccups, Traefik backend-unready). Does not retry on
httpx exceptions — the request may have reached the server and
started running a non-idempotent command.
"""
assert self._client is not None, "Transport not connected"
body = {"command": command}
if params:
body["params"] = params
# When the caller passes a server-side timeout (e.g. push_timeout for
# write_bytes, or timeout for run_command), the server may legitimately
# take that long to respond. Set the httpx read timeout to match so the
# client doesn't drop the connection before the server finishes.
server_timeout = (params or {}).get("timeout")
if server_timeout is not None:
# Add 10s headroom so the server timeout fires before the client one
req_timeout = httpx.Timeout(self._timeout, read=float(server_timeout) + 10)
else:
req_timeout = None # use client default
resp: httpx.Response
for attempt in range(_CMD_MAX_RETRIES):
resp = await self._client.post("/cmd", json=body, timeout=req_timeout)
if resp.status_code < 500 or attempt == _CMD_MAX_RETRIES - 1:
break
backoff = _CMD_RETRY_BACKOFF_S * (2**attempt)
logger.debug(
"[http] /cmd %s returned %d, retrying in %.1fs (attempt %d/%d)",
command,
resp.status_code,
backoff,
attempt + 1,
_CMD_MAX_RETRIES,
)
await asyncio.sleep(backoff)
resp.raise_for_status()
return self._parse_sse(resp.text)
@staticmethod
def _parse_sse(text: str) -> Dict[str, Any]:
"""Extract the first ``data: {...}`` frame from an SSE response.
The server returns one of two failure shapes when ``success`` is
false:
1. Generic handler error — ``{"success": false, "error": "<msg>"}``
2. Shell-command shape — ``{"success": false, "stdout": "...",
"stderr": "...", "return_code": <n>}`` (no ``error`` key)
The old code stringified ``payload.get('error', 'unknown')`` for
both shapes, which turned every shell-command failure into
``Remote error: unknown`` and hid the actual ``stderr`` + exit
code. That masking made ``Command timed out after 10s``,
``UI hierchary dump failed``, and similar concrete failures
indistinguishable from a genuine internal error — the common
pattern where ``await sb.shell.run(cmd)`` returned non-zero
became a debugging dead end.
This rewrite preserves the ``error``-key path verbatim and falls
back to a composite ``return_code=...`` / ``stderr=...`` /
``stdout=...`` string when no ``error`` key is present.
"""
for line in text.splitlines():
if line.startswith("data: "):
payload = json.loads(line[6:])
if isinstance(payload, dict) and not payload.get("success", True):
if "error" in payload:
raise RuntimeError(f"Remote error: {payload['error']}")
# Shell-command shape: surface return_code/stderr/stdout.
parts = []
rc = payload.get("return_code")
if rc is not None:
parts.append(f"return_code={rc}")
stderr = (payload.get("stderr") or "").strip()
if stderr:
parts.append(f"stderr={stderr!r}")
stdout = (payload.get("stdout") or "").strip()
if stdout and not stderr:
# Only surface stdout when there's nothing on
# stderr — saves bloating the message for noisy
# successful-output commands that happened to
# return non-zero.
parts.append(f"stdout={stdout!r}")
detail = ", ".join(parts) or "no detail"
raise RuntimeError(f"Remote error: {detail}")
return payload
raise RuntimeError(f"No SSE data frame in response: {text[:200]}")
async def send(self, action: str, **params: Any) -> Any:
result = await self._cmd(action, params if params else None)
return result.get("result", result)
async def screenshot(self, format: str = "png", quality: int = 95) -> bytes:
params = None if format == "png" else {"format": format, "quality": quality}
result = await self._cmd("screenshot", params)
# computer-server returns {"success": true, "image_data": "..."}
b64 = result.get("image_data", result.get("base64_image", result.get("result", "")))
if isinstance(b64, dict):
b64 = b64.get("image_data", b64.get("base64_image", b64.get("base64", "")))
return base64.b64decode(b64)
async def get_screen_size(self) -> Dict[str, int]:
result = await self._cmd("get_screen_size")
# Flatten nested responses and normalize key names
data = result
if isinstance(data, dict):
# Unwrap nested: {"result": {...}}, {"size": {...}}
data = data.get("size", data.get("result", data))
if isinstance(data, dict):
w = data.get("width") or data.get("screen_width") or data.get("w")
h = data.get("height") or data.get("screen_height") or data.get("h")
if w is not None and h is not None:
return {"width": int(w), "height": int(h)}
raise KeyError(f"Cannot extract screen size from response: {result}")
# ── PTY over dedicated /pty_* routes ────────────────────────────────
async def pty_create(
self,
command: Optional[str] = None,
cols: int = 120,
rows: int = 40,
cwd: Optional[str] = None,
envs: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
assert self._client is not None, "Transport not connected"
body: Dict[str, Any] = {"cols": cols, "rows": rows}
if command is not None:
body["command"] = command
if cwd is not None:
body["cwd"] = cwd
if envs is not None:
body["envs"] = envs
resp = await self._client.post("/pty", json=body)
resp.raise_for_status()
return resp.json()
async def pty_send(self, pid: int, data: str) -> None:
assert self._client is not None, "Transport not connected"
resp = await self._client.post(f"/pty/{pid}/stdin", json={"data": data})
resp.raise_for_status()
async def pty_kill(self, pid: int) -> bool:
assert self._client is not None, "Transport not connected"
resp = await self._client.delete(f"/pty/{pid}")
resp.raise_for_status()
return bool(resp.json().get("killed", True))
async def pty_info(self, pid: int) -> Optional[Dict[str, Any]]:
assert self._client is not None, "Transport not connected"
resp = await self._client.get(f"/pty/{pid}")
if resp.status_code == 404:
return None
resp.raise_for_status()
return resp.json()
async def get_environment(self) -> str:
# computer-server doesn't have a dedicated endpoint; use /status
try:
assert self._client is not None
resp = await self._client.get("/status")
resp.raise_for_status()
data = resp.json()
return data.get("os_type", data.get("platform", "linux"))
except Exception:
return "linux"
@@ -0,0 +1,110 @@
"""LocalTransport — delegates to cua_auto for direct host control.
All cua_auto calls are synchronous; we use asyncio.to_thread() to avoid
blocking the event loop.
"""
from __future__ import annotations
import asyncio
import platform
from typing import Any, Dict
from cua_sandbox.transport.base import Transport
class LocalTransport(Transport):
"""Transport that routes commands directly to cua_auto modules."""
async def connect(self) -> None:
pass # no-op for local
async def disconnect(self) -> None:
pass # no-op for local
async def send(self, action: str, **params: Any) -> Any:
"""Dispatch a named command to the appropriate cua_auto module."""
return await asyncio.to_thread(self._dispatch, action, params)
def _dispatch(self, command: str, params: dict) -> Any:
import cua_auto.clipboard as clipboard
import cua_auto.keyboard as keyboard
import cua_auto.mouse as mouse
import cua_auto.shell as shell
import cua_auto.window as window
dispatch = {
# Mouse
"left_click": lambda p: mouse.click(p["x"], p["y"], p.get("button", "left")),
"right_click": lambda p: mouse.right_click(p["x"], p["y"]),
"double_click": lambda p: mouse.double_click(p["x"], p["y"]),
"move_cursor": lambda p: mouse.move_to(p["x"], p["y"]),
"scroll": lambda p: (
mouse.move_to(p.get("x", 0), p.get("y", 0)),
mouse.scroll(p.get("scroll_x", 0), p.get("scroll_y", 3)),
),
"mouse_down": lambda p: mouse.mouse_down(
p.get("x"), p.get("y"), p.get("button", "left")
),
"mouse_up": lambda p: mouse.mouse_up(p.get("x"), p.get("y"), p.get("button", "left")),
"drag": lambda p: (
mouse.drag(
p["path"][0][0],
p["path"][0][1],
p["path"][-1][0],
p["path"][-1][1],
p.get("button", "left"),
)
if "path" in p
else mouse.drag(
p["start_x"], p["start_y"], p["end_x"], p["end_y"], p.get("button", "left")
)
),
# Keyboard
"type_text": lambda p: keyboard.type_text(p["text"]),
"hotkey": lambda p: keyboard.hotkey(
p["keys"] if isinstance(p["keys"], list) else [p["keys"]]
),
"key_down": lambda p: keyboard.key_down(p["key"]),
"key_up": lambda p: keyboard.key_up(p["key"]),
# Clipboard
"copy_to_clipboard": lambda p: clipboard.get(),
"set_clipboard": lambda p: clipboard.set(p["text"]),
# Shell
"run_command": lambda p: shell.run(p["command"], p.get("timeout", 30)),
# Window
"get_active_window_title": lambda p: window.get_active_window_title(),
}
handler = dispatch.get(command)
if handler is None:
raise ValueError(f"Unknown local command: {command}")
return handler(params)
async def screenshot(self, format: str = "png", quality: int = 95) -> bytes:
png = await asyncio.to_thread(self._screenshot_sync)
from cua_sandbox.transport.base import convert_screenshot
return convert_screenshot(png, format, quality)
def _screenshot_sync(self) -> bytes:
import cua_auto.screen as screen
return screen.screenshot_bytes()
async def get_screen_size(self) -> Dict[str, int]:
return await asyncio.to_thread(self._screen_size_sync)
def _screen_size_sync(self) -> Dict[str, int]:
import cua_auto.screen as screen
w, h = screen.screen_size()
return {"width": w, "height": h}
async def get_environment(self) -> str:
system = platform.system()
if system == "Darwin":
return "mac"
if system == "Windows":
return "windows"
return "linux"
@@ -0,0 +1,75 @@
"""OSWorldTransport — speaks the OSWorld Flask server API (port 5000).
The OSWorld computer-server exposes:
GET /screenshot → raw PNG bytes
POST /screen_size → {"width": ..., "height": ...}
POST /execute → {"command": [...], "shell": false} → {"output": ...}
POST /run_bash_script → {"script": ..., "timeout": ...} → {"status": ..., "output": ..., "error": ..., "returncode": ...}
GET /accessibility → {"AT": ...}
"""
from __future__ import annotations
from typing import Any, Dict, Optional
import httpx
from cua_sandbox.transport.base import Transport
class OSWorldTransport(Transport):
"""Transport for VMs running the OSWorld Flask server (pyautogui-based)."""
def __init__(
self,
base_url: str,
*,
timeout: float = 30.0,
):
self._base_url = base_url.rstrip("/")
self._timeout = timeout
self._client: Optional[httpx.AsyncClient] = None
async def connect(self) -> None:
self._client = httpx.AsyncClient(
base_url=self._base_url,
timeout=self._timeout,
)
async def disconnect(self) -> None:
if self._client:
await self._client.aclose()
self._client = None
async def send(self, action: str, **params: Any) -> Any:
assert self._client is not None, "Transport not connected"
if action in ("execute", "run_bash_script", "run_python"):
resp = await self._client.post(f"/{action}", json=params)
resp.raise_for_status()
return resp.json()
if action == "accessibility":
resp = await self._client.get("/accessibility")
resp.raise_for_status()
return resp.json()
if action == "terminal":
resp = await self._client.get("/terminal")
resp.raise_for_status()
return resp.json()
raise ValueError(f"Unknown action: {action}")
async def screenshot(self, format: str = "png", quality: int = 95) -> bytes:
assert self._client is not None, "Transport not connected"
resp = await self._client.get("/screenshot")
resp.raise_for_status()
from cua_sandbox.transport.base import convert_screenshot
return convert_screenshot(resp.content, format, quality)
async def get_screen_size(self) -> Dict[str, int]:
assert self._client is not None, "Transport not connected"
resp = await self._client.post("/screen_size")
resp.raise_for_status()
data = resp.json()
return {"width": int(data["width"]), "height": int(data["height"])}
async def get_environment(self) -> str:
return "linux"
@@ -0,0 +1,477 @@
"""QMPTransport — control a QEMU VM directly via QMP (no guest agent required).
Uses the QEMU Machine Protocol to provide mouse, keyboard, and screenshot
control without needing computer-server installed inside the guest OS.
This enables sandboxing of stock OS images (e.g. Android-x86, raw Linux ISOs).
QMP commands used:
- screendump → screenshot (PNG via PPM conversion)
- input-send-event → mouse move, click, scroll
- send-key → keyboard input
- query-status → VM status checks
For shell access, this transport uses QEMU Guest Agent (QGA) over virtio-serial
when available, or raises NotImplementedError for raw VMs without an agent.
"""
from __future__ import annotations
import asyncio
import json
import logging
import struct
import tempfile
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from cua_sandbox.transport.base import Transport
logger = logging.getLogger(__name__)
# ── QMP key name mapping ─────────────────────────────────────────────────────
# Maps common key names to QEMU QCode values.
# Full list: https://github.com/qemu/qemu/blob/master/ui/input-keymap-qcode-to-qnum.c
_KEY_MAP: Dict[str, str] = {
# Letters
**{c: c for c in "abcdefghijklmnopqrstuvwxyz"},
# Numbers
**{str(i): str(i) for i in range(10)},
# Special keys
"enter": "ret",
"return": "ret",
"ret": "ret",
"escape": "esc",
"esc": "esc",
"tab": "tab",
"space": "spc",
" ": "spc",
"backspace": "backspace",
"delete": "delete",
"del": "delete",
"insert": "insert",
"ins": "insert",
"home": "home",
"end": "end",
"pageup": "pgup",
"page_up": "pgup",
"pgup": "pgup",
"pagedown": "pgdn",
"page_down": "pgdn",
"pgdn": "pgdn",
"up": "up",
"down": "down",
"left": "left",
"right": "right",
# Modifiers
"shift": "shift",
"ctrl": "ctrl",
"control": "ctrl",
"alt": "alt",
"meta": "meta_l",
"super": "meta_l",
"command": "meta_l",
"cmd": "meta_l",
"win": "meta_l",
# Function keys
**{f"f{i}": f"f{i}" for i in range(1, 13)},
# Punctuation
"-": "minus",
"=": "equal",
"[": "bracket_left",
"]": "bracket_right",
"\\": "backslash",
";": "semicolon",
"'": "apostrophe",
",": "comma",
".": "dot",
"/": "slash",
"`": "grave_accent",
}
# Mouse button mapping
_BTN_MAP: Dict[str, int] = {"left": 0, "middle": 1, "right": 2}
class QMPTransport(Transport):
"""Transport that controls a QEMU VM via QMP socket.
Provides screenshot, mouse, and keyboard control without any guest agent.
Shell commands require QEMU Guest Agent (virtio-serial) in the guest.
"""
def __init__(
self,
qmp_host: str = "127.0.0.1",
qmp_port: int = 4444,
*,
guest_agent_port: Optional[int] = None,
screen_width: int = 1280,
screen_height: int = 720,
environment: str = "linux",
):
self._host = qmp_host
self._port = qmp_port
self._ga_port = guest_agent_port
self._screen_w = screen_width
self._screen_h = screen_height
self._environment = environment
self._reader: Optional[asyncio.StreamReader] = None
self._writer: Optional[asyncio.StreamWriter] = None
self._tmpdir = Path(tempfile.mkdtemp(prefix="cua-qmp-"))
# ── Connection lifecycle ──────────────────────────────────────────────
async def connect(self) -> None:
self._reader, self._writer = await asyncio.open_connection(self._host, self._port)
# QMP greeting — read and discard
await self._read_response()
# Negotiate capabilities
await self._execute("qmp_capabilities")
logger.info(f"QMP connected to {self._host}:{self._port}")
async def disconnect(self) -> None:
if self._writer:
self._writer.close()
try:
await self._writer.wait_closed()
except Exception:
pass
self._writer = None
self._reader = None
# Clean up temp files
import shutil
shutil.rmtree(self._tmpdir, ignore_errors=True)
# ── Low-level QMP protocol ────────────────────────────────────────────
async def _execute(self, command: str, arguments: Optional[Dict] = None) -> Dict:
"""Send a QMP command and return the response."""
assert self._writer and self._reader, "QMP not connected"
msg: Dict[str, Any] = {"execute": command}
if arguments:
msg["arguments"] = arguments
payload = json.dumps(msg).encode() + b"\n"
self._writer.write(payload)
await self._writer.drain()
return await self._read_response()
async def _read_response(self) -> Dict:
"""Read a single QMP JSON response, skipping async events."""
assert self._reader
while True:
line = await self._reader.readline()
if not line:
raise ConnectionError("QMP connection closed")
data = json.loads(line)
# Skip async events ({"event": ...}), return commands/greetings
if "event" not in data:
if "error" in data:
raise RuntimeError(f"QMP error: {data['error']}")
return data
# ── Transport interface ───────────────────────────────────────────────
async def send(self, action: str, **params: Any) -> Any:
"""Dispatch interface actions to QMP commands."""
handler = _ACTION_DISPATCH.get(action)
if handler:
return await handler(self, **params)
raise NotImplementedError(
f"QMP transport does not support action {action!r}. "
f"Supported: {', '.join(sorted(_ACTION_DISPATCH.keys()))}"
)
async def screenshot(self, format: str = "png", quality: int = 95) -> bytes:
"""Capture a screenshot via QMP screendump → PPM → PNG."""
ppm_path = str(self._tmpdir / "screen.ppm")
await self._execute("screendump", {"filename": ppm_path})
# Give QEMU a moment to write the file
await asyncio.sleep(0.1)
ppm_file = Path(ppm_path)
if not ppm_file.exists():
raise RuntimeError(f"screendump failed — {ppm_path} not created")
png_bytes = _ppm_to_png(ppm_file.read_bytes())
# Update screen dimensions from the PPM
w, h = _ppm_dimensions(ppm_file.read_bytes())
if w and h:
self._screen_w = w
self._screen_h = h
from cua_sandbox.transport.base import convert_screenshot
return convert_screenshot(png_bytes, format, quality)
async def get_screen_size(self) -> Dict[str, int]:
return {"width": self._screen_w, "height": self._screen_h}
async def get_environment(self) -> str:
return self._environment
# ── Mouse actions ─────────────────────────────────────────────────────
async def _mouse_move(self, x: int, y: int, **_: Any) -> None:
"""Move the mouse to absolute coordinates via input-send-event."""
await self._execute(
"input-send-event",
{
"events": [
{"type": "abs", "data": {"axis": "x", "value": _abs_coord(x, self._screen_w)}},
{"type": "abs", "data": {"axis": "y", "value": _abs_coord(y, self._screen_h)}},
]
},
)
async def _mouse_click(self, x: int, y: int, button: str = "left", **_: Any) -> None:
"""Move to (x,y) then press+release a mouse button."""
btn = _BTN_MAP.get(button, 0)
await self._execute(
"input-send-event",
{
"events": [
{"type": "abs", "data": {"axis": "x", "value": _abs_coord(x, self._screen_w)}},
{"type": "abs", "data": {"axis": "y", "value": _abs_coord(y, self._screen_h)}},
{"type": "btn", "data": {"down": True, "button": _qmp_btn(btn)}},
{"type": "btn", "data": {"down": False, "button": _qmp_btn(btn)}},
]
},
)
async def _mouse_double_click(self, x: int, y: int, **_: Any) -> None:
await self._mouse_click(x, y, "left")
await asyncio.sleep(0.05)
await self._mouse_click(x, y, "left")
async def _mouse_right_click(self, x: int, y: int, **_: Any) -> None:
await self._mouse_click(x, y, "right")
async def _mouse_down(self, x: int, y: int, button: str = "left", **_: Any) -> None:
btn = _BTN_MAP.get(button, 0)
await self._execute(
"input-send-event",
{
"events": [
{"type": "abs", "data": {"axis": "x", "value": _abs_coord(x, self._screen_w)}},
{"type": "abs", "data": {"axis": "y", "value": _abs_coord(y, self._screen_h)}},
{"type": "btn", "data": {"down": True, "button": _qmp_btn(btn)}},
]
},
)
async def _mouse_up(self, x: int, y: int, button: str = "left", **_: Any) -> None:
btn = _BTN_MAP.get(button, 0)
await self._execute(
"input-send-event",
{
"events": [
{"type": "abs", "data": {"axis": "x", "value": _abs_coord(x, self._screen_w)}},
{"type": "abs", "data": {"axis": "y", "value": _abs_coord(y, self._screen_h)}},
{"type": "btn", "data": {"down": False, "button": _qmp_btn(btn)}},
]
},
)
async def _mouse_scroll(
self, x: int, y: int, scroll_x: int = 0, scroll_y: int = 3, **_: Any
) -> None:
"""Scroll via button 4/5 (up/down) presses."""
# Move to position first
await self._mouse_move(x, y)
# QMP wheel: positive scroll_y = scroll up (button 4), negative = down (button 5)
if scroll_y > 0:
btn_name = "wheel-up"
elif scroll_y < 0:
btn_name = "wheel-down"
else:
return
for _ in range(abs(scroll_y)):
await self._execute(
"input-send-event",
{
"events": [
{"type": "btn", "data": {"down": True, "button": btn_name}},
{"type": "btn", "data": {"down": False, "button": btn_name}},
]
},
)
async def _drag(
self, start_x: int, start_y: int, end_x: int, end_y: int, button: str = "left", **_: Any
) -> None:
await self._mouse_down(start_x, start_y, button)
await asyncio.sleep(0.05)
# Interpolate a few intermediate points for smoother drag
steps = 10
for i in range(1, steps + 1):
ix = start_x + (end_x - start_x) * i // steps
iy = start_y + (end_y - start_y) * i // steps
await self._mouse_move(ix, iy)
await asyncio.sleep(0.01)
await self._mouse_up(end_x, end_y, button)
# ── Keyboard actions ──────────────────────────────────────────────────
async def _type_text(self, text: str, **_: Any) -> None:
"""Type text character by character via send-key."""
for ch in text:
qcode = _KEY_MAP.get(ch.lower())
if qcode is None:
logger.warning(f"QMP: unmapped character {ch!r}, skipping")
continue
keys: List[Dict] = []
if ch.isupper() or ch in '~!@#$%^&*()_+{}|:"<>?':
keys.append({"type": "qcode", "data": "shift"})
keys.append({"type": "qcode", "data": qcode})
await self._execute("send-key", {"keys": keys, "hold-time": 50})
await asyncio.sleep(0.02)
async def _hotkey(self, keys: List[str], **_: Any) -> None:
"""Press a key combination via send-key."""
qcodes = []
for k in keys:
qcode = _KEY_MAP.get(k.lower())
if qcode is None:
raise ValueError(f"Unknown key {k!r}")
qcodes.append({"type": "qcode", "data": qcode})
await self._execute("send-key", {"keys": qcodes, "hold-time": 100})
async def _key_down(self, key: str, **_: Any) -> None:
qcode = _KEY_MAP.get(key.lower())
if qcode is None:
raise ValueError(f"Unknown key {key!r}")
await self._execute(
"input-send-event",
{
"events": [
{
"type": "key",
"data": {"down": True, "key": {"type": "qcode", "data": qcode}},
},
]
},
)
async def _key_up(self, key: str, **_: Any) -> None:
qcode = _KEY_MAP.get(key.lower())
if qcode is None:
raise ValueError(f"Unknown key {key!r}")
await self._execute(
"input-send-event",
{
"events": [
{
"type": "key",
"data": {"down": False, "key": {"type": "qcode", "data": qcode}},
},
]
},
)
# ── Shell (via QEMU Guest Agent if available) ─────────────────────────
async def _run_command(self, command: str, timeout: int = 30, **_: Any) -> Dict:
"""Execute a shell command via QEMU Guest Agent (QGA).
Requires virtio-serial guest agent running inside the VM.
Falls back to a helpful error if QGA is not available.
"""
raise NotImplementedError(
"Shell commands require QEMU Guest Agent (virtio-serial) inside the guest. "
"QMPTransport currently supports screenshot, mouse, and keyboard only. "
"For shell access, use an image with computer-server installed."
)
# ── Action dispatch table ─────────────────────────────────────────────────
_ACTION_DISPATCH: Dict[str, Any] = {
# Mouse
"left_click": QMPTransport._mouse_click,
"right_click": QMPTransport._mouse_right_click,
"double_click": QMPTransport._mouse_double_click,
"move_cursor": QMPTransport._mouse_move,
"scroll": QMPTransport._mouse_scroll,
"mouse_down": QMPTransport._mouse_down,
"mouse_up": QMPTransport._mouse_up,
"drag": QMPTransport._drag,
# Keyboard
"type_text": QMPTransport._type_text,
"hotkey": QMPTransport._hotkey,
"key_down": QMPTransport._key_down,
"key_up": QMPTransport._key_up,
# Shell
"run_command": QMPTransport._run_command,
}
# ── PPM → PNG conversion (pure-Python, no Pillow dependency) ──────────────
def _ppm_dimensions(data: bytes) -> Tuple[int, int]:
"""Parse width and height from a PPM (P6) header."""
try:
# P6\n<width> <height>\n<maxval>\n<pixels>
header_end = data.index(b"\n", data.index(b"\n") + 1)
parts = data[:header_end].split()
return int(parts[1]), int(parts[2])
except (ValueError, IndexError):
return 0, 0
def _ppm_to_png(ppm_data: bytes) -> bytes:
"""Convert a PPM P6 file to PNG using zlib (no external deps)."""
import zlib
# Parse PPM header
idx = 0
# Magic
assert ppm_data[:2] == b"P6", "Not a PPM P6 file"
idx = ppm_data.index(b"\n", idx) + 1
# Skip comments
while ppm_data[idx : idx + 1] == b"#":
idx = ppm_data.index(b"\n", idx) + 1
# Width Height
line_end = ppm_data.index(b"\n", idx)
dims = ppm_data[idx:line_end].split()
width, height = int(dims[0]), int(dims[1])
idx = line_end + 1
# Max value
line_end = ppm_data.index(b"\n", idx)
idx = line_end + 1
# Pixel data (RGB, 3 bytes per pixel)
pixels = ppm_data[idx:]
# Build PNG
def _png_chunk(chunk_type: bytes, data: bytes) -> bytes:
c = chunk_type + data
crc = struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF)
return struct.pack(">I", len(data)) + c + crc
# IHDR
ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) # 8-bit RGB
# IDAT — build raw image data with filter byte 0 per row
raw = bytearray()
stride = width * 3
for y in range(height):
raw.append(0) # filter: None
raw.extend(pixels[y * stride : (y + 1) * stride])
compressed = zlib.compress(bytes(raw))
png = b"\x89PNG\r\n\x1a\n"
png += _png_chunk(b"IHDR", ihdr)
png += _png_chunk(b"IDAT", compressed)
png += _png_chunk(b"IEND", b"")
return png
# ── Helpers ───────────────────────────────────────────────────────────────
def _abs_coord(pixel: int, screen_max: int) -> int:
"""Convert pixel coordinate to QMP absolute input value (032767)."""
return max(0, min(32767, pixel * 32767 // max(screen_max, 1)))
def _qmp_btn(idx: int) -> str:
"""Map button index to QMP button name."""
return {0: "left", 1: "middle", 2: "right"}.get(idx, "left")
@@ -0,0 +1,212 @@
"""SSH transport — shell commands and file transfer over SSH.
Screenshots are not natively supported; pair with VNCSSHTransport for
full desktop interaction over SSH-tunneled VNC.
"""
from __future__ import annotations
import logging
import socket
import threading
from typing import TYPE_CHECKING, Any, Dict, List, Optional
if TYPE_CHECKING:
from cua_sandbox.interfaces.tunnel import TunnelInfo
import paramiko
from cua_sandbox.transport.base import Transport
logger = logging.getLogger(__name__)
class SSHTransport(Transport):
"""Transport that executes commands over SSH using paramiko."""
def __init__(
self,
host: str,
port: int = 22,
username: str = "admin",
password: Optional[str] = "admin",
key_filename: Optional[str] = None,
environment: str = "linux",
):
self._host = host
self._port = port
self._username = username
self._password = password
self._key_filename = key_filename
self._environment = environment
self._client: Optional[paramiko.SSHClient] = None
self._tunnels: List[_SSHTunnel] = []
async def connect(self) -> None:
import asyncio
loop = asyncio.get_event_loop()
self._client = paramiko.SSHClient()
self._client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
await loop.run_in_executor(
None,
lambda: self._client.connect(
self._host,
port=self._port,
username=self._username,
password=self._password,
key_filename=self._key_filename,
timeout=30,
look_for_keys=False,
allow_agent=False,
),
)
logger.info(f"SSH connected to {self._host}:{self._port}")
async def disconnect(self) -> None:
for t in list(self._tunnels):
t.stop()
self._tunnels.clear()
if self._client:
self._client.close()
self._client = None
async def send(self, action: str, **params: Any) -> Any:
import asyncio
if not self._client:
raise RuntimeError("SSH not connected")
if action in ("shell", "run_command"):
command = params.get("command", "")
loop = asyncio.get_event_loop()
_, stdout, stderr = await loop.run_in_executor(
None, lambda: self._client.exec_command(command, timeout=params.get("timeout", 30))
)
out = await loop.run_in_executor(None, stdout.read)
err = await loop.run_in_executor(None, stderr.read)
exit_code = stdout.channel.recv_exit_status()
return {
"stdout": out.decode(errors="replace"),
"stderr": err.decode(errors="replace"),
"returncode": exit_code,
}
raise NotImplementedError(f"SSH transport does not support action: {action}")
async def screenshot(self, format: str = "png", quality: int = 95) -> bytes:
raise NotImplementedError(
"SSH transport does not support screenshots. "
"Use VNCSSHTransport for screenshot support over SSH-tunneled VNC."
)
async def get_screen_size(self) -> Dict[str, int]:
raise NotImplementedError("SSH transport does not support screen size queries.")
async def get_environment(self) -> str:
return self._environment
# ── Tunnel ────────────────────────────────────────────────────────────────
async def forward_tunnel(self, sandbox_port: int) -> "TunnelInfo":
"""Open an SSH local-forward from a free host port to *sandbox_port* on the remote."""
import asyncio
from cua_sandbox.interfaces.tunnel import TunnelInfo
if not self._client:
raise RuntimeError("SSH not connected")
loop = asyncio.get_event_loop()
tunnel = await loop.run_in_executor(
None, lambda: _SSHTunnel.start(self._client, "localhost", sandbox_port)
)
self._tunnels.append(tunnel)
info = TunnelInfo(host="localhost", port=tunnel.local_port, sandbox_port=sandbox_port)
return info
async def close_tunnel(self, info: "TunnelInfo") -> None:
import asyncio
to_close = [t for t in self._tunnels if t.local_port == info.port]
loop = asyncio.get_event_loop()
for t in to_close:
await loop.run_in_executor(None, t.stop)
self._tunnels.remove(t)
class _SSHTunnel:
"""Minimal SSH local-forward: binds a random localhost port, pipes to remote."""
def __init__(self, local_port: int, server_sock: socket.socket):
self.local_port = local_port
self._server_sock = server_sock
self._stop_event = threading.Event()
self._thread = threading.Thread(target=self._serve, daemon=True)
@classmethod
def start(
cls,
ssh_client: paramiko.SSHClient,
remote_host: str,
remote_port: int,
) -> "_SSHTunnel":
server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_sock.bind(("127.0.0.1", 0))
local_port = server_sock.getsockname()[1]
server_sock.listen(5)
server_sock.settimeout(1.0)
tunnel = cls(local_port, server_sock)
tunnel._ssh_client = ssh_client
tunnel._remote_host = remote_host
tunnel._remote_port = remote_port
tunnel._thread.start()
return tunnel
def stop(self) -> None:
self._stop_event.set()
self._server_sock.close()
def _serve(self) -> None:
while not self._stop_event.is_set():
try:
client_sock, _ = self._server_sock.accept()
except OSError:
break
transport = self._ssh_client.get_transport()
if transport is None:
client_sock.close()
break
try:
channel = transport.open_channel(
"direct-tcpip",
(self._remote_host, self._remote_port),
client_sock.getpeername(),
)
except Exception:
client_sock.close()
continue
threading.Thread(target=self._pipe, args=(client_sock, channel), daemon=True).start()
@staticmethod
def _pipe(sock: socket.socket, channel: paramiko.Channel) -> None:
import select
channel.setblocking(False)
sock.setblocking(False)
try:
while True:
r, _, _ = select.select([sock, channel], [], [], 1.0)
if sock in r:
data = sock.recv(4096)
if not data:
break
channel.sendall(data)
if channel in r:
data = channel.recv(4096)
if not data:
break
sock.sendall(data)
finally:
sock.close()
channel.close()
@@ -0,0 +1,107 @@
"""VNC transport — uses vncdotool for screenshots and input over VNC.
Used by the Tart runtime for headless VMs that expose VNC but don't
run computer-server.
"""
from __future__ import annotations
import asyncio
import logging
import tempfile
from pathlib import Path
from typing import Any, Dict, Optional
from cua_sandbox.transport.base import Transport
logger = logging.getLogger(__name__)
class VNCTransport(Transport):
"""Transport that connects to a VM over VNC using vncdotool."""
def __init__(
self,
host: str = "127.0.0.1",
port: int = 5900,
password: Optional[str] = None,
environment: str = "linux",
):
self._host = host
self._port = port
self._password = password
self._environment = environment
self._client: Any = None
async def connect(self) -> None:
from vncdotool import api as vnc_api
loop = asyncio.get_event_loop()
self._client = await loop.run_in_executor(
None,
lambda: vnc_api.connect(
f"{self._host}::{self._port}",
password=self._password,
),
)
logger.info(f"VNC connected to {self._host}:{self._port}")
async def get_display_url(self, *, share: bool = False) -> str:
if share:
raise NotImplementedError("share=True is not supported for local VNC transports.")
return f"vnc://{self._host}:{self._port}"
async def disconnect(self) -> None:
if self._client:
try:
loop = asyncio.get_event_loop()
await asyncio.wait_for(
loop.run_in_executor(None, self._client.disconnect),
timeout=5,
)
except Exception:
pass
self._client = None
# Shut down the Twisted reactor thread used by vncdotool
try:
from vncdotool import api as vnc_api
loop = asyncio.get_event_loop()
await asyncio.wait_for(
loop.run_in_executor(None, vnc_api.shutdown),
timeout=5,
)
except Exception:
pass
async def send(self, action: str, **params: Any) -> Any:
if action == "shell":
raise NotImplementedError("Shell commands not supported over VNC")
raise NotImplementedError(f"VNC transport does not support action: {action}")
async def screenshot(self, format: str = "png", quality: int = 95) -> bytes:
if not self._client:
raise RuntimeError("VNC not connected")
loop = asyncio.get_event_loop()
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
tmp_path = tmp.name
await loop.run_in_executor(None, self._client.captureScreen, tmp_path)
data = Path(tmp_path).read_bytes()
Path(tmp_path).unlink(missing_ok=True)
from cua_sandbox.transport.base import convert_screenshot
return convert_screenshot(data, format, quality)
async def get_screen_size(self) -> Dict[str, int]:
if not self._client:
raise RuntimeError("VNC not connected")
# vncdotool exposes screen dimensions via the client
return {
"width": self._client.screen.width,
"height": self._client.screen.height,
}
async def get_environment(self) -> str:
return self._environment
@@ -0,0 +1,157 @@
"""VNC + SSH transport — screenshots via VNC, shell commands via SSH.
VNC and SSH connect independently to the VM. No tunneling.
"""
from __future__ import annotations
import asyncio
import logging
import tempfile
from pathlib import Path
from typing import Any, Dict, Optional
import paramiko
from cua_sandbox.transport.base import Transport
logger = logging.getLogger(__name__)
class VNCSSHTransport(Transport):
"""Transport using SSH for commands and VNC for screenshots."""
def __init__(
self,
*,
ssh_host: str,
ssh_port: int = 22,
ssh_username: str = "admin",
ssh_password: Optional[str] = "admin",
ssh_key_filename: Optional[str] = None,
vnc_host: str = "127.0.0.1",
vnc_port: int = 5900,
vnc_password: Optional[str] = None,
environment: str = "linux",
):
self._ssh_host = ssh_host
self._ssh_port = ssh_port
self._ssh_username = ssh_username
self._ssh_password = ssh_password
self._ssh_key_filename = ssh_key_filename
self._vnc_host = vnc_host
self._vnc_port = vnc_port
self._vnc_password = vnc_password
self._environment = environment
self._ssh_client: Optional[paramiko.SSHClient] = None
self._vnc_client: Any = None
async def connect(self) -> None:
loop = asyncio.get_event_loop()
# Connect SSH
self._ssh_client = paramiko.SSHClient()
self._ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
await loop.run_in_executor(
None,
lambda: self._ssh_client.connect(
self._ssh_host,
port=self._ssh_port,
username=self._ssh_username,
password=self._ssh_password,
key_filename=self._ssh_key_filename,
timeout=30,
look_for_keys=False,
allow_agent=False,
),
)
logger.info(f"SSH connected to {self._ssh_host}:{self._ssh_port}")
# Connect VNC directly
from vncdotool import api as vnc_api
self._vnc_client = await loop.run_in_executor(
None,
lambda: vnc_api.connect(
f"{self._vnc_host}::{self._vnc_port}",
password=self._vnc_password,
),
)
logger.info(f"VNC connected to {self._vnc_host}:{self._vnc_port}")
async def get_display_url(self, *, share: bool = False) -> str:
if share:
raise NotImplementedError("share=True is not supported for local VNC transports.")
return f"vnc://{self._vnc_host}:{self._vnc_port}"
async def disconnect(self) -> None:
if self._vnc_client:
try:
loop = asyncio.get_event_loop()
await asyncio.wait_for(
loop.run_in_executor(None, self._vnc_client.disconnect),
timeout=5,
)
except Exception:
pass
self._vnc_client = None
# Shut down the Twisted reactor thread used by vncdotool
try:
from vncdotool import api as vnc_api
loop = asyncio.get_event_loop()
await asyncio.wait_for(
loop.run_in_executor(None, vnc_api.shutdown),
timeout=5,
)
except Exception:
pass
if self._ssh_client:
self._ssh_client.close()
self._ssh_client = None
async def send(self, action: str, **params: Any) -> Any:
if not self._ssh_client:
raise RuntimeError("SSH not connected")
if action in ("shell", "run_command"):
command = params.get("command", "")
loop = asyncio.get_event_loop()
_, stdout, stderr = await loop.run_in_executor(
None,
lambda: self._ssh_client.exec_command(command, timeout=params.get("timeout", 30)),
)
out = await loop.run_in_executor(None, stdout.read)
err = await loop.run_in_executor(None, stderr.read)
exit_code = stdout.channel.recv_exit_status()
return {
"stdout": out.decode(errors="replace"),
"stderr": err.decode(errors="replace"),
"returncode": exit_code,
}
raise NotImplementedError(f"VNCSSHTransport does not support action: {action}")
async def screenshot(self, format: str = "png", quality: int = 95) -> bytes:
if not self._vnc_client:
raise RuntimeError("VNC not connected")
loop = asyncio.get_event_loop()
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
tmp_path = tmp.name
await loop.run_in_executor(None, self._vnc_client.captureScreen, tmp_path)
data = Path(tmp_path).read_bytes()
Path(tmp_path).unlink(missing_ok=True)
from cua_sandbox.transport.base import convert_screenshot
return convert_screenshot(data, format, quality)
async def get_screen_size(self) -> Dict[str, int]:
if not self._vnc_client:
raise RuntimeError("VNC not connected")
return {
"width": self._vnc_client.screen.width,
"height": self._vnc_client.screen.height,
}
async def get_environment(self) -> str:
return self._environment
@@ -0,0 +1,71 @@
"""WebSocketTransport — connects to a computer-server instance via WebSocket.
The computer-server exposes a WebSocket endpoint that accepts JSON commands
and returns JSON responses. Screenshots are returned as base64-encoded PNG.
"""
from __future__ import annotations
import base64
import json
from typing import Any, Dict, Optional
import websockets
from cua_sandbox.transport.base import Transport
from websockets.asyncio.client import ClientConnection
class WebSocketTransport(Transport):
"""Transport that communicates with computer-server over WebSocket."""
def __init__(self, url: str, api_key: Optional[str] = None):
"""
Args:
url: WebSocket URL, e.g. "ws://localhost:8000/ws"
api_key: Optional API key for authentication.
"""
self._url = url
self._api_key = api_key
self._ws: Optional[ClientConnection] = None
async def connect(self) -> None:
headers = {}
if self._api_key:
headers["Authorization"] = f"Bearer {self._api_key}"
self._ws = await websockets.connect(self._url, additional_headers=headers)
async def disconnect(self) -> None:
if self._ws:
await self._ws.close()
self._ws = None
async def _request(self, payload: dict) -> Any:
assert self._ws is not None, "Transport not connected"
await self._ws.send(json.dumps(payload))
raw = await self._ws.recv()
return json.loads(raw)
async def send(self, action: str, **params: Any) -> Any:
resp = await self._request({"command": action, **params})
if isinstance(resp, dict) and resp.get("error"):
raise RuntimeError(f"Remote error: {resp['error']}")
return resp.get("result") if isinstance(resp, dict) else resp
async def screenshot(self, format: str = "png", quality: int = 95) -> bytes:
resp = await self._request({"command": "screenshot"})
b64 = resp.get("result", resp.get("screenshot", ""))
if isinstance(b64, dict):
b64 = b64.get("base64", "")
png = base64.b64decode(b64)
from cua_sandbox.transport.base import convert_screenshot
return convert_screenshot(png, format, quality)
async def get_screen_size(self) -> Dict[str, int]:
resp = await self._request({"command": "get_screen_size"})
result = resp.get("result", resp)
return {"width": result["width"], "height": result["height"]}
async def get_environment(self) -> str:
resp = await self._request({"command": "get_environment"})
return resp.get("result", "linux")
+98
View File
@@ -0,0 +1,98 @@
[project]
name = "cua-sandbox"
version = "0.1.17"
description = "CUA Sandbox — ephemeral and persistent sandboxed computer environments"
readme = "README.md"
license = "MIT"
authors = [
{ name = "TryCua", email = "hello@trycua.com" }
]
keywords = [
"sandbox",
"computer-use",
"automation",
"vm",
"container",
]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Software Development :: Libraries",
]
requires-python = ">=3.11,<3.14"
dependencies = [
"cua-core>=0.3.0,<0.4.0",
"cua-auto>=0.1.2",
"websockets>=12.0",
"httpx>=0.27.0",
"oras>=0.2.40",
"vncdotool>=1.2.0",
"paramiko>=5.0.0",
"grpcio==1.78.0",
"protobuf==6.33.6",
"pycdlib>=1.14.0",
]
[project.optional-dependencies]
auth = [
"webbrowser-open>=0.0.1",
]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"ruff>=0.1.0",
]
[project.urls]
Homepage = "https://github.com/trycua/cua"
Repository = "https://github.com/trycua/cua"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build]
include = [
"cua_sandbox/**",
"README.md",
"LICENSE",
]
exclude = [
"cua_sandbox/**/__pycache__",
]
[tool.hatch.build.targets.wheel]
packages = ["cua_sandbox"]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "W"]
ignore = ["E501"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = [
"tests",
"../../../tests/integration/sandbox_sdk",
"../../../tests/integration/sandbox_cli",
]
[dependency-groups]
dev = [
"cua-agent>=0.8.0",
"pytest>=9.0.2",
"pytest-asyncio>=1.3.0",
]
[tool.uv.sources]
cua-agent = { path = "../agent", editable = true }
+153
View File
@@ -0,0 +1,153 @@
"""Shared fixtures for cua-sandbox integration tests.
Each transport/runtime is exposed as a pytest fixture. Tests that need a
specific backend request the fixture by name; parametrized tests pull from
all available backends.
Environment variables control which backends are exercised:
CUA_TEST_LOCAL=1 Enable localhost/local-sandbox tests (default: on)
CUA_TEST_WS_URL=ws://... Enable WebSocket transport tests
CUA_TEST_HTTP_URL=http://... Enable HTTP transport tests
CUA_TEST_API_KEY=sk-... API key for remote transports
CUA_TEST_CONTAINER_NAME=... Container name for HTTP cloud auth
"""
from __future__ import annotations
import os
import pytest
import pytest_asyncio
from cua_sandbox.localhost import Localhost
from cua_sandbox.sandbox import Sandbox
from cua_sandbox.transport.http import HTTPTransport
from cua_sandbox.transport.local import LocalTransport
from cua_sandbox.transport.websocket import WebSocketTransport
# ---------------------------------------------------------------------------
# Helper: read env config
# ---------------------------------------------------------------------------
def _env_bool(key: str, default: bool = False) -> bool:
val = os.environ.get(key, "")
if not val:
return default
return val.lower() in ("1", "true", "yes")
LOCAL_ENABLED = _env_bool("CUA_TEST_LOCAL", default=True)
WS_URL = os.environ.get("CUA_TEST_WS_URL")
HTTP_URL = os.environ.get("CUA_TEST_HTTP_URL")
API_KEY = os.environ.get("CUA_TEST_API_KEY")
CONTAINER_NAME = os.environ.get("CUA_TEST_CONTAINER_NAME")
# ---------------------------------------------------------------------------
# Transport fixtures
# ---------------------------------------------------------------------------
@pytest_asyncio.fixture
async def local_transport():
t = LocalTransport()
await t.connect()
yield t
await t.disconnect()
@pytest_asyncio.fixture
async def ws_transport():
if not WS_URL:
pytest.skip("CUA_TEST_WS_URL not set")
t = WebSocketTransport(WS_URL, api_key=API_KEY)
await t.connect()
yield t
await t.disconnect()
@pytest_asyncio.fixture
async def http_transport():
if not HTTP_URL:
pytest.skip("CUA_TEST_HTTP_URL not set")
t = HTTPTransport(HTTP_URL, api_key=API_KEY, container_name=CONTAINER_NAME)
await t.connect()
yield t
await t.disconnect()
# ---------------------------------------------------------------------------
# Sandbox fixtures (one per transport)
# ---------------------------------------------------------------------------
@pytest_asyncio.fixture
async def local_sandbox():
if not LOCAL_ENABLED:
pytest.skip("CUA_TEST_LOCAL disabled")
async with Localhost.connect() as host:
yield host
@pytest_asyncio.fixture
async def ws_sandbox():
if not WS_URL:
pytest.skip("CUA_TEST_WS_URL not set")
sb = await Sandbox._create(ws_url=WS_URL, api_key=API_KEY, name="test-ws")
yield sb
await sb.disconnect()
@pytest_asyncio.fixture
async def http_sandbox():
if not HTTP_URL:
pytest.skip("CUA_TEST_HTTP_URL not set")
sb = await Sandbox._create(
http_url=HTTP_URL,
api_key=API_KEY,
container_name=CONTAINER_NAME,
name="test-http",
)
yield sb
await sb.disconnect()
@pytest_asyncio.fixture
async def localhost_instance():
if not LOCAL_ENABLED:
pytest.skip("CUA_TEST_LOCAL disabled")
async with Localhost.connect() as host:
yield host
# ---------------------------------------------------------------------------
# Parametrized "any sandbox" fixture — runs test against every available backend
# ---------------------------------------------------------------------------
def _sandbox_params():
params = []
if LOCAL_ENABLED:
params.append("local_sandbox")
if WS_URL:
params.append("ws_sandbox")
if HTTP_URL:
params.append("http_sandbox")
if not params:
params.append("local_sandbox") # fallback
return params
@pytest.fixture(params=_sandbox_params())
def any_sandbox_name(request):
"""Returns the fixture name; used by any_sandbox."""
return request.param
@pytest_asyncio.fixture
async def any_sandbox(any_sandbox_name, request):
"""Yields a Sandbox connected via whichever transport is being parametrized."""
# Dynamically request the named fixture
sb = request.getfixturevalue(any_sandbox_name)
return sb
@@ -0,0 +1,476 @@
"""
Android multi-touch integration tests.
Verifies the full touch action space — single-touch gestures, SDK pinch helpers,
and true two-finger MT Protocol B injection — against the TouchTest APK running
on an Android VM.
Layout
──────
TestAndroidMultitouchLocal bare-metal AndroidEmulatorRuntime via Sandbox.ephemeral()
TestAndroidMultitouchCloud cloud Android VM via Sandbox.ephemeral()
Usage
─────
# Local only (default):
pytest tests/test_android_multitouch.py -v
# Cloud (once implemented):
CUA_TEST_API_KEY=sk-... pytest tests/test_android_multitouch.py -v -k cloud
Environment variables
─────────────────────
CUA_ANDROID_TEST_APK path or URL to a pre-built TouchTest debug APK
default: latest release from
https://github.com/trycua/android-touch-test-app
CUA_TEST_API_KEY Anthropic API key (cloud tests only)
CUA_ANDROID_API_LEVEL Android API level to boot (default: 34)
CUA_ANDROID_AVD_NAME AVD name (default: cua-multitouch-test)
APK source
──────────
The TouchTest APK is built and released from:
https://github.com/trycua/android-touch-test-app
Latest release download:
https://github.com/trycua/android-touch-test-app/releases/latest/download/app-debug.apk
"""
from __future__ import annotations
import asyncio
import json
import os
import re
import urllib.request
from pathlib import Path
import pytest
import pytest_asyncio
from cua_sandbox.image import Image
from cua_sandbox.runtime.android_emulator import AndroidEmulatorRuntime
from cua_sandbox.runtime.compat import skip_if_unsupported
from cua_sandbox.sandbox import Sandbox
# ── Config ─────────────────────────────────────────────────────────────────
_APK_RELEASE_URL = (
"https://github.com/trycua/android-touch-test-app" "/releases/latest/download/app-debug.apk"
)
_APK_ENV = os.environ.get("CUA_ANDROID_TEST_APK", "")
# If env var is a local path use it directly; otherwise download from releases.
_APK_PATH = Path(_APK_ENV) if _APK_ENV and not _APK_ENV.startswith("http") else None
_APK_PACKAGE = "com.cuatest.touchtest"
_APK_ACTIVITY = "com.cuatest.touchtest/.MainActivity"
_LOG_TAG = "TouchTest"
_RESET_ACTION = "com.cuatest.touchtest.RESET_LOG"
_API_LEVEL = int(os.environ.get("CUA_ANDROID_API_LEVEL", "34"))
_AVD_NAME = os.environ.get("CUA_ANDROID_AVD_NAME", "cua-multitouch-test")
_API_KEY = os.environ.get("CUA_TEST_API_KEY")
_SETTLE_S = 0.6
_LAUNCH_WAIT_S = 3.0
# ── Helpers ────────────────────────────────────────────────────────────────
def _get_apk() -> Path:
"""Return path to the TouchTest APK, downloading from GitHub Releases if needed."""
if _APK_PATH is not None:
if _APK_PATH.exists():
return _APK_PATH
raise FileNotFoundError(f"CUA_ANDROID_TEST_APK set but file not found: {_APK_PATH}")
# Download from latest release
cache = Path("/tmp/cua-touch-test-app-debug.apk")
if not cache.exists():
url = os.environ.get("CUA_ANDROID_TEST_APK", _APK_RELEASE_URL)
print(f"\n[setup] Downloading TouchTest APK from {url}")
urllib.request.urlretrieve(url, cache)
return cache
def _parse_touch_events(logcat_stdout: str) -> list[dict]:
events = []
for line in logcat_stdout.splitlines():
m = re.search(r"TouchTest:\s+(\{.*\})", line)
if m:
try:
obj = json.loads(m.group(1))
if "action" in obj:
events.append(obj)
except json.JSONDecodeError:
pass
return events
def _max_pointer_count(events: list[dict]) -> int:
return max((e.get("pointer_count", 0) for e in events), default=0)
def _has_action(events: list[dict], action: str) -> bool:
return any(e.get("action") == action for e in events)
async def _read_events(sb: Sandbox) -> list[dict]:
result = await sb.shell.run(f"logcat -d -s {_LOG_TAG}", timeout=10)
return _parse_touch_events(result.stdout)
async def _reset(sb: Sandbox) -> None:
await sb.shell.run("logcat -c")
await sb.shell.run(f"am broadcast -a {_RESET_ACTION}")
await asyncio.sleep(0.2)
# ── Session-scoped event loop (required for session-scoped async fixtures) ──
@pytest_asyncio.fixture(scope="session")
def event_loop():
loop = asyncio.new_event_loop()
yield loop
loop.close()
# ── Session fixture: one emulator for the entire local test run ─────────────
@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def local_android_sb():
"""
Boot a single AndroidEmulatorRuntime via Sandbox.ephemeral(), install the TouchTest
APK, escalate to root, launch the app, and yield the ready Sandbox.
Shared across all local tests so we only pay the boot cost once.
The compatibility gate lives here (not in the autouse reset fixture) so that
cloud tests, which have their own API-key gate and don't depend on a local
emulator, are not affected when local Android support is absent.
"""
skip_if_unsupported(Image.android(str(_API_LEVEL)))
apk = _get_apk()
runtime = AndroidEmulatorRuntime(
api_level=_API_LEVEL,
memory_mb=4096,
headless=True,
no_boot_anim=True,
)
image = Image.android(str(_API_LEVEL)).apk_install(str(apk))
async with Sandbox.ephemeral(image, runtime=runtime, name=_AVD_NAME) as sb:
# Launch app
await sb.shell.run(f"am start -n {_APK_ACTIVITY}")
await asyncio.sleep(_LAUNCH_WAIT_S)
yield sb
@pytest_asyncio.fixture(autouse=True)
async def _reset_between_tests(request):
"""Clear logcat + app event counter before every local test.
Resolves local_android_sb lazily so that cloud tests (which don't request
the local emulator fixture) don't trigger emulator boot or the compat skip.
"""
if "local_android_sb" not in request.fixturenames:
yield
return
sb = request.getfixturevalue("local_android_sb")
await _reset(sb)
yield
# ══════════════════════════════════════════════════════════════════════════════
# Shared test logic (mixin)
# ══════════════════════════════════════════════════════════════════════════════
class _MultitouchTests:
"""
Full touch action suite. Subclasses must expose a ``sb`` fixture that
yields a ready Sandbox with the TouchTest APK installed and running.
"""
# ── Single-touch ──────────────────────────────────────────────────────
async def test_tap(self, sb: Sandbox):
w, h = await sb.get_dimensions()
await sb.mobile.tap(w // 2, h // 2)
await asyncio.sleep(_SETTLE_S)
te = await _read_events(sb)
assert te, "No touch events received"
assert _has_action(te, "ACTION_DOWN")
assert _has_action(te, "ACTION_UP")
assert _max_pointer_count(te) == 1
async def test_long_press(self, sb: Sandbox):
w, h = await sb.get_dimensions()
await sb.mobile.long_press(w // 2, h // 2, duration_ms=800)
await asyncio.sleep(_SETTLE_S)
te = await _read_events(sb)
assert _has_action(te, "ACTION_DOWN")
assert _has_action(te, "ACTION_UP")
assert _max_pointer_count(te) == 1
async def test_double_tap(self, sb: Sandbox):
w, h = await sb.get_dimensions()
await sb.mobile.double_tap(w // 2, h // 2, delay=0.12)
await asyncio.sleep(_SETTLE_S)
te = await _read_events(sb)
downs = [e for e in te if e.get("action") == "ACTION_DOWN"]
ups = [e for e in te if e.get("action") == "ACTION_UP"]
assert len(downs) >= 2, f"Expected 2 ACTION_DOWNs, got {len(downs)}"
assert len(ups) >= 2, f"Expected 2 ACTION_UPs, got {len(ups)}"
assert _max_pointer_count(te) == 1
async def test_swipe(self, sb: Sandbox):
w, h = await sb.get_dimensions()
await sb.mobile.swipe(w // 2, h * 3 // 4, w // 2, h // 4, duration_ms=300)
await asyncio.sleep(_SETTLE_S)
te = await _read_events(sb)
assert _has_action(te, "ACTION_DOWN")
assert _has_action(te, "ACTION_MOVE"), "Swipe produced no MOVE events"
assert _has_action(te, "ACTION_UP")
assert _max_pointer_count(te) == 1
async def test_fling(self, sb: Sandbox):
w, h = await sb.get_dimensions()
await sb.mobile.fling(w // 2, h * 3 // 4, w // 2, h // 4)
await asyncio.sleep(_SETTLE_S)
te = await _read_events(sb)
assert _has_action(te, "ACTION_DOWN")
assert _has_action(te, "ACTION_UP")
assert _max_pointer_count(te) == 1
async def test_scroll_up(self, sb: Sandbox):
w, h = await sb.get_dimensions()
await sb.mobile.scroll_up(w // 2, h // 2, distance=400, duration_ms=300)
await asyncio.sleep(_SETTLE_S)
te = await _read_events(sb)
assert _has_action(te, "ACTION_DOWN") and _has_action(te, "ACTION_UP")
async def test_scroll_down(self, sb: Sandbox):
w, h = await sb.get_dimensions()
await sb.mobile.scroll_down(w // 2, h // 2, distance=400, duration_ms=300)
await asyncio.sleep(_SETTLE_S)
te = await _read_events(sb)
assert _has_action(te, "ACTION_DOWN") and _has_action(te, "ACTION_UP")
async def test_scroll_left(self, sb: Sandbox):
w, h = await sb.get_dimensions()
await sb.mobile.scroll_left(w // 2, h // 2, distance=300, duration_ms=300)
await asyncio.sleep(_SETTLE_S)
te = await _read_events(sb)
assert _has_action(te, "ACTION_DOWN") and _has_action(te, "ACTION_UP")
async def test_scroll_right(self, sb: Sandbox):
w, h = await sb.get_dimensions()
await sb.mobile.scroll_right(w // 2, h // 2, distance=300, duration_ms=300)
await asyncio.sleep(_SETTLE_S)
te = await _read_events(sb)
assert _has_action(te, "ACTION_DOWN") and _has_action(te, "ACTION_UP")
# ── SDK pinch (MT Protocol B via sendevent) ────────────────────────────
async def test_pinch_in(self, sb: Sandbox):
"""sb.mobile.pinch_in delivers genuine pointer_count == 2 events."""
w, h = await sb.get_dimensions()
await sb.mobile.pinch_in(w // 2, h // 2, spread=200, duration_ms=400)
await asyncio.sleep(_SETTLE_S)
te = await _read_events(sb)
assert te, "No events received"
assert (
_max_pointer_count(te) >= 2
), f"pinch_in: expected pointer_count >= 2, got {_max_pointer_count(te)}"
assert _has_action(
te, "ACTION_POINTER_DOWN"
), f"Missing ACTION_POINTER_DOWN — actions: {[e.get('action') for e in te]}"
async def test_pinch_out(self, sb: Sandbox):
"""sb.mobile.pinch_out delivers genuine pointer_count == 2 events."""
w, h = await sb.get_dimensions()
await sb.mobile.pinch_out(w // 2, h // 2, spread=200, duration_ms=400)
await asyncio.sleep(_SETTLE_S)
te = await _read_events(sb)
assert te, "No events received"
assert (
_max_pointer_count(te) >= 2
), f"pinch_out: expected pointer_count >= 2, got {_max_pointer_count(te)}"
assert _has_action(te, "ACTION_POINTER_DOWN")
# ── SDK gesture() — arbitrary multi-finger paths ──────────────────────
async def test_gesture_pinch_out(self, sb: Sandbox):
"""sb.mobile.gesture() with two finger paths produces pointer_count == 2."""
w, h = await sb.get_dimensions()
cx, cy, spread = w // 2, h // 2, 200
await sb.mobile.gesture(
(cx - 20, cy),
(cx - spread, cy), # finger 0: start → end
(cx + 20, cy),
(cx + spread, cy), # finger 1: start → end
)
await asyncio.sleep(_SETTLE_S)
te = await _read_events(sb)
assert te, "No events received from gesture()"
assert _max_pointer_count(te) >= 2
assert _has_action(te, "ACTION_POINTER_DOWN")
async def test_gesture_two_finger_swipe(self, sb: Sandbox):
"""Two parallel fingers moving down together."""
w, h = await sb.get_dimensions()
offset = 150
await sb.mobile.gesture(
(w // 2 - offset, h // 4),
(w // 2 - offset, h * 3 // 4),
(w // 2 + offset, h // 4),
(w // 2 + offset, h * 3 // 4),
)
await asyncio.sleep(_SETTLE_S)
te = await _read_events(sb)
assert te, "No events received"
move_two = [
e for e in te if e.get("action") == "ACTION_MOVE" and e.get("pointer_count", 0) >= 2
]
assert move_two, "No ACTION_MOVE events with pointer_count >= 2"
# ── True multi-touch via MT Protocol B (server-side injection) ───────────
async def test_true_multitouch_pinch_in(self, sb: Sandbox):
"""MT Protocol B pinch-in via the multitouch_gesture server action."""
w, h = await sb.get_dimensions()
cx, cy, spread = w // 2, h // 2, 250
await sb.mobile.gesture(
(cx - spread, cy),
(cx - 30, cy),
(cx + spread, cy),
(cx + 30, cy),
)
await asyncio.sleep(_SETTLE_S)
te = await _read_events(sb)
assert te, "No touch events"
assert _max_pointer_count(te) >= 2
assert _has_action(te, "ACTION_POINTER_DOWN")
async def test_true_multitouch_pinch_out(self, sb: Sandbox):
"""MT Protocol B pinch-out via the multitouch_gesture server action."""
w, h = await sb.get_dimensions()
cx, cy, spread = w // 2, h // 2, 250
await sb.mobile.gesture(
(cx - 30, cy),
(cx - spread, cy),
(cx + 30, cy),
(cx + spread, cy),
)
await asyncio.sleep(_SETTLE_S)
te = await _read_events(sb)
assert te, "No touch events"
assert _max_pointer_count(te) >= 2
async def test_true_multitouch_two_finger_swipe(self, sb: Sandbox):
"""Two parallel fingers moving in the same direction."""
w, h = await sb.get_dimensions()
offset = 150
await sb.mobile.gesture(
(w // 2 - offset, h * 3 // 4),
(w // 2 - offset, h // 4),
(w // 2 + offset, h * 3 // 4),
(w // 2 + offset, h // 4),
duration_ms=300,
steps=15,
)
await asyncio.sleep(_SETTLE_S)
te = await _read_events(sb)
assert te, "No touch events"
move_with_two = [
e for e in te if e.get("action") == "ACTION_MOVE" and e.get("pointer_count", 0) >= 2
]
assert move_with_two, "No ACTION_MOVE events with pointer_count >= 2"
async def test_pointer_ids_are_distinct(self, sb: Sandbox):
"""Each finger in a two-finger gesture must carry a unique pointer id."""
w, h = await sb.get_dimensions()
cx, cy = w // 2, h // 2
await sb.mobile.gesture(
(cx - 200, cy),
(cx - 50, cy),
(cx + 200, cy),
(cx + 50, cy),
steps=8,
)
await asyncio.sleep(_SETTLE_S)
te = await _read_events(sb)
two_ptr = [e for e in te if e.get("pointer_count", 0) >= 2]
assert two_ptr, "No two-pointer events found"
for ev in two_ptr:
ids = {p["id"] for p in ev.get("pointers", [])}
assert len(ids) >= 2, f"Duplicate pointer ids in event: {ev}"
# ══════════════════════════════════════════════════════════════════════════════
# Local tests
# ══════════════════════════════════════════════════════════════════════════════
@pytest.mark.asyncio
class TestAndroidMultitouchLocal(_MultitouchTests):
"""Full touch action suite against a bare-metal AndroidEmulatorRuntime."""
@pytest_asyncio.fixture(loop_scope="session")
async def sb(self, local_android_sb: Sandbox):
await _reset(local_android_sb)
return local_android_sb
# ══════════════════════════════════════════════════════════════════════════════
# Cloud tests
# ══════════════════════════════════════════════════════════════════════════════
skip_no_api_key = pytest.mark.skipif(not _API_KEY, reason="CUA_TEST_API_KEY not set")
# Note: cloud Android tests are gated by API key only — hardware compat is the cloud's concern.
@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def cloud_android_sb():
"""
Spin up an ephemeral cloud Android VM, install the TouchTest APK, escalate
to root, launch the app, and yield the ready Sandbox.
Shared across all cloud tests so we only pay the boot cost once.
"""
if not _API_KEY:
pytest.skip("CUA_TEST_API_KEY not set — cloud tests skipped")
image = Image.android("14").apk_install(_APK_RELEASE_URL)
async with Sandbox.ephemeral(image, api_key=_API_KEY) as sb:
# Launch app
await sb.shell.run(f"am start -n {_APK_ACTIVITY}")
await asyncio.sleep(_LAUNCH_WAIT_S)
yield sb
@pytest.mark.asyncio(loop_scope="session")
@pytest.mark.skipif(not _API_KEY, reason="CUA_TEST_API_KEY not set")
class TestAndroidMultitouchCloud(_MultitouchTests):
"""Full touch action suite against a cloud-hosted Android VM."""
@pytest_asyncio.fixture(loop_scope="session")
async def sb(self, cloud_android_sb: Sandbox):
await _reset(cloud_android_sb)
return cloud_android_sb
+124
View File
@@ -0,0 +1,124 @@
"""Integration tests — cloud sandbox via CUA API.
CUA_API_KEY=sk-... pytest tests/test_cloud.py -v -s
Requires a running cloud VM. Set CUA_TEST_CLOUD_VM_NAME to the VM name.
"""
from __future__ import annotations
import os
import pytest
from cua_sandbox import Image, Sandbox
pytestmark = pytest.mark.asyncio
API_KEY = os.environ.get("CUA_API_KEY")
VM_NAME = os.environ.get("CUA_TEST_CLOUD_VM_NAME", "steady-bluebird")
skip_no_key = pytest.mark.skipif(not API_KEY, reason="CUA_API_KEY not set")
@skip_no_key
async def test_cloud_connect_by_name():
"""Connect to an existing cloud VM by name and take a screenshot."""
sb = await Sandbox.connect(VM_NAME, api_key=API_KEY)
screenshot = await sb.screenshot()
assert screenshot[:4] == b"\x89PNG"
assert len(screenshot) > 1000
await sb.disconnect()
@skip_no_key
async def test_cloud_shell():
"""Run a shell command on a cloud VM."""
sb = await Sandbox.connect(VM_NAME, api_key=API_KEY)
result = await sb.shell.run("echo hello-cloud")
assert result.success
assert "hello-cloud" in result.stdout
await sb.disconnect()
@skip_no_key
async def test_cloud_screen_size():
"""Get screen dimensions from a cloud VM."""
sb = await Sandbox.connect(VM_NAME, api_key=API_KEY)
w, h = await sb.get_dimensions()
assert w > 0
assert h > 0
await sb.disconnect()
@skip_no_key
async def test_cloud_keyboard_mouse():
"""Basic keyboard and mouse operations on a cloud VM."""
sb = await Sandbox.connect(VM_NAME, api_key=API_KEY)
await sb.mouse.move(100, 100)
await sb.mouse.click(100, 100)
await sb.keyboard.type("hello")
await sb.disconnect()
@skip_no_key
async def test_cloud_environment():
"""Get environment info from a cloud VM."""
sb = await Sandbox.connect(VM_NAME, api_key=API_KEY)
env = await sb.get_environment()
assert env in ("windows", "mac", "linux", "browser")
await sb.disconnect()
async def test_cloud_no_api_key_errors():
"""Connecting with no API key gives a clear error."""
old = os.environ.pop("CUA_API_KEY", None)
try:
with pytest.raises(ValueError, match="No CUA API key found"):
await Sandbox.connect("anything")
finally:
if old:
os.environ["CUA_API_KEY"] = old
async def test_cloud_no_image_no_name_errors():
"""Creating without an image raises a clear error."""
with pytest.raises((ValueError, TypeError)):
await Sandbox._create(api_key="sk-test-fake-key")
@skip_no_key
async def test_cloud_ephemeral_linux():
"""Create an ephemeral cloud Linux VM, use it, and destroy on exit."""
async with Sandbox.ephemeral(Image.linux(), api_key=API_KEY) as sb:
assert sb.name is not None
screenshot = await sb.screenshot()
assert screenshot[:4] == b"\x89PNG"
assert len(screenshot) > 1000
result = await sb.shell.run("echo ephemeral-test")
assert result.success
assert "ephemeral-test" in result.stdout
@skip_no_key
async def test_cloud_ephemeral_android():
"""Create an ephemeral Android cloud VM, verify screenshot and display URL."""
async with Sandbox.ephemeral(Image.android("14"), api_key=API_KEY) as sb:
assert sb.name is not None
screenshot = await sb.screenshot()
assert screenshot[:4] == b"\x89PNG"
assert len(screenshot) > 1000
env = await sb.get_environment()
assert env == "android"
display_url = await sb.get_display_url(share=True)
assert ".cua.sh" in display_url
assert "password=" in display_url
@skip_no_key
async def test_cloud_invalid_api_key_errors():
"""An invalid (reversed) API key should get an HTTP error from the API."""
reversed_key = API_KEY[::-1]
with pytest.raises(Exception):
sb = await Sandbox.connect(VM_NAME, api_key=reversed_key)
await sb.screenshot()
await sb.disconnect()
@@ -0,0 +1,30 @@
"""Unit tests for config and auth modules."""
from cua_sandbox._config import _global_config, configure, get_api_key, get_base_url
class TestConfig:
def setup_method(self):
_global_config.api_key = None
_global_config.base_url = "https://api.trycua.com"
def test_configure_api_key(self):
configure(api_key="sk-test-123")
assert get_api_key() == "sk-test-123"
def test_configure_base_url(self):
configure(base_url="http://localhost:9000")
assert get_base_url() == "http://localhost:9000"
def test_override_takes_priority(self):
configure(api_key="sk-global")
assert get_api_key("sk-override") == "sk-override"
def test_env_var_fallback(self, monkeypatch):
monkeypatch.setenv("CUA_API_KEY", "sk-env")
assert get_api_key() == "sk-env"
def test_configure_overrides_env(self, monkeypatch):
monkeypatch.setenv("CUA_API_KEY", "sk-env")
configure(api_key="sk-configured")
assert get_api_key() == "sk-configured"
+218
View File
@@ -0,0 +1,218 @@
"""Unit tests for the Image builder (no runtime needed)."""
import pytest
from cua_sandbox.image import Image
class TestImageBuilder:
def test_linux_defaults(self):
img = Image.linux()
assert img.os_type == "linux"
assert img.distro == "ubuntu"
assert img.version == "24.04"
def test_macos(self):
img = Image.macos("15")
assert img.os_type == "macos"
def test_windows(self):
img = Image.windows("11")
assert img.os_type == "windows"
def test_chaining_is_immutable(self):
base = Image.linux()
with_curl = base.apt_install("curl")
with_git = base.apt_install("git")
assert len(base._layers) == 0
assert len(with_curl._layers) == 1
assert len(with_git._layers) == 1
assert with_curl._layers[0]["packages"] == ["curl"]
assert with_git._layers[0]["packages"] == ["git"]
def test_full_chain(self):
img = (
Image.linux("debian", "12")
.apt_install("curl", "git")
.pip_install("numpy")
.env(FOO="bar", BAZ="qux")
.run("echo hello")
.copy("/local/file", "/remote/file")
.expose(8080)
.expose(3000)
)
d = img.to_dict()
assert d["os_type"] == "linux"
assert d["distro"] == "debian"
assert len(d["layers"]) == 3 # apt, pip, run
assert d["env"] == {"FOO": "bar", "BAZ": "qux"}
assert d["ports"] == [8080, 3000]
assert d["files"] == [["/local/file", "/remote/file"]]
def test_roundtrip(self):
img = Image.linux().apt_install("curl").env(X="1").expose(80)
d = img.to_dict()
img2 = Image.from_dict(d)
assert img2.to_dict() == d
def test_cloud_init(self):
img = (
Image.linux().apt_install("curl", "git").pip_install("flask").run("systemctl start app")
)
script = img.to_cloud_init()
assert "#!/bin/bash" in script
assert "apt-get" in script
assert "pip install flask" in script
assert "systemctl start app" in script
def test_from_registry(self):
img = Image.from_registry("cua/ubuntu-desktop:24.04")
assert img._registry == "cua/ubuntu-desktop:24.04"
d = img.to_dict()
assert d["registry"] == "cua/ubuntu-desktop:24.04"
def test_repr(self):
img = Image.linux().apt_install("curl")
r = repr(img)
assert "linux" in r
assert "1 layers" in r
def test_brew_and_choco(self):
mac = Image.macos().brew_install("wget")
win = Image.windows().choco_install("firefox")
assert mac._layers[0]["type"] == "brew_install"
assert win._layers[0]["type"] == "choco_install"
class TestApkInstall:
def test_android_apk_install(self):
img = Image.android().apk_install("/path/to/app.apk")
assert len(img._layers) == 1
assert img._layers[0]["type"] == "apk_install"
assert img._layers[0]["packages"] == ["/path/to/app.apk"]
def test_android_apk_install_multiple(self):
img = Image.android().apk_install("app1.apk", "app2.apk", "app3.apk")
assert img._layers[0]["packages"] == ["app1.apk", "app2.apk", "app3.apk"]
def test_android_apk_install_cloud_init(self):
img = Image.android().apk_install("app.apk").run("echo done")
script = img.to_cloud_init()
assert "adb install app.apk" in script
assert "echo done" in script
def test_android_chaining(self):
img = (
Image.android("14")
.apk_install("browser.apk")
.env(ADB_HOST="localhost")
.run("adb shell settings put global window_animation_scale 0.0")
)
assert len(img._layers) == 2 # apk_install + run
assert img._env == (("ADB_HOST", "localhost"),)
class TestFromFileWithLayers:
def test_osworld_apt_install(self):
img = Image.from_file("/tmp/fake.qcow2", os_type="linux", agent_type="osworld").apt_install(
"curl", "git"
)
assert img._disk_path == "/tmp/fake.qcow2"
assert img._agent_type == "osworld"
assert len(img._layers) == 1
assert img._layers[0]["type"] == "apt_install"
def test_osworld_full_chain(self):
img = (
Image.from_file("/tmp/fake.qcow2", os_type="linux", agent_type="osworld")
.apt_install("curl", "git")
.pip_install("numpy")
.env(DISPLAY=":0")
.run("systemctl restart app")
)
assert len(img._layers) == 3 # apt, pip, run
assert img._disk_path == "/tmp/fake.qcow2"
assert img._agent_type == "osworld"
d = img.to_dict()
assert d["os_type"] == "linux"
def test_from_file_android_apk(self):
img = Image.from_file("/tmp/android.qcow2", os_type="android").apk_install("myapp.apk")
assert img._layers[0]["type"] == "apk_install"
assert img._disk_path == "/tmp/android.qcow2"
def test_from_file_preserves_agent_type_through_chain(self):
img = (
Image.from_file("/tmp/fake.qcow2", os_type="linux", agent_type="osworld")
.apt_install("curl")
.pip_install("flask")
.run("echo hi")
)
assert img._agent_type == "osworld"
assert img._disk_path == "/tmp/fake.qcow2"
class TestOsCompatibilityErrors:
"""Verify that install methods raise ValueError for wrong OS types."""
def test_apt_install_on_windows(self):
with pytest.raises(ValueError, match="apt_install is not supported on 'windows'"):
Image.windows().apt_install("curl")
def test_apt_install_on_macos(self):
with pytest.raises(ValueError, match="apt_install is not supported on 'macos'"):
Image.macos().apt_install("curl")
def test_apt_install_on_android(self):
with pytest.raises(ValueError, match="apt_install is not supported on 'android'"):
Image.android().apt_install("curl")
def test_winget_install_on_linux(self):
with pytest.raises(ValueError, match="winget_install is not supported on 'linux'"):
Image.linux().winget_install("Firefox")
def test_winget_install_on_macos(self):
with pytest.raises(ValueError, match="winget_install is not supported on 'macos'"):
Image.macos().winget_install("Firefox")
def test_choco_install_on_linux(self):
with pytest.raises(ValueError, match="choco_install is not supported on 'linux'"):
Image.linux().choco_install("firefox")
def test_brew_install_on_windows(self):
with pytest.raises(ValueError, match="brew_install is not supported on 'windows'"):
Image.windows().brew_install("wget")
def test_brew_install_on_linux(self):
with pytest.raises(ValueError, match="brew_install is not supported on 'linux'"):
Image.linux().brew_install("wget")
def test_apk_install_on_linux(self):
with pytest.raises(ValueError, match="apk_install is not supported on 'linux'"):
Image.linux().apk_install("app.apk")
def test_apk_install_on_windows(self):
with pytest.raises(ValueError, match="apk_install is not supported on 'windows'"):
Image.windows().apk_install("app.apk")
def test_apk_install_on_macos(self):
with pytest.raises(ValueError, match="apk_install is not supported on 'macos'"):
Image.macos().apk_install("app.apk")
def test_from_file_wrong_os(self):
"""from_file with os_type='windows' should reject apt_install."""
with pytest.raises(ValueError, match="apt_install is not supported on 'windows'"):
Image.from_file("/tmp/win.qcow2", os_type="windows").apt_install("curl")
def test_pip_and_uv_allowed_on_all(self):
"""pip_install and uv_install are cross-platform — no OS restriction."""
Image.linux().pip_install("flask")
Image.windows().pip_install("flask")
Image.macos().pip_install("flask")
Image.android().pip_install("flask")
Image.linux().uv_install("flask")
def test_run_and_env_allowed_on_all(self):
"""run() and env() are cross-platform."""
Image.linux().run("echo hi").env(X="1")
Image.windows().run("echo hi").env(X="1")
Image.android().run("echo hi").env(X="1")
@@ -0,0 +1,77 @@
"""Integration tests for agent handler adapters."""
from __future__ import annotations
import pytest
from cua_sandbox.agent import LocalhostHandler, SandboxHandler, is_sandbox
pytestmark = pytest.mark.asyncio
class TestSandboxHandler:
async def test_screenshot(self, local_sandbox):
handler = SandboxHandler(local_sandbox)
b64 = await handler.screenshot()
assert isinstance(b64, str)
assert len(b64) > 100
async def test_get_environment(self, local_sandbox):
handler = SandboxHandler(local_sandbox)
env = await handler.get_environment()
assert env in ("windows", "mac", "linux", "browser")
async def test_get_dimensions(self, local_sandbox):
handler = SandboxHandler(local_sandbox)
w, h = await handler.get_dimensions()
assert w > 0 and h > 0
async def test_click(self, local_sandbox):
handler = SandboxHandler(local_sandbox)
await handler.click(100, 100)
async def test_type(self, local_sandbox):
handler = SandboxHandler(local_sandbox)
await handler.type("test")
async def test_keypress(self, local_sandbox):
handler = SandboxHandler(local_sandbox)
await handler.keypress(["ctrl", "a"])
async def test_scroll(self, local_sandbox):
handler = SandboxHandler(local_sandbox)
await handler.scroll(100, 100, 0, 3)
async def test_move(self, local_sandbox):
handler = SandboxHandler(local_sandbox)
await handler.move(200, 200)
async def test_drag(self, local_sandbox):
handler = SandboxHandler(local_sandbox)
await handler.drag([{"x": 100, "y": 100}, {"x": 200, "y": 200}])
async def test_wait(self, local_sandbox):
handler = SandboxHandler(local_sandbox)
await handler.wait(10) # 10ms
class TestLocalhostHandler:
async def test_screenshot(self, localhost_instance):
handler = LocalhostHandler(localhost_instance)
b64 = await handler.screenshot()
assert isinstance(b64, str)
async def test_click(self, localhost_instance):
handler = LocalhostHandler(localhost_instance)
await handler.click(50, 50)
class TestIsSandbox:
async def test_sandbox_detected(self, local_sandbox):
assert is_sandbox(local_sandbox)
async def test_localhost_detected(self, localhost_instance):
assert is_sandbox(localhost_instance)
def test_other_not_detected(self):
assert not is_sandbox("not a sandbox")
assert not is_sandbox(42)
@@ -0,0 +1,170 @@
"""Integration tests — exercise every interface method against every available transport.
Run with:
pytest tests/test_integration_interfaces.py -v
By default only local transport is tested. Set env vars to enable remote:
CUA_TEST_WS_URL=ws://host:8000/ws
CUA_TEST_HTTP_URL=http://host:8000
"""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.asyncio
# ═══════════════════════════════════════════════════════════════════════════════
# Screen
# ═══════════════════════════════════════════════════════════════════════════════
class TestScreen:
async def test_screenshot_returns_png_bytes(self, any_sandbox):
data = await any_sandbox.screenshot()
assert isinstance(data, bytes)
assert len(data) > 100
# PNG magic bytes
assert data[:4] == b"\x89PNG"
async def test_screenshot_base64(self, any_sandbox):
b64 = await any_sandbox.screenshot_base64()
assert isinstance(b64, str)
assert len(b64) > 100
async def test_screen_size(self, any_sandbox):
w, h = await any_sandbox.get_dimensions()
assert isinstance(w, int)
assert isinstance(h, int)
assert w > 0
assert h > 0
# ═══════════════════════════════════════════════════════════════════════════════
# Mouse
# ═══════════════════════════════════════════════════════════════════════════════
class TestMouse:
async def test_move(self, any_sandbox):
await any_sandbox.mouse.move(100, 100)
async def test_click(self, any_sandbox):
await any_sandbox.mouse.click(100, 100)
async def test_right_click(self, any_sandbox):
await any_sandbox.mouse.right_click(200, 200)
async def test_double_click(self, any_sandbox):
await any_sandbox.mouse.double_click(150, 150)
async def test_scroll(self, any_sandbox):
await any_sandbox.mouse.scroll(100, 100, scroll_y=3)
async def test_mouse_down_up(self, any_sandbox):
await any_sandbox.mouse.mouse_down(100, 100)
await any_sandbox.mouse.mouse_up(200, 200)
async def test_drag(self, any_sandbox):
await any_sandbox.mouse.drag(100, 100, 300, 300)
# ═══════════════════════════════════════════════════════════════════════════════
# Keyboard
# ═══════════════════════════════════════════════════════════════════════════════
class TestKeyboard:
async def test_type_text(self, any_sandbox):
await any_sandbox.keyboard.type("hello")
async def test_keypress_single(self, any_sandbox):
await any_sandbox.keyboard.keypress("enter")
async def test_keypress_combo(self, any_sandbox):
await any_sandbox.keyboard.keypress(["ctrl", "a"])
async def test_key_down_up(self, any_sandbox):
await any_sandbox.keyboard.key_down("shift")
await any_sandbox.keyboard.key_up("shift")
# ═══════════════════════════════════════════════════════════════════════════════
# Clipboard
# ═══════════════════════════════════════════════════════════════════════════════
class TestClipboard:
async def test_set_and_get(self, any_sandbox):
await any_sandbox.clipboard.set("cua-sandbox-test")
result = await any_sandbox.clipboard.get()
assert result == "cua-sandbox-test"
# ═══════════════════════════════════════════════════════════════════════════════
# Shell
# ═══════════════════════════════════════════════════════════════════════════════
class TestShell:
async def test_run_echo(self, any_sandbox):
result = await any_sandbox.shell.run("echo hello-sandbox")
assert result.success
assert "hello-sandbox" in result.stdout
async def test_run_failure(self, any_sandbox):
result = await any_sandbox.shell.run("exit 1")
assert not result.success
assert result.returncode != 0
# ═══════════════════════════════════════════════════════════════════════════════
# Window
# ═══════════════════════════════════════════════════════════════════════════════
class TestWindow:
async def test_get_active_title(self, any_sandbox):
title = await any_sandbox.window.get_active_title()
assert isinstance(title, str)
# ═══════════════════════════════════════════════════════════════════════════════
# Environment / Dimensions
# ═══════════════════════════════════════════════════════════════════════════════
class TestEnvironment:
async def test_get_environment(self, any_sandbox):
env = await any_sandbox.get_environment()
assert env in ("windows", "mac", "linux", "browser")
# ═══════════════════════════════════════════════════════════════════════════════
# Localhost (separate from sandbox parametrization)
# ═══════════════════════════════════════════════════════════════════════════════
class TestLocalhost:
async def test_screenshot(self, localhost_instance):
data = await localhost_instance.screenshot()
assert data[:4] == b"\x89PNG"
async def test_mouse_click(self, localhost_instance):
await localhost_instance.mouse.click(50, 50)
async def test_keyboard_type(self, localhost_instance):
await localhost_instance.keyboard.type("x")
async def test_shell_run(self, localhost_instance):
result = await localhost_instance.shell.run("echo localhost-test")
assert result.success
async def test_environment(self, localhost_instance):
env = await localhost_instance.get_environment()
assert env in ("windows", "mac", "linux")
async def test_dimensions(self, localhost_instance):
w, h = await localhost_instance.get_dimensions()
assert w > 0 and h > 0
@@ -0,0 +1,32 @@
"""Integration tests for the synchronous API wrappers."""
from __future__ import annotations
from cua_sandbox.sync import localhost
class TestSyncLocalhost:
def test_screenshot(self):
with localhost() as host:
data = host.screenshot()
assert isinstance(data, bytes)
assert data[:4] == b"\x89PNG"
def test_mouse_click(self):
with localhost() as host:
host.mouse.click(50, 50)
def test_shell_run(self):
with localhost() as host:
result = host.shell.run("echo sync-test")
assert result.success
assert "sync-test" in result.stdout
def test_keyboard_type(self):
with localhost() as host:
host.keyboard.type("x")
def test_dimensions(self):
with localhost() as host:
w, h = host.get_dimensions()
assert w > 0 and h > 0
+242
View File
@@ -0,0 +1,242 @@
"""OCI registry tests — manifest inspection, kind detection, image formats.
pytest tests/test_oci.py -v -s
Unit tests use synthetic manifests. Integration tests (marked `oci_live`)
fetch real manifests from ghcr.io and require network access.
"""
from __future__ import annotations
import pytest
from cua_sandbox.registry.manifest import (
ImageFormat,
detect_format,
detect_kind,
detect_os,
get_layer_info,
)
from cua_sandbox.registry.media_types import (
DOCKER_IMAGE_CONFIG,
DOCKER_IMAGE_LAYER,
LEGACY_DISK_CHUNK,
OCI_IMAGE_CONFIG,
OCI_IMAGE_LAYER,
OCI_VM_AUX,
OCI_VM_CONFIG,
OCI_VM_DISK,
QEMU_CONFIG,
QEMU_DISK_GZIP,
)
from cua_sandbox.registry.ref import parse_ref
# ═════════════════════════════════════════════════════════════════════════════
# parse_ref
# ═════════════════════════════════════════════════════════════════════════════
class TestParseRef:
def test_full_ref(self):
assert parse_ref("ghcr.io/trycua/macos-sequoia-cua:latest") == (
"ghcr.io",
"trycua",
"macos-sequoia-cua",
"latest",
)
def test_org_name(self):
assert parse_ref("trycua/cua-xfce:v2") == ("ghcr.io", "trycua", "cua-xfce", "v2")
def test_short_name(self):
assert parse_ref("cua-xfce") == ("ghcr.io", "trycua", "cua-xfce", "latest")
def test_short_name_with_tag(self):
assert parse_ref("cua-xfce:nightly") == ("ghcr.io", "trycua", "cua-xfce", "nightly")
# ═════════════════════════════════════════════════════════════════════════════
# detect_format / detect_kind — synthetic manifests
# ═════════════════════════════════════════════════════════════════════════════
def _make_manifest(config_mt: str, layer_mts: list[str], **kwargs) -> dict:
m = {
"config": {"mediaType": config_mt, "digest": "sha256:aaa", "size": 100},
"layers": [
{"mediaType": mt, "digest": f"sha256:{i:03x}", "size": 1000}
for i, mt in enumerate(layer_mts)
],
}
m.update(kwargs)
return m
class TestDetectFormat:
def test_oci_layered_by_config(self):
m = _make_manifest(OCI_VM_CONFIG, [OCI_VM_DISK, OCI_VM_AUX])
assert detect_format(m) == ImageFormat.OCI_LAYERED
def test_oci_layered_by_layer(self):
m = _make_manifest("application/json", [OCI_VM_DISK])
assert detect_format(m) == ImageFormat.OCI_LAYERED
def test_legacy_lz4_by_layer(self):
m = _make_manifest("application/json", [LEGACY_DISK_CHUNK, LEGACY_DISK_CHUNK])
assert detect_format(m) == ImageFormat.LEGACY_LZ4
def test_chunked_parts(self):
m = _make_manifest(
"application/json",
[
f"{OCI_IMAGE_LAYER};part.number=1;part.total=3",
f"{OCI_IMAGE_LAYER};part.number=2;part.total=3",
f"{OCI_IMAGE_LAYER};part.number=3;part.total=3",
],
)
assert detect_format(m) == ImageFormat.CHUNKED_PARTS
def test_container_oci(self):
m = _make_manifest(OCI_IMAGE_CONFIG, [OCI_IMAGE_LAYER])
assert detect_format(m) == ImageFormat.CONTAINER
def test_container_docker(self):
m = _make_manifest(DOCKER_IMAGE_CONFIG, [DOCKER_IMAGE_LAYER])
assert detect_format(m) == ImageFormat.CONTAINER
def test_qemu_by_config(self):
m = _make_manifest(QEMU_CONFIG, [QEMU_DISK_GZIP])
assert detect_format(m) == ImageFormat.QEMU
def test_qemu_by_layer(self):
m = _make_manifest("application/json", [QEMU_DISK_GZIP])
assert detect_format(m) == ImageFormat.QEMU
def test_unknown(self):
m = _make_manifest("application/json", ["application/octet-stream"])
assert detect_format(m) == ImageFormat.UNKNOWN
class TestDetectKind:
def test_vm_oci_layered(self):
m = _make_manifest(OCI_VM_CONFIG, [OCI_VM_DISK])
assert detect_kind(m) == "vm"
def test_vm_legacy(self):
m = _make_manifest("application/json", [LEGACY_DISK_CHUNK])
assert detect_kind(m) == "vm"
def test_vm_chunked(self):
m = _make_manifest(
"application/json",
[
f"{OCI_IMAGE_LAYER};part.number=1;part.total=2",
],
)
assert detect_kind(m) == "vm"
def test_vm_qemu(self):
m = _make_manifest(QEMU_CONFIG, [QEMU_DISK_GZIP])
assert detect_kind(m) == "vm"
def test_container(self):
m = _make_manifest(OCI_IMAGE_CONFIG, [OCI_IMAGE_LAYER])
assert detect_kind(m) == "container"
class TestDetectOs:
def test_agoda_is_macos(self):
m = _make_manifest(OCI_VM_CONFIG, [OCI_VM_DISK])
assert detect_os(m) == "macos"
def test_annotation_linux(self):
m = _make_manifest(
"application/json",
[],
annotations={
"org.trycua.lume.os": "Linux",
},
)
assert detect_os(m) == "linux"
def test_no_os(self):
m = _make_manifest(OCI_IMAGE_CONFIG, [OCI_IMAGE_LAYER])
assert detect_os(m) is None
# ═════════════════════════════════════════════════════════════════════════════
# get_layer_info
# ═════════════════════════════════════════════════════════════════════════════
class TestGetLayerInfo:
def test_part_from_annotations(self):
m = {
"layers": [
{
"mediaType": OCI_VM_DISK,
"digest": "sha256:abc",
"size": 500_000_000,
"annotations": {
"org.trycua.lume.part.number": "3",
"org.trycua.lume.part.total": "10",
"org.opencontainers.image.title": "disk.img.003",
},
}
],
}
info = get_layer_info(m)
assert len(info) == 1
assert info[0]["part_number"] == 3
assert info[0]["part_total"] == 10
assert info[0]["title"] == "disk.img.003"
def test_part_from_mediatype(self):
mt = f"{OCI_IMAGE_LAYER};part.number=7;part.total=164"
m = {"layers": [{"mediaType": mt, "digest": "sha256:def", "size": 100}]}
info = get_layer_info(m)
assert info[0]["part_number"] == 7
assert info[0]["part_total"] == 164
# ═════════════════════════════════════════════════════════════════════════════
# Live registry tests (require network)
# ═════════════════════════════════════════════════════════════════════════════
oci_live = pytest.mark.skipif(
not pytest.importorskip("oras", reason="oras-py not installed"),
reason="oras-py not installed",
)
@oci_live
class TestLiveRegistry:
"""Fetch real manifests from ghcr.io. Requires network + oras."""
def test_macos_sparse_oci_layered(self):
from cua_sandbox.registry.manifest import get_manifest
manifest = get_manifest("ghcr.io/trycua/macos-sequoia-cua-sparse:latest-oci-layered")
assert detect_format(manifest) == ImageFormat.OCI_LAYERED
assert detect_kind(manifest) == "vm"
assert detect_os(manifest) == "macos"
info = get_layer_info(manifest)
assert len(info) > 0
# Should have disk layers with part numbers
disk_parts = [layer for layer in info if layer["part_number"] is not None]
assert len(disk_parts) > 0
def test_macos_chunked_parts(self):
from cua_sandbox.registry.manifest import get_manifest
manifest = get_manifest("ghcr.io/trycua/macos-sequoia-cua:latest")
fmt = detect_format(manifest)
assert fmt in (ImageFormat.CHUNKED_PARTS, ImageFormat.OCI_LAYERED, ImageFormat.LEGACY_LZ4)
assert detect_kind(manifest) == "vm"
def test_ubuntu_container(self):
from cua_sandbox.registry.manifest import get_manifest
manifest = get_manifest("docker.io/trycua/cua-xfce:latest")
assert detect_format(manifest) == ImageFormat.CONTAINER
assert detect_kind(manifest) == "container"
@@ -0,0 +1,90 @@
"""Unit tests for ``HTTPTransport._parse_sse`` — the error-surfacing path.
When the server returns ``{"success": false, ...}``, ``_parse_sse`` raises
``RuntimeError`` with a message derived from the payload. The previous
implementation stringified ``payload.get('error', 'unknown')``, which
turned every shell-command failure (``{"success": false, "stdout": "",
"stderr": "Command timed out after 10s", "return_code": 1}``) into the
opaque ``Remote error: unknown`` — hiding the actual cause.
These tests pin the new behavior:
* explicit ``error`` key still wins
* shell-command shape surfaces ``return_code`` + ``stderr``
* stdout only appears as a fallback when stderr is empty
* successful payloads pass through unchanged
* an SSE stream with no data frame raises a parseable error
"""
import pytest
from cua_sandbox.transport.http import HTTPTransport
def _sse(obj: str) -> str:
"""Wrap a JSON payload in a single SSE data frame."""
return f"data: {obj}\n\n"
class TestParseSSESurfacesFailures:
def test_success_payload_returned_unchanged(self):
out = HTTPTransport._parse_sse(_sse('{"success": true, "result": 42}'))
assert out == {"success": True, "result": 42}
def test_explicit_error_field_preserved_verbatim(self):
with pytest.raises(RuntimeError, match=r"Remote error: boom"):
HTTPTransport._parse_sse(_sse('{"success": false, "error": "boom"}'))
def test_shell_failure_surfaces_return_code_and_stderr(self):
with pytest.raises(RuntimeError) as exc:
HTTPTransport._parse_sse(
_sse(
'{"success": false, "stdout": "", '
'"stderr": "Command timed out after 10s", '
'"return_code": 1}'
)
)
msg = str(exc.value)
assert "Remote error:" in msg
assert "return_code=1" in msg
assert "Command timed out after 10s" in msg
def test_shell_failure_uses_stdout_only_when_stderr_empty(self):
with pytest.raises(RuntimeError) as exc:
HTTPTransport._parse_sse(
_sse(
'{"success": false, "stdout": "partial progress", '
'"stderr": "", "return_code": 137}'
)
)
msg = str(exc.value)
assert "return_code=137" in msg
assert "partial progress" in msg
def test_shell_failure_stdout_not_added_when_stderr_present(self):
"""stderr wins over stdout when both are non-empty — avoids bloating
the message with noisy successful-command output that happened to
return non-zero."""
with pytest.raises(RuntimeError) as exc:
HTTPTransport._parse_sse(
_sse(
'{"success": false, '
'"stdout": "UI hierchary dumped to: /sdcard/ui.xml\\n", '
'"stderr": "Killed", "return_code": 137}'
)
)
msg = str(exc.value)
assert "Killed" in msg
assert "return_code=137" in msg
assert "UI hierchary dumped" not in msg # stdout suppressed
def test_shell_failure_with_no_detail_falls_back_cleanly(self):
with pytest.raises(RuntimeError, match=r"Remote error: no detail"):
HTTPTransport._parse_sse(_sse('{"success": false}'))
def test_missing_data_frame_raises(self):
with pytest.raises(RuntimeError, match=r"No SSE data frame"):
HTTPTransport._parse_sse("event: ping\n\n")
def test_ignores_content_before_data_line(self):
text = ":comment\nevent: message\ndata: " + '{"success": true, "ok": 1}' + "\n\n"
out = HTTPTransport._parse_sse(text)
assert out == {"success": True, "ok": 1}
@@ -0,0 +1,608 @@
"""Integration tests — runtime scenarios.
pytest tests/test_runtime.py -v -s
Skips automatically when the required runtime isn't available.
"""
from __future__ import annotations
import os
import platform
import subprocess
from pathlib import Path
import pytest
from cua_sandbox import Image, Sandbox
pytestmark = pytest.mark.asyncio
IS_WINDOWS = platform.system() == "Windows"
IS_MACOS = platform.system() == "Darwin"
def _has_cua_api_key() -> bool:
return bool(os.environ.get("CUA_API_KEY"))
# ── Helpers ──────────────────────────────────────────────────────────────────
def _has_docker() -> bool:
try:
subprocess.run(["docker", "info"], capture_output=True, check=True, timeout=10)
return True
except (subprocess.SubprocessError, FileNotFoundError):
return False
def _has_lume() -> bool:
try:
subprocess.run(["lume", "--version"], capture_output=True, check=True)
return True
except (subprocess.SubprocessError, FileNotFoundError):
return False
def _has_hyperv() -> bool:
if not IS_WINDOWS:
return False
try:
from cua_sandbox.runtime.hyperv import _has_hyperv as check
return check()
except Exception:
return False
def _has_qemu() -> bool:
"""Check if QEMU is available (on PATH or portable)."""
try:
from cua_sandbox.runtime.qemu_installer import qemu_bin
qemu_bin("x86_64")
return True
except (RuntimeError, Exception):
return False
def _has_android_image() -> bool:
"""Check if an Android-x86 disk image is available for bare-metal QEMU."""
from pathlib import Path
storage = Path.home() / ".cua" / "cua-sandbox" / "qemu-storage" / "android-x86.qcow2"
return storage.exists()
# ── 1. Linux container (Docker) ─────────────────────────────────────────────
@pytest.mark.skipif(not _has_docker(), reason="Docker not available")
async def test_linux_container():
from cua_sandbox.runtime import DockerRuntime
async with Sandbox.ephemeral(
Image.linux("ubuntu", "24.04"),
local=True,
runtime=DockerRuntime(api_port=18000, vnc_port=16901, ephemeral=True),
name="cua-test-linux-container",
) as sb:
result = await sb.shell.run("cat /etc/os-release")
assert result.success
assert "ubuntu" in result.stdout.lower()
screenshot = await sb.screenshot()
assert screenshot[:4] == b"\x89PNG"
# ── 2. Linux VM (QEMU-in-Docker) ────────────────────────────────────────────
@pytest.mark.skipif(not _has_docker(), reason="Docker not available")
async def test_linux_vm():
from cua_sandbox.runtime import QEMURuntime
async with Sandbox.ephemeral(
Image.linux("ubuntu", "24.04"),
local=True,
runtime=QEMURuntime(mode="docker", api_port=18001, vnc_port=18006, ephemeral=True),
name="cua-test-linux-vm",
) as sb:
result = await sb.shell.run("uname -a")
assert result.success
assert "Linux" in result.stdout
screenshot = await sb.screenshot()
assert screenshot[:4] == b"\x89PNG"
# ── 3. Windows VM (bare-metal QEMU on Windows host) ─────────────────────────
@pytest.mark.skipif(not IS_WINDOWS, reason="Windows host only")
@pytest.mark.skipif(not _has_qemu(), reason="QEMU not available")
async def test_windows_vm():
from cua_sandbox.runtime import QEMURuntime
async with Sandbox.ephemeral(
Image.windows("11"),
local=True,
runtime=QEMURuntime(mode="bare-metal", api_port=18002),
name="cua-test-windows-vm",
) as sb:
result = await sb.shell.run("ver")
assert result.success or "Windows" in result.stdout
screenshot = await sb.screenshot()
assert screenshot[:4] == b"\x89PNG"
# ── 3b. Windows VM (Hyper-V) ────────────────────────────────────────────────
@pytest.mark.skipif(not _has_hyperv(), reason="Hyper-V not available")
async def test_windows_hyperv():
from cua_sandbox.runtime import HyperVRuntime
async with Sandbox.ephemeral(
Image.windows("11"),
local=True,
runtime=HyperVRuntime(api_port=18003),
name="cua-test-hyperv",
) as sb:
result = await sb.shell.run("ver")
assert result.success or "Windows" in result.stdout
screenshot = await sb.screenshot()
assert screenshot[:4] == b"\x89PNG"
# ── 4. macOS VM (Lume) ──────────────────────────────────────────────────────
@pytest.mark.skipif(not IS_MACOS or not _has_lume(), reason="Lume only on macOS")
async def test_macos_vm():
from cua_sandbox.runtime import LumeRuntime
async with Sandbox.ephemeral(
Image.macos("26"),
local=True,
runtime=LumeRuntime(),
name="cua-test-macos-vm",
) as sb:
result = await sb.shell.run("sw_vers")
assert result.success
assert "macOS" in result.stdout or "Mac" in result.stdout
screenshot = await sb.screenshot()
assert screenshot[:4] == b"\x89PNG"
# ── 5. Android VM (bare-metal QEMU) ──────────────────────────────────────────
def _has_android_iso() -> bool:
"""Check if an Android-x86 ISO is available in ~/Downloads."""
from pathlib import Path
return any(Path.home().glob("Downloads/android-x86*.iso"))
def _get_android_iso() -> str:
from pathlib import Path
isos = sorted(Path.home().glob("Downloads/android-x86*.iso"))
return str(isos[0]) if isos else ""
@pytest.mark.skipif(not _has_qemu(), reason="QEMU not available")
@pytest.mark.skipif(
not _has_android_image(),
reason="Android-x86 qcow2 not available at ~/.cua/cua-sandbox/qemu-storage/android-x86.qcow2",
)
async def test_android_vm_baremetal():
"""Test Android VM via bare-metal QEMU with a pre-built qcow2."""
from pathlib import Path
from cua_sandbox.runtime import QEMURuntime
android_disk = str(Path.home() / ".cua" / "cua-sandbox" / "qemu-storage" / "android-x86.qcow2")
async with Sandbox.ephemeral(
Image.from_file(android_disk, os_type="android", kind="vm"),
local=True,
runtime=QEMURuntime(
mode="bare-metal", api_port=18010, vnc_display=10, memory_mb=4096, cpu_count=4
),
name="cua-test-android-vm",
) as sb:
screenshot = await sb.screenshot()
assert screenshot[:4] == b"\x89PNG"
assert len(screenshot) > 1000
@pytest.mark.skipif(not _has_qemu(), reason="QEMU not available")
@pytest.mark.skipif(not _has_android_iso(), reason="No android-x86*.iso in ~/Downloads")
async def test_android_vm_from_iso():
"""Test Android VM booted from an ISO file via bare-metal QEMU.
The runtime auto-creates a qcow2 disk and attaches the ISO as CD-ROM.
Uses QMP transport (no computer-server required).
"""
from cua_sandbox.runtime import QEMURuntime
iso_path = _get_android_iso()
async with Sandbox.ephemeral(
Image.from_file(iso_path, os_type="android", kind="vm"),
local=True,
runtime=QEMURuntime(
mode="bare-metal",
api_port=18030,
vnc_display=30,
qmp_port=4460,
memory_mb=2048,
cpu_count=2,
),
name="cua-test-android-iso3",
) as sb:
# Wait for boot to progress past GRUB (Enter keys are sent during start())
import asyncio
await asyncio.sleep(15)
# With QMP transport, we can take screenshots even without computer-server
screenshot = await sb.screenshot()
assert screenshot[:4] == b"\x89PNG"
assert len(screenshot) > 100
# Save screenshot for inspection
with open("/tmp/android-boot-screenshot.png", "wb") as f:
f.write(screenshot)
# ── 5b. Android VM (QEMU-in-Docker) ──────────────────────────────────────────
@pytest.mark.skipif(not _has_docker(), reason="Docker not available")
async def test_android_vm_docker():
"""Test Android VM via QEMU inside Docker."""
from cua_sandbox.runtime import QEMURuntime
async with Sandbox.ephemeral(
Image.android("14"),
local=True,
runtime=QEMURuntime(mode="docker", api_port=18011, vnc_port=18080, ephemeral=True),
name="cua-test-android-docker",
) as sb:
screenshot = await sb.screenshot()
assert screenshot[:4] == b"\x89PNG"
assert len(screenshot) > 1000
# ── 6. QEMU bare-metal error on missing binary ───────────────────────────────
def _has_osworld_image() -> bool:
"""Check if the OSWorld Ubuntu qcow2 is available in the image cache."""
cache = Path.home() / ".cua" / "cua-sandbox" / "image-cache"
return any(cache.rglob("Ubuntu.qcow2")) if cache.exists() else False
@pytest.mark.skipif(not _has_qemu(), reason="QEMU not available")
@pytest.mark.skipif(not _has_osworld_image(), reason="OSWorld Ubuntu.qcow2 not in image cache")
async def test_osworld_ubuntu_vm():
"""Test OSWorld Ubuntu VM via bare-metal QEMU with OSWorld transport."""
from cua_sandbox.runtime import QEMURuntime
async with Sandbox.ephemeral(
Image.from_file(
"https://huggingface.co/datasets/xlangai/ubuntu_osworld/resolve/main/Ubuntu.qcow2.zip",
os_type="linux",
agent_type="osworld",
),
local=True,
runtime=QEMURuntime(
mode="bare-metal", api_port=18020, vnc_display=20, memory_mb=4096, cpu_count=4
),
name="cua-test-osworld",
) as sb:
screenshot = await sb.screenshot()
assert screenshot[:4] == b"\x89PNG"
w, h = await sb.get_dimensions()
assert w == 1920 and h == 1080
# ── 7. Android Emulator (SDK) ────────────────────────────────────────────────
def _has_android_sdk() -> bool:
"""Check if Android SDK emulator is available."""
try:
from cua_sandbox.runtime.android_emulator import _find_bin, _sdk_path
sdk = _sdk_path()
_find_bin(sdk, "emulator")
_find_bin(sdk, "adb")
return True
except (FileNotFoundError, Exception):
return False
@pytest.mark.skipif(not _has_android_sdk(), reason="Android SDK not available")
async def test_android_emulator():
"""Test Android emulator boots to homescreen."""
from cua_sandbox.runtime import AndroidEmulatorRuntime
async with Sandbox.ephemeral(
Image.android("14"),
local=True,
runtime=AndroidEmulatorRuntime(memory_mb=4096, cpu_count=4, adb_port=5559),
name="cua-test-android-sdk",
) as sb:
screenshot = await sb.screenshot()
assert screenshot[:4] == b"\x89PNG"
assert len(screenshot) > 10000
@pytest.mark.skipif(not _has_android_sdk(), reason="Android SDK not available")
async def test_android_emulator_apk_install():
"""Test Android emulator with F-Droid APK installed from URL."""
from cua_sandbox.runtime import AndroidEmulatorRuntime
img = Image.android("14").apk_install("https://f-droid.org/F-Droid.apk")
async with Sandbox.ephemeral(
img,
local=True,
runtime=AndroidEmulatorRuntime(memory_mb=4096, cpu_count=4, adb_port=5561),
name="cua-test-android-apk",
) as sb:
screenshot = await sb.screenshot()
assert screenshot[:4] == b"\x89PNG"
# Verify F-Droid is installed
result = await sb._transport.send("shell", command="pm list packages | grep fdroid")
assert "org.fdroid.fdroid" in result["output"]
# ── 8. Tart (Apple VZ) ────────────────────────────────────────────────────────
def _has_tart() -> bool:
import shutil
return shutil.which("tart") is not None
@pytest.mark.skipif(not IS_MACOS, reason="Tart only on macOS")
@pytest.mark.skipif(not _has_tart(), reason="Tart CLI not available")
async def test_tart_ubuntu():
"""Test Ubuntu VM via Tart (Apple VZ)."""
from cua_sandbox.runtime import TartRuntime
async with Sandbox.ephemeral(
Image.from_registry("ghcr.io/cirruslabs/ubuntu:latest"),
local=True,
runtime=TartRuntime(ephemeral=True, display="1024x768"),
name="cua-test-tart-ubuntu",
) as sb:
screenshot = await sb.screenshot()
assert screenshot[:4] == b"\x89PNG"
assert len(screenshot) > 1000
result = await sb.shell.run("uname -a")
assert result.success
assert "Linux" in result.stdout
@pytest.mark.skipif(not IS_MACOS, reason="Tart only on macOS")
@pytest.mark.skipif(not _has_tart(), reason="Tart CLI not available")
async def test_tart_macos_tahoe():
"""Test macOS Tahoe VM via Tart (Apple VZ)."""
from cua_sandbox.runtime import TartRuntime
async with Sandbox.ephemeral(
Image.from_registry("ghcr.io/cirruslabs/macos-tahoe-base:latest"),
local=True,
runtime=TartRuntime(ephemeral=True),
name="cua-test-tart-tahoe",
) as sb:
screenshot = await sb.screenshot()
assert screenshot[:4] == b"\x89PNG"
assert len(screenshot) > 1000
@pytest.mark.skipif(not IS_MACOS, reason="Tart only on macOS")
@pytest.mark.skipif(not _has_tart(), reason="Tart CLI not available")
async def test_tart_macos_sequoia_cua():
"""Test CUA macOS Sequoia sparse image via Tart."""
from cua_sandbox.runtime import TartRuntime
async with Sandbox.ephemeral(
Image.from_registry("ghcr.io/trycua/macos-sequoia-cua-sparse:latest-oci-layered"),
local=True,
runtime=TartRuntime(ephemeral=True),
name="cua-test-tart-sequoia",
) as sb:
screenshot = await sb.screenshot()
assert screenshot[:4] == b"\x89PNG"
assert len(screenshot) > 1000
@pytest.mark.skipif(not IS_MACOS or not _has_lume(), reason="Lume only on macOS")
async def test_lume_macos_tahoe_cua():
"""Test CUA macOS Tahoe image via Lume from ghcr.io/trycua/macos-tahoe-cua:latest."""
from cua_sandbox.runtime import LumeRuntime
async with Sandbox.ephemeral(
Image.from_registry("ghcr.io/trycua/macos-tahoe-cua:latest"),
local=True,
runtime=LumeRuntime(),
name="cua-test-lume-tahoe",
) as sb:
screenshot = await sb.screenshot()
assert screenshot[:4] == b"\x89PNG"
assert len(screenshot) > 1000
# ── 9. QEMU bare-metal error on missing binary ───────────────────────────────
def test_qemu_baremetal_missing_binary_error():
"""When QEMU isn't installed, bare-metal runtime should give a helpful error."""
import shutil
if shutil.which("qemu-system-x86_64"):
pytest.skip("QEMU is installed — cannot test missing-binary error")
with pytest.raises(RuntimeError, match="brew install qemu|not found"):
from cua_sandbox.runtime.qemu_installer import qemu_bin
qemu_bin("x86_64")
# ── 10. Auto-runtime (local=True, no explicit runtime) ───────────────────────
@pytest.mark.skipif(not _has_docker(), reason="Docker not available")
async def test_auto_runtime_linux_container():
"""local=True with no runtime auto-selects DockerRuntime for linux container images."""
async with Sandbox.ephemeral(
Image.linux("ubuntu", "24.04"), # kind="container" by default
local=True,
name="cua-test-auto-linux-container",
) as sb:
result = await sb.shell.run("uname -s")
assert result.success
assert "Linux" in result.stdout
screenshot = await sb.screenshot()
assert screenshot[:4] == b"\x89PNG"
@pytest.mark.skipif(not _has_docker(), reason="Docker not available")
async def test_auto_runtime_linux_vm():
"""local=True with no runtime auto-selects QEMURuntime(docker) for linux vm images."""
async with Sandbox.ephemeral(
Image.linux("ubuntu", "24.04", kind="vm"),
local=True,
name="cua-test-auto-linux-vm",
) as sb:
result = await sb.shell.run("uname -s")
assert result.success
assert "Linux" in result.stdout
screenshot = await sb.screenshot()
assert screenshot[:4] == b"\x89PNG"
@pytest.mark.skipif(not IS_MACOS or not _has_lume(), reason="Lume only on macOS")
async def test_auto_runtime_macos():
"""local=True with no runtime auto-selects LumeRuntime for macOS images."""
async with Sandbox.ephemeral(
Image.macos("26"),
local=True,
name="cua-test-auto-macos",
) as sb:
result = await sb.shell.run("sw_vers")
assert result.success
assert "macOS" in result.stdout or "Mac" in result.stdout
screenshot = await sb.screenshot()
assert screenshot[:4] == b"\x89PNG"
@pytest.mark.skipif(not _has_android_sdk(), reason="Android SDK not available")
async def test_auto_runtime_android():
"""local=True with no runtime auto-selects AndroidEmulatorRuntime for android images."""
async with Sandbox.ephemeral(
Image.android("14"),
local=True,
name="cua-test-auto-android",
) as sb:
screenshot = await sb.screenshot()
assert screenshot[:4] == b"\x89PNG"
assert len(screenshot) > 10000
@pytest.mark.skipif(not IS_WINDOWS, reason="Windows host only")
@pytest.mark.skipif(not _has_qemu(), reason="QEMU not available")
async def test_auto_runtime_windows():
"""local=True with no runtime auto-selects HyperV or QEMU for Windows images."""
async with Sandbox.ephemeral(
Image.windows("11"),
local=True,
name="cua-test-auto-windows",
) as sb:
result = await sb.shell.run("ver")
assert result.success or "Windows" in result.stdout
screenshot = await sb.screenshot()
assert screenshot[:4] == b"\x89PNG"
# ── 11. Cloud sandboxes (local=False) ────────────────────────────────────────
@pytest.mark.skipif(not _has_cua_api_key(), reason="CUA_API_KEY not set")
async def test_cloud_linux_ephemeral():
"""Cloud sandbox: Sandbox.ephemeral with linux image (local=False)."""
async with Sandbox.ephemeral(
Image.linux("ubuntu", "24.04"),
) as sb:
result = await sb.shell.run("uname -s")
assert result.success
assert "Linux" in result.stdout
screenshot = await sb.screenshot()
assert screenshot[:4] == b"\x89PNG"
@pytest.mark.skipif(not _has_cua_api_key(), reason="CUA_API_KEY not set")
async def test_cloud_android_ephemeral():
"""Cloud sandbox: Sandbox.ephemeral with android image (local=False)."""
async with Sandbox.ephemeral(
Image.android("14"),
) as sb:
screenshot = await sb.screenshot()
assert screenshot[:4] == b"\x89PNG"
assert len(screenshot) > 1000
# ── 12. Sandbox.create (persistent) ─────────────────────────────────────────
@pytest.mark.skipif(not _has_docker(), reason="Docker not available")
async def test_create_persistent_linux():
"""Sandbox.create provisions a persistent sandbox that survives disconnect."""
from cua_sandbox.runtime import DockerRuntime
sb = await Sandbox.create(
Image.linux("ubuntu", "24.04"),
local=True,
runtime=DockerRuntime(api_port=18090, vnc_port=18091, ephemeral=True),
name="cua-test-create-linux",
)
try:
result = await sb.shell.run("echo persistent")
assert result.success
assert "persistent" in result.stdout
screenshot = await sb.screenshot()
assert screenshot[:4] == b"\x89PNG"
finally:
await sb.disconnect()
@pytest.mark.skipif(not _has_cua_api_key(), reason="CUA_API_KEY not set")
async def test_create_persistent_cloud_linux():
"""Sandbox.create provisions a persistent cloud sandbox."""
sb = await Sandbox.create(
Image.linux("ubuntu", "24.04"),
name="cua-test-create-cloud-linux",
)
try:
result = await sb.shell.run("echo persistent")
assert result.success
assert "persistent" in result.stdout
finally:
await sb.disconnect()
@@ -0,0 +1,110 @@
"""Snapshot integration tests — images all the way down.
Tests both Linux VM and Android container snapshot workflows:
1. Create sandbox → install something → snapshot → returns Image
2. Boot from snapshot Image → verify install persists → measure fork is faster
3. Verify state isolation (changes after snapshot don't leak to fork)
Run against local dev stack:
CUA_API_KEY=sk-dev-test-key-local-12345 CUA_BASE_URL=http://localhost:8082 \
pytest tests/test_snapshots.py -v -s
"""
from __future__ import annotations
import os
import time
import pytest
from cua_sandbox import Image, Sandbox
pytestmark = pytest.mark.asyncio
def _has_cua_api_key() -> bool:
return bool(os.environ.get("CUA_API_KEY"))
@pytest.mark.skipif(not _has_cua_api_key(), reason="CUA_API_KEY not set")
async def test_snapshot_linux():
"""Linux: create → install cowsay → snapshot → boot from snapshot → verify."""
t_create_start = time.monotonic()
async with Sandbox.ephemeral(Image.linux("ubuntu", "24.04")) as sb:
# Install something unique
result = await sb.shell.run(
"apt-get update -qq && apt-get install -y -qq cowsay", timeout=120
)
assert result.success, f"Install failed: {result.stderr}"
# Measure create+install time only after install completes
t_create = time.monotonic() - t_create_start
# Verify it works
result = await sb.shell.run("/usr/games/cowsay hello")
assert result.success
assert "hello" in result.stdout
# Snapshot → returns Image
cowsay_image = await sb.snapshot(name="with-cowsay")
assert cowsay_image is not None
assert cowsay_image._snapshot_source is not None
# Boot from snapshot image — should be faster (COW fork)
t_fork_start = time.monotonic()
async with Sandbox.ephemeral(cowsay_image) as sb2:
t_fork = time.monotonic() - t_fork_start
# cowsay should still be installed
result = await sb2.shell.run("/usr/games/cowsay forked!")
assert result.success, f"cowsay not found in fork: {result.stderr}"
assert "forked!" in result.stdout
print(f"\nCreate+install: {t_create:.1f}s, Fork from snapshot: {t_fork:.1f}s")
# Fork should be faster than original create + install
assert (
t_fork < t_create
), f"Fork ({t_fork:.1f}s) should be faster than create ({t_create:.1f}s)"
@pytest.mark.skipif(not _has_cua_api_key(), reason="CUA_API_KEY not set")
async def test_snapshot_android():
"""Android: create with F-Droid → snapshot → boot from snapshot → APK persists."""
FDROID_APK = "https://f-droid.org/F-Droid.apk"
t_create_start = time.monotonic()
async with Sandbox.ephemeral(Image.android("14").apk_install(FDROID_APK)) as sb:
t_create = time.monotonic() - t_create_start
# Verify F-Droid installed (sb.shell.run on android → adb shell)
result = await sb.shell.run("pm list packages org.fdroid.fdroid")
assert result.success
assert "org.fdroid.fdroid" in result.stdout
screenshot = await sb.screenshot()
assert screenshot[:4] == b"\x89PNG"
# Snapshot → Image
fdroid_image = await sb.snapshot(name="with-fdroid")
# Boot from snapshot — F-Droid should persist
t_fork_start = time.monotonic()
async with Sandbox.ephemeral(fdroid_image) as sb2:
t_fork = time.monotonic() - t_fork_start
result = await sb2.shell.run("pm list packages org.fdroid.fdroid")
assert result.success, f"F-Droid not found in fork: {result.stderr}"
assert "org.fdroid.fdroid" in result.stdout
screenshot = await sb2.screenshot()
assert screenshot[:4] == b"\x89PNG"
assert len(screenshot) > 1000
print(f"\nCreate+install: {t_create:.1f}s, Fork from snapshot: {t_fork:.1f}s")
# On COW storage (btrfs/zfs), fork is instant. On dir storage the copy
# is a full rsync so it can be slower than the original create. Only
# assert that the fork completed (timing checked on COW backends in CI).
if t_fork < t_create:
print(" → Fork was faster (COW or small image)")
else:
print(" → Fork was slower (dir storage — expected)")
@@ -0,0 +1,64 @@
"""Integration tests specifically for the HTTP transport against a running computer-server.
Skipped unless CUA_TEST_HTTP_URL is set.
"""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.asyncio
class TestHTTPTransport:
async def test_screenshot(self, http_transport):
data = await http_transport.screenshot()
assert isinstance(data, bytes)
assert data[:4] == b"\x89PNG"
async def test_screen_size(self, http_transport):
size = await http_transport.get_screen_size()
assert size["width"] > 0
assert size["height"] > 0
async def test_get_environment(self, http_transport):
env = await http_transport.get_environment()
assert env in ("windows", "mac", "linux", "browser")
async def test_send_click(self, http_transport):
await http_transport.send("left_click", x=100, y=100)
# Should not raise
async def test_send_type(self, http_transport):
await http_transport.send("type_text", text="hello")
async def test_send_get_screen_size(self, http_transport):
result = await http_transport.send("get_screen_size")
assert "width" in result or isinstance(result, dict)
class TestHTTPSandboxFullInterface:
"""Same interface tests as the main suite, but explicitly via HTTP sandbox."""
async def test_screenshot(self, http_sandbox):
data = await http_sandbox.screenshot()
assert data[:4] == b"\x89PNG"
async def test_mouse_click(self, http_sandbox):
await http_sandbox.mouse.click(100, 100)
async def test_keyboard_type(self, http_sandbox):
await http_sandbox.keyboard.type("http-test")
async def test_clipboard(self, http_sandbox):
await http_sandbox.clipboard.set("http-clip")
val = await http_sandbox.clipboard.get()
assert val == "http-clip"
async def test_shell(self, http_sandbox):
result = await http_sandbox.shell.run("echo http-shell")
assert result.success
async def test_window_title(self, http_sandbox):
title = await http_sandbox.window.get_active_title()
assert isinstance(title, str)
@@ -0,0 +1,129 @@
"""Unit tests for HTTPTransport._cmd retry-on-5xx behavior.
The computer-server can briefly return 5xx (Traefik dropping the pod
from its endpoint list, grpc fork hiccups). The transport retries
up to 3 times with exponential backoff for 5xx responses. 4xx
errors are raised immediately. httpx transport exceptions (read
timeout, connection reset) are not retried because the command may
have already started on the server.
"""
from __future__ import annotations
import json
import httpx
import pytest
from cua_sandbox.transport.http import HTTPTransport
pytestmark = pytest.mark.asyncio
def _sse_body(payload: dict) -> str:
return f"data: {json.dumps(payload)}\n\n"
async def _make_transport(handler) -> HTTPTransport:
t = HTTPTransport("http://server:8000", timeout=5.0)
# Patch the async client after connect so the mock transport is in use.
await t.connect()
assert t._client is not None
await t._client.aclose()
t._client = httpx.AsyncClient(
base_url="http://server:8000",
transport=httpx.MockTransport(handler),
timeout=5.0,
)
return t
async def test_cmd_success_no_retry():
calls = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append(request.url.path)
return httpx.Response(200, text=_sse_body({"width": 1, "height": 2}))
t = await _make_transport(handler)
result = await t._cmd("get_screen_size")
assert result == {"width": 1, "height": 2}
assert len(calls) == 1, "success should not retry"
async def test_cmd_retries_5xx_then_succeeds(monkeypatch):
# Skip sleeps so the test runs instantly.
import cua_sandbox.transport.http as http_mod
async def _no_sleep(_seconds):
pass
monkeypatch.setattr(http_mod.asyncio, "sleep", _no_sleep)
responses = iter(
[
httpx.Response(503, text="service unavailable"),
httpx.Response(502, text="bad gateway"),
httpx.Response(200, text=_sse_body({"ok": True})),
]
)
calls = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append(request.url.path)
return next(responses)
t = await _make_transport(handler)
result = await t._cmd("screenshot")
assert result == {"ok": True}
assert len(calls) == 3, "should retry twice before succeeding"
async def test_cmd_gives_up_after_max_retries(monkeypatch):
import cua_sandbox.transport.http as http_mod
async def _no_sleep(_seconds):
pass
monkeypatch.setattr(http_mod.asyncio, "sleep", _no_sleep)
calls = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append(request.url.path)
return httpx.Response(503, text="still unavailable")
t = await _make_transport(handler)
with pytest.raises(httpx.HTTPStatusError) as exc_info:
await t._cmd("screenshot")
assert exc_info.value.response.status_code == 503
# _CMD_MAX_RETRIES (3) attempts total, then raise.
assert len(calls) == 3
async def test_cmd_does_not_retry_4xx():
calls = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append(request.url.path)
return httpx.Response(401, text="unauthorized")
t = await _make_transport(handler)
with pytest.raises(httpx.HTTPStatusError) as exc_info:
await t._cmd("screenshot")
assert exc_info.value.response.status_code == 401
assert len(calls) == 1, "4xx should not retry"
async def test_cmd_does_not_retry_read_timeout():
calls = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append(request.url.path)
raise httpx.ReadTimeout("server too slow", request=request)
t = await _make_transport(handler)
with pytest.raises(httpx.ReadTimeout):
await t._cmd("run_command", params={"command": "slow"})
# ReadTimeout means the request reached the server — don't retry
# or we risk double-executing a non-idempotent command.
assert len(calls) == 1
@@ -0,0 +1,336 @@
"""Unit tests for VM cleanup on connection failure and destroy() resilience.
These tests mock CloudTransport so they run without a real cloud API.
They verify that:
1. _create() cleans up a provisioned VM when _connect() fails.
2. destroy() runs every cleanup step independently — a failure in one
does not prevent the others from executing.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from cua_sandbox.image import Image
from cua_sandbox.sandbox import Sandbox
from cua_sandbox.transport.cloud import CloudTransport
pytestmark = pytest.mark.asyncio
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_cloud_transport(*, name: str = "test-vm") -> CloudTransport:
"""Return a CloudTransport with internal state set as if _create_vm() succeeded."""
t = CloudTransport.__new__(CloudTransport)
t._name = name
t._api_key_override = "sk-fake"
t._base_url = "https://api.example.com"
t._image = None
t._cpu = None
t._memory_mb = None
t._disk_gb = None
t._region = "us-east-1"
t._inner = None
t._api_client = None
return t
def _make_sandbox(transport: CloudTransport, **kwargs) -> Sandbox:
"""Return a Sandbox wrapping *transport* without calling _connect()."""
return Sandbox(
transport,
name=transport._name,
_ephemeral=kwargs.get("ephemeral", True),
_telemetry_enabled=False,
)
# ===================================================================
# 1. _create() cleans up on _connect() failure
# ===================================================================
class TestCreateCleansUpOnConnectFailure:
"""Sandbox._create() should delete the cloud VM when _connect() raises."""
async def test_delete_vm_called_on_timeout(self):
"""ReadTimeout during _connect() triggers delete_vm()."""
transport = _make_cloud_transport(name="orphan-vm")
transport.connect = AsyncMock(side_effect=httpx.ReadTimeout("poll timed out"))
transport.delete_vm = AsyncMock()
with patch(
"cua_sandbox.sandbox.CloudTransport",
return_value=transport,
):
with pytest.raises(httpx.ReadTimeout):
await Sandbox._create(
image=Image.linux("ubuntu", "24.04"),
api_key="sk-fake",
telemetry_enabled=False,
)
transport.delete_vm.assert_awaited_once()
async def test_delete_vm_called_on_generic_exception(self):
"""Any exception during _connect() triggers delete_vm()."""
transport = _make_cloud_transport(name="orphan-vm")
transport.connect = AsyncMock(side_effect=RuntimeError("unexpected"))
transport.delete_vm = AsyncMock()
with patch(
"cua_sandbox.sandbox.CloudTransport",
return_value=transport,
):
with pytest.raises(RuntimeError, match="unexpected"):
await Sandbox._create(
image=Image.linux("ubuntu", "24.04"),
api_key="sk-fake",
telemetry_enabled=False,
)
transport.delete_vm.assert_awaited_once()
async def test_original_exception_propagates_even_if_delete_fails(self):
"""The original connect error is re-raised even when delete_vm also fails."""
transport = _make_cloud_transport(name="orphan-vm")
transport.connect = AsyncMock(side_effect=TimeoutError("poll timeout"))
transport.delete_vm = AsyncMock(side_effect=httpx.ConnectError("api down"))
with patch(
"cua_sandbox.sandbox.CloudTransport",
return_value=transport,
):
with pytest.raises(TimeoutError, match="poll timeout"):
await Sandbox._create(
image=Image.linux("ubuntu", "24.04"),
api_key="sk-fake",
telemetry_enabled=False,
)
transport.delete_vm.assert_awaited_once()
async def test_no_cleanup_when_vm_not_yet_created(self):
"""If connect() fails before _create_vm (no _name), skip delete_vm."""
transport = _make_cloud_transport()
# Simulate: connect() fails before _create_vm sets _name
transport._name = None
transport.connect = AsyncMock(side_effect=ValueError("no api key"))
transport.delete_vm = AsyncMock()
with patch(
"cua_sandbox.sandbox.CloudTransport",
return_value=transport,
):
with pytest.raises(ValueError, match="no api key"):
await Sandbox._create(
image=Image.linux("ubuntu", "24.04"),
api_key="sk-fake",
telemetry_enabled=False,
)
transport.delete_vm.assert_not_awaited()
async def test_keyboard_interrupt_still_cleans_up(self):
"""BaseException subclasses (KeyboardInterrupt) also trigger cleanup."""
transport = _make_cloud_transport(name="interrupted-vm")
transport.connect = AsyncMock(side_effect=KeyboardInterrupt)
transport.delete_vm = AsyncMock()
with patch(
"cua_sandbox.sandbox.CloudTransport",
return_value=transport,
):
with pytest.raises(KeyboardInterrupt):
await Sandbox._create(
image=Image.linux("ubuntu", "24.04"),
api_key="sk-fake",
telemetry_enabled=False,
)
transport.delete_vm.assert_awaited_once()
# ===================================================================
# 2. destroy() is resilient to individual step failures
# ===================================================================
class TestDestroyResilience:
"""Each cleanup step in destroy() should run independently."""
async def test_delete_vm_runs_even_if_disconnect_fails(self):
"""A failing disconnect() must not prevent delete_vm()."""
transport = _make_cloud_transport(name="leaky-vm")
transport.disconnect = AsyncMock(side_effect=OSError("connection reset"))
transport.delete_vm = AsyncMock()
sb = _make_sandbox(transport)
await sb.destroy()
transport.disconnect.assert_awaited_once()
transport.delete_vm.assert_awaited_once()
async def test_runtime_stop_runs_even_if_delete_vm_fails(self):
"""A failing delete_vm() must not prevent runtime cleanup."""
transport = _make_cloud_transport(name="leaky-vm")
transport.disconnect = AsyncMock()
transport.delete_vm = AsyncMock(side_effect=httpx.ConnectError("api down"))
runtime = AsyncMock()
runtime_info = MagicMock()
runtime_info.name = "leaky-vm"
sb = _make_sandbox(transport)
sb._runtime = runtime
sb._runtime_info = runtime_info
await sb.destroy()
transport.delete_vm.assert_awaited_once()
runtime.delete.assert_awaited_once_with("leaky-vm")
async def test_destroy_succeeds_when_all_steps_fail(self):
"""destroy() must not raise even if every cleanup step fails."""
transport = _make_cloud_transport(name="total-fail")
transport.disconnect = AsyncMock(side_effect=OSError("disconnect fail"))
transport.delete_vm = AsyncMock(side_effect=httpx.ReadTimeout("delete fail"))
runtime = AsyncMock()
runtime.delete = AsyncMock(side_effect=RuntimeError("runtime fail"))
runtime_info = MagicMock()
runtime_info.name = "total-fail"
sb = _make_sandbox(transport)
sb._runtime = runtime
sb._runtime_info = runtime_info
# Should not raise
await sb.destroy()
transport.disconnect.assert_awaited_once()
transport.delete_vm.assert_awaited_once()
runtime.delete.assert_awaited_once()
async def test_destroy_happy_path(self):
"""All steps succeed — basic smoke test."""
transport = _make_cloud_transport(name="good-vm")
transport.disconnect = AsyncMock()
transport.delete_vm = AsyncMock()
sb = _make_sandbox(transport)
await sb.destroy()
transport.disconnect.assert_awaited_once()
transport.delete_vm.assert_awaited_once()
async def test_non_cloud_transport_skips_delete_vm(self):
"""Non-CloudTransport sandboxes should not call delete_vm."""
transport = AsyncMock() # generic mock, not a CloudTransport instance
sb = Sandbox(transport, name="local-vm", _ephemeral=True, _telemetry_enabled=False)
await sb.destroy()
transport.disconnect.assert_awaited_once()
# delete_vm should not be called since transport is not CloudTransport
assert not hasattr(transport, "delete_vm") or not transport.delete_vm.called
# ===================================================================
# 3. ephemeral() integration — cleanup through the context manager
# ===================================================================
class TestEphemeralCleanup:
"""Sandbox.ephemeral() should destroy the VM on normal and error exits."""
async def test_ephemeral_destroys_on_normal_exit(self):
"""VM is destroyed when the async-with block exits normally."""
transport = _make_cloud_transport(name="eph-ok")
transport.connect = AsyncMock()
transport.disconnect = AsyncMock()
transport.delete_vm = AsyncMock()
with (
patch.object(
CloudTransport,
"__init__",
lambda self, **kw: None,
),
patch.object(
CloudTransport,
"__new__",
lambda cls, **kw: transport,
),
):
async with Sandbox.ephemeral(
Image.linux("ubuntu", "24.04"),
api_key="sk-fake",
telemetry_enabled=False,
) as sb:
assert sb.name == "eph-ok"
transport.delete_vm.assert_awaited_once()
async def test_ephemeral_destroys_on_test_failure(self):
"""VM is destroyed even when the body raises an assertion error."""
transport = _make_cloud_transport(name="eph-fail")
transport.connect = AsyncMock()
transport.disconnect = AsyncMock()
transport.delete_vm = AsyncMock()
with (
patch.object(
CloudTransport,
"__init__",
lambda self, **kw: None,
),
patch.object(
CloudTransport,
"__new__",
lambda cls, **kw: transport,
),
):
with pytest.raises(AssertionError):
async with Sandbox.ephemeral(
Image.linux("ubuntu", "24.04"),
api_key="sk-fake",
telemetry_enabled=False,
) as _sb:
raise AssertionError("test failed")
transport.delete_vm.assert_awaited_once()
async def test_ephemeral_cleans_up_when_create_connect_fails(self):
"""If _create raises after VM provisioning, the VM is still cleaned up."""
transport = _make_cloud_transport(name="eph-connect-fail")
transport.connect = AsyncMock(side_effect=httpx.ReadTimeout("poll timed out"))
transport.delete_vm = AsyncMock()
with (
patch.object(
CloudTransport,
"__init__",
lambda self, **kw: None,
),
patch.object(
CloudTransport,
"__new__",
lambda cls, **kw: transport,
),
):
with pytest.raises(httpx.ReadTimeout):
async with Sandbox.ephemeral(
Image.linux("ubuntu", "24.04"),
api_key="sk-fake",
telemetry_enabled=False,
) as _sb:
pass # never reached
transport.delete_vm.assert_awaited_once()
@@ -0,0 +1,112 @@
"""Windows Server E2E test using cua-sandbox SDK.
Run:
CUA_API_KEY=sk-dev-test-key-local-12345 CUA_BASE_URL=http://localhost:8082 \
uv run pytest tests/test_windows_cloud.py -v -s
"""
import os
import time
import pytest
from cua_sandbox import Image, Sandbox
pytestmark = pytest.mark.asyncio
def P(*a, **kw):
print(*a, **kw, flush=True)
def _has_env() -> bool:
return bool(os.environ.get("CUA_API_KEY"))
@pytest.mark.skipif(not _has_env(), reason="CUA_API_KEY not set")
async def test_windows_create_and_snapshot():
"""Image.windows('server-2025') -> Sandbox.ephemeral -> snapshot -> fork."""
t0 = time.monotonic()
P("\n Creating Sandbox with Image.windows('server-2025')...")
async with Sandbox.ephemeral(Image.windows("server-2025"), local=False) as sb:
t_create = time.monotonic() - t0
P(f" Sandbox ready: {t_create:.1f}s name={sb.name}")
# Try screenshot
try:
screen = await sb.screenshot()
P(f" Screenshot: {len(screen)} bytes")
except Exception as e:
P(f" Screenshot not available: {e}")
screen = None
# Snapshot
t1 = time.monotonic()
P(" Taking snapshot...")
snapshot_img = await sb.snapshot()
t_snap = time.monotonic() - t1
P(f" Snapshot: {t_snap:.2f}s")
# Fork from snapshot
t2 = time.monotonic()
P(" Forking from snapshot...")
async with Sandbox.ephemeral(snapshot_img, local=False) as fork:
t_fork = time.monotonic() - t2
P(f" Fork ready: {t_fork:.1f}s name={fork.name}")
try:
fork_screen = await fork.screenshot()
P(f" Fork screenshot: {len(fork_screen)} bytes")
except Exception as e:
P(f" Fork screenshot not available: {e}")
fork_screen = None
t_total = time.monotonic() - t0
P("\n === TIMINGS ===")
P(f" Create+ready: {t_create:.1f}s")
P(f" Snapshot: {t_snap:.2f}s")
P(f" Fork+ready: {t_fork:.1f}s")
P(f" Total: {t_total:.1f}s")
@pytest.mark.skipif(not _has_env(), reason="CUA_API_KEY not set")
async def test_windows_stateful_snapshot_fork():
"""Stateful snapshot captures RAM — fork resumes instantly without rebooting."""
t0 = time.monotonic()
P("\n Creating Sandbox with Image.windows('server-2025')...")
async with Sandbox.ephemeral(Image.windows("server-2025"), local=False) as sb:
t_create = time.monotonic() - t0
P(f" Sandbox ready: {t_create:.1f}s name={sb.name}")
# Verify CUA server is running
screen = await sb.screenshot()
P(f" Screenshot: {len(screen)} bytes")
# Stateful snapshot — captures memory state
t1 = time.monotonic()
P(" Taking stateful snapshot...")
snapshot_img = await sb.snapshot(stateful=True)
t_snap = time.monotonic() - t1
P(f" Stateful snapshot: {t_snap:.2f}s")
# Fork from stateful snapshot — should resume instantly
t2 = time.monotonic()
P(" Forking from stateful snapshot...")
async with Sandbox.ephemeral(snapshot_img, local=False) as fork:
t_fork = time.monotonic() - t2
P(f" Fork ready: {t_fork:.1f}s name={fork.name}")
# Fork should have CUA server immediately available (no reboot)
fork_screen = await fork.screenshot()
P(f" Fork screenshot: {len(fork_screen)} bytes")
assert len(fork_screen) > 1000, "Fork screenshot should be non-trivial"
t_total = time.monotonic() - t0
P("\n === STATEFUL TIMINGS ===")
P(f" Create+ready: {t_create:.1f}s")
P(f" Stateful snapshot: {t_snap:.2f}s")
P(f" Fork+ready: {t_fork:.1f}s (should be <10s with stateful)")
P(f" Total: {t_total:.1f}s")
@@ -0,0 +1,86 @@
"""Compare Windows VM restore timings: fresh create vs snapshot vs stateful snapshot.
Run:
CUA_API_KEY=sk-dev-test-key-local-12345 CUA_BASE_URL=http://localhost:8082 \
uv run pytest tests/test_windows_timing.py -v -s
"""
import os
import time
import pytest
from cua_sandbox import Image, Sandbox
pytestmark = pytest.mark.asyncio
def P(*a, **kw):
print(*a, **kw, flush=True)
def _has_env() -> bool:
return bool(os.environ.get("CUA_API_KEY"))
@pytest.mark.skipif(not _has_env(), reason="CUA_API_KEY not set")
async def test_windows_all_restore_modes():
"""Compare: Image.windows() vs snapshot() vs snapshot(stateful=True) fork times."""
results = {}
# ── 1. Fresh create from Image.windows("server-2025") ──
P("\n [1/3] Creating fresh VM from Image.windows('server-2025')...")
t0 = time.monotonic()
async with Sandbox.ephemeral(Image.windows("server-2025"), local=False) as sb:
t_create = time.monotonic() - t0
P(f" Ready in {t_create:.1f}s name={sb.name}")
results["create"] = t_create
screen = await sb.screenshot()
P(f" Screenshot: {len(screen)} bytes")
# ── 2. Non-stateful snapshot + fork ──
P("\n [2/3] Taking non-stateful snapshot...")
t1 = time.monotonic()
snap_img = await sb.snapshot(stateful=False)
t_snap = time.monotonic() - t1
P(f" Snapshot: {t_snap:.2f}s")
results["snapshot"] = t_snap
P(" Forking from non-stateful snapshot...")
t2 = time.monotonic()
async with Sandbox.ephemeral(snap_img, local=False) as fork1:
t_fork1 = time.monotonic() - t2
P(f" Fork ready: {t_fork1:.1f}s name={fork1.name}")
results["fork_nonstateful"] = t_fork1
fork1_screen = await fork1.screenshot()
P(f" Screenshot: {len(fork1_screen)} bytes")
# ── 3. Stateful snapshot + fork ──
P("\n [3/3] Taking stateful snapshot...")
t3 = time.monotonic()
snap_stateful = await sb.snapshot(stateful=True)
t_snap_s = time.monotonic() - t3
P(f" Stateful snapshot: {t_snap_s:.2f}s")
results["snapshot_stateful"] = t_snap_s
P(" Forking from stateful snapshot...")
t4 = time.monotonic()
async with Sandbox.ephemeral(snap_stateful, local=False) as fork2:
t_fork2 = time.monotonic() - t4
P(f" Fork ready: {t_fork2:.1f}s name={fork2.name}")
results["fork_stateful"] = t_fork2
fork2_screen = await fork2.screenshot()
P(f" Screenshot: {len(fork2_screen)} bytes")
P(f"\n {'='*50}")
P(" TIMING COMPARISON")
P(f" {'='*50}")
P(f" Image.windows() create: {results['create']:6.1f}s")
P(f" snapshot(stateful=False): {results['snapshot']:6.2f}s")
P(f" fork from non-stateful: {results['fork_nonstateful']:6.1f}s")
P(f" snapshot(stateful=True): {results['snapshot_stateful']:6.2f}s")
P(f" fork from stateful: {results['fork_stateful']:6.1f}s")
P(f" {'='*50}")
+4960
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB