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

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})"
)