91e75e620b
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Has been cancelled
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Has been cancelled
CD: Docs MCP Server / build (linux/amd64) (push) Has been cancelled
CD: Docs MCP Server / build (linux/arm64) (push) Has been cancelled
CD: Docs MCP Server / merge (push) Has been cancelled
CI: cua-driver distro-compat matrix / Resolve release version (push) Has been cancelled
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Has been cancelled
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Has been cancelled
CI: cua-driver distro-compat matrix / Distro compat summary (push) Has been cancelled
CI: Rust Linux unit / Rust Linux unit and compile (push) Has been cancelled
CI: Rust Windows unit / Rust Windows unit and compile (push) Has been cancelled
CI: Nix Linux Rust source / Nix / compositor build (push) Has been cancelled
CI: Nix Linux Rust source / Nix / driver package (push) Has been cancelled
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Has been cancelled
1006 lines
37 KiB
Python
1006 lines
37 KiB
Python
"""QEMU runtime — bare-metal or Docker-wrapped QEMU VMs.
|
||
|
||
Two modes:
|
||
QEMURuntime(mode="docker") — default, uses trycua/cua-qemu-* Docker images
|
||
QEMURuntime(mode="bare-metal") — launches qemu-system-* directly on the host
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json as _json
|
||
import logging
|
||
import platform as _plat
|
||
import shutil
|
||
import subprocess
|
||
from pathlib import Path
|
||
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.docker import DockerRuntime, _has_kvm
|
||
from cua_sandbox.runtime.images import (
|
||
DEFAULT_API_PORT,
|
||
QEMU_VNC_PORT,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# ── Storage directory for QEMU disk images ──────────────────────────────────
|
||
|
||
QEMU_STORAGE_ROOT = Path.home() / ".cua" / "cua-sandbox" / "qemu-storage"
|
||
|
||
|
||
async def _qmp_command(
|
||
host: str, port: int, command: str, arguments: Optional[dict] = None
|
||
) -> dict:
|
||
"""Send a single QMP command and return the response."""
|
||
reader, writer = await asyncio.open_connection(host, port)
|
||
try:
|
||
# Read greeting
|
||
await asyncio.wait_for(reader.readline(), timeout=5)
|
||
# Negotiate capabilities
|
||
writer.write(b'{"execute":"qmp_capabilities"}\n')
|
||
await writer.drain()
|
||
await asyncio.wait_for(reader.readline(), timeout=5)
|
||
# Send command
|
||
msg: dict = {"execute": command}
|
||
if arguments:
|
||
msg["arguments"] = arguments
|
||
writer.write((_json.dumps(msg) + "\n").encode())
|
||
await writer.drain()
|
||
raw = await asyncio.wait_for(reader.readline(), timeout=30)
|
||
return _json.loads(raw)
|
||
finally:
|
||
writer.close()
|
||
try:
|
||
await writer.wait_closed()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
class QEMUDockerRuntime(DockerRuntime):
|
||
"""QEMU inside Docker — delegates to DockerRuntime with QEMU image tags.
|
||
|
||
For Windows QEMU images, automatically:
|
||
- Creates a /storage volume mount for the QEMU disk
|
||
- Sets KVM=N if /dev/kvm is not available
|
||
- Mounts an existing cached disk image if available
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
api_port: int = DEFAULT_API_PORT,
|
||
vnc_port: int = QEMU_VNC_PORT,
|
||
ephemeral: bool = True,
|
||
storage_dir: Optional[str | Path] = None,
|
||
memory_mb: int = 8192,
|
||
cpu_count: int = 4,
|
||
):
|
||
self._storage_dir = Path(storage_dir) if storage_dir else None
|
||
self._memory_mb = memory_mb
|
||
self._cpu_count = cpu_count
|
||
super().__init__(api_port=api_port, vnc_port=vnc_port, ephemeral=ephemeral)
|
||
|
||
async def start(self, image: Image, name: str, **opts) -> RuntimeInfo:
|
||
# Resolve storage directory
|
||
storage = self._storage_dir or QEMU_STORAGE_ROOT / name
|
||
storage.mkdir(parents=True, exist_ok=True)
|
||
|
||
# Volume: host storage dir → /storage in container
|
||
self.volumes = [f"{storage}:/storage"]
|
||
|
||
# Environment
|
||
self.environment = {
|
||
"RAM_SIZE": f"{self._memory_mb // 1024}G",
|
||
"CPU_CORES": str(self._cpu_count),
|
||
}
|
||
|
||
# KVM handling
|
||
if _has_kvm():
|
||
self.devices = ["/dev/kvm"]
|
||
else:
|
||
self.environment["KVM"] = "N"
|
||
|
||
# Platform — QEMU Windows/Android images are linux/amd64
|
||
if image.os_type in ("windows", "android"):
|
||
self.platform = "linux/amd64"
|
||
|
||
# Android: forward ADB port (5555) in addition to API
|
||
if image.os_type == "android":
|
||
self.environment["ADB_PORT"] = "5555"
|
||
|
||
# Longer boot timeout for Windows/Android VMs
|
||
opts.pop("boot_timeout", None)
|
||
|
||
info = await super().start(image, name, **opts)
|
||
return info
|
||
|
||
async def is_ready(self, info: RuntimeInfo, timeout: float = 300) -> bool:
|
||
"""Wait for the QEMU VM's computer-server to come up.
|
||
|
||
Windows VMs take longer to boot (3-5 min), so default timeout is 300s.
|
||
"""
|
||
return await super().is_ready(info, timeout=timeout)
|
||
|
||
async def suspend(self, name: str) -> None:
|
||
"""Pause the Docker container running this QEMU VM."""
|
||
subprocess.run(["docker", "pause", name], capture_output=True)
|
||
|
||
async def resume(self, image: "Image", name: str, **opts) -> RuntimeInfo:
|
||
"""Unpause the Docker container and return RuntimeInfo."""
|
||
subprocess.run(["docker", "unpause", name], capture_output=True)
|
||
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
|
||
info = RuntimeInfo(host="localhost", api_port=api_port, vnc_port=self.vnc_port, name=name)
|
||
await self.is_ready(info)
|
||
return info
|
||
|
||
|
||
class QEMUBaremetalRuntime(Runtime):
|
||
"""Bare-metal QEMU — launches qemu-system-* directly on the host.
|
||
|
||
Requires:
|
||
- qemu-system-x86_64 (or qemu-system-aarch64) on PATH
|
||
- A disk image (qcow2/vhdx/raw) with computer-server pre-installed,
|
||
provided via Image.from_file('/path/to/disk.qcow2')
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
api_port: int = DEFAULT_API_PORT,
|
||
vnc_display: int = 0,
|
||
memory_mb: int = 4096,
|
||
cpu_count: int = 2,
|
||
arch: str = "x86_64",
|
||
qmp_port: int = 4444,
|
||
use_qmp_transport: bool = False,
|
||
extra_args: Optional[list[str]] = None,
|
||
):
|
||
self.api_port = api_port
|
||
self.vnc_display = vnc_display
|
||
self.memory_mb = memory_mb
|
||
self.cpu_count = cpu_count
|
||
self.arch = arch
|
||
self.qmp_port = qmp_port
|
||
self.use_qmp_transport = use_qmp_transport
|
||
self.extra_args = extra_args or []
|
||
self._processes: dict[str, subprocess.Popen] = {}
|
||
|
||
def _qemu_bin(self) -> str:
|
||
from cua_sandbox.runtime.qemu_installer import qemu_bin
|
||
|
||
return qemu_bin(self.arch)
|
||
|
||
@staticmethod
|
||
def _create_disk_for_iso(name: str, size_gb: int = 32) -> Path:
|
||
"""Create a qcow2 disk image for ISO-based installations.
|
||
|
||
The disk is stored alongside other QEMU storage and reused across sessions.
|
||
"""
|
||
disk_dir = QEMU_STORAGE_ROOT / name
|
||
disk_dir.mkdir(parents=True, exist_ok=True)
|
||
disk_path = disk_dir / "disk.qcow2"
|
||
if not disk_path.exists():
|
||
result = subprocess.run(
|
||
["qemu-img", "create", "-f", "qcow2", str(disk_path), f"{size_gb}G"],
|
||
capture_output=True,
|
||
text=True,
|
||
)
|
||
if result.returncode != 0:
|
||
raise RuntimeError(f"qemu-img create failed: {result.stderr}")
|
||
return disk_path
|
||
|
||
async def start(self, image: Image, name: str, **opts) -> RuntimeInfo:
|
||
ephemeral = opts.pop("ephemeral", True)
|
||
|
||
# If image has layers or no direct disk path, use the builder to resolve
|
||
if not opts.get("disk_path") and not image._disk_path and image.kind == "vm":
|
||
from cua_sandbox.builder.build import create_session_disk
|
||
|
||
disk_path = str(await create_session_disk(image, name))
|
||
elif image._layers and (image._disk_path or opts.get("disk_path")):
|
||
# Has a base disk AND user layers — build user image + session overlay
|
||
from cua_sandbox.builder.build import create_session_disk
|
||
|
||
base = Path(opts.get("disk_path") or image._disk_path)
|
||
disk_path = str(await create_session_disk(image, name, base_disk=base))
|
||
else:
|
||
disk_path = opts.get("disk_path") or image._disk_path
|
||
|
||
if not disk_path:
|
||
raise ValueError(
|
||
"Bare-metal QEMU requires a disk image path. "
|
||
"Use Image.from_file('/path/to/disk.qcow2') or pass disk_path='...'"
|
||
)
|
||
|
||
# Handle ISO files — create a qcow2 disk and boot from ISO as CD-ROM
|
||
self._iso_path: Optional[str] = None
|
||
if Path(disk_path).suffix.lower() == ".iso":
|
||
self._iso_path = disk_path
|
||
disk_path = str(self._create_disk_for_iso(name, opts.get("disk_size_gb", 32)))
|
||
logger.info(f"Created qcow2 disk for ISO install: {disk_path}")
|
||
|
||
self._session_disk = Path(disk_path) if ephemeral else None
|
||
|
||
from cua_sandbox.runtime.docker import _find_free_port
|
||
|
||
memory = opts.get("memory_mb", self.memory_mb)
|
||
cpus = opts.get("cpu_count", self.cpu_count)
|
||
vnc_display = opts.get("vnc_display", self.vnc_display)
|
||
hostfwd_port = opts.get("api_port") or _find_free_port(self.api_port)
|
||
enable_kvm = opts.get("enable_kvm", True)
|
||
|
||
# Detect guest server port from transport hint
|
||
guest_port = 5000 if image._agent_type == "osworld" else 8000
|
||
|
||
# Detect disk format from extension
|
||
disk_ext = Path(disk_path).suffix.lower()
|
||
disk_fmt = {".qcow2": "qcow2", ".vhdx": "vhdx", ".raw": "raw", ".img": "raw"}.get(
|
||
disk_ext, "raw"
|
||
)
|
||
|
||
# Locate OVMF UEFI firmware for Windows VMs
|
||
qemu_dir = Path(self._qemu_bin()).parent
|
||
ovmf_code = None
|
||
if image.os_type == "windows":
|
||
for candidate in [
|
||
qemu_dir / "share" / "edk2-x86_64-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
|
||
|
||
# EFI vars — look next to disk or copy template
|
||
efivars = Path(disk_path).parent / "efivars.fd"
|
||
if ovmf_code and not efivars.exists():
|
||
import shutil as _shutil
|
||
|
||
vars_template = qemu_dir / "share" / "edk2-i386-vars.fd"
|
||
if vars_template.exists():
|
||
_shutil.copy2(vars_template, efivars)
|
||
else:
|
||
efivars.write_bytes(b"\x00" * (256 * 1024))
|
||
|
||
# Build QEMU command — Android gets different machine/device config
|
||
is_android = image.os_type == "android"
|
||
|
||
if is_android:
|
||
cmd = self._build_android_cmd(
|
||
name,
|
||
disk_path,
|
||
disk_fmt,
|
||
memory,
|
||
cpus,
|
||
hostfwd_port,
|
||
vnc_display,
|
||
enable_kvm,
|
||
)
|
||
else:
|
||
cmd = [
|
||
self._qemu_bin(),
|
||
"-name",
|
||
name,
|
||
"-machine",
|
||
"q35,smm=off",
|
||
"-m",
|
||
str(memory),
|
||
"-smp",
|
||
str(cpus),
|
||
"-cpu",
|
||
"qemu64,+ssse3,+sse4.1,+sse4.2,+popcnt",
|
||
]
|
||
|
||
# UEFI firmware (Windows requires this)
|
||
if ovmf_code:
|
||
cmd += [
|
||
"-drive",
|
||
f"if=pflash,format=raw,readonly=on,file={ovmf_code}",
|
||
"-drive",
|
||
f"if=pflash,format=raw,file={efivars}",
|
||
]
|
||
|
||
cmd += [
|
||
"-drive",
|
||
f"file={disk_path},format={disk_fmt},if=virtio",
|
||
"-netdev",
|
||
f"user,id=net0,restrict=on,hostfwd=tcp:127.0.0.1:{hostfwd_port}-:{guest_port}",
|
||
"-device",
|
||
"virtio-net-pci,netdev=net0,mac=52:55:00:d1:55:01",
|
||
"-vnc",
|
||
f":{vnc_display}",
|
||
]
|
||
|
||
# -daemonize not supported on Windows
|
||
if _plat.system() != "Windows":
|
||
cmd.append("-daemonize")
|
||
|
||
if enable_kvm:
|
||
if _plat.system() == "Darwin":
|
||
host_arm = _plat.machine() in ("arm64", "aarch64")
|
||
guest_x86 = self.arch == "x86_64"
|
||
if host_arm and guest_x86:
|
||
# Apple Silicon can't HVF-accelerate x86_64 guests
|
||
cmd += ["-accel", "tcg"]
|
||
else:
|
||
cmd += ["-accel", "hvf"]
|
||
elif _plat.system() != "Windows":
|
||
cmd.append("-enable-kvm")
|
||
elif ovmf_code:
|
||
# WHPX has MMIO bugs with OVMF pflash — use TCG for UEFI VMs
|
||
cmd += ["-accel", "tcg"]
|
||
else:
|
||
cmd += ["-accel", "whpx"]
|
||
|
||
# QMP socket — always enabled for VM management (suspend/resume)
|
||
cmd += ["-qmp", f"tcp:127.0.0.1:{self.qmp_port},server,nowait"]
|
||
|
||
# Attach ISO as CD-ROM if booting from an ISO file
|
||
if self._iso_path:
|
||
cmd += ["-cdrom", self._iso_path, "-boot", "d"]
|
||
|
||
cmd.extend(self.extra_args)
|
||
|
||
logger.info(f"Starting bare-metal QEMU: {' '.join(cmd)}")
|
||
if "-daemonize" in cmd:
|
||
# daemonize mode: QEMU forks to background and parent exits immediately
|
||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||
if result.returncode != 0:
|
||
raise RuntimeError(f"QEMU launch failed: {result.stderr}")
|
||
else:
|
||
# Popen mode: keep reference to the process (Windows, or when -daemonize removed)
|
||
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
||
self._processes[name] = proc
|
||
import time
|
||
|
||
time.sleep(2)
|
||
if proc.poll() is not None:
|
||
stderr = proc.stderr.read().decode() if proc.stderr else ""
|
||
raise RuntimeError(f"QEMU launch failed (exit {proc.returncode}): {stderr}")
|
||
|
||
use_qmp = is_android or self.use_qmp_transport
|
||
info = RuntimeInfo(
|
||
host="localhost",
|
||
api_port=hostfwd_port,
|
||
vnc_port=5900 + vnc_display,
|
||
name=name,
|
||
qmp_port=self.qmp_port if use_qmp else None,
|
||
environment=image.os_type if use_qmp else None,
|
||
agent_type=image._agent_type,
|
||
guest_server_port=guest_port if not is_android else 8000,
|
||
)
|
||
|
||
# For ISO boots, send Enter key via QMP to skip GRUB countdown
|
||
if self._iso_path and use_qmp:
|
||
await self._send_boot_key(info)
|
||
|
||
# Windows and Android need much longer to boot (3–10 min)
|
||
boot_timeout = 600 if image.os_type in ("windows", "android") else 120
|
||
await self.is_ready(info, timeout=boot_timeout)
|
||
|
||
if not ephemeral:
|
||
from cua_sandbox import sandbox_state
|
||
|
||
sandbox_state.save(
|
||
name,
|
||
runtime_type="qemu-baremetal",
|
||
image=image.to_dict(),
|
||
host="localhost",
|
||
api_port=hostfwd_port,
|
||
vnc_port=5900 + vnc_display,
|
||
qmp_port=self.qmp_port,
|
||
disk_path=str(disk_path) if disk_path else None,
|
||
os_type=image.os_type,
|
||
vnc_display=vnc_display,
|
||
memory_mb=memory,
|
||
cpu_count=cpus,
|
||
arch=self.arch,
|
||
status="running",
|
||
)
|
||
|
||
return info
|
||
|
||
async def _send_boot_key(self, info: RuntimeInfo) -> None:
|
||
"""Send Enter key via QMP repeatedly to skip GRUB/bootloader countdown.
|
||
|
||
GRUB may not be ready immediately after QEMU starts (especially with TCG),
|
||
so we send Enter multiple times over several seconds to ensure it lands.
|
||
"""
|
||
|
||
for attempt in range(15):
|
||
try:
|
||
reader, writer = await asyncio.open_connection(info.host, info.qmp_port)
|
||
await asyncio.wait_for(reader.readline(), timeout=3)
|
||
writer.write(b'{"execute":"qmp_capabilities"}\n')
|
||
await writer.drain()
|
||
await asyncio.wait_for(reader.readline(), timeout=3)
|
||
# Send Enter key several times with delays to catch GRUB at the right moment
|
||
for i in range(5):
|
||
writer.write(
|
||
b'{"execute":"send-key","arguments":{"keys":[{"type":"qcode","data":"ret"}]}}\n'
|
||
)
|
||
await writer.drain()
|
||
await asyncio.wait_for(reader.readline(), timeout=3)
|
||
await asyncio.sleep(2)
|
||
writer.close()
|
||
await writer.wait_closed()
|
||
logger.info(f"Sent boot Enter keys to {info.name}")
|
||
return
|
||
except (ConnectionRefusedError, OSError, asyncio.TimeoutError):
|
||
await asyncio.sleep(2)
|
||
logger.warning(f"Could not send boot keys to {info.name}")
|
||
|
||
def _build_android_cmd(
|
||
self,
|
||
name: str,
|
||
disk_path: str,
|
||
disk_fmt: str,
|
||
memory: int,
|
||
cpus: int,
|
||
hostfwd_port: int,
|
||
vnc_display: int,
|
||
enable_kvm: bool,
|
||
) -> list[str]:
|
||
"""Build QEMU command for Android x86_64 VM.
|
||
|
||
Android-x86 boots from a disk image with GRUB, uses virtio for
|
||
disk/net, and needs a GPU (virtio-gpu or std) for the display.
|
||
Port-forwards host:hostfwd_port -> guest:8000 for the computer-server
|
||
API, and host:5555 -> guest:5555 for ADB.
|
||
"""
|
||
cmd = [
|
||
self._qemu_bin(),
|
||
"-name",
|
||
name,
|
||
"-machine",
|
||
"q35,smm=off",
|
||
"-m",
|
||
str(memory),
|
||
"-smp",
|
||
str(cpus),
|
||
]
|
||
|
||
# CPU config — use HVF on macOS (aarch64 only), KVM on Linux, TCG fallback
|
||
if _plat.system() == "Darwin":
|
||
# HVF only works when host and guest arch match.
|
||
# Apple Silicon (arm64) can't HVF-accelerate x86_64 guests.
|
||
host_arm = _plat.machine() in ("arm64", "aarch64")
|
||
guest_x86 = self.arch == "x86_64"
|
||
if enable_kvm and not (host_arm and guest_x86):
|
||
cmd += ["-cpu", "host", "-accel", "hvf"]
|
||
else:
|
||
cmd += ["-cpu", "max", "-accel", "tcg"]
|
||
elif enable_kvm and _plat.system() != "Windows":
|
||
cmd += ["-cpu", "host", "-enable-kvm"]
|
||
else:
|
||
cmd += ["-cpu", "qemu64,+ssse3,+sse4.1,+sse4.2,+popcnt"]
|
||
|
||
# Disk — Android-x86 image
|
||
cmd += ["-drive", f"file={disk_path},format={disk_fmt},if=virtio"]
|
||
|
||
# Networking — forward API (8000); ADB forwarded on api_port+1
|
||
adb_port = hostfwd_port + 1
|
||
cmd += [
|
||
"-netdev",
|
||
(
|
||
f"user,id=net0,"
|
||
f"hostfwd=tcp:127.0.0.1:{hostfwd_port}-:8000,"
|
||
f"hostfwd=tcp:127.0.0.1:{adb_port}-:5555"
|
||
),
|
||
"-device",
|
||
"virtio-net-pci,netdev=net0",
|
||
]
|
||
|
||
# Display — std VGA for broad compatibility (virtio-gpu hangs on Apple Silicon TCG)
|
||
cmd += [
|
||
"-vga",
|
||
"std",
|
||
"-vnc",
|
||
f":{vnc_display}",
|
||
"-display",
|
||
"none",
|
||
]
|
||
|
||
# USB tablet for absolute pointer (touch input)
|
||
cmd += ["-usb", "-device", "usb-tablet"]
|
||
|
||
# QMP socket for direct VM control (mouse/keyboard/screenshot without guest agent)
|
||
cmd += ["-qmp", f"tcp:127.0.0.1:{self.qmp_port},server,nowait"]
|
||
|
||
# Daemonize on Unix
|
||
if _plat.system() != "Windows":
|
||
cmd.append("-daemonize")
|
||
|
||
return cmd
|
||
|
||
async def stop(self, name: str) -> None:
|
||
# Try to kill tracked process first
|
||
proc = self._processes.pop(name, None)
|
||
if proc and proc.poll() is None:
|
||
proc.terminate()
|
||
try:
|
||
proc.wait(timeout=10)
|
||
except subprocess.TimeoutExpired:
|
||
proc.kill()
|
||
else:
|
||
# Fallback: find by name
|
||
try:
|
||
if shutil.which("pkill"):
|
||
# Match both standard QEMU VMs (-name {name}) and Android emulators (-avd {name})
|
||
subprocess.run(["pkill", "-f", f"qemu.*-name {name}"], capture_output=True)
|
||
subprocess.run(["pkill", "-f", f"qemu.*-avd {name}"], capture_output=True)
|
||
else:
|
||
subprocess.run(
|
||
["taskkill", "/F", "/FI", f"WINDOWTITLE eq {name}*"],
|
||
capture_output=True,
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"Failed to stop QEMU VM {name}: {e}")
|
||
|
||
# Clean up ephemeral session disk
|
||
session_disk = getattr(self, "_session_disk", None)
|
||
if session_disk and session_disk.exists() and "sessions" in str(session_disk):
|
||
try:
|
||
session_disk.unlink()
|
||
logger.info(f"Removed session disk: {session_disk}")
|
||
except OSError:
|
||
pass
|
||
|
||
from cua_sandbox import sandbox_state
|
||
|
||
sandbox_state.delete(name)
|
||
|
||
async def is_ready(self, info: RuntimeInfo, timeout: float = 120) -> bool:
|
||
if info.qmp_port and not info.agent_type:
|
||
return await self._is_ready_qmp(info, timeout)
|
||
# For OSWorld, check the Flask server; for computer-server, check /status
|
||
endpoint = "/screenshot" if info.agent_type == "osworld" else "/status"
|
||
url = f"http://{info.host}:{info.api_port}{endpoint}"
|
||
deadline = asyncio.get_event_loop().time() + timeout
|
||
async with httpx.AsyncClient(timeout=10) as client:
|
||
while asyncio.get_event_loop().time() < deadline:
|
||
try:
|
||
resp = await client.get(url)
|
||
if resp.status_code == 200:
|
||
logger.info(f"Bare-metal QEMU VM {info.name} is ready")
|
||
return True
|
||
except (
|
||
httpx.ConnectError,
|
||
httpx.ReadTimeout,
|
||
httpx.ReadError,
|
||
httpx.RemoteProtocolError,
|
||
httpx.ConnectTimeout,
|
||
):
|
||
pass
|
||
await asyncio.sleep(3)
|
||
raise TimeoutError(f"Bare-metal QEMU VM {info.name} not ready after {timeout}s")
|
||
|
||
async def suspend(self, name: str) -> None:
|
||
"""Save VM state via QMP savevm then quit QEMU."""
|
||
from cua_sandbox import sandbox_state
|
||
|
||
state = sandbox_state.load(name)
|
||
qmp_port = state["qmp_port"] if state else self.qmp_port
|
||
try:
|
||
await _qmp_command("localhost", qmp_port, "stop")
|
||
await _qmp_command("localhost", qmp_port, "savevm", {"name": "cua-snapshot"})
|
||
await _qmp_command("localhost", qmp_port, "quit")
|
||
except Exception as e:
|
||
logger.warning(f"QMP savevm failed for {name}: {e}")
|
||
raise
|
||
sandbox_state.update(name, status="suspended")
|
||
|
||
async def resume(self, image: "Image", name: str, **opts) -> RuntimeInfo:
|
||
"""Relaunch QEMU with -loadvm to restore saved state."""
|
||
from cua_sandbox import sandbox_state
|
||
|
||
state = sandbox_state.load(name)
|
||
if state is None:
|
||
raise ValueError(f"No state file found for sandbox '{name}'. Cannot resume.")
|
||
|
||
# Reconstruct runtime params from state
|
||
disk_path = state.get("disk_path")
|
||
if not disk_path:
|
||
raise ValueError(
|
||
f"State for '{name}' has no disk_path — cannot resume bare-metal QEMU."
|
||
)
|
||
|
||
api_port = state.get("api_port", self.api_port)
|
||
vnc_display = state.get("vnc_display", self.vnc_display)
|
||
memory = state.get("memory_mb", self.memory_mb)
|
||
cpus = state.get("cpu_count", self.cpu_count)
|
||
qmp_port = state.get("qmp_port", self.qmp_port)
|
||
|
||
# Rebuild minimal QEMU command with -loadvm
|
||
cmd = [
|
||
self._qemu_bin(),
|
||
"-name",
|
||
name,
|
||
"-machine",
|
||
"q35,smm=off",
|
||
"-m",
|
||
str(memory),
|
||
"-smp",
|
||
str(cpus),
|
||
"-cpu",
|
||
"qemu64,+ssse3,+sse4.1,+sse4.2,+popcnt",
|
||
]
|
||
|
||
import platform as _platform
|
||
|
||
disk_ext = Path(disk_path).suffix.lower()
|
||
disk_fmt = {".qcow2": "qcow2", ".vhdx": "vhdx", ".raw": "raw", ".img": "raw"}.get(
|
||
disk_ext, "raw"
|
||
)
|
||
guest_port = 8000
|
||
|
||
cmd += [
|
||
"-drive",
|
||
f"file={disk_path},format={disk_fmt},if=virtio",
|
||
"-netdev",
|
||
f"user,id=net0,restrict=on,hostfwd=tcp:127.0.0.1:{api_port}-:{guest_port}",
|
||
"-device",
|
||
"virtio-net-pci,netdev=net0,mac=52:55:00:d1:55:01",
|
||
"-vnc",
|
||
f":{vnc_display}",
|
||
"-qmp",
|
||
f"tcp:127.0.0.1:{qmp_port},server,nowait",
|
||
"-loadvm",
|
||
"cua-snapshot",
|
||
]
|
||
|
||
if _platform.system() != "Windows":
|
||
cmd.append("-daemonize")
|
||
|
||
# Acceleration
|
||
if _platform.system() == "Darwin":
|
||
host_arm = _platform.machine() in ("arm64", "aarch64")
|
||
if host_arm and self.arch == "x86_64":
|
||
cmd += ["-accel", "tcg"]
|
||
else:
|
||
cmd += ["-accel", "hvf"]
|
||
elif _platform.system() != "Windows":
|
||
cmd.append("-enable-kvm")
|
||
|
||
logger.info(f"Resuming bare-metal QEMU from snapshot: {' '.join(cmd)}")
|
||
if "-daemonize" in cmd:
|
||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||
if result.returncode != 0:
|
||
raise RuntimeError(f"QEMU resume failed: {result.stderr}")
|
||
else:
|
||
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
||
self._processes[name] = proc
|
||
import time
|
||
|
||
time.sleep(2)
|
||
if proc.poll() is not None:
|
||
stderr = proc.stderr.read().decode() if proc.stderr else ""
|
||
raise RuntimeError(f"QEMU resume failed (exit {proc.returncode}): {stderr}")
|
||
|
||
info = RuntimeInfo(
|
||
host="localhost",
|
||
api_port=api_port,
|
||
vnc_port=5900 + vnc_display,
|
||
name=name,
|
||
)
|
||
await self.is_ready(info)
|
||
sandbox_state.update(name, status="running")
|
||
return info
|
||
|
||
async def list(self) -> list[dict]:
|
||
"""List known bare-metal QEMU sandboxes from state files, checking if alive."""
|
||
from cua_sandbox import sandbox_state
|
||
|
||
states = [s for s in sandbox_state.list_all() if s.get("runtime_type") == "qemu-baremetal"]
|
||
result = []
|
||
for s in states:
|
||
name = s["name"]
|
||
status = s.get("status", "unknown")
|
||
# Verify QEMU process is still running (standard VMs use -name, Android uses -avd)
|
||
if status == "running":
|
||
try:
|
||
alive = (
|
||
subprocess.run(
|
||
["pgrep", "-f", f"qemu.*-name {name}"], capture_output=True
|
||
).returncode
|
||
== 0
|
||
or 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": "qemu-baremetal",
|
||
"os_type": s.get("os_type"),
|
||
"host": s.get("host"),
|
||
"api_port": s.get("api_port"),
|
||
}
|
||
)
|
||
return result
|
||
|
||
async def _is_ready_qmp(self, info: RuntimeInfo, timeout: float = 120) -> bool:
|
||
"""Wait until QMP socket is responsive (for VMs without computer-server)."""
|
||
import json as _json
|
||
|
||
deadline = asyncio.get_event_loop().time() + timeout
|
||
while asyncio.get_event_loop().time() < deadline:
|
||
try:
|
||
reader, writer = await asyncio.open_connection(info.host, info.qmp_port)
|
||
# Read QMP greeting
|
||
greeting = await asyncio.wait_for(reader.readline(), timeout=3)
|
||
if greeting:
|
||
data = _json.loads(greeting)
|
||
if "QMP" in data:
|
||
writer.close()
|
||
await writer.wait_closed()
|
||
logger.info(f"Bare-metal QEMU VM {info.name} QMP ready")
|
||
return True
|
||
writer.close()
|
||
await writer.wait_closed()
|
||
except (ConnectionRefusedError, OSError, asyncio.TimeoutError):
|
||
pass
|
||
await asyncio.sleep(2)
|
||
raise TimeoutError(f"Bare-metal QEMU VM {info.name} QMP not ready after {timeout}s")
|
||
|
||
|
||
def _win_to_wsl(p: Path | str) -> str:
|
||
"""Convert a Windows path to WSL /mnt/... path."""
|
||
s = str(p).replace("\\", "/")
|
||
# C:/foo → /mnt/c/foo
|
||
if len(s) >= 2 and s[1] == ":":
|
||
return f"/mnt/{s[0].lower()}{s[2:]}"
|
||
return s
|
||
|
||
|
||
class QEMUWSL2Runtime(Runtime):
|
||
"""QEMU via WSL2 with KVM hardware acceleration.
|
||
|
||
Runs qemu-system-x86_64 inside WSL2 where /dev/kvm is available,
|
||
while keeping disk images on the Windows filesystem. Windows paths
|
||
are automatically converted to /mnt/c/... paths for WSL access.
|
||
|
||
This is the fastest way to run QEMU on Windows — native KVM speeds
|
||
vs TCG software emulation (bare-metal Windows) or Hyper-V (needs admin).
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
api_port: int = DEFAULT_API_PORT,
|
||
vnc_display: int = 0,
|
||
memory_mb: int = 4096,
|
||
cpu_count: int = 4,
|
||
arch: str = "x86_64",
|
||
extra_args: Optional[list[str]] = None,
|
||
):
|
||
self.api_port = api_port
|
||
self.vnc_display = vnc_display
|
||
self.memory_mb = memory_mb
|
||
self.cpu_count = cpu_count
|
||
self.arch = arch
|
||
self.extra_args = extra_args or []
|
||
self._processes: dict[str, subprocess.Popen] = {}
|
||
|
||
@staticmethod
|
||
def _wsl(cmd: str, timeout: float = 30) -> str:
|
||
"""Run a command inside WSL2 and return stdout."""
|
||
r = subprocess.run(
|
||
["wsl", "-e", "bash", "-c", cmd],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=timeout,
|
||
)
|
||
if r.returncode != 0:
|
||
raise RuntimeError(f"WSL command failed: {r.stderr.strip()}")
|
||
return r.stdout.strip()
|
||
|
||
@staticmethod
|
||
def available() -> bool:
|
||
"""Check if WSL2 + QEMU + KVM are available."""
|
||
try:
|
||
r = subprocess.run(
|
||
["wsl", "-e", "bash", "-c", "test -e /dev/kvm && which qemu-system-x86_64"],
|
||
capture_output=True,
|
||
timeout=10,
|
||
)
|
||
return r.returncode == 0
|
||
except (subprocess.SubprocessError, FileNotFoundError):
|
||
return False
|
||
|
||
async def start(self, image: Image, name: str, **opts) -> RuntimeInfo:
|
||
ephemeral = opts.pop("ephemeral", True)
|
||
|
||
# Resolve disk path
|
||
if not opts.get("disk_path") and not image._disk_path and image.kind == "vm":
|
||
from cua_sandbox.builder.build import create_session_disk
|
||
|
||
disk_path = str(await create_session_disk(image, name))
|
||
elif image._layers and (image._disk_path or opts.get("disk_path")):
|
||
from cua_sandbox.builder.build import create_session_disk
|
||
|
||
base = Path(opts.get("disk_path") or image._disk_path)
|
||
disk_path = str(await create_session_disk(image, name, base_disk=base))
|
||
else:
|
||
disk_path = opts.get("disk_path") or image._disk_path
|
||
|
||
if not disk_path:
|
||
raise ValueError(
|
||
"WSL2 QEMU requires a disk image path. "
|
||
"Use Image.from_file('/path/to/disk.qcow2') or pass disk_path='...'"
|
||
)
|
||
|
||
self._session_disk = Path(disk_path) if ephemeral else None
|
||
|
||
memory = opts.get("memory_mb", self.memory_mb)
|
||
cpus = opts.get("cpu_count", self.cpu_count)
|
||
vnc_display = opts.get("vnc_display", self.vnc_display)
|
||
hostfwd_port = opts.get("api_port", self.api_port)
|
||
|
||
# Convert Windows paths to WSL paths
|
||
wsl_disk = _win_to_wsl(disk_path)
|
||
disk_ext = Path(disk_path).suffix.lower()
|
||
disk_fmt = {".qcow2": "qcow2", ".vhdx": "vhdx", ".raw": "raw", ".img": "raw"}.get(
|
||
disk_ext, "raw"
|
||
)
|
||
|
||
# Locate OVMF inside WSL
|
||
ovmf_code = None
|
||
if image.os_type == "windows":
|
||
for candidate in [
|
||
"/usr/share/OVMF/OVMF_CODE_4M.fd",
|
||
"/usr/share/OVMF/OVMF_CODE.fd",
|
||
"/usr/share/qemu/edk2-x86_64-code.fd",
|
||
]:
|
||
try:
|
||
self._wsl(f"test -f {candidate}")
|
||
ovmf_code = candidate
|
||
break
|
||
except RuntimeError:
|
||
pass
|
||
|
||
# EFI vars — create in same dir as disk (WSL path)
|
||
efivars_win = Path(disk_path).parent / "efivars.fd"
|
||
wsl_efivars = _win_to_wsl(efivars_win)
|
||
if ovmf_code and not efivars_win.exists():
|
||
# Copy OVMF vars template via WSL
|
||
for vars_candidate in [
|
||
"/usr/share/OVMF/OVMF_VARS_4M.fd",
|
||
"/usr/share/OVMF/OVMF_VARS.fd",
|
||
"/usr/share/qemu/edk2-i386-vars.fd",
|
||
]:
|
||
try:
|
||
self._wsl(f"test -f {vars_candidate} && cp {vars_candidate} '{wsl_efivars}'")
|
||
break
|
||
except RuntimeError:
|
||
continue
|
||
else:
|
||
# Create empty vars file
|
||
efivars_win.write_bytes(b"\x00" * (256 * 1024))
|
||
|
||
# Build QEMU command (runs inside WSL)
|
||
parts = [
|
||
f"qemu-system-{self.arch}",
|
||
f"-name {name}",
|
||
"-machine q35,smm=off",
|
||
f"-m {memory}",
|
||
f"-smp {cpus}",
|
||
"-cpu host",
|
||
"-enable-kvm",
|
||
]
|
||
|
||
if ovmf_code:
|
||
parts += [
|
||
f"-drive if=pflash,format=raw,readonly=on,file={ovmf_code}",
|
||
f"-drive if=pflash,format=raw,file={wsl_efivars}",
|
||
]
|
||
|
||
parts += [
|
||
f"-drive file={wsl_disk},format={disk_fmt},if=virtio",
|
||
f"-netdev user,id=net0,hostfwd=tcp:0.0.0.0:{hostfwd_port}-:8000",
|
||
"-device virtio-net-pci,netdev=net0,mac=52:55:00:d1:55:01",
|
||
f"-vnc :{vnc_display}",
|
||
"-daemonize",
|
||
]
|
||
|
||
for arg in self.extra_args:
|
||
parts.append(arg)
|
||
|
||
qemu_cmd = " ".join(parts)
|
||
logger.info(f"Starting QEMU in WSL2: {qemu_cmd}")
|
||
|
||
# Launch QEMU inside WSL2 (daemonize means it returns immediately)
|
||
result = subprocess.run(
|
||
["wsl", "-e", "bash", "-c", qemu_cmd],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=30,
|
||
)
|
||
if result.returncode != 0:
|
||
raise RuntimeError(f"QEMU WSL2 launch failed: {result.stderr}")
|
||
|
||
info = RuntimeInfo(
|
||
host="localhost",
|
||
api_port=hostfwd_port,
|
||
vnc_port=5900 + vnc_display,
|
||
name=name,
|
||
)
|
||
await self.is_ready(info)
|
||
return info
|
||
|
||
async def stop(self, name: str) -> None:
|
||
try:
|
||
subprocess.run(
|
||
["wsl", "-e", "bash", "-c", f"pkill -f 'qemu.*-name {name}'"],
|
||
capture_output=True,
|
||
timeout=10,
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"Failed to stop QEMU VM {name} in WSL2: {e}")
|
||
|
||
session_disk = getattr(self, "_session_disk", None)
|
||
if session_disk and session_disk.exists() and "sessions" in str(session_disk):
|
||
try:
|
||
session_disk.unlink()
|
||
logger.info(f"Removed session disk: {session_disk}")
|
||
except OSError:
|
||
pass
|
||
|
||
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"WSL2 QEMU VM {info.name} is ready")
|
||
return True
|
||
except (
|
||
httpx.ConnectError,
|
||
httpx.ReadTimeout,
|
||
httpx.RemoteProtocolError,
|
||
httpx.ConnectTimeout,
|
||
):
|
||
pass
|
||
await asyncio.sleep(2)
|
||
raise TimeoutError(f"WSL2 QEMU VM {info.name} not ready after {timeout}s")
|
||
|
||
|
||
def QEMURuntime(mode: str = "docker", **kwargs) -> Runtime:
|
||
"""Factory that returns the appropriate QEMU runtime.
|
||
|
||
Args:
|
||
mode: "docker" (default), "bare-metal", or "wsl2"
|
||
"""
|
||
if mode == "wsl2":
|
||
return QEMUWSL2Runtime(**kwargs)
|
||
if mode == "bare-metal":
|
||
return QEMUBaremetalRuntime(**kwargs)
|
||
return QEMUDockerRuntime(**kwargs)
|