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

624 lines
27 KiB
Python

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