91e75e620b
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Has been cancelled
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Has been cancelled
CD: Docs MCP Server / build (linux/amd64) (push) Has been cancelled
CD: Docs MCP Server / build (linux/arm64) (push) Has been cancelled
CD: Docs MCP Server / merge (push) Has been cancelled
CI: cua-driver distro-compat matrix / Resolve release version (push) Has been cancelled
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Has been cancelled
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Has been cancelled
CI: cua-driver distro-compat matrix / Distro compat summary (push) Has been cancelled
CI: Rust Linux unit / Rust Linux unit and compile (push) Has been cancelled
CI: Rust Windows unit / Rust Windows unit and compile (push) Has been cancelled
CI: Nix Linux Rust source / Nix / compositor build (push) Has been cancelled
CI: Nix Linux Rust source / Nix / driver package (push) Has been cancelled
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Has been cancelled
480 lines
19 KiB
Python
480 lines
19 KiB
Python
"""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})"
|
|
)
|