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

1006 lines
37 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 (310 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)