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
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:
@@ -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}"
|
||||
@@ -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) {{ & $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 5554–5682; 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 5554–5682")
|
||||
|
||||
|
||||
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",
|
||||
]
|
||||
+216
File diff suppressed because one or more lines are too long
+2568
File diff suppressed because it is too large
Load Diff
@@ -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 (0–32767)."""
|
||||
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")
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
Generated
+4960
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Reference in New Issue
Block a user